Skip to main content

longbridge/fundamental/
context.rs

1use std::sync::Arc;
2
3use longbridge_httpcli::{HttpClient, Json, Method};
4use serde::{Serialize, de::DeserializeOwned};
5use tracing::{Subscriber, dispatcher, instrument::WithSubscriber};
6
7use crate::{
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    // ── financial_report ─────────────────────────────────────────
84
85    /// Get financial reports for a security.
86    ///
87    /// Path: `GET /v1/quote/financial-reports`
88    pub async fn financial_report(
89        &self,
90        symbol: impl Into<String>,
91        kind: FinancialReportKind,
92        period: Option<FinancialReportPeriod>,
93    ) -> Result<FinancialReports> {
94        let kind_str = match kind {
95            FinancialReportKind::IncomeStatement => "IS",
96            FinancialReportKind::BalanceSheet => "BS",
97            FinancialReportKind::CashFlow => "CF",
98            FinancialReportKind::All => "ALL",
99        };
100        let period_str = period.map(|p| match p {
101            FinancialReportPeriod::Annual => "af",
102            FinancialReportPeriod::SemiAnnual => "saf",
103            FinancialReportPeriod::Q1 => "q1",
104            FinancialReportPeriod::Q2 => "q2",
105            FinancialReportPeriod::Q3 => "q3",
106            FinancialReportPeriod::QuarterlyFull => "qf",
107            FinancialReportPeriod::ThreeQ => "3q",
108        });
109        #[derive(Serialize)]
110        struct Query {
111            counter_id: String,
112            kind: &'static str,
113            #[serde(skip_serializing_if = "Option::is_none")]
114            report: Option<&'static str>,
115        }
116        self.get(
117            "/v1/quote/financial-reports",
118            Query {
119                counter_id: symbol_to_counter_id(&symbol.into()),
120                kind: kind_str,
121                report: period_str,
122            },
123        )
124        .await
125    }
126
127    // ── institution_rating ────────────────────────────────────────
128
129    /// Get analyst ratings for a security (combines latest + historical).
130    ///
131    /// Path: `GET /v1/quote/institution-rating-latest` +
132    ///       `GET /v1/quote/institution-ratings`
133    pub async fn institution_rating(&self, symbol: impl Into<String>) -> Result<InstitutionRating> {
134        #[derive(Serialize)]
135        struct Query {
136            counter_id: String,
137        }
138        let cid = symbol_to_counter_id(&symbol.into());
139        let q = Query { counter_id: cid };
140        let (latest, summary) = tokio::join!(
141            self.get::<InstitutionRatingLatest, _>(
142                "/v1/quote/institution-rating-latest",
143                Query {
144                    counter_id: q.counter_id.clone()
145                }
146            ),
147            self.get::<InstitutionRatingSummary, _>(
148                "/v1/quote/institution-ratings",
149                Query {
150                    counter_id: q.counter_id.clone()
151                }
152            ),
153        );
154        Ok(InstitutionRating {
155            latest: latest?,
156            summary: summary?,
157        })
158    }
159
160    /// Get historical analyst rating details for a security.
161    ///
162    /// Path: `GET /v1/quote/institution-ratings/detail`
163    pub async fn institution_rating_detail(
164        &self,
165        symbol: impl Into<String>,
166    ) -> Result<InstitutionRatingDetail> {
167        #[derive(Serialize)]
168        struct Query {
169            counter_id: String,
170        }
171        self.get(
172            "/v1/quote/institution-ratings/detail",
173            Query {
174                counter_id: symbol_to_counter_id(&symbol.into()),
175            },
176        )
177        .await
178    }
179
180    // ── dividend ──────────────────────────────────────────────────
181
182    /// Get dividend history for a security.
183    ///
184    /// Path: `GET /v1/quote/dividends`
185    pub async fn dividend(&self, symbol: impl Into<String>) -> Result<DividendList> {
186        #[derive(Serialize)]
187        struct Query {
188            counter_id: String,
189        }
190        self.get(
191            "/v1/quote/dividends",
192            Query {
193                counter_id: symbol_to_counter_id(&symbol.into()),
194            },
195        )
196        .await
197    }
198
199    /// Get detailed dividend information for a security.
200    ///
201    /// Path: `GET /v1/quote/dividends/details`
202    pub async fn dividend_detail(&self, symbol: impl Into<String>) -> Result<DividendList> {
203        #[derive(Serialize)]
204        struct Query {
205            counter_id: String,
206        }
207        self.get(
208            "/v1/quote/dividends/details",
209            Query {
210                counter_id: symbol_to_counter_id(&symbol.into()),
211            },
212        )
213        .await
214    }
215
216    // ── forecast_eps ──────────────────────────────────────────────
217
218    /// Get EPS forecasts for a security.
219    ///
220    /// Path: `GET /v1/quote/forecast-eps`
221    pub async fn forecast_eps(&self, symbol: impl Into<String>) -> Result<ForecastEps> {
222        #[derive(Serialize)]
223        struct Query {
224            counter_id: String,
225        }
226        self.get(
227            "/v1/quote/forecast-eps",
228            Query {
229                counter_id: symbol_to_counter_id(&symbol.into()),
230            },
231        )
232        .await
233    }
234
235    // ── consensus ─────────────────────────────────────────────────
236
237    /// Get financial consensus estimates for a security.
238    ///
239    /// Path: `GET /v1/quote/financial-consensus-detail`
240    pub async fn consensus(&self, symbol: impl Into<String>) -> Result<FinancialConsensus> {
241        #[derive(Serialize)]
242        struct Query {
243            counter_id: String,
244        }
245        self.get(
246            "/v1/quote/financial-consensus-detail",
247            Query {
248                counter_id: symbol_to_counter_id(&symbol.into()),
249            },
250        )
251        .await
252    }
253
254    // ── valuation ─────────────────────────────────────────────────
255
256    /// Get valuation metrics (PE/PB/PS/dividend yield) for a security.
257    ///
258    /// Path: `GET /v1/quote/valuation`
259    pub async fn valuation(&self, symbol: impl Into<String>) -> Result<ValuationData> {
260        #[derive(Serialize)]
261        struct Query {
262            counter_id: String,
263            indicator: &'static str,
264            range: &'static str,
265        }
266        self.get(
267            "/v1/quote/valuation",
268            Query {
269                counter_id: symbol_to_counter_id(&symbol.into()),
270                indicator: "pe",
271                range: "1",
272            },
273        )
274        .await
275    }
276
277    /// Get historical valuation data for a security.
278    ///
279    /// Path: `GET /v1/quote/valuation/detail`
280    pub async fn valuation_history(
281        &self,
282        symbol: impl Into<String>,
283    ) -> Result<ValuationHistoryResponse> {
284        #[derive(Serialize)]
285        struct Query {
286            counter_id: String,
287        }
288        self.get(
289            "/v1/quote/valuation/detail",
290            Query {
291                counter_id: symbol_to_counter_id(&symbol.into()),
292            },
293        )
294        .await
295    }
296
297    // ── industry_valuation ────────────────────────────────────────
298
299    /// Get valuation comparison against industry peers.
300    ///
301    /// Path: `GET /v1/quote/industry-valuation-comparison`
302    pub async fn industry_valuation(
303        &self,
304        symbol: impl Into<String>,
305    ) -> Result<IndustryValuationList> {
306        #[derive(Serialize)]
307        struct Query {
308            counter_id: String,
309        }
310        self.get(
311            "/v1/quote/industry-valuation-comparison",
312            Query {
313                counter_id: symbol_to_counter_id(&symbol.into()),
314            },
315        )
316        .await
317    }
318
319    /// Get valuation distribution within the industry.
320    ///
321    /// Path: `GET /v1/quote/industry-valuation-distribution`
322    pub async fn industry_valuation_dist(
323        &self,
324        symbol: impl Into<String>,
325    ) -> Result<IndustryValuationDist> {
326        #[derive(Serialize)]
327        struct Query {
328            counter_id: String,
329        }
330        self.get(
331            "/v1/quote/industry-valuation-distribution",
332            Query {
333                counter_id: symbol_to_counter_id(&symbol.into()),
334            },
335        )
336        .await
337    }
338
339    // ── company ───────────────────────────────────────────────────
340
341    /// Get company overview information.
342    ///
343    /// Path: `GET /v1/quote/comp-overview`
344    pub async fn company(&self, symbol: impl Into<String>) -> Result<CompanyOverview> {
345        #[derive(Serialize)]
346        struct Query {
347            counter_id: String,
348        }
349        self.get(
350            "/v1/quote/comp-overview",
351            Query {
352                counter_id: symbol_to_counter_id(&symbol.into()),
353            },
354        )
355        .await
356    }
357
358    // ── executive ─────────────────────────────────────────────────
359
360    /// Get executive and board member information.
361    ///
362    /// Path: `GET /v1/quote/company-professionals`
363    pub async fn executive(&self, symbol: impl Into<String>) -> Result<ExecutiveList> {
364        #[derive(Serialize)]
365        struct Query {
366            counter_ids: String,
367        }
368        self.get(
369            "/v1/quote/company-professionals",
370            Query {
371                counter_ids: symbol_to_counter_id(&symbol.into()),
372            },
373        )
374        .await
375    }
376
377    // ── shareholder ───────────────────────────────────────────────
378
379    /// Get major shareholders for a security.
380    ///
381    /// Path: `GET /v1/quote/shareholders`
382    pub async fn shareholder(&self, symbol: impl Into<String>) -> Result<ShareholderList> {
383        #[derive(Serialize)]
384        struct Query {
385            counter_id: String,
386        }
387        self.get(
388            "/v1/quote/shareholders",
389            Query {
390                counter_id: symbol_to_counter_id(&symbol.into()),
391            },
392        )
393        .await
394    }
395
396    // ── fund_holder ───────────────────────────────────────────────
397
398    /// Get funds and ETFs that hold a security.
399    ///
400    /// Path: `GET /v1/quote/fund-holders`
401    pub async fn fund_holder(&self, symbol: impl Into<String>) -> Result<FundHolders> {
402        #[derive(Serialize)]
403        struct Query {
404            counter_id: String,
405        }
406        self.get(
407            "/v1/quote/fund-holders",
408            Query {
409                counter_id: symbol_to_counter_id(&symbol.into()),
410            },
411        )
412        .await
413    }
414
415    // ── corp_action ───────────────────────────────────────────────
416
417    /// Get corporate actions (dividends, splits, buybacks, etc.).
418    ///
419    /// Path: `GET /v1/quote/company-act`
420    pub async fn corp_action(&self, symbol: impl Into<String>) -> Result<CorpActions> {
421        #[derive(Serialize)]
422        struct Query {
423            counter_id: String,
424            req_type: &'static str,
425            version: &'static str,
426        }
427        self.get(
428            "/v1/quote/company-act",
429            Query {
430                counter_id: symbol_to_counter_id(&symbol.into()),
431                req_type: "1",
432                version: "3",
433            },
434        )
435        .await
436    }
437
438    // ── invest_relation ───────────────────────────────────────────
439
440    /// Get investor relations / investment holdings.
441    ///
442    /// Path: `GET /v1/quote/invest-relations`
443    pub async fn invest_relation(&self, symbol: impl Into<String>) -> Result<InvestRelations> {
444        #[derive(Serialize)]
445        struct Query {
446            counter_id: String,
447            count: &'static str,
448        }
449        self.get(
450            "/v1/quote/invest-relations",
451            Query {
452                counter_id: symbol_to_counter_id(&symbol.into()),
453                count: "0",
454            },
455        )
456        .await
457    }
458
459    // ── operating ─────────────────────────────────────────────────
460
461    /// Get operating metrics and financial report summaries.
462    ///
463    /// Path: `GET /v1/quote/operatings`
464    pub async fn operating(&self, symbol: impl Into<String>) -> Result<OperatingList> {
465        #[derive(Serialize)]
466        struct Query {
467            counter_id: String,
468        }
469        self.get(
470            "/v1/quote/operatings",
471            Query {
472                counter_id: symbol_to_counter_id(&symbol.into()),
473            },
474        )
475        .await
476    }
477
478    // ── buyback ───────────────────────────────────────────────────
479
480    /// Get buyback data for a security.
481    ///
482    /// Path: `GET /v1/quote/buy-backs`
483    pub async fn buyback(&self, symbol: impl Into<String>) -> Result<BuybackData> {
484        #[derive(Serialize)]
485        struct Query {
486            counter_id: String,
487        }
488        self.get(
489            "/v1/quote/buy-backs",
490            Query {
491                counter_id: symbol_to_counter_id(&symbol.into()),
492            },
493        )
494        .await
495    }
496
497    // ── ratings ───────────────────────────────────────────────────
498
499    /// Get stock ratings for a security.
500    ///
501    /// Path: `GET /v1/quote/ratings`
502    pub async fn ratings(&self, symbol: impl Into<String>) -> Result<StockRatings> {
503        #[derive(Serialize)]
504        struct Query {
505            counter_id: String,
506        }
507        self.get(
508            "/v1/quote/ratings",
509            Query {
510                counter_id: symbol_to_counter_id(&symbol.into()),
511            },
512        )
513        .await
514    }
515
516    // ── business_segments ────────────────────────────────────────
517
518    /// Get the latest business segment breakdown for a security.
519    ///
520    /// Path: `GET /v1/quote/fundamentals/business-segments`
521    pub async fn business_segments(&self, symbol: impl Into<String>) -> Result<BusinessSegments> {
522        #[derive(Serialize)]
523        struct Query {
524            counter_id: String,
525        }
526        self.get(
527            "/v1/quote/fundamentals/business-segments",
528            Query {
529                counter_id: symbol_to_counter_id(&symbol.into()),
530            },
531        )
532        .await
533    }
534
535    /// Get historical business segment breakdowns for a security.
536    ///
537    /// Path: `GET /v1/quote/fundamentals/business-segments/history`
538    pub async fn business_segments_history(
539        &self,
540        symbol: impl Into<String>,
541        report: Option<&'static str>,
542        cate: Option<String>,
543    ) -> Result<BusinessSegmentsHistory> {
544        #[derive(Serialize)]
545        struct Query {
546            counter_id: String,
547            #[serde(skip_serializing_if = "Option::is_none")]
548            report: Option<&'static str>,
549            #[serde(skip_serializing_if = "Option::is_none")]
550            cate: Option<String>,
551        }
552        self.get(
553            "/v1/quote/fundamentals/business-segments/history",
554            Query {
555                counter_id: symbol_to_counter_id(&symbol.into()),
556                report,
557                cate,
558            },
559        )
560        .await
561    }
562
563    // ── shareholder_top ───────────────────────────────────────────
564
565    /// Get a ranked list of top shareholders for a security.
566    ///
567    /// Path: `GET /v1/quote/shareholders/top`
568    pub async fn shareholder_top(
569        &self,
570        symbol: impl Into<String>,
571    ) -> Result<ShareholderTopResponse> {
572        #[derive(Serialize)]
573        struct Query {
574            counter_id: String,
575        }
576        let raw: serde_json::Value = self
577            .get(
578                "/v1/quote/shareholders/top",
579                Query {
580                    counter_id: symbol_to_counter_id(&symbol.into()),
581                },
582            )
583            .await?;
584        Ok(ShareholderTopResponse { data: raw })
585    }
586
587    // ── institution_rating_views ──────────────────────────────────
588
589    /// Get historical institutional rating view time-series for a security.
590    ///
591    /// Path: `GET /v1/quote/ratings/institutional`
592    pub async fn institution_rating_views(
593        &self,
594        symbol: impl Into<String>,
595    ) -> Result<InstitutionRatingViews> {
596        #[derive(Serialize)]
597        struct Query {
598            counter_id: String,
599        }
600        self.get(
601            "/v1/quote/ratings/institutional",
602            Query {
603                counter_id: symbol_to_counter_id(&symbol.into()),
604            },
605        )
606        .await
607    }
608
609    // ── shareholder_detail ────────────────────────────────────────
610
611    /// Get holding history and detail for one shareholder object.
612    ///
613    /// Path: `GET /v1/quote/shareholders/holding`
614    pub async fn shareholder_detail(
615        &self,
616        symbol: impl Into<String>,
617        object_id: i64,
618    ) -> Result<ShareholderDetailResponse> {
619        #[derive(Serialize)]
620        struct Query {
621            counter_id: String,
622            object_id: String,
623        }
624        let raw: serde_json::Value = self
625            .get(
626                "/v1/quote/shareholders/holding",
627                Query {
628                    counter_id: symbol_to_counter_id(&symbol.into()),
629                    object_id: object_id.to_string(),
630                },
631            )
632            .await?;
633        Ok(ShareholderDetailResponse { data: raw })
634    }
635
636    // ── industry_rank ─────────────────────────────────────────────
637
638    /// Get industry rank for a market.
639    ///
640    /// Path: `GET /v1/quote/industry/rank`
641    pub async fn industry_rank(
642        &self,
643        market: impl Into<String>,
644        indicator: impl Into<String>,
645        sort_type: impl Into<String>,
646        limit: u32,
647    ) -> Result<IndustryRankResponse> {
648        #[derive(Serialize)]
649        struct Query {
650            market: String,
651            indicator: String,
652            sort_type: String,
653            limit: u32,
654        }
655        self.get(
656            "/v1/quote/industry/rank",
657            Query {
658                market: market.into(),
659                indicator: indicator.into(),
660                sort_type: sort_type.into(),
661                limit,
662            },
663        )
664        .await
665    }
666
667    // ── industry_peers ────────────────────────────────────────────
668
669    /// Get the industry peer chain for a security or industry.
670    ///
671    /// Path: `GET /v1/quote/industries/peers`
672    pub async fn industry_peers(
673        &self,
674        counter_id: impl Into<String>,
675        market: impl Into<String>,
676        industry_id: Option<String>,
677    ) -> Result<IndustryPeersResponse> {
678        let raw = counter_id.into();
679        let cid = if raw.contains('/') {
680            raw
681        } else {
682            symbol_to_counter_id(&raw)
683        };
684        #[derive(Serialize)]
685        struct Query {
686            #[serde(rename = "type")]
687            kind: &'static str,
688            market: String,
689            industry_id: String,
690            counter_id: String,
691        }
692        self.get(
693            "/v1/quote/industries/peers",
694            Query {
695                kind: "1",
696                market: market.into(),
697                industry_id: industry_id.unwrap_or_default(),
698                counter_id: cid,
699            },
700        )
701        .await
702    }
703
704    // ── financial_report_snapshot ─────────────────────────────────
705
706    /// Get a financial report snapshot (earnings snapshot) for a security.
707    ///
708    /// Path: `GET /v1/quote/financials/earnings-snapshot`
709    pub async fn financial_report_snapshot(
710        &self,
711        symbol: impl Into<String>,
712        report: Option<&'static str>,
713        fiscal_year: Option<i32>,
714        fiscal_period: Option<&'static str>,
715    ) -> Result<FinancialReportSnapshot> {
716        #[derive(Serialize)]
717        struct Query {
718            counter_id: String,
719            #[serde(skip_serializing_if = "Option::is_none")]
720            report: Option<&'static str>,
721            #[serde(skip_serializing_if = "Option::is_none")]
722            fiscal_year: Option<i32>,
723            #[serde(skip_serializing_if = "Option::is_none")]
724            fiscal_period: Option<&'static str>,
725        }
726        self.get(
727            "/v1/quote/financials/earnings-snapshot",
728            Query {
729                counter_id: symbol_to_counter_id(&symbol.into()),
730                report,
731                fiscal_year,
732                fiscal_period,
733            },
734        )
735        .await
736    }
737
738    // ── valuation_comparison ──────────────────────────────────────
739
740    /// Get valuation comparison between a security and optional peers.
741    ///
742    /// Path: `GET /v1/quote/compare/valuation`
743    pub async fn valuation_comparison(
744        &self,
745        symbol: impl Into<String>,
746        currency: impl Into<String>,
747        comparison_symbols: Option<Vec<String>>,
748    ) -> Result<ValuationComparisonResponse> {
749        #[derive(Serialize)]
750        struct Query {
751            counter_id: String,
752            currency: String,
753            #[serde(skip_serializing_if = "Option::is_none")]
754            comparison_counter_ids: Option<String>,
755        }
756        let comparison_counter_ids = comparison_symbols.map(|syms| {
757            let ids: Vec<String> = syms.iter().map(|s| symbol_to_counter_id(s)).collect();
758            serde_json::to_string(&ids).unwrap_or_default()
759        });
760        let raw: serde_json::Value = self
761            .get(
762                "/v1/quote/compare/valuation",
763                Query {
764                    counter_id: symbol_to_counter_id(&symbol.into()),
765                    currency: currency.into(),
766                    comparison_counter_ids,
767                },
768            )
769            .await?;
770        let list = raw["list"]
771            .as_array()
772            .cloned()
773            .unwrap_or_default()
774            .into_iter()
775            .map(|item| {
776                let history = item["history"]
777                    .as_array()
778                    .cloned()
779                    .unwrap_or_default()
780                    .into_iter()
781                    .map(|h| ValuationHistoryPoint {
782                        date: unix_secs_str_to_rfc3339(h["date"].as_str().unwrap_or("")),
783                        pe: h["pe"].as_str().unwrap_or("").to_string(),
784                        pb: h["pb"].as_str().unwrap_or("").to_string(),
785                        ps: h["ps"].as_str().unwrap_or("").to_string(),
786                    })
787                    .collect();
788                ValuationComparisonItem {
789                    symbol: counter_id_to_symbol(item["counter_id"].as_str().unwrap_or("")),
790                    name: item["name"].as_str().unwrap_or("").to_string(),
791                    currency: item["currency"].as_str().unwrap_or("").to_string(),
792                    market_value: item["market_value"].as_str().unwrap_or("").to_string(),
793                    price_close: item["price_close"].as_str().unwrap_or("").to_string(),
794                    pe: item["pe"].as_str().unwrap_or("").to_string(),
795                    pb: item["pb"].as_str().unwrap_or("").to_string(),
796                    ps: item["ps"].as_str().unwrap_or("").to_string(),
797                    roe: item["roe"].as_str().unwrap_or("").to_string(),
798                    eps: item["eps"].as_str().unwrap_or("").to_string(),
799                    bps: item["bps"].as_str().unwrap_or("").to_string(),
800                    dps: item["dps"].as_str().unwrap_or("").to_string(),
801                    div_yld: item["div_yld"].as_str().unwrap_or("").to_string(),
802                    assets: item["assets"].as_str().unwrap_or("").to_string(),
803                    history,
804                }
805            })
806            .collect();
807        Ok(ValuationComparisonResponse { list })
808    }
809
810    // ── etf_asset_allocation ─────────────────────────────────────
811
812    /// Get ETF asset allocation (holdings / regional / asset class /
813    /// industry).
814    ///
815    /// Path: `GET /v1/quote/etf-asset-allocation`
816    pub async fn etf_asset_allocation(
817        &self,
818        symbol: impl Into<String>,
819    ) -> Result<AssetAllocationResponse> {
820        #[derive(Serialize)]
821        struct Query {
822            counter_id: String,
823        }
824        self.get(
825            "/v1/quote/etf-asset-allocation",
826            Query {
827                counter_id: symbol_to_counter_id(&symbol.into()),
828            },
829        )
830        .await
831    }
832
833    // ── macroeconomic ────────────────────────────────────────────────
834
835    /// List macroeconomic indicators.
836    ///
837    /// `country` accepts a market code string (e.g. `"US"`, `"HK"`, `"ALL"`).
838    /// `keyword` optionally filters indicators by name (fuzzy,
839    /// case-insensitive). `offset` and `limit` are kept for backward
840    /// compatibility but ignored by v2.
841    ///
842    /// Path: `GET /v2/quote/macrodata`
843    pub async fn macroeconomic_indicators(
844        &self,
845        country: Option<MacroeconomicCountry>,
846        keyword: Option<impl Into<String>>,
847        offset: Option<i32>,
848        limit: Option<i32>,
849    ) -> Result<MacroeconomicIndicatorListResponse> {
850        self.macroeconomic_indicators_v2(country, keyword, offset, limit)
851            .await
852    }
853
854    /// List macroeconomic indicators (v2) with optional keyword filter.
855    ///
856    /// Path: `GET /v2/quote/macrodata`
857    pub(crate) async fn macroeconomic_indicators_v2(
858        &self,
859        country: Option<MacroeconomicCountry>,
860        keyword: Option<impl Into<String>>,
861        offset: Option<i32>,
862        limit: Option<i32>,
863    ) -> Result<MacroeconomicIndicatorListResponse> {
864        #[derive(Serialize)]
865        struct Query {
866            market: String,
867            #[serde(skip_serializing_if = "Option::is_none")]
868            keyword: Option<String>,
869            #[serde(skip_serializing_if = "Option::is_none")]
870            offset: Option<i32>,
871            #[serde(skip_serializing_if = "Option::is_none")]
872            limit: Option<i32>,
873        }
874        let market = country
875            .map(|c| match c {
876                MacroeconomicCountry::HongKong => "HK",
877                MacroeconomicCountry::China => "CN",
878                MacroeconomicCountry::UnitedStates => "US",
879                MacroeconomicCountry::EuroZone => "EU",
880                MacroeconomicCountry::Japan => "JP",
881                MacroeconomicCountry::Singapore => "SG",
882            })
883            .unwrap_or("ALL")
884            .to_string();
885
886        let raw: V2MacroIndicatorListResponse = self
887            .get(
888                "/v2/quote/macrodata",
889                Query {
890                    market,
891                    keyword: keyword.map(|k| k.into()),
892                    offset,
893                    limit,
894                },
895            )
896            .await?;
897
898        let total = raw.total;
899        let data = raw
900            .indicator_list
901            .into_iter()
902            .map(|ind| MacroeconomicIndicator {
903                indicator_code: ind.indicator_id.to_string(),
904                country: ind.market,
905                name: ind.indicator_name,
906                periodicity: ind.frequence,
907                describe: ind.description,
908                importance: ind.importance,
909                ..Default::default()
910            })
911            .collect::<Vec<_>>();
912        let count = if total > 0 { total } else { data.len() as i32 };
913        Ok(MacroeconomicIndicatorListResponse { data, count })
914    }
915
916    /// Get historical data for a macroeconomic indicator.
917    ///
918    /// `indicator_code` is the indicator ID (integer as string in v2).
919    /// `start_date` and `end_date` are `"YYYY-MM-DD"` format.
920    /// `sort` can be `"asc"` or `"desc"` (new in v2).
921    ///
922    /// Path: `GET /v2/quote/macrodata/{indicator_id}`
923    pub async fn macroeconomic(
924        &self,
925        indicator_code: impl Into<String>,
926        start_date: Option<impl Into<String>>,
927        end_date: Option<impl Into<String>>,
928        offset: Option<i32>,
929        limit: Option<i32>,
930    ) -> Result<MacroeconomicResponse> {
931        self.macroeconomic_v2(
932            indicator_code,
933            start_date,
934            end_date,
935            offset,
936            limit,
937            None::<String>,
938        )
939        .await
940    }
941
942    /// Get historical data for a macroeconomic indicator (v2) with sort
943    /// support.
944    ///
945    /// Path: `GET /v2/quote/macrodata/{indicator_id}`
946    pub(crate) async fn macroeconomic_v2(
947        &self,
948        indicator_code: impl Into<String>,
949        start_date: Option<impl Into<String>>,
950        end_date: Option<impl Into<String>>,
951        offset: Option<i32>,
952        limit: Option<i32>,
953        sort: Option<impl Into<String>>,
954    ) -> Result<MacroeconomicResponse> {
955        #[derive(Serialize)]
956        struct Query {
957            #[serde(skip_serializing_if = "Option::is_none")]
958            start_date: Option<String>,
959            #[serde(skip_serializing_if = "Option::is_none")]
960            end_date: Option<String>,
961            #[serde(skip_serializing_if = "Option::is_none")]
962            offset: Option<i32>,
963            #[serde(skip_serializing_if = "Option::is_none")]
964            limit: Option<i32>,
965            #[serde(skip_serializing_if = "Option::is_none")]
966            sort: Option<String>,
967        }
968        let path = format!("/v2/quote/macrodata/{}", indicator_code.into());
969        let raw: V2MacroIndicatorDataResponse = self
970            .0
971            .http_cli
972            .request(Method::GET, path)
973            .query_params(Query {
974                start_date: start_date.map(|d| d.into()),
975                end_date: end_date.map(|d| d.into()),
976                offset,
977                limit,
978                sort: Some(sort.map(|s| s.into()).unwrap_or_else(|| "desc".to_string())),
979            })
980            .response::<Json<V2MacroIndicatorDataResponse>>()
981            .send()
982            .with_subscriber(self.0.log_subscriber.clone())
983            .await?
984            .0;
985
986        let total = raw.total;
987        let detail = raw.indicator;
988        let unit_english = detail.unit.clone();
989        let count = detail.indicator_data.len() as i32;
990
991        let info = MacroeconomicIndicator {
992            indicator_code: detail.indicator_id.to_string(),
993            country: detail.market,
994            name: detail.indicator_name,
995            describe: detail.description,
996            periodicity: detail.frequence,
997            importance: detail.importance,
998            ..Default::default()
999        };
1000
1001        let data = detail
1002            .indicator_data
1003            .into_iter()
1004            .map(|d| {
1005                use time::format_description::well_known::Rfc3339;
1006                let release_at = time::OffsetDateTime::parse(&d.published_time, &Rfc3339)
1007                    .ok()
1008                    .or_else(|| {
1009                        // Try without timezone suffix
1010                        time::PrimitiveDateTime::parse(
1011                            &d.published_time,
1012                            &time::macros::format_description!(
1013                                "[year]-[month]-[day]T[hour]:[minute]:[second]"
1014                            ),
1015                        )
1016                        .ok()
1017                        .map(|dt| dt.assume_utc())
1018                    });
1019                Macroeconomic {
1020                    period: d.observation_date,
1021                    release_at,
1022                    actual_value: d.actual_data,
1023                    previous_value: d.previous_data,
1024                    forecast_value: d.estimated_data,
1025                    unit: unit_english.clone(),
1026                    ..Default::default()
1027                }
1028            })
1029            .collect();
1030
1031        let count = if total > 0 { total } else { count };
1032        Ok(MacroeconomicResponse { info, data, count })
1033    }
1034}