Skip to main content

longbridge/fundamental/
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::{
8    Config, Result,
9    fundamental::types::*,
10    utils::counter::{counter_id_to_symbol, symbol_to_counter_id},
11};
12
13/// Convert a Unix-seconds string to RFC 3339.
14fn unix_secs_str_to_rfc3339(s: &str) -> String {
15    s.parse::<i64>()
16        .ok()
17        .and_then(|ts| time::OffsetDateTime::from_unix_timestamp(ts).ok())
18        .map(|dt| {
19            use time::format_description::well_known::Rfc3339;
20            dt.format(&Rfc3339).unwrap_or_default()
21        })
22        .unwrap_or_else(|| s.to_string())
23}
24
25struct InnerFundamentalContext {
26    http_cli: HttpClient,
27    log_subscriber: Arc<dyn Subscriber + Send + Sync>,
28}
29
30impl Drop for InnerFundamentalContext {
31    fn drop(&mut self) {
32        dispatcher::with_default(&self.log_subscriber.clone().into(), || {
33            tracing::info!("fundamental context dropped");
34        });
35    }
36}
37
38/// Fundamental data context — financial reports, analyst ratings, dividends,
39/// valuation, company overview and more.
40#[derive(Clone)]
41pub struct FundamentalContext(Arc<InnerFundamentalContext>);
42
43impl FundamentalContext {
44    /// Create a [`FundamentalContext`]
45    pub fn new(config: Arc<Config>) -> Self {
46        let log_subscriber = config.create_log_subscriber("fundamental");
47        dispatcher::with_default(&log_subscriber.clone().into(), || {
48            tracing::info!(language = ?config.language, "creating fundamental context");
49        });
50        let ctx = Self(Arc::new(InnerFundamentalContext {
51            http_cli: config.create_http_client(),
52            log_subscriber,
53        }));
54        dispatcher::with_default(&ctx.0.log_subscriber.clone().into(), || {
55            tracing::info!("fundamental context created");
56        });
57        ctx
58    }
59
60    /// Returns the log subscriber
61    #[inline]
62    pub fn log_subscriber(&self) -> Arc<dyn Subscriber + Send + Sync> {
63        self.0.log_subscriber.clone()
64    }
65
66    async fn get<R, Q>(&self, path: &'static str, query: Q) -> Result<R>
67    where
68        R: DeserializeOwned + Send + Sync + 'static,
69        Q: Serialize + Send + Sync,
70    {
71        Ok(self
72            .0
73            .http_cli
74            .request(Method::GET, path)
75            .query_params(query)
76            .response::<Json<R>>()
77            .send()
78            .with_subscriber(self.0.log_subscriber.clone())
79            .await?
80            .0)
81    }
82
83    /// Like [`get`](Self::get), but restricted to a single data center. Used by
84    /// region-limited endpoints (e.g. AP-only fundamentals).
85    async fn get_dc<R, Q>(&self, path: &'static str, query: Q, dc_restrict: DcRegion) -> Result<R>
86    where
87        R: DeserializeOwned + Send + Sync + 'static,
88        Q: Serialize + Send + Sync,
89    {
90        Ok(self
91            .0
92            .http_cli
93            .request(Method::GET, path)
94            .dc_restrict(dc_restrict)
95            .query_params(query)
96            .response::<Json<R>>()
97            .send()
98            .with_subscriber(self.0.log_subscriber.clone())
99            .await?
100            .0)
101    }
102
103    // ── financial_report ─────────────────────────────────────────
104
105    /// Get financial reports for a security.
106    ///
107    /// Path: `GET /v1/quote/financial-reports`
108    pub async fn financial_report(
109        &self,
110        symbol: impl Into<String>,
111        kind: FinancialReportKind,
112        period: Option<FinancialReportPeriod>,
113    ) -> Result<FinancialReports> {
114        let kind_str = match kind {
115            FinancialReportKind::IncomeStatement => "IS",
116            FinancialReportKind::BalanceSheet => "BS",
117            FinancialReportKind::CashFlow => "CF",
118            FinancialReportKind::All => "ALL",
119        };
120        let period_str = period.map(|p| match p {
121            FinancialReportPeriod::Annual => "af",
122            FinancialReportPeriod::SemiAnnual => "saf",
123            FinancialReportPeriod::Q1 => "q1",
124            FinancialReportPeriod::Q2 => "q2",
125            FinancialReportPeriod::Q3 => "q3",
126            FinancialReportPeriod::QuarterlyFull => "qf",
127            FinancialReportPeriod::ThreeQ => "3q",
128        });
129        #[derive(Serialize)]
130        struct Query {
131            counter_id: String,
132            kind: &'static str,
133            #[serde(skip_serializing_if = "Option::is_none")]
134            report: Option<&'static str>,
135        }
136        self.get(
137            "/v1/quote/financial-reports",
138            Query {
139                counter_id: symbol_to_counter_id(&symbol.into()),
140                kind: kind_str,
141                report: period_str,
142            },
143        )
144        .await
145    }
146
147    // ── institution_rating ────────────────────────────────────────
148
149    /// Get analyst ratings for a security (combines latest + historical).
150    ///
151    /// Path: `GET /v1/quote/institution-rating-latest` +
152    ///       `GET /v1/quote/institution-ratings`
153    pub async fn institution_rating(&self, symbol: impl Into<String>) -> Result<InstitutionRating> {
154        #[derive(Serialize)]
155        struct Query {
156            counter_id: String,
157        }
158        let cid = symbol_to_counter_id(&symbol.into());
159        let q = Query { counter_id: cid };
160        let (latest, summary) = tokio::join!(
161            self.get::<InstitutionRatingLatest, _>(
162                "/v1/quote/institution-rating-latest",
163                Query {
164                    counter_id: q.counter_id.clone()
165                }
166            ),
167            self.get::<InstitutionRatingSummary, _>(
168                "/v1/quote/institution-ratings",
169                Query {
170                    counter_id: q.counter_id.clone()
171                }
172            ),
173        );
174        Ok(InstitutionRating {
175            latest: latest?,
176            summary: summary?,
177        })
178    }
179
180    /// Get historical analyst rating details for a security.
181    ///
182    /// Path: `GET /v1/quote/institution-ratings/detail`
183    pub async fn institution_rating_detail(
184        &self,
185        symbol: impl Into<String>,
186    ) -> Result<InstitutionRatingDetail> {
187        #[derive(Serialize)]
188        struct Query {
189            counter_id: String,
190        }
191        self.get(
192            "/v1/quote/institution-ratings/detail",
193            Query {
194                counter_id: symbol_to_counter_id(&symbol.into()),
195            },
196        )
197        .await
198    }
199
200    // ── dividend ──────────────────────────────────────────────────
201
202    /// Get dividend history for a security.
203    ///
204    /// Path: `GET /v1/quote/dividends`
205    pub async fn dividend(&self, symbol: impl Into<String>) -> Result<DividendList> {
206        #[derive(Serialize)]
207        struct Query {
208            counter_id: String,
209        }
210        self.get(
211            "/v1/quote/dividends",
212            Query {
213                counter_id: symbol_to_counter_id(&symbol.into()),
214            },
215        )
216        .await
217    }
218
219    /// Get detailed dividend information for a security.
220    ///
221    /// Path: `GET /v1/quote/dividends/details`
222    pub async fn dividend_detail(&self, symbol: impl Into<String>) -> Result<DividendList> {
223        #[derive(Serialize)]
224        struct Query {
225            counter_id: String,
226        }
227        self.get(
228            "/v1/quote/dividends/details",
229            Query {
230                counter_id: symbol_to_counter_id(&symbol.into()),
231            },
232        )
233        .await
234    }
235
236    // ── forecast_eps ──────────────────────────────────────────────
237
238    /// Get EPS forecasts for a security.
239    ///
240    /// Path: `GET /v1/quote/forecast-eps`
241    pub async fn forecast_eps(&self, symbol: impl Into<String>) -> Result<ForecastEps> {
242        #[derive(Serialize)]
243        struct Query {
244            counter_id: String,
245        }
246        self.get(
247            "/v1/quote/forecast-eps",
248            Query {
249                counter_id: symbol_to_counter_id(&symbol.into()),
250            },
251        )
252        .await
253    }
254
255    // ── consensus ─────────────────────────────────────────────────
256
257    /// Get financial consensus estimates for a security.
258    ///
259    /// Path: `GET /v1/quote/financial-consensus-detail`
260    pub async fn consensus(&self, symbol: impl Into<String>) -> Result<FinancialConsensus> {
261        #[derive(Serialize)]
262        struct Query {
263            counter_id: String,
264        }
265        self.get(
266            "/v1/quote/financial-consensus-detail",
267            Query {
268                counter_id: symbol_to_counter_id(&symbol.into()),
269            },
270        )
271        .await
272    }
273
274    // ── valuation ─────────────────────────────────────────────────
275
276    /// Get valuation metrics (PE/PB/PS/dividend yield) for a security.
277    ///
278    /// Path: `GET /v1/quote/valuation`
279    pub async fn valuation(&self, symbol: impl Into<String>) -> Result<ValuationData> {
280        #[derive(Serialize)]
281        struct Query {
282            counter_id: String,
283            indicator: &'static str,
284            range: &'static str,
285        }
286        self.get(
287            "/v1/quote/valuation",
288            Query {
289                counter_id: symbol_to_counter_id(&symbol.into()),
290                indicator: "pe",
291                range: "1",
292            },
293        )
294        .await
295    }
296
297    /// Get historical valuation data for a security.
298    ///
299    /// Path: `GET /v1/quote/valuation/detail`
300    pub async fn valuation_history(
301        &self,
302        symbol: impl Into<String>,
303    ) -> Result<ValuationHistoryResponse> {
304        #[derive(Serialize)]
305        struct Query {
306            counter_id: String,
307        }
308        self.get(
309            "/v1/quote/valuation/detail",
310            Query {
311                counter_id: symbol_to_counter_id(&symbol.into()),
312            },
313        )
314        .await
315    }
316
317    // ── industry_valuation ────────────────────────────────────────
318
319    /// Get valuation comparison against industry peers.
320    ///
321    /// Path: `GET /v1/quote/industry-valuation-comparison`
322    pub async fn industry_valuation(
323        &self,
324        symbol: impl Into<String>,
325    ) -> Result<IndustryValuationList> {
326        #[derive(Serialize)]
327        struct Query {
328            counter_id: String,
329        }
330        self.get(
331            "/v1/quote/industry-valuation-comparison",
332            Query {
333                counter_id: symbol_to_counter_id(&symbol.into()),
334            },
335        )
336        .await
337    }
338
339    /// Get valuation distribution within the industry.
340    ///
341    /// Path: `GET /v1/quote/industry-valuation-distribution`
342    pub async fn industry_valuation_dist(
343        &self,
344        symbol: impl Into<String>,
345    ) -> Result<IndustryValuationDist> {
346        #[derive(Serialize)]
347        struct Query {
348            counter_id: String,
349        }
350        self.get(
351            "/v1/quote/industry-valuation-distribution",
352            Query {
353                counter_id: symbol_to_counter_id(&symbol.into()),
354            },
355        )
356        .await
357    }
358
359    // ── company ───────────────────────────────────────────────────
360
361    /// Get company overview information.
362    ///
363    /// Path: `GET /v1/quote/comp-overview`
364    pub async fn company(&self, symbol: impl Into<String>) -> Result<CompanyOverview> {
365        #[derive(Serialize)]
366        struct Query {
367            counter_id: String,
368        }
369        self.get(
370            "/v1/quote/comp-overview",
371            Query {
372                counter_id: symbol_to_counter_id(&symbol.into()),
373            },
374        )
375        .await
376    }
377
378    // ── executive ─────────────────────────────────────────────────
379
380    /// Get executive and board member information.
381    ///
382    /// Path: `GET /v1/quote/company-professionals`
383    pub async fn executive(&self, symbol: impl Into<String>) -> Result<ExecutiveList> {
384        #[derive(Serialize)]
385        struct Query {
386            counter_ids: String,
387        }
388        self.get(
389            "/v1/quote/company-professionals",
390            Query {
391                counter_ids: symbol_to_counter_id(&symbol.into()),
392            },
393        )
394        .await
395    }
396
397    // ── shareholder ───────────────────────────────────────────────
398
399    /// Get major shareholders for a security.
400    ///
401    /// Path: `GET /v1/quote/shareholders`
402    pub async fn shareholder(&self, symbol: impl Into<String>) -> Result<ShareholderList> {
403        #[derive(Serialize)]
404        struct Query {
405            counter_id: String,
406        }
407        self.get(
408            "/v1/quote/shareholders",
409            Query {
410                counter_id: symbol_to_counter_id(&symbol.into()),
411            },
412        )
413        .await
414    }
415
416    // ── fund_holder ───────────────────────────────────────────────
417
418    /// Get funds and ETFs that hold a security.
419    ///
420    /// Path: `GET /v1/quote/fund-holders`
421    pub async fn fund_holder(&self, symbol: impl Into<String>) -> Result<FundHolders> {
422        #[derive(Serialize)]
423        struct Query {
424            counter_id: String,
425        }
426        self.get(
427            "/v1/quote/fund-holders",
428            Query {
429                counter_id: symbol_to_counter_id(&symbol.into()),
430            },
431        )
432        .await
433    }
434
435    // ── corp_action ───────────────────────────────────────────────
436
437    /// Get corporate actions (dividends, splits, buybacks, etc.).
438    ///
439    /// Path: `GET /v1/quote/company-act`
440    pub async fn corp_action(&self, symbol: impl Into<String>) -> Result<CorpActions> {
441        #[derive(Serialize)]
442        struct Query {
443            counter_id: String,
444            req_type: &'static str,
445            version: &'static str,
446        }
447        self.get(
448            "/v1/quote/company-act",
449            Query {
450                counter_id: symbol_to_counter_id(&symbol.into()),
451                req_type: "1",
452                version: "3",
453            },
454        )
455        .await
456    }
457
458    // ── invest_relation ───────────────────────────────────────────
459
460    /// Get investor relations / investment holdings.
461    ///
462    /// Path: `GET /v1/quote/invest-relations`
463    pub async fn invest_relation(&self, symbol: impl Into<String>) -> Result<InvestRelations> {
464        #[derive(Serialize)]
465        struct Query {
466            counter_id: String,
467            count: &'static str,
468        }
469        self.get(
470            "/v1/quote/invest-relations",
471            Query {
472                counter_id: symbol_to_counter_id(&symbol.into()),
473                count: "0",
474            },
475        )
476        .await
477    }
478
479    // ── operating ─────────────────────────────────────────────────
480
481    /// Get operating metrics and financial report summaries.
482    ///
483    /// Path: `GET /v1/quote/operatings`
484    pub async fn operating(&self, symbol: impl Into<String>) -> Result<OperatingList> {
485        #[derive(Serialize)]
486        struct Query {
487            counter_id: String,
488        }
489        self.get_dc(
490            "/v1/quote/operatings",
491            Query {
492                counter_id: symbol_to_counter_id(&symbol.into()),
493            },
494            DcRegion::Ap,
495        )
496        .await
497    }
498
499    // ── buyback ───────────────────────────────────────────────────
500
501    /// Get buyback data for a security.
502    ///
503    /// Path: `GET /v1/quote/buy-backs`
504    pub async fn buyback(&self, symbol: impl Into<String>) -> Result<BuybackData> {
505        #[derive(Serialize)]
506        struct Query {
507            counter_id: String,
508        }
509        self.get(
510            "/v1/quote/buy-backs",
511            Query {
512                counter_id: symbol_to_counter_id(&symbol.into()),
513            },
514        )
515        .await
516    }
517
518    // ── ratings ───────────────────────────────────────────────────
519
520    /// Get stock ratings for a security.
521    ///
522    /// Path: `GET /v1/quote/ratings`
523    pub async fn ratings(&self, symbol: impl Into<String>) -> Result<StockRatings> {
524        #[derive(Serialize)]
525        struct Query {
526            counter_id: String,
527        }
528        self.get(
529            "/v1/quote/ratings",
530            Query {
531                counter_id: symbol_to_counter_id(&symbol.into()),
532            },
533        )
534        .await
535    }
536
537    // ── business_segments ────────────────────────────────────────
538
539    /// Get the latest business segment breakdown for a security.
540    ///
541    /// Path: `GET /v1/quote/fundamentals/business-segments`
542    pub async fn business_segments(&self, symbol: impl Into<String>) -> Result<BusinessSegments> {
543        #[derive(Serialize)]
544        struct Query {
545            counter_id: String,
546        }
547        self.get(
548            "/v1/quote/fundamentals/business-segments",
549            Query {
550                counter_id: symbol_to_counter_id(&symbol.into()),
551            },
552        )
553        .await
554    }
555
556    /// Get historical business segment breakdowns for a security.
557    ///
558    /// Path: `GET /v1/quote/fundamentals/business-segments/history`
559    pub async fn business_segments_history(
560        &self,
561        symbol: impl Into<String>,
562        report: Option<&'static str>,
563        cate: Option<String>,
564    ) -> Result<BusinessSegmentsHistory> {
565        #[derive(Serialize)]
566        struct Query {
567            counter_id: String,
568            #[serde(skip_serializing_if = "Option::is_none")]
569            report: Option<&'static str>,
570            #[serde(skip_serializing_if = "Option::is_none")]
571            cate: Option<String>,
572        }
573        self.get(
574            "/v1/quote/fundamentals/business-segments/history",
575            Query {
576                counter_id: symbol_to_counter_id(&symbol.into()),
577                report,
578                cate,
579            },
580        )
581        .await
582    }
583
584    // ── shareholder_top ───────────────────────────────────────────
585
586    /// Get a ranked list of top shareholders for a security.
587    ///
588    /// Path: `GET /v1/quote/shareholders/top`
589    pub async fn shareholder_top(
590        &self,
591        symbol: impl Into<String>,
592    ) -> Result<ShareholderTopResponse> {
593        #[derive(Serialize)]
594        struct Query {
595            counter_id: String,
596        }
597        let raw: serde_json::Value = self
598            .get(
599                "/v1/quote/shareholders/top",
600                Query {
601                    counter_id: symbol_to_counter_id(&symbol.into()),
602                },
603            )
604            .await?;
605        Ok(ShareholderTopResponse { data: raw })
606    }
607
608    // ── institution_rating_views ──────────────────────────────────
609
610    /// Get historical institutional rating view time-series for a security.
611    ///
612    /// Path: `GET /v1/quote/ratings/institutional`
613    pub async fn institution_rating_views(
614        &self,
615        symbol: impl Into<String>,
616    ) -> Result<InstitutionRatingViews> {
617        #[derive(Serialize)]
618        struct Query {
619            counter_id: String,
620        }
621        self.get(
622            "/v1/quote/ratings/institutional",
623            Query {
624                counter_id: symbol_to_counter_id(&symbol.into()),
625            },
626        )
627        .await
628    }
629
630    // ── shareholder_detail ────────────────────────────────────────
631
632    /// Get holding history and detail for one shareholder object.
633    ///
634    /// Path: `GET /v1/quote/shareholders/holding`
635    pub async fn shareholder_detail(
636        &self,
637        symbol: impl Into<String>,
638        object_id: i64,
639    ) -> Result<ShareholderDetailResponse> {
640        #[derive(Serialize)]
641        struct Query {
642            counter_id: String,
643            object_id: String,
644        }
645        let raw: serde_json::Value = self
646            .get(
647                "/v1/quote/shareholders/holding",
648                Query {
649                    counter_id: symbol_to_counter_id(&symbol.into()),
650                    object_id: object_id.to_string(),
651                },
652            )
653            .await?;
654        Ok(ShareholderDetailResponse { data: raw })
655    }
656
657    // ── industry_rank ─────────────────────────────────────────────
658
659    /// Get industry rank for a market.
660    ///
661    /// Path: `GET /v1/quote/industry/rank`
662    pub async fn industry_rank(
663        &self,
664        market: impl Into<String>,
665        indicator: impl Into<String>,
666        sort_type: impl Into<String>,
667        limit: u32,
668    ) -> Result<IndustryRankResponse> {
669        #[derive(Serialize)]
670        struct Query {
671            market: String,
672            indicator: String,
673            sort_type: String,
674            limit: u32,
675        }
676        self.get(
677            "/v1/quote/industry/rank",
678            Query {
679                market: market.into(),
680                indicator: indicator.into(),
681                sort_type: sort_type.into(),
682                limit,
683            },
684        )
685        .await
686    }
687
688    // ── industry_peers ────────────────────────────────────────────
689
690    /// Get the industry peer chain for a security or industry.
691    ///
692    /// Path: `GET /v1/quote/industries/peers`
693    pub async fn industry_peers(
694        &self,
695        counter_id: impl Into<String>,
696        market: impl Into<String>,
697        industry_id: Option<String>,
698    ) -> Result<IndustryPeersResponse> {
699        let raw = counter_id.into();
700        let cid = if raw.contains('/') {
701            raw
702        } else {
703            symbol_to_counter_id(&raw)
704        };
705        #[derive(Serialize)]
706        struct Query {
707            #[serde(rename = "type")]
708            kind: &'static str,
709            market: String,
710            industry_id: String,
711            counter_id: String,
712        }
713        self.get(
714            "/v1/quote/industries/peers",
715            Query {
716                kind: "1",
717                market: market.into(),
718                industry_id: industry_id.unwrap_or_default(),
719                counter_id: cid,
720            },
721        )
722        .await
723    }
724
725    // ── financial_report_snapshot ─────────────────────────────────
726
727    /// Get a financial report snapshot (earnings snapshot) for a security.
728    ///
729    /// Path: `GET /v1/quote/financials/earnings-snapshot`
730    pub async fn financial_report_snapshot(
731        &self,
732        symbol: impl Into<String>,
733        report: Option<&'static str>,
734        fiscal_year: Option<i32>,
735        fiscal_period: Option<&'static str>,
736    ) -> Result<FinancialReportSnapshot> {
737        #[derive(Serialize)]
738        struct Query {
739            counter_id: String,
740            #[serde(skip_serializing_if = "Option::is_none")]
741            report: Option<&'static str>,
742            #[serde(skip_serializing_if = "Option::is_none")]
743            fiscal_year: Option<i32>,
744            #[serde(skip_serializing_if = "Option::is_none")]
745            fiscal_period: Option<&'static str>,
746        }
747        self.get(
748            "/v1/quote/financials/earnings-snapshot",
749            Query {
750                counter_id: symbol_to_counter_id(&symbol.into()),
751                report,
752                fiscal_year,
753                fiscal_period,
754            },
755        )
756        .await
757    }
758
759    // ── valuation_comparison ──────────────────────────────────────
760
761    /// Get valuation comparison between a security and optional peers.
762    ///
763    /// Path: `GET /v1/quote/compare/valuation`
764    pub async fn valuation_comparison(
765        &self,
766        symbol: impl Into<String>,
767        currency: impl Into<String>,
768        comparison_symbols: Option<Vec<String>>,
769    ) -> Result<ValuationComparisonResponse> {
770        #[derive(Serialize)]
771        struct Query {
772            counter_id: String,
773            currency: String,
774            #[serde(skip_serializing_if = "Option::is_none")]
775            comparison_counter_ids: Option<String>,
776        }
777        let comparison_counter_ids = comparison_symbols.map(|syms| {
778            let ids: Vec<String> = syms.iter().map(|s| symbol_to_counter_id(s)).collect();
779            serde_json::to_string(&ids).unwrap_or_default()
780        });
781        let raw: serde_json::Value = self
782            .get(
783                "/v1/quote/compare/valuation",
784                Query {
785                    counter_id: symbol_to_counter_id(&symbol.into()),
786                    currency: currency.into(),
787                    comparison_counter_ids,
788                },
789            )
790            .await?;
791        let list = raw["list"]
792            .as_array()
793            .cloned()
794            .unwrap_or_default()
795            .into_iter()
796            .map(|item| {
797                let history = item["history"]
798                    .as_array()
799                    .cloned()
800                    .unwrap_or_default()
801                    .into_iter()
802                    .map(|h| ValuationHistoryPoint {
803                        date: unix_secs_str_to_rfc3339(h["date"].as_str().unwrap_or("")),
804                        pe: h["pe"].as_str().unwrap_or("").to_string(),
805                        pb: h["pb"].as_str().unwrap_or("").to_string(),
806                        ps: h["ps"].as_str().unwrap_or("").to_string(),
807                    })
808                    .collect();
809                ValuationComparisonItem {
810                    symbol: counter_id_to_symbol(item["counter_id"].as_str().unwrap_or("")),
811                    name: item["name"].as_str().unwrap_or("").to_string(),
812                    currency: item["currency"].as_str().unwrap_or("").to_string(),
813                    market_value: item["market_value"].as_str().unwrap_or("").to_string(),
814                    price_close: item["price_close"].as_str().unwrap_or("").to_string(),
815                    pe: item["pe"].as_str().unwrap_or("").to_string(),
816                    pb: item["pb"].as_str().unwrap_or("").to_string(),
817                    ps: item["ps"].as_str().unwrap_or("").to_string(),
818                    roe: item["roe"].as_str().unwrap_or("").to_string(),
819                    eps: item["eps"].as_str().unwrap_or("").to_string(),
820                    bps: item["bps"].as_str().unwrap_or("").to_string(),
821                    dps: item["dps"].as_str().unwrap_or("").to_string(),
822                    div_yld: item["div_yld"].as_str().unwrap_or("").to_string(),
823                    assets: item["assets"].as_str().unwrap_or("").to_string(),
824                    history,
825                }
826            })
827            .collect();
828        Ok(ValuationComparisonResponse { list })
829    }
830
831    // ── etf_asset_allocation ─────────────────────────────────────
832
833    /// Get ETF asset allocation (holdings / regional / asset class /
834    /// industry).
835    ///
836    /// Path: `GET /v1/quote/etf-asset-allocation`
837    pub async fn etf_asset_allocation(
838        &self,
839        symbol: impl Into<String>,
840    ) -> Result<AssetAllocationResponse> {
841        #[derive(Serialize)]
842        struct Query {
843            counter_id: String,
844        }
845        self.get(
846            "/v1/quote/etf-asset-allocation",
847            Query {
848                counter_id: symbol_to_counter_id(&symbol.into()),
849            },
850        )
851        .await
852    }
853
854    // ── macroeconomic ────────────────────────────────────────────────
855
856    /// List macroeconomic indicators.
857    ///
858    /// `country` accepts a market code string (e.g. `"US"`, `"HK"`, `"ALL"`).
859    /// `keyword` optionally filters indicators by name (fuzzy,
860    /// case-insensitive). `offset` and `limit` are kept for backward
861    /// compatibility but ignored by v2.
862    ///
863    /// Path: `GET /v2/quote/macrodata`
864    pub async fn macroeconomic_indicators(
865        &self,
866        country: Option<MacroeconomicCountry>,
867        keyword: Option<impl Into<String>>,
868        offset: Option<i32>,
869        limit: Option<i32>,
870    ) -> Result<MacroeconomicIndicatorListResponse> {
871        self.macroeconomic_indicators_v2(country, keyword, offset, limit)
872            .await
873    }
874
875    /// List macroeconomic indicators (v2) with optional keyword filter.
876    ///
877    /// Path: `GET /v2/quote/macrodata`
878    pub(crate) async fn macroeconomic_indicators_v2(
879        &self,
880        country: Option<MacroeconomicCountry>,
881        keyword: Option<impl Into<String>>,
882        offset: Option<i32>,
883        limit: Option<i32>,
884    ) -> Result<MacroeconomicIndicatorListResponse> {
885        #[derive(Serialize)]
886        struct Query {
887            market: String,
888            #[serde(skip_serializing_if = "Option::is_none")]
889            keyword: Option<String>,
890            #[serde(skip_serializing_if = "Option::is_none")]
891            offset: Option<i32>,
892            #[serde(skip_serializing_if = "Option::is_none")]
893            limit: Option<i32>,
894        }
895        let market = country
896            .map(|c| match c {
897                MacroeconomicCountry::HongKong => "HK",
898                MacroeconomicCountry::China => "CN",
899                MacroeconomicCountry::UnitedStates => "US",
900                MacroeconomicCountry::EuroZone => "EU",
901                MacroeconomicCountry::Japan => "JP",
902                MacroeconomicCountry::Singapore => "SG",
903            })
904            .unwrap_or("ALL")
905            .to_string();
906
907        let raw: V2MacroIndicatorListResponse = self
908            .get(
909                "/v2/quote/macrodata",
910                Query {
911                    market,
912                    keyword: keyword.map(|k| k.into()),
913                    offset,
914                    limit,
915                },
916            )
917            .await?;
918
919        let total = raw.total;
920        let data = raw
921            .indicator_list
922            .into_iter()
923            .map(|ind| MacroeconomicIndicator {
924                indicator_code: ind.indicator_id.to_string(),
925                country: ind.market,
926                name: ind.indicator_name,
927                periodicity: ind.frequence,
928                describe: ind.description,
929                importance: ind.importance,
930                ..Default::default()
931            })
932            .collect::<Vec<_>>();
933        let count = if total > 0 { total } else { data.len() as i32 };
934        Ok(MacroeconomicIndicatorListResponse { data, count })
935    }
936
937    /// Get historical data for a macroeconomic indicator.
938    ///
939    /// `indicator_code` is the indicator ID (integer as string in v2).
940    /// `start_date` and `end_date` are `"YYYY-MM-DD"` format.
941    /// `sort` can be `"asc"` or `"desc"` (new in v2).
942    ///
943    /// Path: `GET /v2/quote/macrodata/{indicator_id}`
944    pub async fn macroeconomic(
945        &self,
946        indicator_code: impl Into<String>,
947        start_date: Option<impl Into<String>>,
948        end_date: Option<impl Into<String>>,
949        offset: Option<i32>,
950        limit: Option<i32>,
951    ) -> Result<MacroeconomicResponse> {
952        self.macroeconomic_v2(
953            indicator_code,
954            start_date,
955            end_date,
956            offset,
957            limit,
958            None::<String>,
959        )
960        .await
961    }
962
963    /// Get historical data for a macroeconomic indicator (v2) with sort
964    /// support.
965    ///
966    /// Path: `GET /v2/quote/macrodata/{indicator_id}`
967    pub(crate) async fn macroeconomic_v2(
968        &self,
969        indicator_code: impl Into<String>,
970        start_date: Option<impl Into<String>>,
971        end_date: Option<impl Into<String>>,
972        offset: Option<i32>,
973        limit: Option<i32>,
974        sort: Option<impl Into<String>>,
975    ) -> Result<MacroeconomicResponse> {
976        #[derive(Serialize)]
977        struct Query {
978            #[serde(skip_serializing_if = "Option::is_none")]
979            start_date: Option<String>,
980            #[serde(skip_serializing_if = "Option::is_none")]
981            end_date: Option<String>,
982            #[serde(skip_serializing_if = "Option::is_none")]
983            offset: Option<i32>,
984            #[serde(skip_serializing_if = "Option::is_none")]
985            limit: Option<i32>,
986            #[serde(skip_serializing_if = "Option::is_none")]
987            sort: Option<String>,
988        }
989        let path = format!("/v2/quote/macrodata/{}", indicator_code.into());
990        let raw: V2MacroIndicatorDataResponse = self
991            .0
992            .http_cli
993            .request(Method::GET, path)
994            .query_params(Query {
995                start_date: start_date.map(|d| d.into()),
996                end_date: end_date.map(|d| d.into()),
997                offset,
998                limit,
999                sort: Some(sort.map(|s| s.into()).unwrap_or_else(|| "desc".to_string())),
1000            })
1001            .response::<Json<V2MacroIndicatorDataResponse>>()
1002            .send()
1003            .with_subscriber(self.0.log_subscriber.clone())
1004            .await?
1005            .0;
1006
1007        let total = raw.total;
1008        let detail = raw.indicator;
1009        let unit_english = detail.unit.clone();
1010        let count = detail.indicator_data.len() as i32;
1011
1012        let info = MacroeconomicIndicator {
1013            indicator_code: detail.indicator_id.to_string(),
1014            country: detail.market,
1015            name: detail.indicator_name,
1016            describe: detail.description,
1017            periodicity: detail.frequence,
1018            importance: detail.importance,
1019            ..Default::default()
1020        };
1021
1022        let data = detail
1023            .indicator_data
1024            .into_iter()
1025            .map(|d| {
1026                use time::format_description::well_known::Rfc3339;
1027                let release_at = time::OffsetDateTime::parse(&d.published_time, &Rfc3339)
1028                    .ok()
1029                    .or_else(|| {
1030                        // Try without timezone suffix
1031                        time::PrimitiveDateTime::parse(
1032                            &d.published_time,
1033                            &time::macros::format_description!(
1034                                "[year]-[month]-[day]T[hour]:[minute]:[second]"
1035                            ),
1036                        )
1037                        .ok()
1038                        .map(|dt| dt.assume_utc())
1039                    });
1040                Macroeconomic {
1041                    period: d.observation_date,
1042                    release_at,
1043                    actual_value: d.actual_data,
1044                    previous_value: d.previous_data,
1045                    forecast_value: d.estimated_data,
1046                    unit: unit_english.clone(),
1047                    ..Default::default()
1048                }
1049            })
1050            .collect();
1051
1052        let count = if total > 0 { total } else { count };
1053        Ok(MacroeconomicResponse { info, data, count })
1054    }
1055
1056    // ── US-market APIs (US token required) ────────────────────────────────────
1057
1058    /// Get US company overview.
1059    ///
1060    /// Path: `GET /v1/us/stock-info/company-overview`
1061    ///
1062    /// US token required — returns
1063    /// [`longbridge_httpcli::HttpClientError::DcRegionRestricted`]
1064    /// for non-US credentials.
1065    pub async fn us_company_overview(
1066        &self,
1067        symbol: impl Into<String>,
1068    ) -> Result<USCompanyOverview> {
1069        #[derive(Serialize)]
1070        struct Query {
1071            counter_id: String,
1072        }
1073        self.get_dc(
1074            "/v1/us/stock-info/company-overview",
1075            Query {
1076                counter_id: symbol_to_counter_id(&symbol.into()),
1077            },
1078            DcRegion::Us,
1079        )
1080        .await
1081    }
1082
1083    /// Get US valuation overview snapshot.
1084    ///
1085    /// Path: `GET /v1/us/stock-info/valuation-overview`
1086    ///
1087    /// US token required.
1088    pub async fn us_valuation_overview(
1089        &self,
1090        symbol: impl Into<String>,
1091    ) -> Result<USValuationOverview> {
1092        #[derive(Serialize)]
1093        struct Query {
1094            counter_id: String,
1095        }
1096        self.get_dc(
1097            "/v1/us/stock-info/valuation-overview",
1098            Query {
1099                counter_id: symbol_to_counter_id(&symbol.into()),
1100            },
1101            DcRegion::Us,
1102        )
1103        .await
1104    }
1105
1106    /// Get US financial overview (revenue, net income, EPS, cash flow).
1107    ///
1108    /// `report`: `"annual"` or `"quarterly"`.
1109    ///
1110    /// Path: `GET /v1/us/stock-info/finn-overview`
1111    ///
1112    /// US token required. Returns raw JSON for maximum flexibility.
1113    pub async fn us_financial_overview(
1114        &self,
1115        symbol: impl Into<String>,
1116        report: impl Into<String>,
1117    ) -> Result<USFinancialOverview> {
1118        #[derive(Serialize)]
1119        struct Query {
1120            counter_id: String,
1121            report: String,
1122        }
1123        self.get_dc(
1124            "/v1/us/stock-info/finn-overview",
1125            Query {
1126                counter_id: symbol_to_counter_id(&symbol.into()),
1127                report: report.into(),
1128            },
1129            DcRegion::Us,
1130        )
1131        .await
1132    }
1133
1134    /// Get US financial statement detail (IS / BS / CF).
1135    ///
1136    /// `kind`: `"IS"` (income statement), `"BS"` (balance sheet), `"CF"` (cash
1137    /// flow). `report`: `"annual"` or `"quarterly"`.
1138    ///
1139    /// Path: `GET /v1/us/quote/financials/statements`
1140    ///
1141    /// US token required.
1142    pub async fn us_financial_statement(
1143        &self,
1144        symbol: impl Into<String>,
1145        kind: impl Into<String>,
1146        report: impl Into<String>,
1147    ) -> Result<USFinancialStatement> {
1148        #[derive(Serialize)]
1149        struct Query {
1150            counter_id: String,
1151            kind: String,
1152            report: String,
1153        }
1154        self.get_dc(
1155            "/v1/us/quote/financials/statements",
1156            Query {
1157                counter_id: symbol_to_counter_id(&symbol.into()),
1158                kind: kind.into(),
1159                report: report.into(),
1160            },
1161            DcRegion::Us,
1162        )
1163        .await
1164    }
1165
1166    /// Get key financial metrics (ROE, margins, leverage ratios).
1167    ///
1168    /// `report`: `"annual"` or `"quarterly"`.
1169    ///
1170    /// Path: `GET /v1/us/stock-info/fin-keyfactor`
1171    ///
1172    /// US token required. Returns raw JSON.
1173    pub async fn us_key_financial_metrics(
1174        &self,
1175        symbol: impl Into<String>,
1176        report: impl Into<String>,
1177    ) -> Result<USKeyFinancialMetrics> {
1178        #[derive(Serialize)]
1179        struct Query {
1180            counter_id: String,
1181            report: String,
1182        }
1183        self.get_dc(
1184            "/v1/us/stock-info/fin-keyfactor",
1185            Query {
1186                counter_id: symbol_to_counter_id(&symbol.into()),
1187                report: report.into(),
1188            },
1189            DcRegion::Us,
1190        )
1191        .await
1192    }
1193
1194    /// Get analyst consensus estimates (EPS and revenue forecasts).
1195    ///
1196    /// `report`: `"annual"` or `"quarterly"`.
1197    ///
1198    /// Path: `GET /v1/us/stock-info/fin-consensus`
1199    ///
1200    /// US token required. Returns raw JSON.
1201    pub async fn us_analyst_consensus(
1202        &self,
1203        symbol: impl Into<String>,
1204        report: impl Into<String>,
1205    ) -> Result<USAnalystConsensus> {
1206        #[derive(Serialize)]
1207        struct Query {
1208            counter_id: String,
1209            report: String,
1210        }
1211        self.get_dc(
1212            "/v1/us/stock-info/fin-consensus",
1213            Query {
1214                counter_id: symbol_to_counter_id(&symbol.into()),
1215                report: report.into(),
1216            },
1217            DcRegion::Us,
1218        )
1219        .await
1220    }
1221
1222    /// Get ETF dividend history.
1223    ///
1224    /// Path: `GET /v1/us/stock-info/etf-dividend-info`
1225    ///
1226    /// US token required.
1227    pub async fn us_etf_dividend_info(
1228        &self,
1229        symbol: impl Into<String>,
1230    ) -> Result<USETFDividendInfo> {
1231        #[derive(Serialize)]
1232        struct Query {
1233            counter_id: String,
1234        }
1235        self.get_dc(
1236            "/v1/us/stock-info/etf-dividend-info",
1237            Query {
1238                counter_id: symbol_to_counter_id(&symbol.into()),
1239            },
1240            DcRegion::Us,
1241        )
1242        .await
1243    }
1244
1245    /// Get company historical dividend payments.
1246    ///
1247    /// Path: `GET /v1/us/stock-info/company-dividends`
1248    ///
1249    /// US token required.
1250    pub async fn us_company_dividends(
1251        &self,
1252        symbol: impl Into<String>,
1253    ) -> Result<USCompanyDividends> {
1254        #[derive(Serialize)]
1255        struct Query {
1256            counter_id: String,
1257        }
1258        self.get_dc(
1259            "/v1/us/stock-info/company-dividends",
1260            Query {
1261                counter_id: symbol_to_counter_id(&symbol.into()),
1262            },
1263            DcRegion::Us,
1264        )
1265        .await
1266    }
1267
1268    /// Get ETF document list (prospectus, annual reports, etc.).
1269    ///
1270    /// `size`: number of files to return; `None` returns all (server default 0
1271    /// = all).
1272    ///
1273    /// Path: `GET /v1/us/stock-info/etf-files`
1274    ///
1275    /// US token required.
1276    pub async fn us_etf_files(
1277        &self,
1278        symbol: impl Into<String>,
1279        size: Option<u32>,
1280    ) -> Result<USETFFilesResponse> {
1281        #[derive(Serialize)]
1282        struct Query {
1283            counter_id: String,
1284            #[serde(skip_serializing_if = "Option::is_none")]
1285            size: Option<u32>,
1286        }
1287        self.get_dc(
1288            "/v1/us/stock-info/etf-files",
1289            Query {
1290                counter_id: symbol_to_counter_id(&symbol.into()),
1291                size,
1292            },
1293            DcRegion::Us,
1294        )
1295        .await
1296    }
1297}