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