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#[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> {
72 #[derive(Serialize)]
73 struct Empty {}
74 self.get("/v1/asset/exchange_rates", Empty {}).await
75 }
76
77 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 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 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 #[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
239fn 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
257fn date_to_unix_end_opt(date: Option<&str>) -> Option<i64> {
259 date_to_unix_opt(date).map(|ts| ts + 86399)
260}