1use 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#[derive(Debug, Default, Clone)]
67pub struct OptionContractQuery {
68 pub underlying_ticker: Option<String>,
70 pub contract_type: Option<OptionKind>,
72 pub expiration_date: Option<NaiveDate>,
74 pub expiration_date_gte: Option<NaiveDate>,
76 pub expiration_date_gt: Option<NaiveDate>,
78 pub expiration_date_lte: Option<NaiveDate>,
80 pub expiration_date_lt: Option<NaiveDate>,
82 pub strike_price: Option<Decimal>,
84 pub strike_price_gte: Option<Decimal>,
86 pub strike_price_gt: Option<Decimal>,
88 pub strike_price_lte: Option<Decimal>,
90 pub strike_price_lt: Option<Decimal>,
92 pub expired: Option<bool>,
94 pub as_of: Option<NaiveDate>,
96 pub limit: Option<u16>,
98 pub order: Option<SortOrder>,
100 pub sort: Option<String>,
102}
103
104impl OptionContractQuery {
105 #[must_use]
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 #[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 #[must_use]
120 pub fn contract_type(mut self, v: OptionKind) -> Self {
121 self.contract_type = Some(v);
122 self
123 }
124
125 #[must_use]
127 pub fn expiration_date(mut self, v: NaiveDate) -> Self {
128 self.expiration_date = Some(v);
129 self
130 }
131
132 #[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 #[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 #[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 #[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 #[must_use]
162 pub fn strike_price(mut self, v: Decimal) -> Self {
163 self.strike_price = Some(v);
164 self
165 }
166
167 #[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 #[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 #[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 #[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 #[must_use]
197 pub fn expired(mut self, v: bool) -> Self {
198 self.expired = Some(v);
199 self
200 }
201
202 #[must_use]
204 pub fn as_of(mut self, v: NaiveDate) -> Self {
205 self.as_of = Some(v);
206 self
207 }
208
209 #[must_use]
211 pub fn limit(mut self, v: u16) -> Self {
212 self.limit = Some(v.min(1000));
213 self
214 }
215
216 #[must_use]
218 pub fn order(mut self, v: SortOrder) -> Self {
219 self.order = Some(v);
220 self
221 }
222
223 #[must_use]
225 pub fn sort(mut self, v: impl Into<String>) -> Self {
226 self.sort = Some(v.into());
227 self
228 }
229
230 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 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#[derive(Debug, Default, Clone)]
337pub struct OptionSnapshotQuery {
338 pub strike_price: Option<Decimal>,
340 pub strike_price_gte: Option<Decimal>,
342 pub strike_price_lte: Option<Decimal>,
344 pub expiration_date: Option<NaiveDate>,
346 pub expiration_date_gte: Option<NaiveDate>,
348 pub expiration_date_lte: Option<NaiveDate>,
350 pub contract_type: Option<OptionKind>,
352 pub limit: Option<u16>,
354 pub order: Option<SortOrder>,
356 pub sort: Option<String>,
358}
359
360impl OptionSnapshotQuery {
361 #[must_use]
363 pub fn new() -> Self {
364 Self::default()
365 }
366
367 #[must_use]
369 pub fn strike_price(mut self, v: Decimal) -> Self {
370 self.strike_price = Some(v);
371 self
372 }
373
374 #[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 #[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 #[must_use]
390 pub fn expiration_date(mut self, v: NaiveDate) -> Self {
391 self.expiration_date = Some(v);
392 self
393 }
394
395 #[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 #[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 #[must_use]
411 pub fn contract_type(mut self, v: OptionKind) -> Self {
412 self.contract_type = Some(v);
413 self
414 }
415
416 #[must_use]
418 pub fn limit(mut self, v: u16) -> Self {
419 self.limit = Some(v.min(250));
420 self
421 }
422
423 #[must_use]
425 pub fn order(mut self, v: SortOrder) -> Self {
426 self.order = Some(v);
427 self
428 }
429
430 #[must_use]
432 pub fn sort(mut self, v: impl Into<String>) -> Self {
433 self.sort = Some(v.into());
434 self
435 }
436
437 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 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#[derive(Debug, Clone, PartialEq)]
520#[non_exhaustive]
521pub struct MassiveOptionContract {
522 pub ticker: String,
524 pub underlying_ticker: String,
526 pub contract_type: OptionKind,
528 pub exercise_style: OptionExercise,
530 pub expiration_date: NaiveDate,
532 pub strike_price: Decimal,
534 pub shares_per_contract: u32,
536 pub primary_exchange: Option<String>,
538 pub cfi: Option<String>,
540}
541
542impl MassiveOptionContract {
543 #[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#[derive(Debug, Clone, PartialEq)]
574#[non_exhaustive]
575pub struct MassiveOptionSnapshot {
576 pub contract: MassiveOptionContract,
578 pub greeks: Option<OptionGreeks>,
580 pub implied_volatility: Option<f64>,
582 pub open_interest: Option<u64>,
584 pub break_even_price: Option<Decimal>,
586 pub day: Option<OptionDayBar>,
588 pub last_quote: Option<OptionQuote>,
590 pub last_trade: Option<OptionTrade>,
592 pub underlying: Option<UnderlyingAsset>,
594}
595
596#[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#[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#[derive(Debug, Clone, PartialEq)]
623#[non_exhaustive]
624pub struct OptionTrade {
625 pub price: Option<Decimal>,
626 pub size: Option<u64>,
627 pub exchange: Option<i32>,
630 pub conditions: Option<Vec<i32>>,
633}
634
635#[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#[derive(Deserialize)]
650struct ContractsResponse {
651 results: Option<Vec<RawContract>>,
652 next_url: Option<String>,
653 #[allow(dead_code)]
655 status: Option<String>,
656 #[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 #[allow(dead_code)]
748 status: Option<String>,
749 #[allow(dead_code)]
751 request_id: Option<String>,
752}
753
754#[derive(Deserialize)]
755struct SingleSnapshotResponse {
756 results: Option<RawSnapshot>,
757 #[allow(dead_code)]
759 status: Option<String>,
760 #[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 rho: None,
853 implied_volatility: self.implied_volatility,
854 theoretical_price: None,
855 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
915impl MassiveRestClient {
920 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 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 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#[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); }
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 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 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}