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::{
8 Config, Result,
9 market::types::*,
10 utils::counter::{counter_id_to_symbol, index_symbol_to_counter_id, symbol_to_counter_id},
11};
12
13fn unix_secs_to_rfc3339(ts: i64) -> String {
15 time::OffsetDateTime::from_unix_timestamp(ts)
16 .map(|dt| {
17 use time::format_description::well_known::Rfc3339;
18 dt.format(&Rfc3339).unwrap_or_default()
19 })
20 .unwrap_or_else(|_| ts.to_string())
21}
22
23fn unix_secs_str_to_rfc3339(s: &str) -> String {
25 s.parse::<i64>()
26 .map(unix_secs_to_rfc3339)
27 .unwrap_or_else(|_| s.to_string())
28}
29
30struct InnerMarketContext {
31 http_cli: HttpClient,
32 log_subscriber: Arc<dyn Subscriber + Send + Sync>,
33}
34
35impl Drop for InnerMarketContext {
36 fn drop(&mut self) {
37 dispatcher::with_default(&self.log_subscriber.clone().into(), || {
38 tracing::info!("market context dropped");
39 });
40 }
41}
42
43#[derive(Clone)]
46pub struct MarketContext(Arc<InnerMarketContext>);
47
48impl MarketContext {
49 pub fn new(config: Arc<Config>) -> Self {
51 let log_subscriber = config.create_log_subscriber("market");
52 dispatcher::with_default(&log_subscriber.clone().into(), || {
53 tracing::info!(language = ?config.language, "creating market context");
54 });
55 let ctx = Self(Arc::new(InnerMarketContext {
56 http_cli: config.create_http_client(),
57 log_subscriber,
58 }));
59 dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
60 tracing::info!("market context created");
61 });
62 ctx
63 }
64
65 #[inline]
67 pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
68 self.0.log_subscriber.clone()
69 }
70
71 async fn get<R, Q>(&self, path: &'static str, query: Q) -> Result<R>
72 where
73 R: DeserializeOwned + Send + Sync + 'static,
74 Q: Serialize + Send + Sync,
75 {
76 Ok(self
77 .0
78 .http_cli
79 .request(Method::GET, path)
80 .query_params(query)
81 .response::<Json<R>>()
82 .send()
83 .with_subscriber(self.0.log_subscriber.clone())
84 .await?
85 .0)
86 }
87
88 async fn get_dc<R, Q>(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result<R>
91 where
92 R: DeserializeOwned + Send + Sync + 'static,
93 Q: Serialize + Send + Sync,
94 {
95 Ok(self
96 .0
97 .http_cli
98 .request(Method::GET, path)
99 .dc_restrict(dc_restrict)
100 .query_params(query)
101 .response::<Json<R>>()
102 .send()
103 .with_subscriber(self.0.log_subscriber.clone())
104 .await?
105 .0)
106 }
107
108 async fn post<R, B>(&self, path: &'static str, body: B) -> Result<R>
109 where
110 R: DeserializeOwned + Send + Sync + 'static,
111 B: std::fmt::Debug + Serialize + Send + Sync + 'static,
112 {
113 Ok(self
114 .0
115 .http_cli
116 .request(Method::POST, path)
117 .body(Json(body))
118 .response::<Json<R>>()
119 .send()
120 .with_subscriber(self.0.log_subscriber.clone())
121 .await?
122 .0)
123 }
124
125 pub async fn market_status(&self) -> Result<MarketStatusResponse> {
131 #[derive(Serialize)]
132 struct Empty {}
133 self.get("/v1/quote/market-status", Empty {}).await
134 }
135
136 pub async fn broker_holding(
142 &self,
143 symbol: impl Into<String>,
144 period: BrokerHoldingPeriod,
145 ) -> Result<BrokerHoldingTop> {
146 let period_str = match period {
147 BrokerHoldingPeriod::Rct1 => "rct_1",
148 BrokerHoldingPeriod::Rct5 => "rct_5",
149 BrokerHoldingPeriod::Rct20 => "rct_20",
150 BrokerHoldingPeriod::Rct60 => "rct_60",
151 };
152 #[derive(Serialize)]
153 struct Query {
154 counter_id: String,
155 #[serde(rename = "type")]
156 period: &'static str,
157 }
158 self.get_dc(
159 "/v1/quote/broker-holding",
160 Query {
161 counter_id: symbol_to_counter_id(&symbol.into()),
162 period: period_str,
163 },
164 DcRegion::Ap,
165 )
166 .await
167 }
168
169 pub async fn broker_holding_detail(
173 &self,
174 symbol: impl Into<String>,
175 ) -> Result<BrokerHoldingDetail> {
176 #[derive(Serialize)]
177 struct Query {
178 counter_id: String,
179 }
180 self.get_dc(
181 "/v1/quote/broker-holding/detail",
182 Query {
183 counter_id: symbol_to_counter_id(&symbol.into()),
184 },
185 DcRegion::Ap,
186 )
187 .await
188 }
189
190 pub async fn broker_holding_daily(
194 &self,
195 symbol: impl Into<String>,
196 broker_id: impl Into<String>,
197 ) -> Result<BrokerHoldingDailyHistory> {
198 #[derive(Serialize)]
199 struct Query {
200 counter_id: String,
201 parti_number: String,
202 }
203 self.get_dc(
204 "/v1/quote/broker-holding/daily",
205 Query {
206 counter_id: symbol_to_counter_id(&symbol.into()),
207 parti_number: broker_id.into(),
208 },
209 DcRegion::Ap,
210 )
211 .await
212 }
213
214 pub async fn ah_premium(
220 &self,
221 symbol: impl Into<String>,
222 period: AhPremiumPeriod,
223 count: u32,
224 ) -> Result<AhPremiumKlines> {
225 #[derive(Serialize)]
226 struct Query {
227 counter_id: String,
228 line_type: &'static str,
229 line_num: u32,
230 }
231 self.get(
232 "/v1/quote/ahpremium/klines",
233 Query {
234 counter_id: symbol_to_counter_id(&symbol.into()),
235 line_type: period.to_line_type(),
236 line_num: count,
237 },
238 )
239 .await
240 }
241
242 pub async fn ah_premium_intraday(
246 &self,
247 symbol: impl Into<String>,
248 ) -> Result<AhPremiumIntraday> {
249 #[derive(Serialize)]
250 struct Query {
251 counter_id: String,
252 days: &'static str,
253 }
254 self.get(
255 "/v1/quote/ahpremium/timeshares",
256 Query {
257 counter_id: symbol_to_counter_id(&symbol.into()),
258 days: "1",
259 },
260 )
261 .await
262 }
263
264 pub async fn trade_stats(&self, symbol: impl Into<String>) -> Result<TradeStatsResponse> {
270 #[derive(Serialize)]
271 struct Query {
272 counter_id: String,
273 }
274 self.get(
275 "/v1/quote/trades-statistics",
276 Query {
277 counter_id: symbol_to_counter_id(&symbol.into()),
278 },
279 )
280 .await
281 }
282
283 pub async fn anomaly(&self, market: impl Into<String>) -> Result<AnomalyResponse> {
289 #[derive(Serialize)]
290 struct Query {
291 market: String,
292 category: &'static str,
293 }
294 self.get(
295 "/v1/quote/changes",
296 Query {
297 market: market.into().to_uppercase(),
298 category: "0",
299 },
300 )
301 .await
302 }
303
304 pub async fn constituent(&self, symbol: impl Into<String>) -> Result<IndexConstituents> {
312 #[derive(Serialize)]
313 struct Query {
314 counter_id: String,
315 }
316 self.get(
317 "/v1/quote/index-constituents",
318 Query {
319 counter_id: index_symbol_to_counter_id(&symbol.into()),
320 },
321 )
322 .await
323 }
324
325 pub async fn top_movers(
335 &self,
336 markets: Vec<String>,
337 sort: u32,
338 date: Option<String>,
339 limit: u32,
340 ) -> Result<TopMoversResponse> {
341 #[derive(Debug, Serialize)]
342 struct Body {
343 limit: u32,
344 sort: u32,
345 markets: Vec<String>,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 date: Option<String>,
348 }
349 let raw: serde_json::Value = self
350 .post(
351 "/v1/quote/market/stock-events",
352 Body {
353 limit,
354 sort,
355 markets,
356 date,
357 },
358 )
359 .await?;
360
361 let events = raw["events"]
362 .as_array()
363 .cloned()
364 .unwrap_or_default()
365 .into_iter()
366 .map(|ev| {
367 let ts = if let Some(n) = ev["timestamp"].as_i64() {
368 unix_secs_to_rfc3339(n)
369 } else if let Some(s) = ev["timestamp"].as_str() {
370 unix_secs_str_to_rfc3339(s)
371 } else {
372 String::new()
373 };
374 let stock_val = &ev["stock"];
375 let stock = TopMoversStock {
376 symbol: counter_id_to_symbol(stock_val["counter_id"].as_str().unwrap_or("")),
377 code: stock_val["code"].as_str().unwrap_or("").to_string(),
378 name: stock_val["name"].as_str().unwrap_or("").to_string(),
379 full_name: stock_val["full_name"].as_str().unwrap_or("").to_string(),
380 change: stock_val["change"].as_str().unwrap_or("").to_string(),
381 last_done: stock_val["last_done"].as_str().unwrap_or("").to_string(),
382 market: stock_val["market"].as_str().unwrap_or("").to_string(),
383 labels: stock_val["labels"]
384 .as_array()
385 .map(|arr| {
386 arr.iter()
387 .filter_map(|l| l.as_str().map(|s| s.to_string()))
388 .collect()
389 })
390 .unwrap_or_default(),
391 logo: stock_val["logo"].as_str().unwrap_or("").to_string(),
392 };
393 TopMoversEvent {
394 timestamp: ts,
395 alert_reason: ev["alert_reason"].as_str().unwrap_or("").to_string(),
396 alert_type: ev["alert_type"].as_i64().unwrap_or(0),
397 stock,
398 post: ev["post"].clone(),
399 }
400 })
401 .collect();
402 let next_params = raw["next_params"].clone();
403 Ok(TopMoversResponse {
404 events,
405 next_params,
406 })
407 }
408
409 pub async fn rank_categories(&self) -> Result<RankCategoriesResponse> {
415 #[derive(Serialize)]
416 struct Empty {}
417 let mut raw: serde_json::Value = self
418 .get("/v1/quote/market/rank/categories", Empty {})
419 .await?;
420 if let Some(tags) = raw["first_tags"].as_array_mut() {
423 for tag in tags.iter_mut() {
424 if let Some(k) = tag["key"].as_str() {
425 let stripped = k.strip_prefix("ib_").unwrap_or(k).to_string();
426 tag["key"] = serde_json::Value::String(stripped);
427 }
428 if let Some(subs) = tag["second_tags"].as_array_mut() {
429 for sub in subs.iter_mut() {
430 if let Some(sk) = sub["key"].as_str() {
431 let stripped = sk.strip_prefix("ib_").unwrap_or(sk).to_string();
432 sub["key"] = serde_json::Value::String(stripped);
433 }
434 }
435 }
436 }
437 }
438 Ok(RankCategoriesResponse { data: raw })
439 }
440
441 pub async fn rank_list(
447 &self,
448 key: impl Into<String>,
449 need_article: bool,
450 ) -> Result<RankListResponse> {
451 #[derive(Serialize)]
452 struct Query {
453 key: String,
454 delay_bmp: &'static str,
455 need_article: &'static str,
456 }
457 let key_str = key.into();
458 let api_key = if key_str.starts_with("ib_") {
460 key_str
461 } else {
462 format!("ib_{key_str}")
463 };
464 let raw: serde_json::Value = self
465 .get(
466 "/v1/quote/market/rank/list",
467 Query {
468 key: api_key,
469 delay_bmp: "false",
470 need_article: if need_article { "true" } else { "false" },
471 },
472 )
473 .await?;
474 let bmp = raw["bmp"].as_bool().unwrap_or(false);
475 let lists = raw["lists"]
476 .as_array()
477 .cloned()
478 .unwrap_or_default()
479 .into_iter()
480 .map(|item| RankListItem {
481 symbol: counter_id_to_symbol(item["counter_id"].as_str().unwrap_or("")),
482 code: item["code"].as_str().unwrap_or("").to_string(),
483 name: item["name"].as_str().unwrap_or("").to_string(),
484 last_done: item["last_done"].as_str().unwrap_or("").to_string(),
485 chg: item["chg"].as_str().unwrap_or("").to_string(),
486 change: item["change"].as_str().unwrap_or("").to_string(),
487 inflow: item["inflow"].as_str().unwrap_or("").to_string(),
488 market_cap: item["market_cap"].as_str().unwrap_or("").to_string(),
489 industry: item["industry"].as_str().unwrap_or("").to_string(),
490 pre_post_price: item["pre_post_price"].as_str().unwrap_or("").to_string(),
491 pre_post_chg: item["pre_post_chg"].as_str().unwrap_or("").to_string(),
492 amplitude: item["amplitude"].as_str().unwrap_or("").to_string(),
493 five_day_chg: item["five_day_chg"].as_str().unwrap_or("").to_string(),
494 turnover_rate: item["turnover_rate"].as_str().unwrap_or("").to_string(),
495 volume_rate: item["volume_rate"].as_str().unwrap_or("").to_string(),
496 pb_ttm: item["pb_ttm"].as_str().unwrap_or("").to_string(),
497 })
498 .collect();
499 Ok(RankListResponse { bmp, lists })
500 }
501}