Skip to main content

longbridge/portfolio/
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, portfolio::types::*, utils::counter::symbol_to_counter_id};
8
9struct InnerPortfolioContext {
10    http_cli: HttpClient,
11    log_subscriber: Arc<dyn Subscriber + Send + Sync>,
12}
13
14impl Drop for InnerPortfolioContext {
15    fn drop(&mut self) {
16        dispatcher::with_default(&self.log_subscriber.clone().into(), || {
17            tracing::info!("portfolio context dropped");
18        });
19    }
20}
21
22/// Portfolio analytics context — exchange rates, P&L analysis.
23#[derive(Clone)]
24pub struct PortfolioContext(Arc<InnerPortfolioContext>);
25
26impl PortfolioContext {
27    /// Create a [`PortfolioContext`]
28    pub fn new(config: Arc<Config>) -> Self {
29        let log_subscriber = config.create_log_subscriber("portfolio");
30        dispatcher::with_default(&log_subscriber.clone().into(), || {
31            tracing::info!(language = ?config.language, "creating portfolio context");
32        });
33        let ctx = Self(Arc::new(InnerPortfolioContext {
34            http_cli: config.create_http_client(),
35            log_subscriber,
36        }));
37        dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
38            tracing::info!("portfolio 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    // ── exchange_rate ─────────────────────────────────────────────
67
68    /// Get exchange rates for supported currencies.
69    ///
70    /// Path: `GET /v1/asset/exchange_rates`
71    pub async fn exchange_rate(&self) -> Result<ExchangeRates> {
72        #[derive(Serialize)]
73        struct Empty {}
74        self.get("/v1/asset/exchange_rates", Empty {}).await
75    }
76
77    // ── profit_analysis ───────────────────────────────────────────
78
79    /// Get portfolio P&L analysis (summary + per-security breakdown).
80    ///
81    /// Combines `GET /v1/portfolio/profit-analysis-summary` and
82    /// `GET /v1/portfolio/profit-analysis-sublist` concurrently.
83    pub async fn profit_analysis(
84        &self,
85        start: Option<String>,
86        end: Option<String>,
87    ) -> Result<ProfitAnalysis> {
88        let start_ts = date_to_unix_opt(start.as_deref());
89        let end_ts = date_to_unix_end_opt(end.as_deref());
90
91        #[derive(Serialize)]
92        struct SummaryQuery {
93            #[serde(skip_serializing_if = "Option::is_none")]
94            start: Option<i64>,
95            #[serde(skip_serializing_if = "Option::is_none")]
96            end: Option<i64>,
97        }
98        #[derive(Serialize)]
99        struct SublistQuery {
100            profit_or_loss: &'static str,
101            #[serde(skip_serializing_if = "Option::is_none")]
102            start: Option<i64>,
103            #[serde(skip_serializing_if = "Option::is_none")]
104            end: Option<i64>,
105        }
106
107        let (summary, sublist) = tokio::join!(
108            self.get::<ProfitAnalysisSummary, _>(
109                "/v1/portfolio/profit-analysis-summary",
110                SummaryQuery {
111                    start: start_ts,
112                    end: end_ts
113                }
114            ),
115            self.get::<ProfitAnalysisSublist, _>(
116                "/v1/portfolio/profit-analysis-sublist",
117                SublistQuery {
118                    profit_or_loss: "all",
119                    start: start_ts,
120                    end: end_ts
121                }
122            ),
123        );
124
125        Ok(ProfitAnalysis {
126            summary: summary?,
127            sublist: sublist?,
128        })
129    }
130
131    /// Get paginated P&L analysis filtered by market.
132    ///
133    /// Path: `GET /v1/portfolio/profit-analysis/by-market`
134    pub async fn profit_analysis_by_market(
135        &self,
136        market: Option<String>,
137        start: Option<String>,
138        end: Option<String>,
139        currency: Option<String>,
140        page: u32,
141        size: u32,
142    ) -> Result<ProfitAnalysisByMarket> {
143        #[derive(Serialize)]
144        struct Query {
145            page: u32,
146            size: u32,
147            #[serde(skip_serializing_if = "Option::is_none")]
148            market: Option<String>,
149            #[serde(skip_serializing_if = "Option::is_none")]
150            start: Option<i64>,
151            #[serde(skip_serializing_if = "Option::is_none")]
152            end: Option<i64>,
153            #[serde(skip_serializing_if = "Option::is_none")]
154            currency: Option<String>,
155        }
156        self.get(
157            "/v1/portfolio/profit-analysis/by-market",
158            Query {
159                page,
160                size,
161                market,
162                start: date_to_unix_opt(start.as_deref()),
163                end: date_to_unix_end_opt(end.as_deref()),
164                currency,
165            },
166        )
167        .await
168    }
169
170    /// Get P&L detail for a specific security.
171    ///
172    /// Path: `GET /v1/portfolio/profit-analysis/detail`
173    pub async fn profit_analysis_detail(
174        &self,
175        symbol: impl Into<String>,
176        start: Option<String>,
177        end: Option<String>,
178    ) -> Result<ProfitAnalysisDetail> {
179        #[derive(Serialize)]
180        struct Query {
181            counter_id: String,
182            #[serde(skip_serializing_if = "Option::is_none")]
183            start: Option<i64>,
184            #[serde(skip_serializing_if = "Option::is_none")]
185            end: Option<i64>,
186        }
187        self.get(
188            "/v1/portfolio/profit-analysis/detail",
189            Query {
190                counter_id: symbol_to_counter_id(&symbol.into()),
191                start: date_to_unix_opt(start.as_deref()),
192                end: date_to_unix_end_opt(end.as_deref()),
193            },
194        )
195        .await
196    }
197
198    // ── profit_analysis_flows ─────────────────────────────────────
199
200    /// Get paginated P&L flow records for a security.
201    ///
202    /// Path: `GET /v1/portfolio/profit-analysis/flows`
203    #[allow(clippy::too_many_arguments)]
204    pub async fn profit_analysis_flows(
205        &self,
206        symbol: impl Into<String>,
207        page: u32,
208        size: u32,
209        derivative: bool,
210        start: Option<String>,
211        end: Option<String>,
212    ) -> Result<ProfitAnalysisFlows> {
213        #[derive(Serialize)]
214        struct Query {
215            counter_id: String,
216            page: u32,
217            size: u32,
218            derivative: bool,
219            #[serde(skip_serializing_if = "Option::is_none")]
220            start: Option<String>,
221            #[serde(skip_serializing_if = "Option::is_none")]
222            end: Option<String>,
223        }
224        self.get(
225            "/v1/portfolio/profit-analysis/flows",
226            Query {
227                counter_id: symbol_to_counter_id(&symbol.into()),
228                page,
229                size,
230                derivative,
231                start,
232                end,
233            },
234        )
235        .await
236    }
237}
238
239/// Convert an optional `YYYY-MM-DD` date string to a unix timestamp (midnight
240/// UTC).
241fn date_to_unix_opt(date: Option<&str>) -> Option<i64> {
242    date.and_then(|d| {
243        let parts: Vec<&str> = d.split('-').collect();
244        if parts.len() == 3 {
245            let y: i32 = parts[0].parse().ok()?;
246            let m: u8 = parts[1].parse().ok()?;
247            let d: u8 = parts[2].parse().ok()?;
248            let date = time::Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok()?;
249            let dt = date.midnight().assume_utc();
250            Some(dt.unix_timestamp())
251        } else {
252            None
253        }
254    })
255}
256
257/// Convert to end-of-day unix timestamp (23:59:59 UTC).
258fn date_to_unix_end_opt(date: Option<&str>) -> Option<i64> {
259    date_to_unix_opt(date).map(|ts| ts + 86399)
260}