Skip to main content

longbridge/dca/
context.rs

1use std::sync::Arc;
2
3use longbridge_httpcli::{DcRegion, HttpClient, Json, Method};
4use serde::{Serialize, de::DeserializeOwned};
5use tracing::{Subscriber, dispatcher, instrument::WithSubscriber};
6
7use crate::{Config, Result, dca::types::*};
8
9struct InnerDCAContext {
10    http_cli: HttpClient,
11    log_subscriber: Arc<dyn Subscriber + Send + Sync>,
12}
13
14impl Drop for InnerDCAContext {
15    fn drop(&mut self) {
16        dispatcher::with_default(&self.log_subscriber.clone().into(), || {
17            tracing::info!("dca context dropped");
18        });
19    }
20}
21
22/// Dollar-cost averaging (DCA) plan management context.
23#[derive(Clone)]
24pub struct DCAContext(Arc<InnerDCAContext>);
25
26impl DCAContext {
27    /// Create a [`DCAContext`]
28    pub fn new(config: Arc<Config>) -> Self {
29        let log_subscriber = config.create_log_subscriber("dca");
30        dispatcher::with_default(&log_subscriber.clone().into(), || {
31            tracing::info!(language = ?config.language, "creating dca context");
32        });
33        let ctx = Self(Arc::new(InnerDCAContext {
34            http_cli: config.create_http_client(),
35            log_subscriber,
36        }));
37        dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
38            tracing::info!("dca context created");
39        });
40        ctx
41    }
42
43    /// Returns the log subscriber
44    #[inline]
45    pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
46        self.0.log_subscriber.clone()
47    }
48
49    async fn get<R, Q>(&self, path: &'static str, query: Q) -> Result<R>
50    where
51        R: DeserializeOwned + Send + Sync + 'static,
52        Q: Serialize + Send + Sync,
53    {
54        Ok(self
55            .0
56            .http_cli
57            .request(Method::GET, path)
58            // Recurring investment (DCA) is served only by the AP data center.
59            .dc_restrict(DcRegion::Ap)
60            .query_params(query)
61            .response::<Json<R>>()
62            .send()
63            .with_subscriber(self.0.log_subscriber.clone())
64            .await?
65            .0)
66    }
67
68    async fn post<R, B>(&self, path: &'static str, body: B) -> Result<R>
69    where
70        R: DeserializeOwned + Send + Sync + 'static,
71        B: std::fmt::Debug + Serialize + Send + Sync + 'static,
72    {
73        Ok(self
74            .0
75            .http_cli
76            .request(Method::POST, path)
77            // Recurring investment (DCA) is served only by the AP data center.
78            .dc_restrict(DcRegion::Ap)
79            .body(Json(body))
80            .response::<Json<R>>()
81            .send()
82            .with_subscriber(self.0.log_subscriber.clone())
83            .await?
84            .0)
85    }
86
87    /// List DCA plans.
88    ///
89    /// Path: `GET /v1/dailycoins/query`
90    pub async fn list(&self, status: Option<DCAStatus>, symbol: Option<String>) -> Result<DcaList> {
91        #[derive(Serialize)]
92        struct Query {
93            page: i32,
94            limit: i32,
95            #[serde(skip_serializing_if = "Option::is_none")]
96            status: Option<DCAStatus>,
97            #[serde(skip_serializing_if = "Option::is_none")]
98            symbol: Option<String>,
99        }
100        self.get(
101            "/v1/dailycoins/query",
102            Query {
103                page: 1,
104                limit: 100,
105                status,
106                symbol,
107            },
108        )
109        .await
110    }
111
112    /// Create a new DCA plan.
113    ///
114    /// Path: `POST /v1/dailycoins/create`
115    pub async fn create(
116        &self,
117        symbol: impl Into<String>,
118        amount: impl Into<String>,
119        frequency: DCAFrequency,
120        day_of_week: Option<String>,
121        day_of_month: Option<u32>,
122        allow_margin: bool,
123    ) -> Result<DcaCreateResult> {
124        let mut body = serde_json::json!({
125            "symbol": symbol.into(),
126            "per_invest_amount": amount.into(),
127            "invest_frequency": frequency,
128            "allow_margin_finance": if allow_margin { 1 } else { 0 }
129        });
130        if let Some(dow) = day_of_week {
131            body["invest_day_of_week"] = serde_json::Value::String(dow);
132        }
133        if let Some(dom) = day_of_month {
134            body["invest_day_of_month"] = serde_json::Value::String(dom.to_string());
135        }
136        self.post("/v1/dailycoins/create", body).await
137    }
138
139    /// Update a DCA plan.
140    ///
141    /// Path: `POST /v1/dailycoins/update`
142    pub async fn update(
143        &self,
144        plan_id: impl Into<String>,
145        amount: Option<String>,
146        frequency: Option<DCAFrequency>,
147        day_of_week: Option<String>,
148        day_of_month: Option<u32>,
149        allow_margin: Option<bool>,
150    ) -> Result<DcaCreateResult> {
151        let mut body = serde_json::json!({ "plan_id": plan_id.into() });
152        if let Some(a) = amount {
153            body["per_invest_amount"] = serde_json::Value::String(a);
154        }
155        if let Some(f) = frequency {
156            body["invest_frequency"] = serde_json::to_value(f).unwrap_or_default();
157        }
158        if let Some(dow) = day_of_week {
159            body["invest_day_of_week"] = serde_json::Value::String(dow);
160        }
161        if let Some(dom) = day_of_month {
162            body["invest_day_of_month"] = serde_json::Value::String(dom.to_string());
163        }
164        if let Some(m) = allow_margin {
165            body["allow_margin_finance"] =
166                serde_json::Value::Number((if m { 1 } else { 0 }).into());
167        }
168        self.post::<DcaCreateResult, _>("/v1/dailycoins/update", body)
169            .await
170    }
171
172    /// Pause a DCA plan.
173    pub async fn pause(&self, plan_id: impl Into<String>) -> Result<()> {
174        self.post::<serde_json::Value, _>(
175            "/v1/dailycoins/toggle",
176            serde_json::json!({ "plan_id": plan_id.into(), "status": "Suspended" }),
177        )
178        .await?;
179        Ok(())
180    }
181
182    /// Resume a suspended DCA plan.
183    pub async fn resume(&self, plan_id: impl Into<String>) -> Result<()> {
184        self.post::<serde_json::Value, _>(
185            "/v1/dailycoins/toggle",
186            serde_json::json!({ "plan_id": plan_id.into(), "status": "Active" }),
187        )
188        .await?;
189        Ok(())
190    }
191
192    /// Stop (permanently finish) a DCA plan.
193    pub async fn stop(&self, plan_id: impl Into<String>) -> Result<()> {
194        self.post::<serde_json::Value, _>(
195            "/v1/dailycoins/toggle",
196            serde_json::json!({ "plan_id": plan_id.into(), "status": "Finished" }),
197        )
198        .await?;
199        Ok(())
200    }
201
202    /// Get execution history for a DCA plan.
203    ///
204    /// Path: `GET /v1/dailycoins/query-records`
205    pub async fn history(
206        &self,
207        plan_id: impl Into<String>,
208        page: i32,
209        limit: i32,
210    ) -> Result<DcaHistoryResponse> {
211        #[derive(Serialize)]
212        struct Query {
213            plan_id: String,
214            page: i32,
215            limit: i32,
216        }
217        self.get(
218            "/v1/dailycoins/query-records",
219            Query {
220                plan_id: plan_id.into(),
221                page,
222                limit,
223            },
224        )
225        .await
226    }
227
228    /// Get DCA statistics.
229    ///
230    /// Path: `GET /v1/dailycoins/statistic`
231    pub async fn stats(&self, symbol: Option<String>) -> Result<DcaStats> {
232        #[derive(Serialize)]
233        struct Query {
234            #[serde(skip_serializing_if = "Option::is_none")]
235            symbol: Option<String>,
236        }
237        self.get("/v1/dailycoins/statistic", Query { symbol }).await
238    }
239
240    /// Check DCA support for a list of securities.
241    ///
242    /// Path: `POST /v1/dailycoins/batch-check-support`
243    pub async fn check_support(&self, symbols: Vec<String>) -> Result<DcaSupportList> {
244        self.post(
245            "/v1/dailycoins/batch-check-support",
246            serde_json::json!({ "symbols": symbols }),
247        )
248        .await
249    }
250
251    /// Calculate the next projected trade date for a DCA plan with the given
252    /// schedule parameters.
253    ///
254    /// Path: `POST /v1/dailycoins/calc-trd-date`
255    pub async fn calc_date(
256        &self,
257        symbol: impl Into<String>,
258        frequency: DCAFrequency,
259        day_of_week: Option<String>,
260        day_of_month: Option<u32>,
261    ) -> Result<DcaCalcDateResult> {
262        let mut body = serde_json::json!({
263            "symbol": symbol.into(),
264            "invest_frequency": frequency,
265        });
266        if let Some(dow) = day_of_week {
267            body["invest_day_of_week"] = serde_json::Value::String(dow);
268        }
269        if let Some(dom) = day_of_month {
270            body["invest_day_of_month"] = serde_json::Value::String(dom.to_string());
271        }
272        self.post("/v1/dailycoins/calc-trd-date", body).await
273    }
274
275    /// Update the advance reminder hours for DCA execution notifications.
276    ///
277    /// `hours` must be one of `"1"`, `"6"`, or `"12"`.
278    ///
279    /// Path: `POST /v1/dailycoins/update-alter-hours`
280    pub async fn set_reminder(&self, hours: impl Into<String>) -> Result<()> {
281        #[derive(serde::Deserialize)]
282        struct Empty {}
283        self.post::<Empty, _>(
284            "/v1/dailycoins/update-alter-hours",
285            serde_json::json!({ "alter_hours": hours.into() }),
286        )
287        .await?;
288        Ok(())
289    }
290}