Skip to main content

longbridge/fund/
context.rs

1use std::sync::Arc;
2
3use longbridge_httpcli::{HttpClient, Json, Method};
4use serde::{Deserialize, Serialize};
5use tracing::{Subscriber, dispatcher, instrument::WithSubscriber};
6
7use crate::{
8    Config, Result,
9    fund::{
10        FundAnalysis, FundAnalysisDetail, FundAnnualReturn, FundBrief, FundDetail, FundDividends,
11        FundFilters, FundHoldings, FundNavRangeOptions, FundNavValue, FundOrder, FundOrderDetail,
12        FundOrderSubmitResponse, FundOrderValidation, FundPageOptions, FundPerformance,
13        FundPerformanceComparison, FundPositionDetail, FundPositionNav, FundPositionPerformance,
14        FundPositionProfits, FundPositions, FundQuarterlyReturn, FundStockHolding, FundTransaction,
15        FundTrend, GetFundAnalysisOptions, GetFundHoldingsOptions, GetFundOrdersOptions,
16        GetFundPositionDividendsOptions, GetFundPositionOptions, GetFundPositionProfitsOptions,
17        GetFundPositionsOptions, GetFundStockHoldingsOptions, GetFundTransactionsOptions,
18        GetFundsOptions, HotFund, SubmitFundOrderOptions, ValidateFundOrderOptions,
19    },
20};
21
22#[derive(Debug, Deserialize)]
23struct WList<T> {
24    #[serde(default = "Vec::new")]
25    list: Vec<T>,
26}
27
28#[derive(Debug, Deserialize)]
29struct WFunds {
30    #[serde(default)]
31    funds: Vec<FundBrief>,
32}
33
34#[derive(Debug, Deserialize)]
35struct WValue<T> {
36    #[serde(default = "Vec::new")]
37    value: Vec<T>,
38}
39
40#[derive(Debug, Deserialize)]
41struct WHistory<T> {
42    #[serde(default = "Vec::new")]
43    history_value: Vec<T>,
44}
45
46#[derive(Debug, Deserialize)]
47struct WLists<T> {
48    #[serde(default = "Vec::new")]
49    lists: Vec<T>,
50}
51
52#[derive(Debug, Deserialize)]
53struct WOrders {
54    #[serde(default)]
55    orders: Vec<FundOrder>,
56}
57
58/// Query wrapper that prepends the fund `counter_id` before flattening the
59/// endpoint-specific options. The fund identifier (`counter_id`, e.g.
60/// `UT/FD/HK0000384492`) contains `/`, so it cannot live in the URL path and is
61/// passed as the `counter_id` query parameter instead.
62#[derive(Serialize)]
63struct CounterIdQuery<T> {
64    counter_id: String,
65    #[serde(flatten)]
66    options: T,
67}
68
69/// Empty option set for `counter_id`-only endpoints.
70#[derive(Serialize, Default)]
71struct NoQuery {}
72
73/// Query wrapper for the batch endpoints (latest NAV, daily performance, held
74/// fund performance) whose backend takes a JSON-array `counter_ids` parameter.
75/// The single `counter_id` is wrapped into a one-element JSON array to match
76/// the backend contract — sending the scalar `counter_id` makes the backend
77/// fail.
78#[derive(Serialize)]
79struct CounterIdsQuery {
80    counter_ids: String,
81}
82
83impl CounterIdsQuery {
84    fn single(counter_id: String) -> Self {
85        Self {
86            counter_ids: serde_json::to_string(&[counter_id]).expect("serialize counter_ids array"),
87        }
88    }
89}
90
91struct InnerFundContext {
92    http_cli: HttpClient,
93    log_subscriber: Arc<dyn Subscriber + Send + Sync>,
94}
95
96impl Drop for InnerFundContext {
97    fn drop(&mut self) {
98        dispatcher::with_default(&self.log_subscriber.clone().into(), || {
99            tracing::info!("fund context dropped");
100        });
101    }
102}
103
104/// Fund (mutual fund) channel context.
105#[derive(Clone)]
106pub struct FundContext(Arc<InnerFundContext>);
107
108impl FundContext {
109    /// Create a [`FundContext`]
110    pub fn new(config: Arc<Config>) -> Self {
111        let log_subscriber = config.create_log_subscriber("fund");
112        let ctx = Self(Arc::new(InnerFundContext {
113            http_cli: config.create_http_client(),
114            log_subscriber,
115        }));
116        dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
117            tracing::info!("fund context created");
118        });
119        ctx
120    }
121
122    /// Returns the log subscriber
123    #[inline]
124    pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
125        self.0.log_subscriber.clone()
126    }
127
128    // ----- fund catalog / market data (scope: quote) -----
129
130    /// Get the hot-selling fund list.
131    pub async fn hot_funds(&self) -> Result<Vec<HotFund>> {
132        Ok(self
133            .0
134            .http_cli
135            .request(Method::GET, "/v1/fund/hot-funds")
136            .response::<Json<WList<HotFund>>>()
137            .send()
138            .with_subscriber(self.0.log_subscriber.clone())
139            .await?
140            .0
141            .list)
142    }
143
144    /// Get the fund list.
145    pub async fn funds(
146        &self,
147        options: impl Into<Option<GetFundsOptions>>,
148    ) -> Result<Vec<FundBrief>> {
149        Ok(self
150            .0
151            .http_cli
152            .request(Method::POST, "/v1/fund/funds")
153            .body(Json(options.into().unwrap_or_default()))
154            .response::<Json<WFunds>>()
155            .send()
156            .with_subscriber(self.0.log_subscriber.clone())
157            .await?
158            .0
159            .funds)
160    }
161
162    /// Get the fund list filter options.
163    pub async fn filters(&self) -> Result<FundFilters> {
164        Ok(self
165            .0
166            .http_cli
167            .request(Method::GET, "/v1/fund/filters")
168            .response::<Json<FundFilters>>()
169            .send()
170            .with_subscriber(self.0.log_subscriber.clone())
171            .await?
172            .0)
173    }
174
175    /// Get fund detail.
176    pub async fn detail(&self, counter_id: impl Into<String>) -> Result<FundDetail> {
177        Ok(self
178            .0
179            .http_cli
180            .request(Method::GET, "/v1/fund/funds/detail")
181            .query_params(CounterIdQuery {
182                counter_id: counter_id.into(),
183                options: NoQuery {},
184            })
185            .response::<Json<FundDetail>>()
186            .send()
187            .with_subscriber(self.0.log_subscriber.clone())
188            .await?
189            .0)
190    }
191
192    /// Get fund analysis (level 1).
193    pub async fn analysis(
194        &self,
195        counter_id: impl Into<String>,
196        options: impl Into<Option<GetFundAnalysisOptions>>,
197    ) -> Result<FundAnalysis> {
198        Ok(self
199            .0
200            .http_cli
201            .request(Method::GET, "/v1/fund/funds/analysis")
202            .query_params(CounterIdQuery {
203                counter_id: counter_id.into(),
204                options: options.into().unwrap_or_default(),
205            })
206            .response::<Json<FundAnalysis>>()
207            .send()
208            .with_subscriber(self.0.log_subscriber.clone())
209            .await?
210            .0)
211    }
212
213    /// Get fund analysis detail (level 2).
214    pub async fn analysis_detail(
215        &self,
216        counter_id: impl Into<String>,
217        options: impl Into<Option<GetFundAnalysisOptions>>,
218    ) -> Result<FundAnalysisDetail> {
219        Ok(self
220            .0
221            .http_cli
222            .request(Method::GET, "/v1/fund/funds/analysis/detail")
223            .query_params(CounterIdQuery {
224                counter_id: counter_id.into(),
225                options: options.into().unwrap_or_default(),
226            })
227            .response::<Json<FundAnalysisDetail>>()
228            .send()
229            .with_subscriber(self.0.log_subscriber.clone())
230            .await?
231            .0)
232    }
233
234    /// Get fund trend chart.
235    pub async fn trend(
236        &self,
237        counter_id: impl Into<String>,
238        options: impl Into<Option<GetFundAnalysisOptions>>,
239    ) -> Result<FundTrend> {
240        Ok(self
241            .0
242            .http_cli
243            .request(Method::GET, "/v1/fund/funds/trend")
244            .query_params(CounterIdQuery {
245                counter_id: counter_id.into(),
246                options: options.into().unwrap_or_default(),
247            })
248            .response::<Json<FundTrend>>()
249            .send()
250            .with_subscriber(self.0.log_subscriber.clone())
251            .await?
252            .0)
253    }
254
255    /// Get fund annual returns.
256    pub async fn annual_returns(
257        &self,
258        counter_id: impl Into<String>,
259        options: impl Into<Option<FundPageOptions>>,
260    ) -> Result<Vec<FundAnnualReturn>> {
261        Ok(self
262            .0
263            .http_cli
264            .request(Method::GET, "/v1/fund/funds/returns/annual")
265            .query_params(CounterIdQuery {
266                counter_id: counter_id.into(),
267                options: options.into().unwrap_or_default(),
268            })
269            .response::<Json<WList<FundAnnualReturn>>>()
270            .send()
271            .with_subscriber(self.0.log_subscriber.clone())
272            .await?
273            .0
274            .list)
275    }
276
277    /// Get fund quarterly returns.
278    pub async fn quarterly_returns(
279        &self,
280        counter_id: impl Into<String>,
281        options: impl Into<Option<FundPageOptions>>,
282    ) -> Result<Vec<FundQuarterlyReturn>> {
283        Ok(self
284            .0
285            .http_cli
286            .request(Method::GET, "/v1/fund/funds/returns/quarterly")
287            .query_params(CounterIdQuery {
288                counter_id: counter_id.into(),
289                options: options.into().unwrap_or_default(),
290            })
291            .response::<Json<WList<FundQuarterlyReturn>>>()
292            .send()
293            .with_subscriber(self.0.log_subscriber.clone())
294            .await?
295            .0
296            .list)
297    }
298
299    /// Get fund performance figures.
300    pub async fn performance(&self, counter_id: impl Into<String>) -> Result<Vec<FundPerformance>> {
301        Ok(self
302            .0
303            .http_cli
304            .request(Method::GET, "/v1/fund/funds/performance")
305            .query_params(CounterIdsQuery::single(counter_id.into()))
306            .response::<Json<WValue<FundPerformance>>>()
307            .send()
308            .with_subscriber(self.0.log_subscriber.clone())
309            .await?
310            .0
311            .value)
312    }
313
314    /// Get fund performance comparison.
315    pub async fn performance_comparison(
316        &self,
317        counter_id: impl Into<String>,
318        options: impl Into<Option<GetFundAnalysisOptions>>,
319    ) -> Result<FundPerformanceComparison> {
320        Ok(self
321            .0
322            .http_cli
323            .request(Method::GET, "/v1/fund/funds/performance/comparison")
324            .query_params(CounterIdQuery {
325                counter_id: counter_id.into(),
326                options: options.into().unwrap_or_default(),
327            })
328            .response::<Json<FundPerformanceComparison>>()
329            .send()
330            .with_subscriber(self.0.log_subscriber.clone())
331            .await?
332            .0)
333    }
334
335    /// Get fund latest net value.
336    pub async fn nav(&self, counter_id: impl Into<String>) -> Result<Vec<FundNavValue>> {
337        Ok(self
338            .0
339            .http_cli
340            .request(Method::GET, "/v1/fund/funds/nav")
341            .query_params(CounterIdsQuery::single(counter_id.into()))
342            .response::<Json<WValue<FundNavValue>>>()
343            .send()
344            .with_subscriber(self.0.log_subscriber.clone())
345            .await?
346            .0
347            .value)
348    }
349
350    /// Get fund historical net value (paged).
351    pub async fn nav_history(
352        &self,
353        counter_id: impl Into<String>,
354        options: impl Into<Option<FundPageOptions>>,
355    ) -> Result<Vec<FundNavValue>> {
356        Ok(self
357            .0
358            .http_cli
359            .request(Method::GET, "/v1/fund/funds/nav-history")
360            .query_params(CounterIdQuery {
361                counter_id: counter_id.into(),
362                options: options.into().unwrap_or_default(),
363            })
364            .response::<Json<WHistory<FundNavValue>>>()
365            .send()
366            .with_subscriber(self.0.log_subscriber.clone())
367            .await?
368            .0
369            .history_value)
370    }
371
372    /// Get fund historical net value by relative time range.
373    pub async fn nav_range(
374        &self,
375        counter_id: impl Into<String>,
376        options: impl Into<Option<FundNavRangeOptions>>,
377    ) -> Result<Vec<FundNavValue>> {
378        Ok(self
379            .0
380            .http_cli
381            .request(Method::GET, "/v1/fund/funds/nav-range")
382            .query_params(CounterIdQuery {
383                counter_id: counter_id.into(),
384                options: options.into().unwrap_or_default(),
385            })
386            .response::<Json<WHistory<FundNavValue>>>()
387            .send()
388            .with_subscriber(self.0.log_subscriber.clone())
389            .await?
390            .0
391            .history_value)
392    }
393
394    /// Get a fund's top-10 holdings.
395    pub async fn holdings(
396        &self,
397        counter_id: impl Into<String>,
398        options: impl Into<Option<GetFundHoldingsOptions>>,
399    ) -> Result<FundHoldings> {
400        Ok(self
401            .0
402            .http_cli
403            .request(Method::GET, "/v1/fund/funds/holdings")
404            .query_params(CounterIdQuery {
405                counter_id: counter_id.into(),
406                options: options.into().unwrap_or_default(),
407            })
408            .response::<Json<FundHoldings>>()
409            .send()
410            .with_subscriber(self.0.log_subscriber.clone())
411            .await?
412            .0)
413    }
414
415    /// Get the stocks held by a fund (reverse lookup).
416    pub async fn stock_holdings(
417        &self,
418        counter_id: impl Into<String>,
419        options: impl Into<Option<GetFundStockHoldingsOptions>>,
420    ) -> Result<Vec<FundStockHolding>> {
421        Ok(self
422            .0
423            .http_cli
424            .request(Method::GET, "/v1/fund/funds/stock-holdings")
425            .query_params(CounterIdQuery {
426                counter_id: counter_id.into(),
427                options: options.into().unwrap_or_default(),
428            })
429            .response::<Json<WLists<FundStockHolding>>>()
430            .send()
431            .with_subscriber(self.0.log_subscriber.clone())
432            .await?
433            .0
434            .lists)
435    }
436
437    // ----- user fund positions (scope: portfolio-asset) -----
438
439    /// Get the user's fund positions overview.
440    pub async fn positions(
441        &self,
442        options: impl Into<Option<GetFundPositionsOptions>>,
443    ) -> Result<FundPositions> {
444        Ok(self
445            .0
446            .http_cli
447            .request(Method::GET, "/v1/asset/funds")
448            .query_params(options.into().unwrap_or_default())
449            .response::<Json<FundPositions>>()
450            .send()
451            .with_subscriber(self.0.log_subscriber.clone())
452            .await?
453            .0)
454    }
455
456    /// Get the user's single fund position detail.
457    pub async fn position(
458        &self,
459        counter_id: impl Into<String>,
460        options: impl Into<Option<GetFundPositionOptions>>,
461    ) -> Result<FundPositionDetail> {
462        Ok(self
463            .0
464            .http_cli
465            .request(Method::GET, "/v1/asset/funds/detail")
466            .query_params(CounterIdQuery {
467                counter_id: counter_id.into(),
468                options: options.into().unwrap_or_default(),
469            })
470            .response::<Json<FundPositionDetail>>()
471            .send()
472            .with_subscriber(self.0.log_subscriber.clone())
473            .await?
474            .0)
475    }
476
477    /// Get the performance figures of a held fund.
478    pub async fn position_performance(
479        &self,
480        counter_id: impl Into<String>,
481    ) -> Result<Vec<FundPositionPerformance>> {
482        Ok(self
483            .0
484            .http_cli
485            .request(Method::GET, "/v1/asset/funds/performance")
486            .query_params(CounterIdsQuery::single(counter_id.into()))
487            .response::<Json<WValue<FundPositionPerformance>>>()
488            .send()
489            .with_subscriber(self.0.log_subscriber.clone())
490            .await?
491            .0
492            .value)
493    }
494
495    /// Get the cumulative-profit series of a held fund.
496    pub async fn position_profits(
497        &self,
498        counter_id: impl Into<String>,
499        options: impl Into<Option<GetFundPositionProfitsOptions>>,
500    ) -> Result<FundPositionProfits> {
501        Ok(self
502            .0
503            .http_cli
504            .request(Method::GET, "/v1/asset/funds/profits")
505            .query_params(CounterIdQuery {
506                counter_id: counter_id.into(),
507                options: options.into().unwrap_or_default(),
508            })
509            .response::<Json<FundPositionProfits>>()
510            .send()
511            .with_subscriber(self.0.log_subscriber.clone())
512            .await?
513            .0)
514    }
515
516    /// Get the net-value history of a held fund.
517    pub async fn position_nav(
518        &self,
519        counter_id: impl Into<String>,
520        options: impl Into<Option<FundNavRangeOptions>>,
521    ) -> Result<Vec<FundPositionNav>> {
522        Ok(self
523            .0
524            .http_cli
525            .request(Method::GET, "/v1/asset/funds/nav-history")
526            .query_params(CounterIdQuery {
527                counter_id: counter_id.into(),
528                options: options.into().unwrap_or_default(),
529            })
530            .response::<Json<WHistory<FundPositionNav>>>()
531            .send()
532            .with_subscriber(self.0.log_subscriber.clone())
533            .await?
534            .0
535            .history_value)
536    }
537
538    /// Get the dividend records of a held fund.
539    pub async fn position_dividends(
540        &self,
541        counter_id: impl Into<String>,
542        options: impl Into<Option<GetFundPositionDividendsOptions>>,
543    ) -> Result<FundDividends> {
544        Ok(self
545            .0
546            .http_cli
547            .request(Method::GET, "/v1/asset/funds/dividends")
548            .query_params(CounterIdQuery {
549                counter_id: counter_id.into(),
550                options: options.into().unwrap_or_default(),
551            })
552            .response::<Json<FundDividends>>()
553            .send()
554            .with_subscriber(self.0.log_subscriber.clone())
555            .await?
556            .0)
557    }
558
559    // ----- fund orders & trading (scope: order) -----
560
561    /// Get the user's fund orders (also serves as the trade/execution record).
562    pub async fn orders(
563        &self,
564        options: impl Into<Option<GetFundOrdersOptions>>,
565    ) -> Result<Vec<FundOrder>> {
566        Ok(self
567            .0
568            .http_cli
569            .request(Method::GET, "/v1/fund/orders")
570            .query_params(options.into().unwrap_or_default())
571            .response::<Json<WOrders>>()
572            .send()
573            .with_subscriber(self.0.log_subscriber.clone())
574            .await?
575            .0
576            .orders)
577    }
578
579    /// Get a fund order detail.
580    pub async fn order(&self, order_id: i64) -> Result<FundOrderDetail> {
581        Ok(self
582            .0
583            .http_cli
584            .request(Method::GET, format!("/v1/fund/orders/{order_id}"))
585            .response::<Json<FundOrderDetail>>()
586            .send()
587            .with_subscriber(self.0.log_subscriber.clone())
588            .await?
589            .0)
590    }
591
592    /// Get the user's fund transactions (cash-flow records).
593    pub async fn transactions(
594        &self,
595        options: impl Into<Option<GetFundTransactionsOptions>>,
596    ) -> Result<Vec<FundTransaction>> {
597        Ok(self
598            .0
599            .http_cli
600            .request(Method::GET, "/v1/fund/transactions")
601            .query_params(options.into().unwrap_or_default())
602            .response::<Json<WList<FundTransaction>>>()
603            .send()
604            .with_subscriber(self.0.log_subscriber.clone())
605            .await?
606            .0
607            .list)
608    }
609
610    /// Validate a fund order before submitting.
611    pub async fn validate_order(
612        &self,
613        options: ValidateFundOrderOptions,
614    ) -> Result<FundOrderValidation> {
615        Ok(self
616            .0
617            .http_cli
618            .request(Method::POST, "/v1/fund/orders/validate")
619            .body(Json(options))
620            .response::<Json<FundOrderValidation>>()
621            .send()
622            .with_subscriber(self.0.log_subscriber.clone())
623            .await?
624            .0)
625    }
626
627    /// Submit a fund order (buy / sell).
628    pub async fn submit_order(
629        &self,
630        options: SubmitFundOrderOptions,
631    ) -> Result<FundOrderSubmitResponse> {
632        Ok(self
633            .0
634            .http_cli
635            .request(Method::POST, "/v1/fund/orders")
636            .body(Json(options))
637            .response::<Json<FundOrderSubmitResponse>>()
638            .send()
639            .with_subscriber(self.0.log_subscriber.clone())
640            .await?
641            .0)
642    }
643
644    /// Cancel (withdraw) a fund order.
645    pub async fn cancel_order(&self, order_id: i64) -> Result<()> {
646        #[derive(Debug, serde::Serialize)]
647        struct Req {
648            ut_id: i64,
649        }
650        #[derive(Deserialize)]
651        struct Resp {
652            #[serde(default)]
653            #[allow(dead_code)]
654            msg: String,
655        }
656        self.0
657            .http_cli
658            .request(Method::POST, format!("/v1/fund/orders/{order_id}/cancel"))
659            .body(Json(Req { ut_id: order_id }))
660            .response::<Json<Resp>>()
661            .send()
662            .with_subscriber(self.0.log_subscriber.clone())
663            .await?;
664        Ok(())
665    }
666}