Skip to main content

longbridge/portfolio/
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, portfolio::types::*};
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    /// AP accounts only: US data-center accounts get a region error; use
71    /// `TradeContext::us_exchange_rates` instead.
72    ///
73    /// Path: `GET /v1/asset/exchange_rates`
74    pub async fn exchange_rate(&self) -> Result<ExchangeRates> {
75        Ok(self
76            .0
77            .http_cli
78            .request(Method::GET, "/v1/asset/exchange_rates")
79            .dc_restrict(DcRegion::Ap)
80            .response::<Json<ExchangeRates>>()
81            .send()
82            .with_subscriber(self.0.log_subscriber.clone())
83            .await?
84            .0)
85    }
86
87    // ── profit_analysis ───────────────────────────────────────────
88
89    /// Get portfolio P&L analysis (summary + per-security breakdown).
90    ///
91    /// Combines `GET /v1/portfolio/profit-analysis-summary` and
92    /// `GET /v1/portfolio/profit-analysis-sublist` concurrently.
93    pub async fn profit_analysis(
94        &self,
95        start: Option<String>,
96        end: Option<String>,
97    ) -> Result<ProfitAnalysis> {
98        let start_ts = date_to_unix_opt(start.as_deref());
99        let end_ts = date_to_unix_end_opt(end.as_deref());
100
101        #[derive(Serialize)]
102        struct SummaryQuery {
103            #[serde(skip_serializing_if = "Option::is_none")]
104            start: Option<i64>,
105            #[serde(skip_serializing_if = "Option::is_none")]
106            end: Option<i64>,
107        }
108        #[derive(Serialize)]
109        struct SublistQuery {
110            profit_or_loss: &'static str,
111            #[serde(skip_serializing_if = "Option::is_none")]
112            start: Option<i64>,
113            #[serde(skip_serializing_if = "Option::is_none")]
114            end: Option<i64>,
115        }
116
117        let (summary, sublist) = tokio::join!(
118            self.get::<ProfitAnalysisSummary, _>(
119                "/v1/portfolio/profit-analysis-summary",
120                SummaryQuery {
121                    start: start_ts,
122                    end: end_ts
123                }
124            ),
125            self.get::<ProfitAnalysisSublist, _>(
126                "/v1/portfolio/profit-analysis-sublist",
127                SublistQuery {
128                    profit_or_loss: "all",
129                    start: start_ts,
130                    end: end_ts
131                }
132            ),
133        );
134
135        Ok(ProfitAnalysis {
136            summary: summary?,
137            sublist: sublist?,
138        })
139    }
140
141    /// Get paginated P&L analysis filtered by market.
142    ///
143    /// Path: `GET /v1/portfolio/profit-analysis/by-market`
144    pub async fn profit_analysis_by_market(
145        &self,
146        market: Option<String>,
147        start: Option<String>,
148        end: Option<String>,
149        currency: Option<String>,
150        page: u32,
151        size: u32,
152    ) -> Result<ProfitAnalysisByMarket> {
153        #[derive(Serialize)]
154        struct Query {
155            page: u32,
156            size: u32,
157            #[serde(skip_serializing_if = "Option::is_none")]
158            market: Option<String>,
159            #[serde(skip_serializing_if = "Option::is_none")]
160            start: Option<i64>,
161            #[serde(skip_serializing_if = "Option::is_none")]
162            end: Option<i64>,
163            #[serde(skip_serializing_if = "Option::is_none")]
164            currency: Option<String>,
165        }
166        self.get(
167            "/v1/portfolio/profit-analysis/by-market",
168            Query {
169                page,
170                size,
171                market,
172                start: date_to_unix_opt(start.as_deref()),
173                end: date_to_unix_end_opt(end.as_deref()),
174                currency,
175            },
176        )
177        .await
178    }
179
180    /// Get P&L detail for a specific security.
181    ///
182    /// Path: `GET /v1/portfolio/profit-analysis/detail`
183    pub async fn profit_analysis_detail(
184        &self,
185        symbol: impl Into<String>,
186        start: Option<String>,
187        end: Option<String>,
188    ) -> Result<ProfitAnalysisDetail> {
189        #[derive(Serialize)]
190        struct Query {
191            symbol: String,
192            #[serde(skip_serializing_if = "Option::is_none")]
193            start: Option<i64>,
194            #[serde(skip_serializing_if = "Option::is_none")]
195            end: Option<i64>,
196        }
197        self.get(
198            "/v1/portfolio/profit-analysis/detail",
199            Query {
200                symbol: symbol.into(),
201                start: date_to_unix_opt(start.as_deref()),
202                end: date_to_unix_end_opt(end.as_deref()),
203            },
204        )
205        .await
206    }
207
208    // ── profit_analysis_flows ─────────────────────────────────────
209
210    /// Get paginated P&L flow records for a security.
211    ///
212    /// Path: `GET /v1/portfolio/profit-analysis/flows`
213    #[allow(clippy::too_many_arguments)]
214    pub async fn profit_analysis_flows(
215        &self,
216        symbol: impl Into<String>,
217        page: u32,
218        size: u32,
219        derivative: bool,
220        start: Option<String>,
221        end: Option<String>,
222    ) -> Result<ProfitAnalysisFlows> {
223        #[derive(Serialize)]
224        struct Query {
225            symbol: String,
226            page: u32,
227            size: u32,
228            derivative: bool,
229            #[serde(skip_serializing_if = "Option::is_none")]
230            start: Option<String>,
231            #[serde(skip_serializing_if = "Option::is_none")]
232            end: Option<String>,
233        }
234        self.get(
235            "/v1/portfolio/profit-analysis/flows",
236            Query {
237                symbol: symbol.into(),
238                page,
239                size,
240                derivative,
241                start,
242                end,
243            },
244        )
245        .await
246    }
247}
248
249/// Convert an optional `YYYY-MM-DD` date string to a unix timestamp (midnight
250/// UTC).
251fn date_to_unix_opt(date: Option<&str>) -> Option<i64> {
252    date.and_then(|d| {
253        let parts: Vec<&str> = d.split('-').collect();
254        if parts.len() == 3 {
255            let y: i32 = parts[0].parse().ok()?;
256            let m: u8 = parts[1].parse().ok()?;
257            let d: u8 = parts[2].parse().ok()?;
258            let date = time::Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok()?;
259            let dt = date.midnight().assume_utc();
260            Some(dt.unix_timestamp())
261        } else {
262            None
263        }
264    })
265}
266
267/// Convert to end-of-day unix timestamp (23:59:59 UTC).
268fn date_to_unix_end_opt(date: Option<&str>) -> Option<i64> {
269    date_to_unix_opt(date).map(|ts| ts + 86399)
270}
271
272#[cfg(test)]
273mod dc_restrict_tests {
274    use longbridge_httpcli::HttpClientError;
275
276    use super::*;
277    use crate::Error;
278
279    /// `exchange_rate` fails fast for a US (`us_`) token.
280    #[tokio::test(flavor = "multi_thread")]
281    async fn exchange_rate_rejects_us_token() {
282        let config = Arc::new(Config::from_apikey("us_app_key", "app_secret", "us_token"));
283        let ctx = PortfolioContext::new(config);
284        match ctx.exchange_rate().await {
285            Err(Error::HttpClient(HttpClientError::DcRegionRestricted {
286                path,
287                required,
288                current,
289            })) => {
290                assert_eq!(path, "/v1/asset/exchange_rates", "restricted path mismatch");
291                assert_eq!(required, DcRegion::Ap, "expected AP-only restriction");
292                assert_eq!(current, DcRegion::Us, "expected US session region");
293            }
294            other => panic!("expected DcRegionRestricted, got {other:?}"),
295        }
296    }
297}