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#[derive(Clone)]
24pub struct PortfolioContext(Arc<InnerPortfolioContext>);
25
26impl PortfolioContext {
27 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 #[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 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 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 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 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 #[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
249fn 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
267fn 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 #[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}