alpaca-option 0.24.9

Provider-neutral option semantics and math for the alpaca-rust workspace
Documentation
use alpaca_time::clock;

use crate::error::{OptionError, OptionResult};
use crate::types::{OptionContract, OptionPosition, OptionRight};

const OCC_TAIL_LENGTH: usize = 15;
const MAX_UNDERLYING_LENGTH: usize = 6;

fn canonical_underlying_symbol(symbol: &str) -> String {
    symbol
        .trim()
        .chars()
        .filter(|ch| ch.is_ascii_alphanumeric())
        .map(|ch| ch.to_ascii_uppercase())
        .collect()
}

fn ensure_underlying_symbol(symbol: &str) -> OptionResult<String> {
    let trimmed = symbol.trim();
    if trimmed.is_empty()
        || !trimmed
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '/')
    {
        return Err(OptionError::new(
            "invalid_underlying_symbol",
            format!("invalid underlying symbol: {symbol}"),
        ));
    }

    let normalized = canonical_underlying_symbol(symbol);
    if normalized.is_empty() || normalized.len() > MAX_UNDERLYING_LENGTH {
        return Err(OptionError::new(
            "invalid_underlying_symbol",
            format!("invalid underlying symbol: {symbol}"),
        ));
    }
    Ok(normalized)
}

fn normalize_option_right_text(option_right: &str) -> Option<&'static str> {
    match option_right.trim().to_ascii_lowercase().as_str() {
        "call" | "c" => Some("call"),
        "put" | "p" => Some("put"),
        _ => None,
    }
}

fn canonical_contract_from_option_contract(contract: &OptionContract) -> Option<OptionContract> {
    if let Some(parsed) = parse_occ_symbol(&contract.occ_symbol) {
        return Some(parsed);
    }

    let occ_symbol = build_occ_symbol(
        &contract.underlying_symbol,
        &contract.expiration_date,
        contract.strike,
        contract.option_right.as_str(),
    )?;
    parse_occ_symbol(&occ_symbol)
}

pub trait ContractLike {
    fn canonical_contract(&self) -> Option<OptionContract>;
}

impl<T: ContractLike + ?Sized> ContractLike for &T {
    fn canonical_contract(&self) -> Option<OptionContract> {
        (*self).canonical_contract()
    }
}

impl ContractLike for OptionContract {
    fn canonical_contract(&self) -> Option<OptionContract> {
        canonical_contract_from_option_contract(self)
    }
}

impl ContractLike for str {
    fn canonical_contract(&self) -> Option<OptionContract> {
        parse_occ_symbol(self)
    }
}

impl ContractLike for String {
    fn canonical_contract(&self) -> Option<OptionContract> {
        self.as_str().canonical_contract()
    }
}

impl ContractLike for OptionPosition {
    fn canonical_contract(&self) -> Option<OptionContract> {
        parse_occ_symbol(&self.contract)
    }
}

pub fn normalize_underlying_symbol(symbol: &str) -> String {
    canonical_underlying_symbol(symbol)
}

pub fn is_occ_symbol(occ_symbol: &str) -> bool {
    parse_occ_symbol(occ_symbol).is_some()
}

pub fn parse_occ_symbol(occ_symbol: &str) -> Option<OptionContract> {
    let normalized = occ_symbol.trim().to_ascii_uppercase();
    if normalized.len() <= OCC_TAIL_LENGTH {
        return None;
    }

    let split = normalized.len() - OCC_TAIL_LENGTH;
    let underlying_symbol = &normalized[..split];
    if underlying_symbol.is_empty()
        || underlying_symbol.len() > MAX_UNDERLYING_LENGTH
        || !underlying_symbol
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric())
    {
        return None;
    }

    let yy = &normalized[split..split + 2];
    let mm = &normalized[split + 2..split + 4];
    let dd = &normalized[split + 4..split + 6];
    let expiration_date = clock::parse_date(&format!("20{yy}-{mm}-{dd}")).ok()?;

    let option_right = OptionRight::from_code(normalized.as_bytes()[split + 6] as char).ok()?;

    let strike_digits = &normalized[split + 7..];
    if strike_digits.len() != 8 || !strike_digits.chars().all(|ch| ch.is_ascii_digit()) {
        return None;
    }

    let strike = strike_digits.parse::<u32>().ok()? as f64 / 1000.0;

    Some(OptionContract {
        underlying_symbol: underlying_symbol.to_string(),
        expiration_date,
        strike,
        option_right,
        occ_symbol: normalized,
    })
}

pub fn build_occ_symbol(
    underlying_symbol: &str,
    expiration_date: &str,
    strike: f64,
    option_right: &str,
) -> Option<String> {
    let underlying_symbol = ensure_underlying_symbol(underlying_symbol).ok()?;
    let expiration_date = clock::parse_date(expiration_date).ok()?;
    let option_right = OptionRight::from_str(normalize_option_right_text(option_right)?).ok()?;

    if !strike.is_finite() || strike < 0.0 {
        return None;
    }

    let strike_thousandths = (strike * 1000.0).round();
    if !(0.0..=99_999_999.0).contains(&strike_thousandths) {
        return None;
    }
    let yymmdd =
        expiration_date[2..4].to_string() + &expiration_date[5..7] + &expiration_date[8..10];

    Some(format!(
        "{}{}{}{:08}",
        underlying_symbol,
        yymmdd,
        option_right.code(),
        strike_thousandths as u32
    ))
}

pub fn canonical_contract(input: &impl ContractLike) -> Option<OptionContract> {
    input.canonical_contract()
}