Skip to main content

rustrade_data/exchange/massive/
reference.rs

1//! Reference data endpoints for Massive API.
2//!
3//! Provides access to ticker information, exchanges, market status, and holidays.
4
5use super::error::MassiveError;
6use super::pagination::PaginationGuard;
7use super::rest::MassiveRestClient;
8use async_stream::try_stream;
9use chrono::{DateTime, NaiveDate, Utc};
10use futures::Stream;
11use rust_decimal::Decimal;
12use serde::Deserialize;
13use std::collections::HashMap;
14use tracing::{debug, warn};
15
16// ============================================================================
17// Query Builders
18// ============================================================================
19
20/// Sort order for paginated results.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub enum SortOrder {
23    #[default]
24    Asc,
25    Desc,
26}
27
28impl SortOrder {
29    fn as_str(self) -> &'static str {
30        match self {
31            SortOrder::Asc => "asc",
32            SortOrder::Desc => "desc",
33        }
34    }
35}
36
37impl std::fmt::Display for SortOrder {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(self.as_str())
40    }
41}
42
43/// Filter parameters for the `/v3/reference/tickers` endpoint.
44///
45/// All fields are optional. Construct with [`TickerQuery::new`] and chain
46/// setters. The stream handles pagination automatically.
47///
48/// # Example
49///
50/// ```ignore
51/// use rustrade_data::exchange::massive::{MassiveRestClient, TickerQuery};
52///
53/// let client = MassiveRestClient::from_env()?;
54/// let query = TickerQuery::new()
55///     .market("stocks")
56///     .active(true)
57///     .limit(500);
58///
59/// let mut stream = client.fetch_tickers(&query);
60/// while let Some(ticker) = stream.next().await {
61///     println!("{:?}", ticker?);
62/// }
63/// ```
64#[derive(Debug, Default, Clone)]
65pub struct TickerQuery {
66    /// Filter by ticker symbol (exact match or prefix).
67    pub ticker: Option<String>,
68    /// Filter by asset type (CS, ETF, ADRC, etc.).
69    pub asset_type: Option<String>,
70    /// Filter by market (stocks, crypto, fx, otc, indices).
71    pub market: Option<String>,
72    /// Filter by primary exchange MIC code (XNYS, XNAS, etc.).
73    pub exchange: Option<String>,
74    /// Filter by active trading status.
75    pub active: Option<bool>,
76    /// Text search across ticker and company name.
77    pub search: Option<String>,
78    /// Results per page (max 1000).
79    pub limit: Option<u16>,
80    /// Sort direction.
81    pub order: Option<SortOrder>,
82    /// Field to sort by (e.g., "ticker", "name").
83    pub sort: Option<String>,
84    /// Ticker greater than or equal to.
85    pub ticker_gte: Option<String>,
86    /// Ticker greater than.
87    pub ticker_gt: Option<String>,
88    /// Ticker less than or equal to.
89    pub ticker_lte: Option<String>,
90    /// Ticker less than.
91    pub ticker_lt: Option<String>,
92}
93
94impl TickerQuery {
95    /// Create a new empty query (all tickers, default pagination).
96    #[must_use]
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Filter by exact ticker symbol or prefix.
102    #[must_use]
103    pub fn ticker(mut self, v: impl Into<String>) -> Self {
104        self.ticker = Some(v.into());
105        self
106    }
107
108    /// Filter by asset type (CS, ETF, ADRC, etc.).
109    #[must_use]
110    pub fn asset_type(mut self, v: impl Into<String>) -> Self {
111        self.asset_type = Some(v.into());
112        self
113    }
114
115    /// Filter by market (stocks, crypto, fx, otc, indices).
116    #[must_use]
117    pub fn market(mut self, v: impl Into<String>) -> Self {
118        self.market = Some(v.into());
119        self
120    }
121
122    /// Filter by primary exchange MIC code (XNYS, XNAS, etc.).
123    #[must_use]
124    pub fn exchange(mut self, v: impl Into<String>) -> Self {
125        self.exchange = Some(v.into());
126        self
127    }
128
129    /// Filter by active trading status.
130    #[must_use]
131    pub fn active(mut self, v: bool) -> Self {
132        self.active = Some(v);
133        self
134    }
135
136    /// Text search across ticker and company name.
137    #[must_use]
138    pub fn search(mut self, v: impl Into<String>) -> Self {
139        self.search = Some(v.into());
140        self
141    }
142
143    /// Set results per page (clamped to max 1000).
144    #[must_use]
145    pub fn limit(mut self, v: u16) -> Self {
146        self.limit = Some(v.min(1000));
147        self
148    }
149
150    /// Set sort direction.
151    #[must_use]
152    pub fn order(mut self, v: SortOrder) -> Self {
153        self.order = Some(v);
154        self
155    }
156
157    /// Set field to sort by.
158    #[must_use]
159    pub fn sort(mut self, v: impl Into<String>) -> Self {
160        self.sort = Some(v.into());
161        self
162    }
163
164    /// Filter tickers >= value.
165    #[must_use]
166    pub fn ticker_gte(mut self, v: impl Into<String>) -> Self {
167        self.ticker_gte = Some(v.into());
168        self
169    }
170
171    /// Filter tickers > value.
172    #[must_use]
173    pub fn ticker_gt(mut self, v: impl Into<String>) -> Self {
174        self.ticker_gt = Some(v.into());
175        self
176    }
177
178    /// Filter tickers <= value.
179    #[must_use]
180    pub fn ticker_lte(mut self, v: impl Into<String>) -> Self {
181        self.ticker_lte = Some(v.into());
182        self
183    }
184
185    /// Filter tickers < value.
186    #[must_use]
187    pub fn ticker_lt(mut self, v: impl Into<String>) -> Self {
188        self.ticker_lt = Some(v.into());
189        self
190    }
191
192    /// Build query string parameters.
193    fn to_query_string(&self) -> String {
194        let mut pairs: Vec<(&str, String)> = Vec::new();
195
196        if let Some(ref v) = self.ticker {
197            pairs.push(("ticker", v.clone()));
198        }
199        if let Some(ref v) = self.asset_type {
200            pairs.push(("type", v.clone()));
201        }
202        if let Some(ref v) = self.market {
203            pairs.push(("market", v.clone()));
204        }
205        if let Some(ref v) = self.exchange {
206            pairs.push(("exchange", v.clone()));
207        }
208        if let Some(v) = self.active {
209            pairs.push(("active", v.to_string()));
210        }
211        if let Some(ref v) = self.search {
212            pairs.push(("search", v.clone()));
213        }
214        if let Some(v) = self.limit {
215            pairs.push(("limit", v.to_string()));
216        }
217        if let Some(v) = self.order {
218            pairs.push(("order", v.as_str().to_string()));
219        }
220        if let Some(ref v) = self.sort {
221            pairs.push(("sort", v.clone()));
222        }
223        if let Some(ref v) = self.ticker_gte {
224            pairs.push(("ticker.gte", v.clone()));
225        }
226        if let Some(ref v) = self.ticker_gt {
227            pairs.push(("ticker.gt", v.clone()));
228        }
229        if let Some(ref v) = self.ticker_lte {
230            pairs.push(("ticker.lte", v.clone()));
231        }
232        if let Some(ref v) = self.ticker_lt {
233            pairs.push(("ticker.lt", v.clone()));
234        }
235
236        if pairs.is_empty() {
237            String::new()
238        } else {
239            let encoded: Vec<String> = pairs
240                .into_iter()
241                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
242                .collect();
243            format!("?{}", encoded.join("&"))
244        }
245    }
246}
247
248// ============================================================================
249// Response Types
250// ============================================================================
251
252/// Ticker summary from the tickers list endpoint.
253#[derive(Debug, Clone, PartialEq)]
254pub struct Ticker {
255    /// Ticker symbol.
256    pub ticker: String,
257    /// Company or asset name.
258    pub name: String,
259    /// Market category (stocks, crypto, fx, etc.).
260    pub market: String,
261    /// Asset type (CS, ETF, CRYPTO, etc.).
262    pub asset_type: String,
263    /// Whether the ticker is actively traded.
264    pub active: bool,
265    /// Primary exchange MIC code.
266    pub primary_exchange: Option<String>,
267    /// Trading currency.
268    pub currency_name: Option<String>,
269    /// Composite FIGI identifier.
270    pub composite_figi: Option<String>,
271    /// Locale (us, global).
272    pub locale: Option<String>,
273    /// Last update timestamp.
274    pub last_updated_utc: Option<DateTime<Utc>>,
275}
276
277/// Detailed ticker information from the ticker details endpoint.
278#[derive(Debug, Clone, PartialEq)]
279pub struct TickerDetails {
280    /// Basic ticker info.
281    pub ticker: Ticker,
282    /// Company description.
283    pub description: Option<String>,
284    /// Company homepage URL.
285    pub homepage_url: Option<String>,
286    /// Total number of employees.
287    pub total_employees: Option<u64>,
288    /// Market capitalization.
289    pub market_cap: Option<Decimal>,
290    /// Phone number.
291    pub phone_number: Option<String>,
292    /// Company address.
293    pub address: Option<Address>,
294    /// SIC code.
295    pub sic_code: Option<String>,
296    /// SIC description.
297    pub sic_description: Option<String>,
298    /// Ticker root (e.g., AAPL for AAPL).
299    pub ticker_root: Option<String>,
300    /// IPO/listing date.
301    pub list_date: Option<NaiveDate>,
302    /// Delisting date (if delisted).
303    pub delisted_utc: Option<DateTime<Utc>>,
304    /// Shares outstanding.
305    pub share_class_shares_outstanding: Option<u64>,
306    /// Weighted shares outstanding.
307    pub weighted_shares_outstanding: Option<u64>,
308    /// Round lot size.
309    pub round_lot: Option<u32>,
310}
311
312/// Company address.
313#[derive(Debug, Clone, PartialEq, Default)]
314pub struct Address {
315    pub address1: Option<String>,
316    pub city: Option<String>,
317    pub state: Option<String>,
318    pub postal_code: Option<String>,
319}
320
321/// Exchange information.
322#[derive(Debug, Clone, PartialEq)]
323pub struct Exchange {
324    /// Internal exchange ID.
325    pub id: i64,
326    /// Exchange name.
327    pub name: String,
328    /// Exchange acronym (NYSE, NASDAQ, etc.).
329    pub acronym: Option<String>,
330    /// Market Identifier Code (ISO 10383).
331    pub mic: Option<String>,
332    /// Operating MIC.
333    pub operating_mic: Option<String>,
334    /// Asset class (stocks, crypto, fx, etc.).
335    pub asset_class: String,
336    /// Locale (us, global).
337    pub locale: String,
338    /// Exchange type.
339    pub exchange_type: Option<String>,
340    /// Exchange URL.
341    pub url: Option<String>,
342}
343
344/// Current market status.
345#[derive(Debug, Clone, PartialEq)]
346pub struct MarketStatus {
347    /// Overall market status.
348    pub market: String,
349    /// Server timestamp.
350    pub server_time: DateTime<Utc>,
351    /// Whether after-hours trading is active.
352    pub after_hours: bool,
353    /// Whether early/pre-market trading is active.
354    pub early_hours: bool,
355    /// Status of currency markets.
356    pub currencies: CurrencyStatus,
357    /// Status of individual exchanges.
358    pub exchanges: HashMap<String, String>,
359    /// Status of index groups.
360    pub indices_groups: HashMap<String, String>,
361}
362
363/// Currency market status.
364#[derive(Debug, Clone, PartialEq, Default)]
365pub struct CurrencyStatus {
366    /// Crypto market status.
367    pub crypto: String,
368    /// Forex market status.
369    pub fx: String,
370}
371
372/// Market holiday information.
373#[derive(Debug, Clone, PartialEq)]
374pub struct MarketHoliday {
375    /// Holiday date.
376    pub date: NaiveDate,
377    /// Exchange code.
378    pub exchange: String,
379    /// Holiday name.
380    pub name: String,
381    /// Status: "closed" or "early-close".
382    pub status: String,
383    /// Trading open time (for early-close days).
384    pub open: Option<DateTime<Utc>>,
385    /// Trading close time (for early-close days).
386    pub close: Option<DateTime<Utc>>,
387}
388
389/// Dividend payment frequency.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
391pub enum DividendFrequency {
392    /// Once per year.
393    Annual,
394    /// Twice per year.
395    SemiAnnual,
396    /// Four times per year.
397    Quarterly,
398    /// Twelve times per year.
399    Monthly,
400    /// Frequency not specified or unrecognized.
401    Unknown(u8),
402}
403
404impl DividendFrequency {
405    /// Create from raw API value.
406    #[must_use]
407    pub fn from_raw(value: u8) -> Self {
408        match value {
409            1 => Self::Annual,
410            2 => Self::SemiAnnual,
411            4 => Self::Quarterly,
412            12 => Self::Monthly,
413            other => Self::Unknown(other),
414        }
415    }
416
417    /// Convert to raw API value.
418    #[must_use]
419    pub fn to_raw(self) -> u8 {
420        match self {
421            Self::Annual => 1,
422            Self::SemiAnnual => 2,
423            Self::Quarterly => 4,
424            Self::Monthly => 12,
425            Self::Unknown(v) => v,
426        }
427    }
428}
429
430impl std::fmt::Display for DividendFrequency {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        match self {
433            Self::Annual => write!(f, "annual"),
434            Self::SemiAnnual => write!(f, "semi-annual"),
435            Self::Quarterly => write!(f, "quarterly"),
436            Self::Monthly => write!(f, "monthly"),
437            Self::Unknown(v) => write!(f, "unknown({v})"),
438        }
439    }
440}
441
442/// Dividend information.
443#[derive(Debug, Clone, PartialEq)]
444pub struct Dividend {
445    /// Ticker symbol.
446    pub ticker: String,
447    /// Cash amount per share.
448    pub cash_amount: Decimal,
449    /// Currency of the dividend.
450    pub currency: String,
451    /// Declaration date.
452    pub declaration_date: Option<NaiveDate>,
453    /// Ex-dividend date (must hold shares before this date).
454    pub ex_dividend_date: NaiveDate,
455    /// Record date.
456    pub record_date: Option<NaiveDate>,
457    /// Payment date.
458    pub pay_date: Option<NaiveDate>,
459    /// Dividend frequency.
460    pub frequency: Option<DividendFrequency>,
461    /// Distribution type (CD=regular, SC=special, LT=long-term, ST=short-term).
462    pub dividend_type: Option<String>,
463}
464
465/// Stock split information.
466#[derive(Debug, Clone, PartialEq)]
467pub struct StockSplit {
468    /// Ticker symbol.
469    pub ticker: String,
470    /// Execution date of the split.
471    pub execution_date: NaiveDate,
472    /// Split ratio numerator (shares after split).
473    pub split_to: Decimal,
474    /// Split ratio denominator (shares before split).
475    pub split_from: Decimal,
476}
477
478// ============================================================================
479// Query Builders — Corporate Actions
480// ============================================================================
481
482/// Filter parameters for the `/v3/reference/dividends` endpoint.
483#[derive(Debug, Default, Clone)]
484pub struct DividendQuery {
485    /// Filter by ticker symbol.
486    pub ticker: Option<String>,
487    /// Filter by ex-dividend date (exact).
488    pub ex_dividend_date: Option<NaiveDate>,
489    /// Filter by ex-dividend date >= value.
490    pub ex_dividend_date_gte: Option<NaiveDate>,
491    /// Filter by ex-dividend date <= value.
492    pub ex_dividend_date_lte: Option<NaiveDate>,
493    /// Filter by dividend type (CD, SC, LT, ST).
494    pub dividend_type: Option<String>,
495    /// Results per page (max 1000).
496    pub limit: Option<u16>,
497    /// Sort direction.
498    pub order: Option<SortOrder>,
499}
500
501impl DividendQuery {
502    /// Create a new empty query.
503    #[must_use]
504    pub fn new() -> Self {
505        Self::default()
506    }
507
508    /// Filter by ticker symbol.
509    #[must_use]
510    pub fn ticker(mut self, v: impl Into<String>) -> Self {
511        self.ticker = Some(v.into());
512        self
513    }
514
515    /// Filter by exact ex-dividend date.
516    #[must_use]
517    pub fn ex_dividend_date(mut self, v: NaiveDate) -> Self {
518        self.ex_dividend_date = Some(v);
519        self
520    }
521
522    /// Filter by ex-dividend date >= value.
523    #[must_use]
524    pub fn ex_dividend_date_gte(mut self, v: NaiveDate) -> Self {
525        self.ex_dividend_date_gte = Some(v);
526        self
527    }
528
529    /// Filter by ex-dividend date <= value.
530    #[must_use]
531    pub fn ex_dividend_date_lte(mut self, v: NaiveDate) -> Self {
532        self.ex_dividend_date_lte = Some(v);
533        self
534    }
535
536    /// Filter by dividend type (CD=regular, SC=special, LT=long-term, ST=short-term).
537    #[must_use]
538    pub fn dividend_type(mut self, v: impl Into<String>) -> Self {
539        self.dividend_type = Some(v.into());
540        self
541    }
542
543    /// Set results per page (clamped to max 1000).
544    #[must_use]
545    pub fn limit(mut self, v: u16) -> Self {
546        self.limit = Some(v.min(1000));
547        self
548    }
549
550    /// Set sort direction.
551    #[must_use]
552    pub fn order(mut self, v: SortOrder) -> Self {
553        self.order = Some(v);
554        self
555    }
556
557    /// Validate the query for conflicting filters.
558    ///
559    /// Returns an error if both an exact `ex_dividend_date` and a range filter
560    /// (`ex_dividend_date_gte` or `ex_dividend_date_lte`) are set. The API behavior
561    /// when both are provided is undefined.
562    pub fn validate(&self) -> Result<(), MassiveError> {
563        let has_exact = self.ex_dividend_date.is_some();
564        let has_range = self.ex_dividend_date_gte.is_some() || self.ex_dividend_date_lte.is_some();
565
566        if has_exact && has_range {
567            return Err(MassiveError::InvalidInput {
568                message: "DividendQuery: cannot set both ex_dividend_date (exact) and \
569                          ex_dividend_date_gte/lte (range) filters"
570                    .into(),
571            });
572        }
573        Ok(())
574    }
575
576    /// Build query string parameters.
577    fn to_query_string(&self) -> String {
578        let mut pairs: Vec<(&str, String)> = Vec::new();
579
580        if let Some(ref v) = self.ticker {
581            pairs.push(("ticker", v.clone()));
582        }
583        if let Some(v) = self.ex_dividend_date {
584            pairs.push(("ex_dividend_date", v.to_string()));
585        }
586        if let Some(v) = self.ex_dividend_date_gte {
587            pairs.push(("ex_dividend_date.gte", v.to_string()));
588        }
589        if let Some(v) = self.ex_dividend_date_lte {
590            pairs.push(("ex_dividend_date.lte", v.to_string()));
591        }
592        if let Some(ref v) = self.dividend_type {
593            pairs.push(("dividend_type", v.clone()));
594        }
595        if let Some(v) = self.limit {
596            pairs.push(("limit", v.to_string()));
597        }
598        if let Some(v) = self.order {
599            pairs.push(("order", v.as_str().to_string()));
600        }
601
602        if pairs.is_empty() {
603            String::new()
604        } else {
605            let encoded: Vec<String> = pairs
606                .into_iter()
607                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
608                .collect();
609            format!("?{}", encoded.join("&"))
610        }
611    }
612}
613
614/// Filter parameters for the `/v3/reference/splits` endpoint.
615#[derive(Debug, Default, Clone)]
616pub struct SplitQuery {
617    /// Filter by ticker symbol.
618    pub ticker: Option<String>,
619    /// Filter by execution date (exact).
620    pub execution_date: Option<NaiveDate>,
621    /// Filter by execution date >= value.
622    pub execution_date_gte: Option<NaiveDate>,
623    /// Filter by execution date <= value.
624    pub execution_date_lte: Option<NaiveDate>,
625    /// Results per page (max 1000).
626    pub limit: Option<u16>,
627    /// Sort direction.
628    pub order: Option<SortOrder>,
629}
630
631impl SplitQuery {
632    /// Create a new empty query.
633    #[must_use]
634    pub fn new() -> Self {
635        Self::default()
636    }
637
638    /// Filter by ticker symbol.
639    #[must_use]
640    pub fn ticker(mut self, v: impl Into<String>) -> Self {
641        self.ticker = Some(v.into());
642        self
643    }
644
645    /// Filter by exact execution date.
646    #[must_use]
647    pub fn execution_date(mut self, v: NaiveDate) -> Self {
648        self.execution_date = Some(v);
649        self
650    }
651
652    /// Filter by execution date >= value.
653    #[must_use]
654    pub fn execution_date_gte(mut self, v: NaiveDate) -> Self {
655        self.execution_date_gte = Some(v);
656        self
657    }
658
659    /// Filter by execution date <= value.
660    #[must_use]
661    pub fn execution_date_lte(mut self, v: NaiveDate) -> Self {
662        self.execution_date_lte = Some(v);
663        self
664    }
665
666    /// Set results per page (clamped to max 1000).
667    #[must_use]
668    pub fn limit(mut self, v: u16) -> Self {
669        self.limit = Some(v.min(1000));
670        self
671    }
672
673    /// Set sort direction.
674    #[must_use]
675    pub fn order(mut self, v: SortOrder) -> Self {
676        self.order = Some(v);
677        self
678    }
679
680    /// Validate the query for conflicting filters.
681    ///
682    /// Returns an error if both an exact `execution_date` and a range filter
683    /// (`execution_date_gte` or `execution_date_lte`) are set. The API behavior
684    /// when both are provided is undefined.
685    pub fn validate(&self) -> Result<(), MassiveError> {
686        let has_exact = self.execution_date.is_some();
687        let has_range = self.execution_date_gte.is_some() || self.execution_date_lte.is_some();
688
689        if has_exact && has_range {
690            return Err(MassiveError::InvalidInput {
691                message: "SplitQuery: cannot set both execution_date (exact) and \
692                          execution_date_gte/lte (range) filters"
693                    .into(),
694            });
695        }
696        Ok(())
697    }
698
699    /// Build query string parameters.
700    fn to_query_string(&self) -> String {
701        let mut pairs: Vec<(&str, String)> = Vec::new();
702
703        if let Some(ref v) = self.ticker {
704            pairs.push(("ticker", v.clone()));
705        }
706        if let Some(v) = self.execution_date {
707            pairs.push(("execution_date", v.to_string()));
708        }
709        if let Some(v) = self.execution_date_gte {
710            pairs.push(("execution_date.gte", v.to_string()));
711        }
712        if let Some(v) = self.execution_date_lte {
713            pairs.push(("execution_date.lte", v.to_string()));
714        }
715        if let Some(v) = self.limit {
716            pairs.push(("limit", v.to_string()));
717        }
718        if let Some(v) = self.order {
719            pairs.push(("order", v.as_str().to_string()));
720        }
721
722        if pairs.is_empty() {
723            String::new()
724        } else {
725            let encoded: Vec<String> = pairs
726                .into_iter()
727                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
728                .collect();
729            format!("?{}", encoded.join("&"))
730        }
731    }
732}
733
734// ============================================================================
735// Internal Deserialization Types
736// ============================================================================
737
738#[derive(Deserialize)]
739struct TickersResponse {
740    results: Option<Vec<RawTicker>>,
741    next_url: Option<String>,
742    // Deserialized to satisfy serde; not surfaced in public type.
743    #[allow(dead_code)]
744    status: Option<String>,
745    // Deserialized to satisfy serde; not surfaced in public type.
746    #[allow(dead_code)]
747    count: Option<u32>,
748}
749
750#[derive(Deserialize)]
751struct RawTicker {
752    ticker: String,
753    name: Option<String>,
754    market: Option<String>,
755    #[serde(rename = "type")]
756    asset_type: Option<String>,
757    active: Option<bool>,
758    primary_exchange: Option<String>,
759    currency_name: Option<String>,
760    composite_figi: Option<String>,
761    locale: Option<String>,
762    last_updated_utc: Option<String>,
763}
764
765impl RawTicker {
766    fn into_ticker(self) -> Ticker {
767        Ticker {
768            ticker: self.ticker,
769            name: self.name.unwrap_or_default(),
770            market: self.market.unwrap_or_default(),
771            asset_type: self.asset_type.unwrap_or_default(),
772            active: self.active.unwrap_or(true),
773            primary_exchange: self.primary_exchange,
774            currency_name: self.currency_name,
775            composite_figi: self.composite_figi,
776            locale: self.locale,
777            last_updated_utc: self.last_updated_utc.and_then(parse_rfc3339_or_warn),
778        }
779    }
780}
781
782/// Parse an RFC3339 timestamp, logging a warning and returning `None` on failure.
783fn parse_rfc3339_or_warn(s: String) -> Option<DateTime<Utc>> {
784    match DateTime::parse_from_rfc3339(&s) {
785        Ok(dt) => Some(dt.with_timezone(&Utc)),
786        Err(e) => {
787            warn!(value = %s, error = %e, "Failed to parse RFC3339 timestamp from Massive API");
788            None
789        }
790    }
791}
792
793#[derive(Deserialize)]
794struct TickerDetailsResponse {
795    results: Option<RawTickerDetails>,
796    // Deserialized to satisfy serde; not surfaced in public type.
797    #[allow(dead_code)]
798    status: Option<String>,
799}
800
801#[derive(Deserialize)]
802struct RawTickerDetails {
803    ticker: String,
804    name: Option<String>,
805    market: Option<String>,
806    #[serde(rename = "type")]
807    asset_type: Option<String>,
808    active: Option<bool>,
809    primary_exchange: Option<String>,
810    currency_name: Option<String>,
811    composite_figi: Option<String>,
812    locale: Option<String>,
813    last_updated_utc: Option<String>,
814    description: Option<String>,
815    homepage_url: Option<String>,
816    total_employees: Option<u64>,
817    market_cap: Option<f64>,
818    phone_number: Option<String>,
819    address: Option<RawAddress>,
820    sic_code: Option<String>,
821    sic_description: Option<String>,
822    ticker_root: Option<String>,
823    list_date: Option<String>,
824    delisted_utc: Option<String>,
825    share_class_shares_outstanding: Option<u64>,
826    weighted_shares_outstanding: Option<u64>,
827    round_lot: Option<u32>,
828}
829
830#[derive(Deserialize)]
831struct RawAddress {
832    address1: Option<String>,
833    city: Option<String>,
834    state: Option<String>,
835    postal_code: Option<String>,
836}
837
838impl RawTickerDetails {
839    fn into_ticker_details(self) -> TickerDetails {
840        let ticker = Ticker {
841            ticker: self.ticker,
842            name: self.name.unwrap_or_default(),
843            market: self.market.unwrap_or_default(),
844            asset_type: self.asset_type.unwrap_or_default(),
845            active: self.active.unwrap_or(true),
846            primary_exchange: self.primary_exchange,
847            currency_name: self.currency_name,
848            composite_figi: self.composite_figi,
849            locale: self.locale,
850            last_updated_utc: self.last_updated_utc.and_then(parse_rfc3339_or_warn),
851        };
852
853        TickerDetails {
854            ticker,
855            description: self.description,
856            homepage_url: self.homepage_url,
857            total_employees: self.total_employees,
858            market_cap: self.market_cap.and_then(|v| {
859                let d = Decimal::from_f64_retain(v);
860                if d.is_none() {
861                    warn!(value = %v, "Non-finite market_cap from Massive API; dropping");
862                }
863                d
864            }),
865            phone_number: self.phone_number,
866            address: self.address.map(|a| Address {
867                address1: a.address1,
868                city: a.city,
869                state: a.state,
870                postal_code: a.postal_code,
871            }),
872            sic_code: self.sic_code,
873            sic_description: self.sic_description,
874            ticker_root: self.ticker_root,
875            list_date: self.list_date.and_then(|s| match s.parse() {
876                Ok(d) => Some(d),
877                Err(e) => {
878                    warn!(value = %s, error = %e, "Failed to parse list_date from Massive API");
879                    None
880                }
881            }),
882            delisted_utc: self.delisted_utc.and_then(parse_rfc3339_or_warn),
883            share_class_shares_outstanding: self.share_class_shares_outstanding,
884            weighted_shares_outstanding: self.weighted_shares_outstanding,
885            round_lot: self.round_lot,
886        }
887    }
888}
889
890#[derive(Deserialize)]
891struct ExchangesResponse {
892    results: Option<Vec<RawExchange>>,
893    // Deserialized to satisfy serde; not surfaced in public type.
894    #[allow(dead_code)]
895    status: Option<String>,
896}
897
898#[derive(Deserialize)]
899struct RawExchange {
900    id: i64,
901    name: String,
902    acronym: Option<String>,
903    mic: Option<String>,
904    operating_mic: Option<String>,
905    asset_class: Option<String>,
906    locale: Option<String>,
907    #[serde(rename = "type")]
908    exchange_type: Option<String>,
909    url: Option<String>,
910}
911
912impl RawExchange {
913    fn into_exchange(self) -> Exchange {
914        Exchange {
915            id: self.id,
916            name: self.name,
917            acronym: self.acronym,
918            mic: self.mic,
919            operating_mic: self.operating_mic,
920            asset_class: self.asset_class.unwrap_or_default(),
921            locale: self.locale.unwrap_or_default(),
922            exchange_type: self.exchange_type,
923            url: self.url,
924        }
925    }
926}
927
928#[derive(Deserialize)]
929struct RawMarketStatus {
930    market: Option<String>,
931    #[serde(rename = "serverTime")]
932    server_time: Option<String>,
933    #[serde(rename = "afterHours")]
934    after_hours: Option<bool>,
935    #[serde(rename = "earlyHours")]
936    early_hours: Option<bool>,
937    currencies: Option<RawCurrencyStatus>,
938    exchanges: Option<HashMap<String, String>>,
939    #[serde(rename = "indicesGroups")]
940    indices_groups: Option<HashMap<String, String>>,
941}
942
943#[derive(Deserialize)]
944struct RawCurrencyStatus {
945    crypto: Option<String>,
946    fx: Option<String>,
947}
948
949impl RawMarketStatus {
950    fn into_market_status(self) -> Result<MarketStatus, MassiveError> {
951        let server_time = match self.server_time {
952            Some(s) => DateTime::parse_from_rfc3339(&s)
953                .map(|dt| dt.with_timezone(&Utc))
954                .map_err(|e| MassiveError::Deserialize {
955                    message: format!("invalid serverTime: {e}"),
956                    payload: s,
957                })?,
958            None => {
959                return Err(MassiveError::Deserialize {
960                    message: "missing serverTime".into(),
961                    payload: String::new(),
962                });
963            }
964        };
965
966        Ok(MarketStatus {
967            market: self.market.unwrap_or_else(|| "unknown".into()),
968            server_time,
969            after_hours: self.after_hours.unwrap_or(false),
970            early_hours: self.early_hours.unwrap_or(false),
971            currencies: self
972                .currencies
973                .map(|c| CurrencyStatus {
974                    crypto: c.crypto.unwrap_or_else(|| "unknown".into()),
975                    fx: c.fx.unwrap_or_else(|| "unknown".into()),
976                })
977                .unwrap_or_default(),
978            exchanges: self.exchanges.unwrap_or_default(),
979            indices_groups: self.indices_groups.unwrap_or_default(),
980        })
981    }
982}
983
984#[derive(Deserialize)]
985struct RawMarketHoliday {
986    date: String,
987    exchange: Option<String>,
988    name: Option<String>,
989    status: Option<String>,
990    open: Option<String>,
991    close: Option<String>,
992}
993
994impl RawMarketHoliday {
995    fn into_market_holiday(self) -> Result<MarketHoliday, MassiveError> {
996        let date = self.date.parse().map_err(|e: chrono::format::ParseError| {
997            MassiveError::Deserialize {
998                message: format!("invalid date format: {e}"),
999                payload: self.date.clone(),
1000            }
1001        })?;
1002
1003        Ok(MarketHoliday {
1004            date,
1005            exchange: self.exchange.unwrap_or_default(),
1006            name: self.name.unwrap_or_default(),
1007            status: self.status.unwrap_or_else(|| "closed".into()),
1008            open: self.open.and_then(parse_rfc3339_or_warn),
1009            close: self.close.and_then(parse_rfc3339_or_warn),
1010        })
1011    }
1012}
1013
1014#[derive(Deserialize)]
1015struct DividendsResponse {
1016    results: Option<Vec<RawDividend>>,
1017    next_url: Option<String>,
1018    // Deserialized to satisfy serde; not surfaced in public type.
1019    #[allow(dead_code)]
1020    status: Option<String>,
1021}
1022
1023#[derive(Deserialize)]
1024struct RawDividend {
1025    ticker: String,
1026    #[serde(with = "rust_decimal::serde::float")]
1027    cash_amount: Decimal,
1028    #[serde(default)]
1029    currency: Option<String>,
1030    declaration_date: Option<String>,
1031    ex_dividend_date: String,
1032    record_date: Option<String>,
1033    pay_date: Option<String>,
1034    frequency: Option<u8>,
1035    dividend_type: Option<String>,
1036}
1037
1038impl RawDividend {
1039    fn into_dividend(self) -> Result<Dividend, MassiveError> {
1040        let ex_dividend_date =
1041            self.ex_dividend_date
1042                .parse()
1043                .map_err(|e: chrono::format::ParseError| MassiveError::Deserialize {
1044                    message: format!("invalid ex_dividend_date: {e}"),
1045                    payload: self.ex_dividend_date.clone(),
1046                })?;
1047
1048        Ok(Dividend {
1049            ticker: self.ticker,
1050            cash_amount: self.cash_amount,
1051            currency: self.currency.unwrap_or_else(|| "USD".into()),
1052            declaration_date: self.declaration_date.and_then(|s| match s.parse() {
1053                Ok(d) => Some(d),
1054                Err(e) => {
1055                    warn!(value = %s, error = %e, "Failed to parse declaration_date");
1056                    None
1057                }
1058            }),
1059            ex_dividend_date,
1060            record_date: self.record_date.and_then(|s| match s.parse() {
1061                Ok(d) => Some(d),
1062                Err(e) => {
1063                    warn!(value = %s, error = %e, "Failed to parse record_date");
1064                    None
1065                }
1066            }),
1067            pay_date: self.pay_date.and_then(|s| match s.parse() {
1068                Ok(d) => Some(d),
1069                Err(e) => {
1070                    warn!(value = %s, error = %e, "Failed to parse pay_date");
1071                    None
1072                }
1073            }),
1074            frequency: self.frequency.map(DividendFrequency::from_raw),
1075            dividend_type: self.dividend_type,
1076        })
1077    }
1078}
1079
1080#[derive(Deserialize)]
1081struct SplitsResponse {
1082    results: Option<Vec<RawSplit>>,
1083    next_url: Option<String>,
1084    // Deserialized to satisfy serde; not surfaced in public type.
1085    #[allow(dead_code)]
1086    status: Option<String>,
1087}
1088
1089#[derive(Deserialize)]
1090struct RawSplit {
1091    ticker: String,
1092    execution_date: String,
1093    #[serde(with = "rust_decimal::serde::float")]
1094    split_to: Decimal,
1095    #[serde(with = "rust_decimal::serde::float")]
1096    split_from: Decimal,
1097}
1098
1099impl RawSplit {
1100    fn into_stock_split(self) -> Result<StockSplit, MassiveError> {
1101        let execution_date =
1102            self.execution_date
1103                .parse()
1104                .map_err(|e: chrono::format::ParseError| MassiveError::Deserialize {
1105                    message: format!("invalid execution_date: {e}"),
1106                    payload: self.execution_date.clone(),
1107                })?;
1108
1109        Ok(StockSplit {
1110            ticker: self.ticker,
1111            execution_date,
1112            split_to: self.split_to,
1113            split_from: self.split_from,
1114        })
1115    }
1116}
1117
1118// ============================================================================
1119// Client Implementation
1120// ============================================================================
1121
1122impl MassiveRestClient {
1123    /// Fetch all tickers matching the query.
1124    ///
1125    /// Returns a stream that handles pagination automatically.
1126    ///
1127    /// # Example
1128    ///
1129    /// ```ignore
1130    /// let query = TickerQuery::new().market("stocks").active(true);
1131    /// let mut stream = client.fetch_tickers(&query);
1132    /// while let Some(ticker) = stream.next().await {
1133    ///     println!("{:?}", ticker?);
1134    /// }
1135    /// ```
1136    pub fn fetch_tickers<'a>(
1137        &'a self,
1138        query: &'a TickerQuery,
1139    ) -> impl Stream<Item = Result<Ticker, MassiveError>> + 'a {
1140        let base_url = self.base_url();
1141        try_stream! {
1142            let initial_url = format!(
1143                "{}/v3/reference/tickers{}",
1144                base_url,
1145                query.to_query_string()
1146            );
1147
1148            let mut next_url: Option<String> = Some(initial_url);
1149            let mut guard = PaginationGuard::default();
1150
1151            while let Some(url) = next_url.take() {
1152                guard.observe(&url)?;
1153                debug!(url = %url, "Fetching tickers page");
1154
1155                let body = self.fetch_page_body(&url).await?;
1156                let parsed: TickersResponse = serde_json::from_str(&body).map_err(|e| {
1157                    MassiveError::Deserialize {
1158                        message: e.to_string(),
1159                        payload: body,
1160                    }
1161                })?;
1162
1163                if let Some(results) = parsed.results {
1164                    for raw in results {
1165                        yield raw.into_ticker();
1166                    }
1167                }
1168
1169                next_url = parsed.next_url;
1170            }
1171        }
1172    }
1173
1174    /// Fetch detailed information for a single ticker.
1175    ///
1176    /// # Arguments
1177    ///
1178    /// * `ticker` - Ticker symbol (e.g., "AAPL", "X:BTCUSD")
1179    ///
1180    /// # Example
1181    ///
1182    /// ```ignore
1183    /// let details = client.fetch_ticker_details("AAPL").await?;
1184    /// println!("Market cap: {:?}", details.market_cap);
1185    /// ```
1186    pub async fn fetch_ticker_details(&self, ticker: &str) -> Result<TickerDetails, MassiveError> {
1187        Self::validate_ticker(ticker)?;
1188
1189        let url = format!("{}/v3/reference/tickers/{}", self.base_url(), ticker);
1190        debug!(url = %url, "Fetching ticker details");
1191
1192        let body = self.fetch_page_body(&url).await?;
1193        let parsed: TickerDetailsResponse =
1194            serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1195                message: e.to_string(),
1196                payload: body,
1197            })?;
1198
1199        parsed
1200            .results
1201            .map(|r| r.into_ticker_details())
1202            .ok_or_else(|| MassiveError::Api {
1203                status: 404,
1204                message: format!("Ticker not found: {}", ticker),
1205            })
1206    }
1207
1208    /// Fetch all exchanges.
1209    ///
1210    /// # Arguments
1211    ///
1212    /// * `asset_class` - Optional filter by asset class (stocks, crypto, fx, etc.)
1213    /// * `locale` - Optional filter by locale (us, global)
1214    ///
1215    /// # Example
1216    ///
1217    /// ```ignore
1218    /// let exchanges = client.fetch_exchanges(Some("stocks"), Some("us")).await?;
1219    /// for exchange in exchanges {
1220    ///     println!("{}: {}", exchange.mic.unwrap_or_default(), exchange.name);
1221    /// }
1222    /// ```
1223    pub async fn fetch_exchanges(
1224        &self,
1225        asset_class: Option<&str>,
1226        locale: Option<&str>,
1227    ) -> Result<Vec<Exchange>, MassiveError> {
1228        let mut url = format!("{}/v3/reference/exchanges", self.base_url());
1229
1230        let mut params = Vec::new();
1231        if let Some(ac) = asset_class {
1232            params.push(format!("asset_class={}", urlencoding::encode(ac)));
1233        }
1234        if let Some(loc) = locale {
1235            params.push(format!("locale={}", urlencoding::encode(loc)));
1236        }
1237        if !params.is_empty() {
1238            url.push('?');
1239            url.push_str(&params.join("&"));
1240        }
1241
1242        debug!(url = %url, "Fetching exchanges");
1243
1244        let body = self.fetch_page_body(&url).await?;
1245        let parsed: ExchangesResponse =
1246            serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1247                message: e.to_string(),
1248                payload: body,
1249            })?;
1250
1251        Ok(parsed
1252            .results
1253            .unwrap_or_default()
1254            .into_iter()
1255            .map(|r| r.into_exchange())
1256            .collect())
1257    }
1258
1259    /// Fetch current market status.
1260    ///
1261    /// Returns the current trading status for various exchanges and overall markets.
1262    ///
1263    /// # Example
1264    ///
1265    /// ```ignore
1266    /// let status = client.fetch_market_status().await?;
1267    /// println!("Market: {}, Crypto: {}", status.market, status.currencies.crypto);
1268    /// ```
1269    pub async fn fetch_market_status(&self) -> Result<MarketStatus, MassiveError> {
1270        let url = format!("{}/v1/marketstatus/now", self.base_url());
1271        debug!(url = %url, "Fetching market status");
1272
1273        let body = self.fetch_page_body(&url).await?;
1274        let parsed: RawMarketStatus =
1275            serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1276                message: e.to_string(),
1277                payload: body,
1278            })?;
1279
1280        parsed.into_market_status()
1281    }
1282
1283    /// Fetch upcoming market holidays.
1284    ///
1285    /// Returns a list of upcoming holidays with their corresponding open/close times.
1286    ///
1287    /// # Example
1288    ///
1289    /// ```ignore
1290    /// let holidays = client.fetch_market_holidays().await?;
1291    /// for holiday in holidays {
1292    ///     println!("{}: {} ({})", holiday.date, holiday.name, holiday.status);
1293    /// }
1294    /// ```
1295    pub async fn fetch_market_holidays(&self) -> Result<Vec<MarketHoliday>, MassiveError> {
1296        let url = format!("{}/v1/marketstatus/upcoming", self.base_url());
1297        debug!(url = %url, "Fetching market holidays");
1298
1299        let body = self.fetch_page_body(&url).await?;
1300        let parsed: Vec<RawMarketHoliday> =
1301            serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1302                message: e.to_string(),
1303                payload: body,
1304            })?;
1305
1306        parsed
1307            .into_iter()
1308            .map(|r| r.into_market_holiday())
1309            .collect()
1310    }
1311
1312    /// Fetch dividends matching the query.
1313    ///
1314    /// Returns a stream that handles pagination automatically.
1315    ///
1316    /// # Example
1317    ///
1318    /// ```ignore
1319    /// let query = DividendQuery::new().ticker("AAPL").limit(100);
1320    /// let mut stream = client.fetch_dividends(&query);
1321    /// while let Some(dividend) = stream.next().await {
1322    ///     println!("{:?}", dividend?);
1323    /// }
1324    /// ```
1325    pub fn fetch_dividends<'a>(
1326        &'a self,
1327        query: &'a DividendQuery,
1328    ) -> impl Stream<Item = Result<Dividend, MassiveError>> + 'a {
1329        let base_url = self.base_url();
1330        try_stream! {
1331            query.validate()?;
1332
1333            let initial_url = format!(
1334                "{}/v3/reference/dividends{}",
1335                base_url,
1336                query.to_query_string()
1337            );
1338
1339            let mut next_url: Option<String> = Some(initial_url);
1340            let mut guard = PaginationGuard::default();
1341
1342            while let Some(url) = next_url.take() {
1343                guard.observe(&url)?;
1344                debug!(url = %url, "Fetching dividends page");
1345
1346                let body = self.fetch_page_body(&url).await?;
1347                let parsed: DividendsResponse = serde_json::from_str(&body).map_err(|e| {
1348                    MassiveError::Deserialize {
1349                        message: e.to_string(),
1350                        payload: body,
1351                    }
1352                })?;
1353
1354                if let Some(results) = parsed.results {
1355                    for raw in results {
1356                        yield raw.into_dividend()?;
1357                    }
1358                }
1359
1360                next_url = parsed.next_url;
1361            }
1362        }
1363    }
1364
1365    /// Fetch stock splits matching the query.
1366    ///
1367    /// Returns a stream that handles pagination automatically.
1368    ///
1369    /// # Example
1370    ///
1371    /// ```ignore
1372    /// let query = SplitQuery::new().ticker("AAPL").limit(100);
1373    /// let mut stream = client.fetch_splits_raw(&query);
1374    /// while let Some(split) = stream.next().await {
1375    ///     println!("{:?}", split?);
1376    /// }
1377    /// ```
1378    pub fn fetch_splits_raw<'a>(
1379        &'a self,
1380        query: &'a SplitQuery,
1381    ) -> impl Stream<Item = Result<StockSplit, MassiveError>> + 'a {
1382        let base_url = self.base_url();
1383        try_stream! {
1384            query.validate()?;
1385
1386            let initial_url = format!(
1387                "{}/v3/reference/splits{}",
1388                base_url,
1389                query.to_query_string()
1390            );
1391
1392            let mut next_url: Option<String> = Some(initial_url);
1393            let mut guard = PaginationGuard::default();
1394
1395            while let Some(url) = next_url.take() {
1396                guard.observe(&url)?;
1397                debug!(url = %url, "Fetching splits page");
1398
1399                let body = self.fetch_page_body(&url).await?;
1400                let parsed: SplitsResponse = serde_json::from_str(&body).map_err(|e| {
1401                    MassiveError::Deserialize {
1402                        message: e.to_string(),
1403                        payload: body,
1404                    }
1405                })?;
1406
1407                if let Some(results) = parsed.results {
1408                    for raw in results {
1409                        yield raw.into_stock_split()?;
1410                    }
1411                }
1412
1413                next_url = parsed.next_url;
1414            }
1415        }
1416    }
1417}
1418
1419// ============================================================================
1420// Tests
1421// ============================================================================
1422
1423#[cfg(test)]
1424#[allow(clippy::unwrap_used)] // Tests should panic on unexpected values
1425mod tests {
1426    use super::*;
1427
1428    #[test]
1429    fn test_ticker_query_empty() {
1430        let query = TickerQuery::new();
1431        assert_eq!(query.to_query_string(), "");
1432    }
1433
1434    #[test]
1435    fn test_ticker_query_single_param() {
1436        let query = TickerQuery::new().market("stocks");
1437        assert_eq!(query.to_query_string(), "?market=stocks");
1438    }
1439
1440    #[test]
1441    fn test_ticker_query_multiple_params() {
1442        let query = TickerQuery::new().market("stocks").active(true).limit(500);
1443        let qs = query.to_query_string();
1444        assert!(qs.contains("market=stocks"));
1445        assert!(qs.contains("active=true"));
1446        assert!(qs.contains("limit=500"));
1447    }
1448
1449    #[test]
1450    fn test_ticker_query_type_serialization() {
1451        let query = TickerQuery::new().asset_type("CS");
1452        assert_eq!(query.to_query_string(), "?type=CS");
1453    }
1454
1455    #[test]
1456    fn test_ticker_query_limit_clamping() {
1457        let query = TickerQuery::new().limit(5000);
1458        assert_eq!(query.limit, Some(1000));
1459    }
1460
1461    #[test]
1462    fn test_ticker_query_url_encoding() {
1463        let query = TickerQuery::new().search("apple inc");
1464        assert_eq!(query.to_query_string(), "?search=apple%20inc");
1465    }
1466
1467    #[test]
1468    fn test_ticker_query_range_operators() {
1469        let query = TickerQuery::new().ticker_gte("A").ticker_lt("B");
1470        let qs = query.to_query_string();
1471        assert!(qs.contains("ticker.gte=A"));
1472        assert!(qs.contains("ticker.lt=B"));
1473    }
1474
1475    #[test]
1476    fn test_parse_ticker_response() {
1477        let json = r#"{
1478            "results": [{
1479                "ticker": "AAPL",
1480                "name": "Apple Inc.",
1481                "market": "stocks",
1482                "type": "CS",
1483                "active": true,
1484                "primary_exchange": "XNAS",
1485                "currency_name": "usd"
1486            }],
1487            "status": "OK",
1488            "count": 1
1489        }"#;
1490
1491        let parsed: TickersResponse = serde_json::from_str(json).unwrap();
1492        let results = parsed.results.unwrap();
1493        assert_eq!(results.len(), 1);
1494
1495        let ticker = results.into_iter().next().unwrap().into_ticker();
1496        assert_eq!(ticker.ticker, "AAPL");
1497        assert_eq!(ticker.name, "Apple Inc.");
1498        assert_eq!(ticker.market, "stocks");
1499        assert_eq!(ticker.asset_type, "CS");
1500        assert!(ticker.active);
1501        assert_eq!(ticker.primary_exchange, Some("XNAS".into()));
1502    }
1503
1504    #[test]
1505    fn test_parse_ticker_details_response() {
1506        let json = r#"{
1507            "results": {
1508                "ticker": "AAPL",
1509                "name": "Apple Inc.",
1510                "market": "stocks",
1511                "type": "CS",
1512                "active": true,
1513                "description": "Apple Inc. designs, manufactures, and markets smartphones.",
1514                "market_cap": 2500000000000.0,
1515                "total_employees": 164000,
1516                "homepage_url": "https://www.apple.com",
1517                "address": {
1518                    "address1": "One Apple Park Way",
1519                    "city": "Cupertino",
1520                    "state": "CA",
1521                    "postal_code": "95014"
1522                },
1523                "list_date": "1980-12-12"
1524            },
1525            "status": "OK"
1526        }"#;
1527
1528        let parsed: TickerDetailsResponse = serde_json::from_str(json).unwrap();
1529        let details = parsed.results.unwrap().into_ticker_details();
1530
1531        assert_eq!(details.ticker.ticker, "AAPL");
1532        assert!(details.description.unwrap().contains("Apple"));
1533        assert_eq!(
1534            details.market_cap,
1535            Decimal::from_f64_retain(2_500_000_000_000.0)
1536        );
1537        assert_eq!(details.total_employees, Some(164_000));
1538        assert!(details.address.is_some());
1539        let addr = details.address.unwrap();
1540        assert_eq!(addr.city, Some("Cupertino".into()));
1541        assert_eq!(
1542            details.list_date,
1543            Some(NaiveDate::from_ymd_opt(1980, 12, 12).unwrap())
1544        );
1545    }
1546
1547    #[test]
1548    fn test_parse_exchanges_response() {
1549        let json = r#"{
1550            "results": [{
1551                "id": 1,
1552                "name": "NYSE American, LLC",
1553                "acronym": "AMEX",
1554                "mic": "XASE",
1555                "operating_mic": "XNYS",
1556                "asset_class": "stocks",
1557                "locale": "us",
1558                "type": "exchange"
1559            }],
1560            "status": "OK"
1561        }"#;
1562
1563        let parsed: ExchangesResponse = serde_json::from_str(json).unwrap();
1564        let results = parsed.results.unwrap();
1565        assert_eq!(results.len(), 1);
1566
1567        let exchange = results.into_iter().next().unwrap().into_exchange();
1568        assert_eq!(exchange.id, 1);
1569        assert_eq!(exchange.name, "NYSE American, LLC");
1570        assert_eq!(exchange.mic, Some("XASE".into()));
1571        assert_eq!(exchange.asset_class, "stocks");
1572    }
1573
1574    #[test]
1575    fn test_parse_market_status_response() {
1576        let json = r#"{
1577            "market": "open",
1578            "serverTime": "2026-05-06T14:30:00-04:00",
1579            "afterHours": false,
1580            "earlyHours": false,
1581            "currencies": {
1582                "crypto": "open",
1583                "fx": "open"
1584            },
1585            "exchanges": {
1586                "nasdaq": "open",
1587                "nyse": "open"
1588            },
1589            "indicesGroups": {
1590                "s_and_p": "open"
1591            }
1592        }"#;
1593
1594        let parsed: RawMarketStatus = serde_json::from_str(json).unwrap();
1595        let status = parsed.into_market_status().unwrap();
1596
1597        assert_eq!(status.market, "open");
1598        assert!(!status.after_hours);
1599        assert_eq!(status.currencies.crypto, "open");
1600        assert_eq!(status.exchanges.get("nasdaq"), Some(&"open".into()));
1601    }
1602
1603    #[test]
1604    fn test_parse_market_holidays_response() {
1605        let json = r#"[
1606            {
1607                "date": "2026-12-25",
1608                "exchange": "NYSE",
1609                "name": "Christmas",
1610                "status": "closed"
1611            },
1612            {
1613                "date": "2026-11-27",
1614                "exchange": "NYSE",
1615                "name": "Thanksgiving",
1616                "status": "early-close",
1617                "open": "2026-11-27T13:30:00Z",
1618                "close": "2026-11-27T18:00:00Z"
1619            }
1620        ]"#;
1621
1622        let parsed: Vec<RawMarketHoliday> = serde_json::from_str(json).unwrap();
1623        let holidays: Vec<MarketHoliday> = parsed
1624            .into_iter()
1625            .map(|r| r.into_market_holiday().unwrap())
1626            .collect();
1627
1628        assert_eq!(holidays.len(), 2);
1629        assert_eq!(holidays[0].name, "Christmas");
1630        assert_eq!(holidays[0].status, "closed");
1631        assert!(holidays[0].open.is_none());
1632
1633        assert_eq!(holidays[1].name, "Thanksgiving");
1634        assert_eq!(holidays[1].status, "early-close");
1635        assert!(holidays[1].open.is_some());
1636        assert!(holidays[1].close.is_some());
1637    }
1638
1639    #[test]
1640    fn test_sort_order() {
1641        assert_eq!(SortOrder::Asc.as_str(), "asc");
1642        assert_eq!(SortOrder::Desc.as_str(), "desc");
1643        assert_eq!(SortOrder::default(), SortOrder::Asc);
1644    }
1645
1646    // ========================================================================
1647    // Corporate Actions Tests
1648    // ========================================================================
1649
1650    #[test]
1651    fn test_dividend_query_empty() {
1652        let query = DividendQuery::new();
1653        assert_eq!(query.to_query_string(), "");
1654    }
1655
1656    #[test]
1657    fn test_dividend_query_with_ticker() {
1658        let query = DividendQuery::new().ticker("AAPL");
1659        assert_eq!(query.to_query_string(), "?ticker=AAPL");
1660    }
1661
1662    #[test]
1663    fn test_dividend_query_with_date_range() {
1664        let from = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1665        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1666        let query = DividendQuery::new()
1667            .ticker("AAPL")
1668            .ex_dividend_date_gte(from)
1669            .ex_dividend_date_lte(to);
1670        let qs = query.to_query_string();
1671        assert!(qs.contains("ticker=AAPL"));
1672        assert!(qs.contains("ex_dividend_date.gte=2024-01-01"));
1673        assert!(qs.contains("ex_dividend_date.lte=2024-12-31"));
1674    }
1675
1676    #[test]
1677    fn test_dividend_query_limit_clamping() {
1678        let query = DividendQuery::new().limit(5000);
1679        assert_eq!(query.limit, Some(1000));
1680    }
1681
1682    #[test]
1683    fn test_parse_dividends_response() {
1684        let json = r#"{
1685            "results": [{
1686                "ticker": "AAPL",
1687                "cash_amount": 0.25,
1688                "currency": "USD",
1689                "declaration_date": "2024-02-01",
1690                "ex_dividend_date": "2024-02-09",
1691                "record_date": "2024-02-12",
1692                "pay_date": "2024-02-15",
1693                "frequency": 4,
1694                "dividend_type": "CD"
1695            }],
1696            "status": "OK"
1697        }"#;
1698
1699        let parsed: DividendsResponse = serde_json::from_str(json).unwrap();
1700        let results = parsed.results.unwrap();
1701        assert_eq!(results.len(), 1);
1702
1703        let dividend = results.into_iter().next().unwrap().into_dividend().unwrap();
1704        assert_eq!(dividend.ticker, "AAPL");
1705        assert_eq!(
1706            dividend.cash_amount,
1707            Decimal::from_f64_retain(0.25).unwrap()
1708        );
1709        assert_eq!(dividend.currency, "USD");
1710        assert_eq!(
1711            dividend.ex_dividend_date,
1712            NaiveDate::from_ymd_opt(2024, 2, 9).unwrap()
1713        );
1714        assert_eq!(dividend.frequency, Some(DividendFrequency::Quarterly));
1715        assert_eq!(dividend.dividend_type, Some("CD".into()));
1716    }
1717
1718    #[test]
1719    fn test_parse_dividends_minimal() {
1720        let json = r#"{
1721            "results": [{
1722                "ticker": "MSFT",
1723                "cash_amount": 0.75,
1724                "ex_dividend_date": "2024-05-15"
1725            }],
1726            "status": "OK"
1727        }"#;
1728
1729        let parsed: DividendsResponse = serde_json::from_str(json).unwrap();
1730        let results = parsed.results.unwrap();
1731        let dividend = results.into_iter().next().unwrap().into_dividend().unwrap();
1732
1733        assert_eq!(dividend.ticker, "MSFT");
1734        assert_eq!(dividend.currency, "USD"); // Defaults to USD
1735        assert!(dividend.declaration_date.is_none());
1736        assert!(dividend.frequency.is_none());
1737    }
1738
1739    #[test]
1740    fn test_split_query_empty() {
1741        let query = SplitQuery::new();
1742        assert_eq!(query.to_query_string(), "");
1743    }
1744
1745    #[test]
1746    fn test_split_query_with_ticker() {
1747        let query = SplitQuery::new().ticker("TSLA");
1748        assert_eq!(query.to_query_string(), "?ticker=TSLA");
1749    }
1750
1751    #[test]
1752    fn test_split_query_with_date_range() {
1753        let from = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
1754        let to = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
1755        let query = SplitQuery::new()
1756            .ticker("NVDA")
1757            .execution_date_gte(from)
1758            .execution_date_lte(to);
1759        let qs = query.to_query_string();
1760        assert!(qs.contains("ticker=NVDA"));
1761        assert!(qs.contains("execution_date.gte=2020-01-01"));
1762        assert!(qs.contains("execution_date.lte=2024-12-31"));
1763    }
1764
1765    #[test]
1766    fn test_split_query_limit_clamping() {
1767        let query = SplitQuery::new().limit(2000);
1768        assert_eq!(query.limit, Some(1000));
1769    }
1770
1771    #[test]
1772    fn test_parse_splits_response() {
1773        let json = r#"{
1774            "results": [{
1775                "ticker": "TSLA",
1776                "execution_date": "2022-08-25",
1777                "split_to": 3.0,
1778                "split_from": 1.0
1779            }],
1780            "status": "OK"
1781        }"#;
1782
1783        let parsed: SplitsResponse = serde_json::from_str(json).unwrap();
1784        let results = parsed.results.unwrap();
1785        assert_eq!(results.len(), 1);
1786
1787        let split = results
1788            .into_iter()
1789            .next()
1790            .unwrap()
1791            .into_stock_split()
1792            .unwrap();
1793        assert_eq!(split.ticker, "TSLA");
1794        assert_eq!(
1795            split.execution_date,
1796            NaiveDate::from_ymd_opt(2022, 8, 25).unwrap()
1797        );
1798        assert_eq!(split.split_to, Decimal::from(3));
1799        assert_eq!(split.split_from, Decimal::from(1));
1800    }
1801
1802    #[test]
1803    fn test_parse_splits_reverse_split() {
1804        let json = r#"{
1805            "results": [{
1806                "ticker": "GE",
1807                "execution_date": "2021-08-02",
1808                "split_to": 1.0,
1809                "split_from": 8.0
1810            }],
1811            "status": "OK"
1812        }"#;
1813
1814        let parsed: SplitsResponse = serde_json::from_str(json).unwrap();
1815        let results = parsed.results.unwrap();
1816        let split = results
1817            .into_iter()
1818            .next()
1819            .unwrap()
1820            .into_stock_split()
1821            .unwrap();
1822
1823        assert_eq!(split.ticker, "GE");
1824        assert_eq!(split.split_to, Decimal::from(1));
1825        assert_eq!(split.split_from, Decimal::from(8)); // 1:8 reverse split
1826    }
1827}