Skip to main content

rustrade_data/exchange/massive/
options.rs

1//! Options reference data endpoints for Massive API.
2//!
3//! Provides access to option contract discovery and snapshots with Greeks.
4//!
5//! # Subscription Requirements
6//!
7//! | Endpoint | Minimum Plan | Data Recency |
8//! |----------|--------------|--------------|
9//! | Contracts (`/v3/reference/options/contracts`) | Options Basic (free) | Daily |
10//! | Chain Snapshot (`/v3/snapshot/options/{underlying}`) | Options Starter | 15-min delayed (Starter/Developer), Real-time (Advanced/Business) |
11//! | Contract Snapshot (`/v3/snapshot/options/{underlying}/{contract}`) | Options Starter | 15-min delayed (Starter/Developer), Real-time (Advanced/Business) |
12//!
13//! # Example
14//!
15//! ```ignore
16//! use rustrade_data::exchange::massive::{MassiveRestClient, OptionContractQuery};
17//!
18//! let client = MassiveRestClient::from_env()?;
19//!
20//! // Discover AAPL options expiring in the next 30 days
21//! let query = OptionContractQuery::new()
22//!     .underlying_ticker("AAPL")
23//!     .expiration_date_gte(chrono::Utc::now().date_naive())
24//!     .expiration_date_lte(chrono::Utc::now().date_naive() + chrono::Duration::days(30))
25//!     .limit(100);
26//!
27//! let contracts = client.fetch_option_contracts(&query).await?;
28//! ```
29
30use super::error::MassiveError;
31use super::pagination::PaginationGuard;
32use super::reference::SortOrder;
33use super::rest::MassiveRestClient;
34use crate::subscription::greeks::OptionGreeks;
35use chrono::{NaiveDate, NaiveTime};
36use rust_decimal::Decimal;
37use rust_decimal::prelude::ToPrimitive;
38use rustrade_instrument::instrument::kind::option::{OptionExercise, OptionKind};
39use rustrade_instrument::instrument::market_data::kind::MarketDataOptionContract;
40use serde::Deserialize;
41use tracing::{debug, warn};
42
43// ============================================================================
44// Query Builders
45// ============================================================================
46
47/// Filter parameters for the `/v3/reference/options/contracts` endpoint.
48///
49/// All fields are optional. Construct with [`OptionContractQuery::new`] and chain
50/// setters. The fetch method handles pagination automatically.
51///
52/// # Example
53///
54/// ```ignore
55/// use rustrade_data::exchange::massive::OptionContractQuery;
56/// use chrono::NaiveDate;
57///
58/// let query = OptionContractQuery::new()
59///     .underlying_ticker("AAPL")
60///     .contract_type(OptionKind::Call)
61///     .expiration_date_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
62///     .strike_price_gte(dec!(150))
63///     .strike_price_lte(dec!(200))
64///     .limit(500);
65/// ```
66#[derive(Debug, Default, Clone)]
67pub struct OptionContractQuery {
68    /// Filter by underlying stock ticker.
69    pub underlying_ticker: Option<String>,
70    /// Filter by contract type (call/put).
71    pub contract_type: Option<OptionKind>,
72    /// Filter by exact expiration date.
73    pub expiration_date: Option<NaiveDate>,
74    /// Filter by expiration date >= value.
75    pub expiration_date_gte: Option<NaiveDate>,
76    /// Filter by expiration date > value.
77    pub expiration_date_gt: Option<NaiveDate>,
78    /// Filter by expiration date <= value.
79    pub expiration_date_lte: Option<NaiveDate>,
80    /// Filter by expiration date < value.
81    pub expiration_date_lt: Option<NaiveDate>,
82    /// Filter by exact strike price.
83    pub strike_price: Option<Decimal>,
84    /// Filter by strike price >= value.
85    pub strike_price_gte: Option<Decimal>,
86    /// Filter by strike price > value.
87    pub strike_price_gt: Option<Decimal>,
88    /// Filter by strike price <= value.
89    pub strike_price_lte: Option<Decimal>,
90    /// Filter by strike price < value.
91    pub strike_price_lt: Option<Decimal>,
92    /// Include expired contracts (default: false).
93    pub expired: Option<bool>,
94    /// Point-in-time query date (default: today).
95    pub as_of: Option<NaiveDate>,
96    /// Results per page (max 1000).
97    pub limit: Option<u16>,
98    /// Sort direction.
99    pub order: Option<SortOrder>,
100    /// Field to sort by.
101    pub sort: Option<String>,
102}
103
104impl OptionContractQuery {
105    /// Create a new empty query.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Filter by underlying stock ticker (e.g., "AAPL").
112    #[must_use]
113    pub fn underlying_ticker(mut self, v: impl Into<String>) -> Self {
114        self.underlying_ticker = Some(v.into());
115        self
116    }
117
118    /// Filter by contract type (Call or Put).
119    #[must_use]
120    pub fn contract_type(mut self, v: OptionKind) -> Self {
121        self.contract_type = Some(v);
122        self
123    }
124
125    /// Filter by exact expiration date.
126    #[must_use]
127    pub fn expiration_date(mut self, v: NaiveDate) -> Self {
128        self.expiration_date = Some(v);
129        self
130    }
131
132    /// Filter by expiration date >= value.
133    #[must_use]
134    pub fn expiration_date_gte(mut self, v: NaiveDate) -> Self {
135        self.expiration_date_gte = Some(v);
136        self
137    }
138
139    /// Filter by expiration date > value.
140    #[must_use]
141    pub fn expiration_date_gt(mut self, v: NaiveDate) -> Self {
142        self.expiration_date_gt = Some(v);
143        self
144    }
145
146    /// Filter by expiration date <= value.
147    #[must_use]
148    pub fn expiration_date_lte(mut self, v: NaiveDate) -> Self {
149        self.expiration_date_lte = Some(v);
150        self
151    }
152
153    /// Filter by expiration date < value.
154    #[must_use]
155    pub fn expiration_date_lt(mut self, v: NaiveDate) -> Self {
156        self.expiration_date_lt = Some(v);
157        self
158    }
159
160    /// Filter by exact strike price.
161    #[must_use]
162    pub fn strike_price(mut self, v: Decimal) -> Self {
163        self.strike_price = Some(v);
164        self
165    }
166
167    /// Filter by strike price >= value.
168    #[must_use]
169    pub fn strike_price_gte(mut self, v: Decimal) -> Self {
170        self.strike_price_gte = Some(v);
171        self
172    }
173
174    /// Filter by strike price > value.
175    #[must_use]
176    pub fn strike_price_gt(mut self, v: Decimal) -> Self {
177        self.strike_price_gt = Some(v);
178        self
179    }
180
181    /// Filter by strike price <= value.
182    #[must_use]
183    pub fn strike_price_lte(mut self, v: Decimal) -> Self {
184        self.strike_price_lte = Some(v);
185        self
186    }
187
188    /// Filter by strike price < value.
189    #[must_use]
190    pub fn strike_price_lt(mut self, v: Decimal) -> Self {
191        self.strike_price_lt = Some(v);
192        self
193    }
194
195    /// Include expired contracts (default: false).
196    #[must_use]
197    pub fn expired(mut self, v: bool) -> Self {
198        self.expired = Some(v);
199        self
200    }
201
202    /// Point-in-time query date (default: today).
203    #[must_use]
204    pub fn as_of(mut self, v: NaiveDate) -> Self {
205        self.as_of = Some(v);
206        self
207    }
208
209    /// Set results per page (clamped to max 1000).
210    #[must_use]
211    pub fn limit(mut self, v: u16) -> Self {
212        self.limit = Some(v.min(1000));
213        self
214    }
215
216    /// Set sort direction.
217    #[must_use]
218    pub fn order(mut self, v: SortOrder) -> Self {
219        self.order = Some(v);
220        self
221    }
222
223    /// Set field to sort by.
224    #[must_use]
225    pub fn sort(mut self, v: impl Into<String>) -> Self {
226        self.sort = Some(v.into());
227        self
228    }
229
230    /// Validate the query for conflicting filters.
231    pub fn validate(&self) -> Result<(), MassiveError> {
232        let has_exact_exp = self.expiration_date.is_some();
233        let has_range_exp = self.expiration_date_gte.is_some()
234            || self.expiration_date_gt.is_some()
235            || self.expiration_date_lte.is_some()
236            || self.expiration_date_lt.is_some();
237
238        if has_exact_exp && has_range_exp {
239            return Err(MassiveError::InvalidInput {
240                message: "OptionContractQuery: cannot set both expiration_date (exact) and \
241                          expiration_date range filters"
242                    .into(),
243            });
244        }
245
246        let has_exact_strike = self.strike_price.is_some();
247        let has_range_strike = self.strike_price_gte.is_some()
248            || self.strike_price_gt.is_some()
249            || self.strike_price_lte.is_some()
250            || self.strike_price_lt.is_some();
251
252        if has_exact_strike && has_range_strike {
253            return Err(MassiveError::InvalidInput {
254                message: "OptionContractQuery: cannot set both strike_price (exact) and \
255                          strike_price range filters"
256                    .into(),
257            });
258        }
259
260        Ok(())
261    }
262
263    /// Build query string parameters.
264    fn to_query_string(&self) -> String {
265        let mut pairs: Vec<(&str, String)> = Vec::new();
266
267        if let Some(ref v) = self.underlying_ticker {
268            pairs.push(("underlying_ticker", v.clone()));
269        }
270        if let Some(v) = self.contract_type {
271            pairs.push(("contract_type", v.to_string()));
272        }
273        if let Some(v) = self.expiration_date {
274            pairs.push(("expiration_date", v.to_string()));
275        }
276        if let Some(v) = self.expiration_date_gte {
277            pairs.push(("expiration_date.gte", v.to_string()));
278        }
279        if let Some(v) = self.expiration_date_gt {
280            pairs.push(("expiration_date.gt", v.to_string()));
281        }
282        if let Some(v) = self.expiration_date_lte {
283            pairs.push(("expiration_date.lte", v.to_string()));
284        }
285        if let Some(v) = self.expiration_date_lt {
286            pairs.push(("expiration_date.lt", v.to_string()));
287        }
288        if let Some(v) = self.strike_price {
289            pairs.push(("strike_price", v.to_string()));
290        }
291        if let Some(v) = self.strike_price_gte {
292            pairs.push(("strike_price.gte", v.to_string()));
293        }
294        if let Some(v) = self.strike_price_gt {
295            pairs.push(("strike_price.gt", v.to_string()));
296        }
297        if let Some(v) = self.strike_price_lte {
298            pairs.push(("strike_price.lte", v.to_string()));
299        }
300        if let Some(v) = self.strike_price_lt {
301            pairs.push(("strike_price.lt", v.to_string()));
302        }
303        if let Some(v) = self.expired {
304            pairs.push(("expired", v.to_string()));
305        }
306        if let Some(v) = self.as_of {
307            pairs.push(("as_of", v.to_string()));
308        }
309        if let Some(v) = self.limit {
310            pairs.push(("limit", v.to_string()));
311        }
312        if let Some(v) = self.order {
313            pairs.push(("order", v.to_string()));
314        }
315        if let Some(ref v) = self.sort {
316            pairs.push(("sort", v.clone()));
317        }
318
319        if pairs.is_empty() {
320            String::new()
321        } else {
322            let encoded: Vec<String> = pairs
323                .into_iter()
324                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
325                .collect();
326            format!("?{}", encoded.join("&"))
327        }
328    }
329}
330
331/// Filter parameters for option snapshot endpoints.
332///
333/// Used with [`MassiveRestClient::fetch_option_chain_snapshot`]. Note that
334/// the snapshot endpoints expose only `gte`/`lte` range operators (no `gt`/`lt`),
335/// unlike [`OptionContractQuery`] which supports the full set.
336#[derive(Debug, Default, Clone)]
337pub struct OptionSnapshotQuery {
338    /// Filter by exact strike price.
339    pub strike_price: Option<Decimal>,
340    /// Filter by strike price >= value.
341    pub strike_price_gte: Option<Decimal>,
342    /// Filter by strike price <= value.
343    pub strike_price_lte: Option<Decimal>,
344    /// Filter by exact expiration date.
345    pub expiration_date: Option<NaiveDate>,
346    /// Filter by expiration date >= value.
347    pub expiration_date_gte: Option<NaiveDate>,
348    /// Filter by expiration date <= value.
349    pub expiration_date_lte: Option<NaiveDate>,
350    /// Filter by contract type (call/put).
351    pub contract_type: Option<OptionKind>,
352    /// Results per page (max 250 for snapshots).
353    pub limit: Option<u16>,
354    /// Sort direction.
355    pub order: Option<SortOrder>,
356    /// Field to sort by.
357    pub sort: Option<String>,
358}
359
360impl OptionSnapshotQuery {
361    /// Create a new empty query.
362    #[must_use]
363    pub fn new() -> Self {
364        Self::default()
365    }
366
367    /// Filter by exact strike price.
368    #[must_use]
369    pub fn strike_price(mut self, v: Decimal) -> Self {
370        self.strike_price = Some(v);
371        self
372    }
373
374    /// Filter by strike price >= value.
375    #[must_use]
376    pub fn strike_price_gte(mut self, v: Decimal) -> Self {
377        self.strike_price_gte = Some(v);
378        self
379    }
380
381    /// Filter by strike price <= value.
382    #[must_use]
383    pub fn strike_price_lte(mut self, v: Decimal) -> Self {
384        self.strike_price_lte = Some(v);
385        self
386    }
387
388    /// Filter by exact expiration date.
389    #[must_use]
390    pub fn expiration_date(mut self, v: NaiveDate) -> Self {
391        self.expiration_date = Some(v);
392        self
393    }
394
395    /// Filter by expiration date >= value.
396    #[must_use]
397    pub fn expiration_date_gte(mut self, v: NaiveDate) -> Self {
398        self.expiration_date_gte = Some(v);
399        self
400    }
401
402    /// Filter by expiration date <= value.
403    #[must_use]
404    pub fn expiration_date_lte(mut self, v: NaiveDate) -> Self {
405        self.expiration_date_lte = Some(v);
406        self
407    }
408
409    /// Filter by contract type (Call or Put).
410    #[must_use]
411    pub fn contract_type(mut self, v: OptionKind) -> Self {
412        self.contract_type = Some(v);
413        self
414    }
415
416    /// Set results per page (clamped to max 250 for snapshots).
417    #[must_use]
418    pub fn limit(mut self, v: u16) -> Self {
419        self.limit = Some(v.min(250));
420        self
421    }
422
423    /// Set sort direction.
424    #[must_use]
425    pub fn order(mut self, v: SortOrder) -> Self {
426        self.order = Some(v);
427        self
428    }
429
430    /// Set field to sort by.
431    #[must_use]
432    pub fn sort(mut self, v: impl Into<String>) -> Self {
433        self.sort = Some(v.into());
434        self
435    }
436
437    /// Validate the query for conflicting filters.
438    pub fn validate(&self) -> Result<(), MassiveError> {
439        if self.strike_price.is_some()
440            && (self.strike_price_gte.is_some() || self.strike_price_lte.is_some())
441        {
442            return Err(MassiveError::InvalidInput {
443                message: "OptionSnapshotQuery: cannot set both strike_price (exact) and \
444                          strike_price range filters"
445                    .into(),
446            });
447        }
448
449        if self.expiration_date.is_some()
450            && (self.expiration_date_gte.is_some() || self.expiration_date_lte.is_some())
451        {
452            return Err(MassiveError::InvalidInput {
453                message: "OptionSnapshotQuery: cannot set both expiration_date (exact) and \
454                          expiration_date range filters"
455                    .into(),
456            });
457        }
458
459        Ok(())
460    }
461
462    /// Build query string parameters.
463    fn to_query_string(&self) -> String {
464        let mut pairs: Vec<(&str, String)> = Vec::new();
465
466        if let Some(v) = self.strike_price {
467            pairs.push(("strike_price", v.to_string()));
468        }
469        if let Some(v) = self.strike_price_gte {
470            pairs.push(("strike_price.gte", v.to_string()));
471        }
472        if let Some(v) = self.strike_price_lte {
473            pairs.push(("strike_price.lte", v.to_string()));
474        }
475        if let Some(v) = self.expiration_date {
476            pairs.push(("expiration_date", v.to_string()));
477        }
478        if let Some(v) = self.expiration_date_gte {
479            pairs.push(("expiration_date.gte", v.to_string()));
480        }
481        if let Some(v) = self.expiration_date_lte {
482            pairs.push(("expiration_date.lte", v.to_string()));
483        }
484        if let Some(v) = self.contract_type {
485            pairs.push(("contract_type", v.to_string()));
486        }
487        if let Some(v) = self.limit {
488            pairs.push(("limit", v.to_string()));
489        }
490        if let Some(v) = self.order {
491            pairs.push(("order", v.to_string()));
492        }
493        if let Some(ref v) = self.sort {
494            pairs.push(("sort", v.clone()));
495        }
496
497        if pairs.is_empty() {
498            String::new()
499        } else {
500            let encoded: Vec<String> = pairs
501                .into_iter()
502                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
503                .collect();
504            format!("?{}", encoded.join("&"))
505        }
506    }
507}
508
509// ============================================================================
510// Response Types
511// ============================================================================
512
513/// Option contract from the Massive contracts endpoint.
514///
515/// Carries Massive-specific fields (OCC ticker symbol, primary exchange MIC, CFI code,
516/// shares per contract) alongside the standard option terms. Use
517/// [`MassiveOptionContract::to_market_data_contract`] to obtain a library-standard
518/// [`MarketDataOptionContract`] for cross-exchange consumers.
519#[derive(Debug, Clone, PartialEq)]
520#[non_exhaustive]
521pub struct MassiveOptionContract {
522    /// Options contract ticker symbol (e.g., "O:AAPL251219C00150000").
523    pub ticker: String,
524    /// Underlying stock ticker (e.g., "AAPL").
525    pub underlying_ticker: String,
526    /// Contract type (Call or Put).
527    pub contract_type: OptionKind,
528    /// Exercise style (American, European, Bermudan).
529    pub exercise_style: OptionExercise,
530    /// Expiration date.
531    pub expiration_date: NaiveDate,
532    /// Strike price.
533    pub strike_price: Decimal,
534    /// Number of shares per contract (typically 100).
535    pub shares_per_contract: u32,
536    /// Primary exchange MIC code (e.g., "XCBO" for CBOE).
537    pub primary_exchange: Option<String>,
538    /// ISO 10962 CFI code.
539    pub cfi: Option<String>,
540}
541
542impl MassiveOptionContract {
543    /// Convert to a library-standard [`MarketDataOptionContract`].
544    ///
545    /// Massive returns expiration as a date with no intraday time component;
546    /// this method anchors it to UTC midnight for the `DateTime<Utc>` field.
547    ///
548    /// A `From` impl is not provided because both `MassiveOptionContract` and
549    /// `MarketDataOptionContract` are foreign to each other's defining crate
550    /// (orphan rule).
551    #[must_use]
552    pub fn to_market_data_contract(&self) -> MarketDataOptionContract {
553        MarketDataOptionContract {
554            kind: self.contract_type,
555            exercise: self.exercise_style,
556            expiry: self.expiration_date.and_time(NaiveTime::MIN).and_utc(),
557            strike: self.strike_price,
558        }
559    }
560}
561
562/// Option snapshot with Greeks and market data from Massive.
563///
564/// Returned by the snapshot endpoints. Requires Options Starter+ subscription.
565///
566/// # Note on Greeks
567///
568/// Greeks may be `None` for deep ITM options or when the exchange doesn't
569/// compute them. Rho is NOT available from Massive.
570///
571/// `implied_volatility` is exposed at the snapshot level because Massive may
572/// return IV even when no greeks block is present (deep ITM case).
573#[derive(Debug, Clone, PartialEq)]
574#[non_exhaustive]
575pub struct MassiveOptionSnapshot {
576    /// Contract details.
577    pub contract: MassiveOptionContract,
578    /// Greeks values (delta, gamma, theta, vega).
579    pub greeks: Option<OptionGreeks>,
580    /// Implied volatility. May be present even when `greeks` is `None`.
581    pub implied_volatility: Option<f64>,
582    /// Open interest (end of day).
583    pub open_interest: Option<u64>,
584    /// Break-even price.
585    pub break_even_price: Option<Decimal>,
586    /// Daily OHLCV data.
587    pub day: Option<OptionDayBar>,
588    /// Last quote (bid/ask).
589    pub last_quote: Option<OptionQuote>,
590    /// Last trade.
591    pub last_trade: Option<OptionTrade>,
592    /// Underlying asset price and metrics.
593    pub underlying: Option<UnderlyingAsset>,
594}
595
596/// Daily OHLCV bar for an option contract.
597#[derive(Debug, Clone, PartialEq)]
598#[non_exhaustive]
599pub struct OptionDayBar {
600    pub open: Option<Decimal>,
601    pub high: Option<Decimal>,
602    pub low: Option<Decimal>,
603    pub close: Option<Decimal>,
604    pub volume: Option<u64>,
605    pub vwap: Option<Decimal>,
606    pub change: Option<Decimal>,
607    pub change_percent: Option<f64>,
608}
609
610/// Option quote (bid/ask).
611#[derive(Debug, Clone, PartialEq)]
612#[non_exhaustive]
613pub struct OptionQuote {
614    pub bid: Option<Decimal>,
615    pub bid_size: Option<u64>,
616    pub ask: Option<Decimal>,
617    pub ask_size: Option<u64>,
618    pub midpoint: Option<Decimal>,
619}
620
621/// Option trade.
622#[derive(Debug, Clone, PartialEq)]
623#[non_exhaustive]
624pub struct OptionTrade {
625    pub price: Option<Decimal>,
626    pub size: Option<u64>,
627    /// Massive exchange ID integer code. See Massive API "Exchanges" reference docs
628    /// for the mapping from integer to exchange MIC.
629    pub exchange: Option<i32>,
630    /// Massive sale condition integer codes. See Massive API "Conditions" reference
631    /// docs for the mapping from integer to condition meaning.
632    pub conditions: Option<Vec<i32>>,
633}
634
635/// Underlying asset data included in option snapshots.
636#[derive(Debug, Clone, PartialEq)]
637#[non_exhaustive]
638pub struct UnderlyingAsset {
639    pub ticker: String,
640    pub price: Option<Decimal>,
641    pub change: Option<Decimal>,
642    pub change_percent: Option<f64>,
643}
644
645// ============================================================================
646// Internal Deserialization Types
647// ============================================================================
648
649#[derive(Deserialize)]
650struct ContractsResponse {
651    results: Option<Vec<RawContract>>,
652    next_url: Option<String>,
653    // Deserialized to satisfy serde; not surfaced in public type.
654    #[allow(dead_code)]
655    status: Option<String>,
656    // Deserialized to satisfy serde; not surfaced in public type.
657    #[allow(dead_code)]
658    request_id: Option<String>,
659}
660
661#[derive(Deserialize)]
662struct RawContract {
663    ticker: String,
664    underlying_ticker: Option<String>,
665    contract_type: Option<String>,
666    exercise_style: Option<String>,
667    expiration_date: Option<String>,
668    #[serde(default, with = "rust_decimal::serde::float_option")]
669    strike_price: Option<Decimal>,
670    shares_per_contract: Option<u32>,
671    primary_exchange: Option<String>,
672    cfi: Option<String>,
673}
674
675impl RawContract {
676    fn into_massive_option_contract(self) -> Result<MassiveOptionContract, MassiveError> {
677        let ticker = self.ticker;
678
679        let underlying_ticker =
680            self.underlying_ticker
681                .ok_or_else(|| MassiveError::Deserialize {
682                    message: "missing underlying_ticker".into(),
683                    payload: ticker.clone(),
684                })?;
685
686        let contract_type = match self.contract_type.as_deref() {
687            Some("call") => OptionKind::Call,
688            Some("put") => OptionKind::Put,
689            other => {
690                return Err(MassiveError::Deserialize {
691                    message: format!("invalid contract_type: {:?}", other),
692                    payload: ticker.clone(),
693                });
694            }
695        };
696
697        let exercise_style = match self.exercise_style.as_deref() {
698            Some("american") => OptionExercise::American,
699            Some("european") => OptionExercise::European,
700            Some("bermudan") => OptionExercise::Bermudan,
701            other => {
702                warn!(
703                    ticker = %ticker,
704                    exercise_style = ?other,
705                    "Unknown exercise_style, defaulting to American"
706                );
707                OptionExercise::American
708            }
709        };
710
711        let expiration_date = self
712            .expiration_date
713            .ok_or_else(|| MassiveError::Deserialize {
714                message: "missing expiration_date".into(),
715                payload: ticker.clone(),
716            })?
717            .parse()
718            .map_err(|e| MassiveError::Deserialize {
719                message: format!("invalid expiration_date: {e}"),
720                payload: ticker.clone(),
721            })?;
722
723        let strike_price = self.strike_price.ok_or_else(|| MassiveError::Deserialize {
724            message: "missing strike_price".into(),
725            payload: ticker.clone(),
726        })?;
727
728        Ok(MassiveOptionContract {
729            ticker,
730            underlying_ticker,
731            contract_type,
732            exercise_style,
733            expiration_date,
734            strike_price,
735            shares_per_contract: self.shares_per_contract.unwrap_or(100),
736            primary_exchange: self.primary_exchange,
737            cfi: self.cfi,
738        })
739    }
740}
741
742#[derive(Deserialize)]
743struct SnapshotResponse {
744    results: Option<Vec<RawSnapshot>>,
745    next_url: Option<String>,
746    // Deserialized to satisfy serde; not surfaced in public type.
747    #[allow(dead_code)]
748    status: Option<String>,
749    // Deserialized to satisfy serde; not surfaced in public type.
750    #[allow(dead_code)]
751    request_id: Option<String>,
752}
753
754#[derive(Deserialize)]
755struct SingleSnapshotResponse {
756    results: Option<RawSnapshot>,
757    // Deserialized to satisfy serde; not surfaced in public type.
758    #[allow(dead_code)]
759    status: Option<String>,
760    // Deserialized to satisfy serde; not surfaced in public type.
761    #[allow(dead_code)]
762    request_id: Option<String>,
763}
764
765#[derive(Deserialize)]
766struct RawSnapshot {
767    details: Option<RawContract>,
768    greeks: Option<RawGreeks>,
769    implied_volatility: Option<f64>,
770    open_interest: Option<u64>,
771    #[serde(default, with = "rust_decimal::serde::float_option")]
772    break_even_price: Option<Decimal>,
773    day: Option<RawDayBar>,
774    last_quote: Option<RawQuote>,
775    last_trade: Option<RawTrade>,
776    underlying_asset: Option<RawUnderlying>,
777}
778
779#[derive(Deserialize)]
780struct RawGreeks {
781    delta: Option<f64>,
782    gamma: Option<f64>,
783    theta: Option<f64>,
784    vega: Option<f64>,
785}
786
787#[derive(Deserialize)]
788struct RawDayBar {
789    #[serde(default, with = "rust_decimal::serde::float_option")]
790    open: Option<Decimal>,
791    #[serde(default, with = "rust_decimal::serde::float_option")]
792    high: Option<Decimal>,
793    #[serde(default, with = "rust_decimal::serde::float_option")]
794    low: Option<Decimal>,
795    #[serde(default, with = "rust_decimal::serde::float_option")]
796    close: Option<Decimal>,
797    volume: Option<u64>,
798    #[serde(default, with = "rust_decimal::serde::float_option")]
799    vwap: Option<Decimal>,
800    #[serde(default, with = "rust_decimal::serde::float_option")]
801    change: Option<Decimal>,
802    change_percent: Option<f64>,
803}
804
805#[derive(Deserialize)]
806struct RawQuote {
807    #[serde(default, with = "rust_decimal::serde::float_option")]
808    bid: Option<Decimal>,
809    bid_size: Option<u64>,
810    #[serde(default, with = "rust_decimal::serde::float_option")]
811    ask: Option<Decimal>,
812    ask_size: Option<u64>,
813    #[serde(default, with = "rust_decimal::serde::float_option")]
814    midpoint: Option<Decimal>,
815}
816
817#[derive(Deserialize)]
818struct RawTrade {
819    #[serde(default, with = "rust_decimal::serde::float_option")]
820    price: Option<Decimal>,
821    size: Option<u64>,
822    exchange: Option<i32>,
823    conditions: Option<Vec<i32>>,
824}
825
826#[derive(Deserialize)]
827struct RawUnderlying {
828    ticker: Option<String>,
829    #[serde(default, with = "rust_decimal::serde::float_option")]
830    price: Option<Decimal>,
831    #[serde(default, with = "rust_decimal::serde::float_option")]
832    change: Option<Decimal>,
833    change_percent: Option<f64>,
834}
835
836impl RawSnapshot {
837    fn into_massive_option_snapshot(self) -> Result<MassiveOptionSnapshot, MassiveError> {
838        let contract = self
839            .details
840            .ok_or_else(|| MassiveError::Deserialize {
841                message: "missing details in snapshot".into(),
842                payload: String::new(),
843            })?
844            .into_massive_option_contract()?;
845
846        let greeks = self.greeks.map(|g| OptionGreeks {
847            delta: g.delta,
848            gamma: g.gamma,
849            theta: g.theta,
850            vega: g.vega,
851            // The snapshot's `greeks` object carries no rho.
852            rho: None,
853            implied_volatility: self.implied_volatility,
854            theoretical_price: None,
855            // Decimal::to_f64 returns None only for values outside f64 range
856            // (impossible for realistic underlying prices); fall back to NAN
857            // rather than 0.0 to preserve the "data unavailable" signal in
858            // downstream Greeks computations.
859            underlying_price: self
860                .underlying_asset
861                .as_ref()
862                .and_then(|u| u.price)
863                .map(|d| d.to_f64().unwrap_or(f64::NAN)),
864        });
865
866        let day = self.day.map(|d| OptionDayBar {
867            open: d.open,
868            high: d.high,
869            low: d.low,
870            close: d.close,
871            volume: d.volume,
872            vwap: d.vwap,
873            change: d.change,
874            change_percent: d.change_percent,
875        });
876
877        let last_quote = self.last_quote.map(|q| OptionQuote {
878            bid: q.bid,
879            bid_size: q.bid_size,
880            ask: q.ask,
881            ask_size: q.ask_size,
882            midpoint: q.midpoint,
883        });
884
885        let last_trade = self.last_trade.map(|t| OptionTrade {
886            price: t.price,
887            size: t.size,
888            exchange: t.exchange,
889            conditions: t.conditions,
890        });
891
892        let underlying = self.underlying_asset.and_then(|u| {
893            u.ticker.map(|ticker| UnderlyingAsset {
894                ticker,
895                price: u.price,
896                change: u.change,
897                change_percent: u.change_percent,
898            })
899        });
900
901        Ok(MassiveOptionSnapshot {
902            contract,
903            greeks,
904            implied_volatility: self.implied_volatility,
905            open_interest: self.open_interest,
906            break_even_price: self.break_even_price,
907            day,
908            last_quote,
909            last_trade,
910            underlying,
911        })
912    }
913}
914
915// ============================================================================
916// Client Implementation
917// ============================================================================
918
919impl MassiveRestClient {
920    /// Fetch option contracts matching the query.
921    ///
922    /// Automatically paginates through all results.
923    ///
924    /// # Subscription
925    ///
926    /// Available on all Options plans including Basic (free tier).
927    ///
928    /// # Example
929    ///
930    /// ```ignore
931    /// use rustrade_data::exchange::massive::{MassiveRestClient, OptionContractQuery};
932    ///
933    /// let client = MassiveRestClient::from_env()?;
934    /// let query = OptionContractQuery::new()
935    ///     .underlying_ticker("AAPL")
936    ///     .contract_type(OptionKind::Call)
937    ///     .limit(100);
938    ///
939    /// let contracts = client.fetch_option_contracts(&query).await?;
940    /// for c in &contracts {
941    ///     println!("{}: {} {} @ {}", c.ticker, c.expiration_date, c.contract_type, c.strike_price);
942    /// }
943    /// ```
944    pub async fn fetch_option_contracts(
945        &self,
946        query: &OptionContractQuery,
947    ) -> Result<Vec<MassiveOptionContract>, MassiveError> {
948        query.validate()?;
949
950        let base_url = self.base_url();
951        let initial_url = format!(
952            "{}/v3/reference/options/contracts{}",
953            base_url,
954            query.to_query_string()
955        );
956
957        let mut contracts: Vec<MassiveOptionContract> =
958            Vec::with_capacity(query.limit.unwrap_or(100) as usize);
959        let mut next_url: Option<String> = Some(initial_url);
960        let mut guard = PaginationGuard::default();
961
962        while let Some(url) = next_url.take() {
963            guard.observe(&url)?;
964            debug!(url = %url, "Fetching option contracts page");
965
966            let body = self.fetch_page_body(&url).await?;
967            let parsed: ContractsResponse =
968                serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
969                    message: e.to_string(),
970                    payload: body,
971                })?;
972
973            if let Some(results) = parsed.results {
974                for raw in results {
975                    contracts.push(raw.into_massive_option_contract()?);
976                }
977            }
978
979            next_url = parsed.next_url;
980        }
981
982        Ok(contracts)
983    }
984
985    /// Fetch option chain snapshot for an underlying asset.
986    ///
987    /// Returns all option contracts for the underlying with current market data
988    /// including Greeks.
989    ///
990    /// # Subscription
991    ///
992    /// Requires Options Starter or higher subscription. Data recency:
993    /// - Starter/Developer: 15-minute delayed
994    /// - Advanced/Business: Real-time
995    ///
996    /// # Note
997    ///
998    /// Greeks may be `None` for deep ITM options. Rho is NOT available.
999    ///
1000    /// # Example
1001    ///
1002    /// ```ignore
1003    /// use rustrade_data::exchange::massive::{MassiveRestClient, OptionSnapshotQuery};
1004    ///
1005    /// let client = MassiveRestClient::from_env()?;
1006    /// let query = OptionSnapshotQuery::new()
1007    ///     .strike_price_gte(dec!(150))
1008    ///     .strike_price_lte(dec!(200))
1009    ///     .limit(50);
1010    ///
1011    /// let snapshots = client.fetch_option_chain_snapshot("AAPL", &query).await?;
1012    /// for snap in snapshots {
1013    ///     if let Some(greeks) = &snap.greeks {
1014    ///         println!("{}: delta={:?}", snap.contract.ticker, greeks.delta);
1015    ///     }
1016    /// }
1017    /// ```
1018    pub async fn fetch_option_chain_snapshot(
1019        &self,
1020        underlying: &str,
1021        query: &OptionSnapshotQuery,
1022    ) -> Result<Vec<MassiveOptionSnapshot>, MassiveError> {
1023        Self::validate_ticker(underlying)?;
1024        query.validate()?;
1025
1026        let base_url = self.base_url();
1027        let initial_url = format!(
1028            "{}/v3/snapshot/options/{}{}",
1029            base_url,
1030            underlying,
1031            query.to_query_string()
1032        );
1033
1034        let mut snapshots: Vec<MassiveOptionSnapshot> =
1035            Vec::with_capacity(query.limit.unwrap_or(50) as usize);
1036        let mut next_url: Option<String> = Some(initial_url);
1037        let mut guard = PaginationGuard::default();
1038
1039        while let Some(url) = next_url.take() {
1040            guard.observe(&url)?;
1041            debug!(url = %url, "Fetching option chain snapshot page");
1042
1043            let body = self.fetch_page_body(&url).await?;
1044            let parsed: SnapshotResponse =
1045                serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1046                    message: e.to_string(),
1047                    payload: body,
1048                })?;
1049
1050            if let Some(results) = parsed.results {
1051                for raw in results {
1052                    snapshots.push(raw.into_massive_option_snapshot()?);
1053                }
1054            }
1055
1056            next_url = parsed.next_url;
1057        }
1058
1059        Ok(snapshots)
1060    }
1061
1062    /// Fetch a single option contract snapshot.
1063    ///
1064    /// # Subscription
1065    ///
1066    /// Requires Options Starter or higher subscription.
1067    ///
1068    /// # Arguments
1069    ///
1070    /// * `underlying` - Underlying ticker (e.g., "AAPL")
1071    /// * `contract` - Option contract ticker (e.g., "O:AAPL251219C00150000")
1072    ///
1073    /// # Example
1074    ///
1075    /// ```ignore
1076    /// let snapshot = client.fetch_option_snapshot("AAPL", "O:AAPL251219C00150000").await?;
1077    /// println!("IV: {:?}, Delta: {:?}",
1078    ///     snapshot.implied_volatility,
1079    ///     snapshot.greeks.as_ref().and_then(|g| g.delta)
1080    /// );
1081    /// ```
1082    pub async fn fetch_option_snapshot(
1083        &self,
1084        underlying: &str,
1085        contract: &str,
1086    ) -> Result<MassiveOptionSnapshot, MassiveError> {
1087        Self::validate_ticker(underlying)?;
1088        Self::validate_ticker(contract)?;
1089
1090        let url = format!(
1091            "{}/v3/snapshot/options/{}/{}",
1092            self.base_url(),
1093            underlying,
1094            contract
1095        );
1096
1097        debug!(url = %url, "Fetching option contract snapshot");
1098
1099        let body = self.fetch_page_body(&url).await?;
1100        let parsed: SingleSnapshotResponse =
1101            serde_json::from_str(&body).map_err(|e| MassiveError::Deserialize {
1102                message: e.to_string(),
1103                payload: body,
1104            })?;
1105
1106        parsed
1107            .results
1108            .ok_or_else(|| MassiveError::Api {
1109                status: 404,
1110                message: format!("Option contract not found: {}", contract),
1111            })?
1112            .into_massive_option_snapshot()
1113    }
1114}
1115
1116// ============================================================================
1117// Tests
1118// ============================================================================
1119
1120#[cfg(test)]
1121#[allow(clippy::unwrap_used)]
1122mod tests {
1123    use super::*;
1124    use rust_decimal_macros::dec;
1125
1126    #[test]
1127    fn test_option_contract_query_empty() {
1128        let query = OptionContractQuery::new();
1129        assert_eq!(query.to_query_string(), "");
1130    }
1131
1132    #[test]
1133    fn test_option_contract_query_underlying() {
1134        let query = OptionContractQuery::new().underlying_ticker("AAPL");
1135        assert_eq!(query.to_query_string(), "?underlying_ticker=AAPL");
1136    }
1137
1138    #[test]
1139    fn test_option_contract_query_full() {
1140        let query = OptionContractQuery::new()
1141            .underlying_ticker("AAPL")
1142            .contract_type(OptionKind::Call)
1143            .expiration_date_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
1144            .strike_price_gte(dec!(150))
1145            .strike_price_lte(dec!(200))
1146            .limit(500);
1147
1148        let qs = query.to_query_string();
1149        assert!(qs.contains("underlying_ticker=AAPL"));
1150        assert!(qs.contains("contract_type=call"));
1151        assert!(qs.contains("expiration_date.gte=2024-01-01"));
1152        assert!(qs.contains("strike_price.gte=150"));
1153        assert!(qs.contains("strike_price.lte=200"));
1154        assert!(qs.contains("limit=500"));
1155    }
1156
1157    #[test]
1158    fn test_option_contract_query_validation_ok() {
1159        let query = OptionContractQuery::new()
1160            .expiration_date_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
1161            .expiration_date_lte(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
1162        assert!(query.validate().is_ok());
1163    }
1164
1165    #[test]
1166    fn test_option_contract_query_validation_conflict_expiration() {
1167        let query = OptionContractQuery::new()
1168            .expiration_date(NaiveDate::from_ymd_opt(2024, 6, 21).unwrap())
1169            .expiration_date_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
1170        assert!(query.validate().is_err());
1171    }
1172
1173    #[test]
1174    fn test_option_contract_query_validation_conflict_strike() {
1175        let query = OptionContractQuery::new()
1176            .strike_price(dec!(150))
1177            .strike_price_gte(dec!(100));
1178        assert!(query.validate().is_err());
1179    }
1180
1181    #[test]
1182    fn test_option_snapshot_query_empty() {
1183        let query = OptionSnapshotQuery::new();
1184        assert_eq!(query.to_query_string(), "");
1185    }
1186
1187    #[test]
1188    fn test_option_snapshot_query_limit_clamped() {
1189        let query = OptionSnapshotQuery::new().limit(500);
1190        assert!(query.to_query_string().contains("limit=250"));
1191    }
1192
1193    #[test]
1194    fn test_option_snapshot_query_validation_conflict_strike() {
1195        let query = OptionSnapshotQuery::new()
1196            .strike_price(dec!(150))
1197            .strike_price_gte(dec!(100));
1198        assert!(query.validate().is_err());
1199    }
1200
1201    #[test]
1202    fn test_option_snapshot_query_validation_conflict_expiration() {
1203        let query = OptionSnapshotQuery::new()
1204            .expiration_date(NaiveDate::from_ymd_opt(2024, 6, 21).unwrap())
1205            .expiration_date_lte(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
1206        assert!(query.validate().is_err());
1207    }
1208
1209    #[test]
1210    fn test_raw_contract_parsing() {
1211        let json = r#"{
1212            "ticker": "O:AAPL251219C00150000",
1213            "underlying_ticker": "AAPL",
1214            "contract_type": "call",
1215            "exercise_style": "american",
1216            "expiration_date": "2025-12-19",
1217            "strike_price": 150.0,
1218            "shares_per_contract": 100,
1219            "primary_exchange": "XCBO",
1220            "cfi": "OCASPS"
1221        }"#;
1222
1223        let raw: RawContract = serde_json::from_str(json).unwrap();
1224        let contract = raw.into_massive_option_contract().unwrap();
1225
1226        assert_eq!(contract.ticker, "O:AAPL251219C00150000");
1227        assert_eq!(contract.underlying_ticker, "AAPL");
1228        assert_eq!(contract.contract_type, OptionKind::Call);
1229        assert_eq!(contract.exercise_style, OptionExercise::American);
1230        assert_eq!(
1231            contract.expiration_date,
1232            NaiveDate::from_ymd_opt(2025, 12, 19).unwrap()
1233        );
1234        assert_eq!(contract.strike_price, dec!(150));
1235        assert_eq!(contract.shares_per_contract, 100);
1236        assert_eq!(contract.primary_exchange, Some("XCBO".to_string()));
1237    }
1238
1239    #[test]
1240    fn test_raw_contract_parsing_put() {
1241        let json = r#"{
1242            "ticker": "O:AAPL251219P00150000",
1243            "underlying_ticker": "AAPL",
1244            "contract_type": "put",
1245            "exercise_style": "european",
1246            "expiration_date": "2025-12-19",
1247            "strike_price": 150.0
1248        }"#;
1249
1250        let raw: RawContract = serde_json::from_str(json).unwrap();
1251        let contract = raw.into_massive_option_contract().unwrap();
1252
1253        assert_eq!(contract.contract_type, OptionKind::Put);
1254        assert_eq!(contract.exercise_style, OptionExercise::European);
1255        assert_eq!(contract.shares_per_contract, 100); // default
1256    }
1257
1258    #[test]
1259    fn test_to_market_data_contract() {
1260        let raw: RawContract = serde_json::from_str(
1261            r#"{
1262                "ticker": "O:AAPL251219C00150000",
1263                "underlying_ticker": "AAPL",
1264                "contract_type": "call",
1265                "exercise_style": "american",
1266                "expiration_date": "2025-12-19",
1267                "strike_price": 150.0
1268            }"#,
1269        )
1270        .unwrap();
1271        let massive = raw.into_massive_option_contract().unwrap();
1272        let md = massive.to_market_data_contract();
1273
1274        assert_eq!(md.kind, OptionKind::Call);
1275        assert_eq!(md.exercise, OptionExercise::American);
1276        assert_eq!(md.strike, dec!(150));
1277        // Expiration is anchored at UTC midnight on the expiration date.
1278        assert_eq!(
1279            md.expiry,
1280            NaiveDate::from_ymd_opt(2025, 12, 19)
1281                .unwrap()
1282                .and_time(NaiveTime::MIN)
1283                .and_utc()
1284        );
1285    }
1286
1287    #[test]
1288    fn test_raw_snapshot_parsing() {
1289        let json = r#"{
1290            "details": {
1291                "ticker": "O:AAPL251219C00150000",
1292                "underlying_ticker": "AAPL",
1293                "contract_type": "call",
1294                "exercise_style": "american",
1295                "expiration_date": "2025-12-19",
1296                "strike_price": 150.0
1297            },
1298            "greeks": {
1299                "delta": 0.55,
1300                "gamma": 0.02,
1301                "theta": -0.05,
1302                "vega": 0.15
1303            },
1304            "implied_volatility": 0.25,
1305            "open_interest": 5000,
1306            "break_even_price": 155.50,
1307            "day": {
1308                "open": 5.00,
1309                "high": 5.50,
1310                "low": 4.80,
1311                "close": 5.25,
1312                "volume": 1000
1313            },
1314            "last_quote": {
1315                "bid": 5.20,
1316                "ask": 5.30,
1317                "bid_size": 50,
1318                "ask_size": 75
1319            },
1320            "underlying_asset": {
1321                "ticker": "AAPL",
1322                "price": 175.50,
1323                "change": 2.50,
1324                "change_percent": 1.44
1325            }
1326        }"#;
1327
1328        let raw: RawSnapshot = serde_json::from_str(json).unwrap();
1329        let snapshot = raw.into_massive_option_snapshot().unwrap();
1330
1331        assert_eq!(snapshot.contract.ticker, "O:AAPL251219C00150000");
1332        assert!(snapshot.greeks.is_some());
1333
1334        let greeks = snapshot.greeks.unwrap();
1335        assert_eq!(greeks.delta, Some(0.55));
1336        assert_eq!(greeks.gamma, Some(0.02));
1337        assert_eq!(greeks.theta, Some(-0.05));
1338        assert_eq!(greeks.vega, Some(0.15));
1339        assert_eq!(greeks.implied_volatility, Some(0.25));
1340        assert_eq!(greeks.underlying_price, Some(175.50));
1341
1342        assert_eq!(snapshot.implied_volatility, Some(0.25));
1343        assert_eq!(snapshot.open_interest, Some(5000));
1344        assert_eq!(snapshot.break_even_price, Some(dec!(155.50)));
1345
1346        let day = snapshot.day.unwrap();
1347        assert_eq!(day.open, Some(dec!(5.00)));
1348        assert_eq!(day.close, Some(dec!(5.25)));
1349
1350        let underlying = snapshot.underlying.unwrap();
1351        assert_eq!(underlying.ticker, "AAPL");
1352        assert_eq!(underlying.price, Some(dec!(175.50)));
1353    }
1354
1355    #[test]
1356    fn test_raw_snapshot_minimal() {
1357        let json = r#"{
1358            "details": {
1359                "ticker": "O:AAPL251219C00150000",
1360                "underlying_ticker": "AAPL",
1361                "contract_type": "call",
1362                "exercise_style": "american",
1363                "expiration_date": "2025-12-19",
1364                "strike_price": 150.0
1365            }
1366        }"#;
1367
1368        let raw: RawSnapshot = serde_json::from_str(json).unwrap();
1369        let snapshot = raw.into_massive_option_snapshot().unwrap();
1370
1371        assert_eq!(snapshot.contract.ticker, "O:AAPL251219C00150000");
1372        assert!(snapshot.greeks.is_none());
1373        assert!(snapshot.day.is_none());
1374        assert!(snapshot.last_quote.is_none());
1375    }
1376
1377    #[test]
1378    fn test_raw_snapshot_iv_without_greeks() {
1379        // Massive may return implied_volatility at the snapshot level
1380        // even when no greeks block is present (deep ITM case).
1381        let json = r#"{
1382            "details": {
1383                "ticker": "O:AAPL251219C00150000",
1384                "underlying_ticker": "AAPL",
1385                "contract_type": "call",
1386                "exercise_style": "american",
1387                "expiration_date": "2025-12-19",
1388                "strike_price": 150.0
1389            },
1390            "implied_volatility": 0.42
1391        }"#;
1392
1393        let raw: RawSnapshot = serde_json::from_str(json).unwrap();
1394        let snapshot = raw.into_massive_option_snapshot().unwrap();
1395
1396        assert!(snapshot.greeks.is_none());
1397        assert_eq!(snapshot.implied_volatility, Some(0.42));
1398    }
1399}