Skip to main content

longbridge/dca/
context.rs

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