1use 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
14const MAX_PAGE_SIZE: usize = 10_000;
16
17const DEFAULT_PAGE_SIZE: usize = 1_000;
19
20const MAX_PAGES: usize = 100;
23
24#[derive(Debug, Clone, Default)]
44pub struct AlpacaOptionContractQuery {
45 pub underlying_symbols: Vec<String>,
47 pub expiration_date_gte: Option<NaiveDate>,
49 pub expiration_date_lte: Option<NaiveDate>,
51 pub strike_price_gte: Option<Decimal>,
53 pub strike_price_lte: Option<Decimal>,
55 pub option_type: Option<OptionKind>,
57 pub style: Option<OptionExercise>,
59 pub limit: Option<usize>,
61}
62
63impl AlpacaOptionContractQuery {
64 pub fn new(underlying_symbols: Vec<String>) -> Self {
66 Self {
67 underlying_symbols,
68 ..Default::default()
69 }
70 }
71
72 #[must_use]
74 pub fn expiration_gte(mut self, date: NaiveDate) -> Self {
75 self.expiration_date_gte = Some(date);
76 self
77 }
78
79 #[must_use]
81 pub fn expiration_lte(mut self, date: NaiveDate) -> Self {
82 self.expiration_date_lte = Some(date);
83 self
84 }
85
86 #[must_use]
88 pub fn strike_gte(mut self, strike: Decimal) -> Self {
89 self.strike_price_gte = Some(strike);
90 self
91 }
92
93 #[must_use]
95 pub fn strike_lte(mut self, strike: Decimal) -> Self {
96 self.strike_price_lte = Some(strike);
97 self
98 }
99
100 #[must_use]
102 pub fn call_only(mut self) -> Self {
103 self.option_type = Some(OptionKind::Call);
104 self
105 }
106
107 #[must_use]
109 pub fn put_only(mut self) -> Self {
110 self.option_type = Some(OptionKind::Put);
111 self
112 }
113
114 #[must_use]
116 pub fn option_type(mut self, kind: OptionKind) -> Self {
117 self.option_type = Some(kind);
118 self
119 }
120
121 #[must_use]
123 pub fn style(mut self, style: OptionExercise) -> Self {
124 self.style = Some(style);
125 self
126 }
127
128 #[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 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 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 params.push(("status", "active".to_string()));
184
185 params
186 }
187}
188
189#[non_exhaustive]
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub struct AlpacaOptionContract {
195 pub id: String,
197 pub symbol: String,
199 pub name: String,
201 pub status: String,
203 pub tradable: bool,
205 #[serde(with = "date_format")]
207 pub expiration_date: NaiveDate,
208 pub root_symbol: String,
210 pub underlying_symbol: String,
212 pub underlying_asset_id: String,
214 #[serde(rename = "type")]
216 pub option_type: String,
217 pub style: String,
219 #[serde(deserialize_with = "deserialize_decimal_string")]
221 pub strike_price: Decimal,
222 #[serde(deserialize_with = "deserialize_decimal_string")]
224 pub size: Decimal,
225 #[serde(default, deserialize_with = "deserialize_option_decimal_string")]
227 pub open_interest: Option<Decimal>,
228 #[serde(default, with = "option_date_format")]
230 pub open_interest_date: Option<NaiveDate>,
231 #[serde(default, deserialize_with = "deserialize_option_decimal_string")]
233 pub close_price: Option<Decimal>,
234 #[serde(default, with = "option_date_format")]
236 pub close_price_date: Option<NaiveDate>,
237}
238
239impl AlpacaOptionContract {
240 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 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#[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 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 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(¶ms);
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
343fn 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)] mod 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 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}