Skip to main content

rustrade_data/exchange/alpaca/options/
contracts.rs

1//! Option contract discovery types and methods.
2//!
3//! Implements `GET /v2/options/contracts` for querying available option contracts.
4
5use super::super::pagination::PaginationGuard;
6use super::{AlpacaOptionsClient, AlpacaOptionsError};
7use chrono::NaiveDate;
8use rust_decimal::Decimal;
9use rustrade_instrument::instrument::kind::option::{OptionExercise, OptionKind};
10use serde::{Deserialize, Serialize};
11use std::str::FromStr;
12use tracing::{debug, warn};
13
14/// Maximum contracts per page (Alpaca API limit).
15const MAX_PAGE_SIZE: usize = 10_000;
16
17/// Default page size for contract queries.
18const DEFAULT_PAGE_SIZE: usize = 1_000;
19
20/// Maximum pages a single fetch will follow before failing with a terminal
21/// `PaginationLimitExceeded` (a runaway backstop, not a business limit).
22const MAX_PAGES: usize = 100;
23
24/// Query parameters for option contract discovery.
25///
26/// Use builder methods to construct the query, then pass to
27/// [`AlpacaOptionsClient::fetch_contracts`].
28///
29/// # Example
30///
31/// ```
32/// use rustrade_data::exchange::alpaca::options::AlpacaOptionContractQuery;
33/// use chrono::NaiveDate;
34/// use rust_decimal_macros::dec;
35///
36/// let query = AlpacaOptionContractQuery::new(vec!["AAPL".into(), "TSLA".into()])
37///     .expiration_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
38///     .expiration_lte(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
39///     .strike_gte(dec!(100))
40///     .strike_lte(dec!(200))
41///     .call_only();
42/// ```
43#[derive(Debug, Clone, Default)]
44pub struct AlpacaOptionContractQuery {
45    /// Filter by underlying symbols (e.g., ["AAPL", "TSLA"]).
46    pub underlying_symbols: Vec<String>,
47    /// Minimum expiration date (inclusive).
48    pub expiration_date_gte: Option<NaiveDate>,
49    /// Maximum expiration date (inclusive).
50    pub expiration_date_lte: Option<NaiveDate>,
51    /// Minimum strike price (inclusive).
52    pub strike_price_gte: Option<Decimal>,
53    /// Maximum strike price (inclusive).
54    pub strike_price_lte: Option<Decimal>,
55    /// Filter by option type (call or put).
56    pub option_type: Option<OptionKind>,
57    /// Filter by exercise style (american or european).
58    pub style: Option<OptionExercise>,
59    /// Page size (default 1000, max 10000).
60    pub limit: Option<usize>,
61}
62
63impl AlpacaOptionContractQuery {
64    /// Create a new query for the given underlying symbols.
65    pub fn new(underlying_symbols: Vec<String>) -> Self {
66        Self {
67            underlying_symbols,
68            ..Default::default()
69        }
70    }
71
72    /// Filter contracts expiring on or after this date.
73    #[must_use]
74    pub fn expiration_gte(mut self, date: NaiveDate) -> Self {
75        self.expiration_date_gte = Some(date);
76        self
77    }
78
79    /// Filter contracts expiring on or before this date.
80    #[must_use]
81    pub fn expiration_lte(mut self, date: NaiveDate) -> Self {
82        self.expiration_date_lte = Some(date);
83        self
84    }
85
86    /// Filter contracts with strike price at or above this value.
87    #[must_use]
88    pub fn strike_gte(mut self, strike: Decimal) -> Self {
89        self.strike_price_gte = Some(strike);
90        self
91    }
92
93    /// Filter contracts with strike price at or below this value.
94    #[must_use]
95    pub fn strike_lte(mut self, strike: Decimal) -> Self {
96        self.strike_price_lte = Some(strike);
97        self
98    }
99
100    /// Filter to call options only.
101    #[must_use]
102    pub fn call_only(mut self) -> Self {
103        self.option_type = Some(OptionKind::Call);
104        self
105    }
106
107    /// Filter to put options only.
108    #[must_use]
109    pub fn put_only(mut self) -> Self {
110        self.option_type = Some(OptionKind::Put);
111        self
112    }
113
114    /// Filter by option type.
115    #[must_use]
116    pub fn option_type(mut self, kind: OptionKind) -> Self {
117        self.option_type = Some(kind);
118        self
119    }
120
121    /// Filter by exercise style.
122    #[must_use]
123    pub fn style(mut self, style: OptionExercise) -> Self {
124        self.style = Some(style);
125        self
126    }
127
128    /// Set page size (max 10000).
129    #[must_use]
130    pub fn limit(mut self, limit: usize) -> Self {
131        self.limit = Some(limit.min(MAX_PAGE_SIZE));
132        self
133    }
134
135    /// Build query parameters for the API request.
136    fn to_query_params(&self) -> Vec<(&'static str, String)> {
137        let mut params = Vec::new();
138
139        if !self.underlying_symbols.is_empty() {
140            params.push(("underlying_symbols", self.underlying_symbols.join(",")));
141        }
142        if let Some(date) = self.expiration_date_gte {
143            params.push(("expiration_date_gte", date.format("%Y-%m-%d").to_string()));
144        }
145        if let Some(date) = self.expiration_date_lte {
146            params.push(("expiration_date_lte", date.format("%Y-%m-%d").to_string()));
147        }
148        if let Some(strike) = self.strike_price_gte {
149            params.push(("strike_price_gte", strike.to_string()));
150        }
151        if let Some(strike) = self.strike_price_lte {
152            params.push(("strike_price_lte", strike.to_string()));
153        }
154        if let Some(kind) = self.option_type {
155            params.push((
156                "type",
157                match kind {
158                    OptionKind::Call => "call",
159                    OptionKind::Put => "put",
160                }
161                .to_string(),
162            ));
163        }
164        if let Some(style) = self.style {
165            // Alpaca only supports American and European styles. Bermudan has no API filter, so it
166            // is omitted here rather than silently returning an unfiltered result set the caller did
167            // not ask for. The omission is warned about once per logical query in `fetch_contracts`
168            // (this builder runs once per page, so warning here would repeat up to MAX_PAGES times).
169            let style_str = match style {
170                OptionExercise::American => Some("american"),
171                OptionExercise::European => Some("european"),
172                OptionExercise::Bermudan => None,
173            };
174            if let Some(s) = style_str {
175                params.push(("style", s.to_string()));
176            }
177        }
178
179        let limit = self.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
180        params.push(("limit", limit.to_string()));
181
182        // Only return active contracts
183        params.push(("status", "active".to_string()));
184
185        params
186    }
187}
188
189/// Alpaca option contract from the API response.
190///
191/// Represents a single option contract with its metadata.
192#[non_exhaustive]
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct AlpacaOptionContract {
195    /// Unique contract identifier.
196    pub id: String,
197    /// OCC symbol (e.g., "AAPL240119C00150000").
198    pub symbol: String,
199    /// Human-readable name (e.g., "AAPL Jan 19 2024 150 Call").
200    pub name: String,
201    /// Contract status ("active" or "inactive").
202    pub status: String,
203    /// Whether the contract is tradable.
204    pub tradable: bool,
205    /// Expiration date.
206    #[serde(with = "date_format")]
207    pub expiration_date: NaiveDate,
208    /// Root symbol (e.g., "AAPL").
209    pub root_symbol: String,
210    /// Underlying symbol.
211    pub underlying_symbol: String,
212    /// Underlying asset ID.
213    pub underlying_asset_id: String,
214    /// Option type ("call" or "put").
215    #[serde(rename = "type")]
216    pub option_type: String,
217    /// Exercise style ("american" or "european").
218    pub style: String,
219    /// Strike price.
220    #[serde(deserialize_with = "deserialize_decimal_string")]
221    pub strike_price: Decimal,
222    /// Contract multiplier (e.g., "100").
223    #[serde(deserialize_with = "deserialize_decimal_string")]
224    pub size: Decimal,
225    /// Open interest (number of outstanding contracts).
226    #[serde(default, deserialize_with = "deserialize_option_decimal_string")]
227    pub open_interest: Option<Decimal>,
228    /// Date of open interest data.
229    #[serde(default, with = "option_date_format")]
230    pub open_interest_date: Option<NaiveDate>,
231    /// Previous close price.
232    #[serde(default, deserialize_with = "deserialize_option_decimal_string")]
233    pub close_price: Option<Decimal>,
234    /// Date of close price.
235    #[serde(default, with = "option_date_format")]
236    pub close_price_date: Option<NaiveDate>,
237}
238
239impl AlpacaOptionContract {
240    /// Get the option kind (Call or Put).
241    pub fn kind(&self) -> Option<OptionKind> {
242        match self.option_type.as_str() {
243            "call" => Some(OptionKind::Call),
244            "put" => Some(OptionKind::Put),
245            _ => None,
246        }
247    }
248
249    /// Get the exercise style (American or European).
250    pub fn exercise(&self) -> Option<OptionExercise> {
251        match self.style.as_str() {
252            "american" => Some(OptionExercise::American),
253            "european" => Some(OptionExercise::European),
254            _ => None,
255        }
256    }
257}
258
259/// API response wrapper for option contracts.
260#[derive(Debug, Deserialize)]
261struct ContractsResponse {
262    option_contracts: Option<Vec<AlpacaOptionContract>>,
263    #[serde(default)]
264    next_page_token: Option<String>,
265}
266
267impl AlpacaOptionsClient {
268    /// Fetch option contracts matching the query.
269    ///
270    /// Automatically paginates through all results. Pagination is bounded: the fetch returns a
271    /// terminal [`AlpacaOptionsError::PaginationLimitExceeded`] if it exceeds 100 pages, and
272    /// [`AlpacaOptionsError::CyclicPagination`] if the server repeats a `page_token` — a
273    /// silently truncated result would be indistinguishable from a genuinely small one, so
274    /// incomplete pagination fails loudly instead.
275    ///
276    /// # Arguments
277    ///
278    /// * `query` - Query parameters for filtering contracts
279    ///
280    /// # Returns
281    ///
282    /// Vector of matching option contracts.
283    ///
284    /// # Errors
285    ///
286    /// Returns error on network failure, API error, invalid response, or out-of-bounds
287    /// pagination (see above).
288    pub async fn fetch_contracts(
289        &self,
290        query: &AlpacaOptionContractQuery,
291    ) -> Result<Vec<AlpacaOptionContract>, AlpacaOptionsError> {
292        let mut all_contracts = Vec::new();
293        let mut page_token: Option<String> = None;
294        let mut guard = PaginationGuard::new(MAX_PAGES);
295
296        // Warn once per logical query (not once per page) if the caller requested a style Alpaca
297        // cannot filter on. `to_query_params` silently omits the `style` param in that case; this
298        // is where the caller learns their filter was dropped.
299        if query.style == Some(OptionExercise::Bermudan) {
300            warn!(
301                style = ?OptionExercise::Bermudan,
302                "AlpacaOptionContractQuery: exercise style is not supported by Alpaca; \
303                 omitting the `style` filter (results will not be style-restricted)"
304            );
305        }
306
307        loop {
308            guard.observe(page_token.as_deref())?;
309
310            let mut params = query.to_query_params();
311            if let Some(ref token) = page_token {
312                params.push(("page_token", token.clone()));
313            }
314
315            let url = format!("{}/v2/options/contracts", self.rest.broker_base());
316            let request = self.rest.get(&url).query(&params);
317
318            let response: ContractsResponse = self.rest.request_with_retry(request).await?;
319
320            let contracts = response.option_contracts.unwrap_or_default();
321            let count = contracts.len();
322            all_contracts.extend(contracts);
323
324            debug!(
325                page = guard.pages(),
326                count,
327                total = all_contracts.len(),
328                "fetched contracts page"
329            );
330
331            match response.next_page_token {
332                Some(token) if !token.is_empty() => {
333                    page_token = Some(token);
334                }
335                _ => break,
336            }
337        }
338
339        Ok(all_contracts)
340    }
341}
342
343// Custom deserializer for decimal strings
344fn deserialize_decimal_string<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
345where
346    D: serde::Deserializer<'de>,
347{
348    let s = String::deserialize(deserializer)?;
349    Decimal::from_str(&s).map_err(serde::de::Error::custom)
350}
351
352fn deserialize_option_decimal_string<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
353where
354    D: serde::Deserializer<'de>,
355{
356    let opt: Option<String> = Option::deserialize(deserializer)?;
357    match opt {
358        Some(s) if !s.is_empty() => Decimal::from_str(&s)
359            .map(Some)
360            .map_err(serde::de::Error::custom),
361        _ => Ok(None),
362    }
363}
364
365mod date_format {
366    use chrono::NaiveDate;
367    use serde::{self, Deserialize, Deserializer, Serializer};
368
369    pub(super) const FORMAT: &str = "%Y-%m-%d";
370
371    pub fn serialize<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
372    where
373        S: Serializer,
374    {
375        serializer.serialize_str(&date.format(FORMAT).to_string())
376    }
377
378    pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
379    where
380        D: Deserializer<'de>,
381    {
382        let s = String::deserialize(deserializer)?;
383        NaiveDate::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)
384    }
385}
386
387mod option_date_format {
388    use chrono::NaiveDate;
389    use serde::{self, Deserialize, Deserializer, Serializer};
390
391    use super::date_format::FORMAT;
392
393    pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
394    where
395        S: Serializer,
396    {
397        match date {
398            Some(d) => serializer.serialize_some(&d.format(FORMAT).to_string()),
399            None => serializer.serialize_none(),
400        }
401    }
402
403    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
404    where
405        D: Deserializer<'de>,
406    {
407        let opt: Option<String> = Option::deserialize(deserializer)?;
408        match opt {
409            Some(s) if !s.is_empty() => NaiveDate::parse_from_str(&s, FORMAT)
410                .map(Some)
411                .map_err(serde::de::Error::custom),
412            _ => Ok(None),
413        }
414    }
415}
416
417#[cfg(test)]
418#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
419mod tests {
420    use super::*;
421    use rust_decimal_macros::dec;
422
423    #[test]
424    fn query_builder_basic() {
425        let query = AlpacaOptionContractQuery::new(vec!["AAPL".into()]);
426        let params = query.to_query_params();
427
428        assert!(
429            params
430                .iter()
431                .any(|(k, v)| *k == "underlying_symbols" && v == "AAPL")
432        );
433        assert!(params.iter().any(|(k, v)| *k == "status" && v == "active"));
434    }
435
436    #[test]
437    fn query_builder_full() {
438        let query = AlpacaOptionContractQuery::new(vec!["AAPL".into(), "TSLA".into()])
439            .expiration_gte(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap())
440            .expiration_lte(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
441            .strike_gte(dec!(100))
442            .strike_lte(dec!(200))
443            .call_only()
444            .style(OptionExercise::American)
445            .limit(500);
446
447        let params = query.to_query_params();
448
449        assert!(
450            params
451                .iter()
452                .any(|(k, v)| *k == "underlying_symbols" && v == "AAPL,TSLA")
453        );
454        assert!(
455            params
456                .iter()
457                .any(|(k, v)| *k == "expiration_date_gte" && v == "2024-01-01")
458        );
459        assert!(
460            params
461                .iter()
462                .any(|(k, v)| *k == "expiration_date_lte" && v == "2024-03-31")
463        );
464        assert!(
465            params
466                .iter()
467                .any(|(k, v)| *k == "strike_price_gte" && v == "100")
468        );
469        assert!(
470            params
471                .iter()
472                .any(|(k, v)| *k == "strike_price_lte" && v == "200")
473        );
474        assert!(params.iter().any(|(k, v)| *k == "type" && v == "call"));
475        assert!(params.iter().any(|(k, v)| *k == "style" && v == "american"));
476        assert!(params.iter().any(|(k, v)| *k == "limit" && v == "500"));
477    }
478
479    #[test]
480    fn query_bermudan_style_skips_style_param() {
481        // Alpaca does not support Bermudan; the builder must omit the `style` filter
482        // while preserving `limit` and `status` (regression guard for #65).
483        let query =
484            AlpacaOptionContractQuery::new(vec!["AAPL".into()]).style(OptionExercise::Bermudan);
485        let params = query.to_query_params();
486
487        assert!(!params.iter().any(|(k, _)| *k == "style"));
488        assert!(params.iter().any(|(k, _)| *k == "limit"));
489        assert!(params.iter().any(|(k, v)| *k == "status" && v == "active"));
490    }
491
492    #[test]
493    fn query_limit_capped_at_max() {
494        let query = AlpacaOptionContractQuery::new(vec!["AAPL".into()]).limit(999_999);
495        let params = query.to_query_params();
496
497        let limit = params
498            .iter()
499            .find(|(k, _)| *k == "limit")
500            .map(|(_, v)| v.as_str())
501            .unwrap();
502        assert_eq!(limit, "10000");
503    }
504
505    #[test]
506    fn contract_deserialize() {
507        let json = r#"{
508            "id": "test-id",
509            "symbol": "AAPL240119C00150000",
510            "name": "AAPL Jan 19 2024 150 Call",
511            "status": "active",
512            "tradable": true,
513            "expiration_date": "2024-01-19",
514            "root_symbol": "AAPL",
515            "underlying_symbol": "AAPL",
516            "underlying_asset_id": "asset-id",
517            "type": "call",
518            "style": "american",
519            "strike_price": "150.00",
520            "size": "100",
521            "open_interest": "1234",
522            "open_interest_date": "2024-01-18",
523            "close_price": "5.50",
524            "close_price_date": "2024-01-18"
525        }"#;
526
527        let contract: AlpacaOptionContract = serde_json::from_str(json).unwrap();
528
529        assert_eq!(contract.symbol, "AAPL240119C00150000");
530        assert_eq!(contract.strike_price, dec!(150.00));
531        assert_eq!(contract.size, dec!(100));
532        assert_eq!(contract.kind(), Some(OptionKind::Call));
533        assert_eq!(contract.exercise(), Some(OptionExercise::American));
534        assert_eq!(
535            contract.expiration_date,
536            NaiveDate::from_ymd_opt(2024, 1, 19).unwrap()
537        );
538    }
539
540    #[test]
541    fn contract_deserialize_minimal() {
542        let json = r#"{
543            "id": "test-id",
544            "symbol": "AAPL240119P00150000",
545            "name": "AAPL Jan 19 2024 150 Put",
546            "status": "active",
547            "tradable": true,
548            "expiration_date": "2024-01-19",
549            "root_symbol": "AAPL",
550            "underlying_symbol": "AAPL",
551            "underlying_asset_id": "asset-id",
552            "type": "put",
553            "style": "european",
554            "strike_price": "150",
555            "size": "100"
556        }"#;
557
558        let contract: AlpacaOptionContract = serde_json::from_str(json).unwrap();
559
560        assert_eq!(contract.kind(), Some(OptionKind::Put));
561        assert_eq!(contract.exercise(), Some(OptionExercise::European));
562        assert!(contract.open_interest.is_none());
563        assert!(contract.close_price.is_none());
564    }
565
566    #[test]
567    fn contracts_response_deserialize() {
568        let json = r#"{
569            "option_contracts": [
570                {
571                    "id": "test-id",
572                    "symbol": "AAPL240119C00150000",
573                    "name": "AAPL Jan 19 2024 150 Call",
574                    "status": "active",
575                    "tradable": true,
576                    "expiration_date": "2024-01-19",
577                    "root_symbol": "AAPL",
578                    "underlying_symbol": "AAPL",
579                    "underlying_asset_id": "asset-id",
580                    "type": "call",
581                    "style": "american",
582                    "strike_price": "150",
583                    "size": "100"
584                }
585            ],
586            "next_page_token": "abc123"
587        }"#;
588
589        let response: ContractsResponse = serde_json::from_str(json).unwrap();
590
591        assert_eq!(response.option_contracts.unwrap().len(), 1);
592        assert_eq!(response.next_page_token, Some("abc123".to_string()));
593    }
594
595    #[test]
596    fn contracts_response_empty() {
597        let json = r#"{}"#;
598
599        let response: ContractsResponse = serde_json::from_str(json).unwrap();
600
601        assert!(response.option_contracts.is_none());
602        assert!(response.next_page_token.is_none());
603    }
604}