alpaca-option 0.24.9

Provider-neutral option semantics and math for the alpaca-rust workspace
Documentation
use serde::{Deserialize, Serialize};

use crate::error::{OptionError, OptionResult};
use crate::types::{OptionChain, OptionRight, OptionSnapshot};
use alpaca_time::expiration;

#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SnapshotFilter<'a> {
    pub occ_symbol: Option<&'a str>,
    pub expiration_date: Option<&'a str>,
    pub strike: Option<f64>,
    pub option_right: Option<&'a str>,
    pub strike_tolerance: Option<f64>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExpirationDate {
    pub expiration_date: String,
    pub calendar_days: i64,
}

fn strike_tolerance(value: Option<f64>) -> f64 {
    match value {
        Some(parsed) if parsed >= 0.0 => parsed,
        _ => 0.01,
    }
}

fn matches_contract(snapshot: &OptionSnapshot, filter: SnapshotFilter<'_>) -> bool {
    if let Some(occ_symbol) = filter.occ_symbol {
        return snapshot
            .contract
            .occ_symbol
            .eq_ignore_ascii_case(occ_symbol.trim());
    }

    if let Some(expiration_date) = filter.expiration_date {
        if snapshot.contract.expiration_date != expiration_date {
            return false;
        }
    }

    if let Some(option_right) = filter.option_right {
        let Ok(option_right) = OptionRight::from_str(option_right) else {
            return false;
        };
        if snapshot.contract.option_right != option_right {
            return false;
        }
    }

    if let Some(strike) = filter.strike {
        if (snapshot.contract.strike - strike).abs() > strike_tolerance(filter.strike_tolerance) {
            return false;
        }
    }

    true
}

pub fn list_snapshots<'a>(
    chain: &'a OptionChain,
    filter: SnapshotFilter<'_>,
) -> Vec<&'a OptionSnapshot> {
    chain
        .snapshots
        .iter()
        .filter(|snapshot| matches_contract(snapshot, filter))
        .collect()
}

pub fn find_snapshot<'a>(
    chain: &'a OptionChain,
    filter: SnapshotFilter<'_>,
) -> Option<&'a OptionSnapshot> {
    chain
        .snapshots
        .iter()
        .find(|snapshot| matches_contract(snapshot, filter))
}

pub fn expiration_dates(
    chain: &OptionChain,
    option_right: Option<&str>,
    min_calendar_days: Option<i64>,
    max_calendar_days: Option<i64>,
    now: Option<&str>,
) -> OptionResult<Vec<ExpirationDate>> {
    let option_right = option_right.map(OptionRight::from_str).transpose()?;
    let mut results = Vec::new();

    for snapshot in &chain.snapshots {
        if let Some(expected) = &option_right {
            if &snapshot.contract.option_right != expected {
                continue;
            }
        }

        if results
            .iter()
            .any(|item: &ExpirationDate| item.expiration_date == snapshot.contract.expiration_date)
        {
            continue;
        }

        let calendar_days = expiration::calendar_days(&snapshot.contract.expiration_date, now)
            .map_err(|error| OptionError::new(error.code, error.message))?;
        if let Some(min_calendar_days) = min_calendar_days {
            if calendar_days < min_calendar_days {
                continue;
            }
        }
        if let Some(max_calendar_days) = max_calendar_days {
            if calendar_days > max_calendar_days {
                continue;
            }
        }

        results.push(ExpirationDate {
            expiration_date: snapshot.contract.expiration_date.clone(),
            calendar_days,
        });
    }

    results.sort_by(|left, right| {
        left.calendar_days
            .cmp(&right.calendar_days)
            .then_with(|| left.expiration_date.cmp(&right.expiration_date))
    });

    Ok(results)
}