alpaca-option 0.27.0

Provider-neutral option semantics and math for the alpaca-rust workspace
Documentation
use std::cmp::Ordering;

use alpaca_time::clock;

use crate::types::{
    MarketStructureAnalysis, MarketStructureFilters, MarketStructureLevel,
    MarketStructureOptionRecord, OptionRight,
};

#[derive(Debug, Clone, Default)]
struct LevelAccumulator {
    strike: f64,
    call_open_interest: f64,
    put_open_interest: f64,
    call_gamma_exposure: f64,
    put_gamma_exposure: f64,
    call_volume: u64,
    put_volume: u64,
}

impl LevelAccumulator {
    fn into_level(self, labels: Vec<String>) -> MarketStructureLevel {
        let total_open_interest = self.call_open_interest + self.put_open_interest;
        let net_gamma_exposure = self.call_gamma_exposure + self.put_gamma_exposure;
        let absolute_gamma_exposure =
            self.call_gamma_exposure.abs() + self.put_gamma_exposure.abs();
        let total_volume = self.call_volume.saturating_add(self.put_volume);

        MarketStructureLevel {
            strike: self.strike,
            call_open_interest: self.call_open_interest,
            put_open_interest: self.put_open_interest,
            total_open_interest,
            call_gamma_exposure: self.call_gamma_exposure,
            put_gamma_exposure: self.put_gamma_exposure,
            net_gamma_exposure,
            absolute_gamma_exposure,
            call_volume: self.call_volume,
            put_volume: self.put_volume,
            total_volume,
            labels,
        }
    }
}

pub fn gamma_exposure(record: &MarketStructureOptionRecord, underlying_price: f64) -> Option<f64> {
    let gamma = finite(record.gamma?)?;
    let open_interest = finite(record.open_interest?)?;
    let multiplier = finite(record.multiplier?)?;
    let spot = finite(underlying_price)?;

    if open_interest < 0.0 || multiplier <= 0.0 || spot <= 0.0 {
        return None;
    }

    let exposure = gamma * open_interest * multiplier * spot * spot * 0.01;
    let signed_exposure = match record.option_right {
        OptionRight::Call => exposure,
        OptionRight::Put => -exposure,
    };
    finite(signed_exposure)
}

pub fn filter_market_structure_records(
    records: &[MarketStructureOptionRecord],
    filters: &MarketStructureFilters,
) -> Vec<MarketStructureOptionRecord> {
    records
        .iter()
        .filter(|record| matches_expiration(record, filters))
        .filter(|record| matches_option_right(record, filters))
        .filter(|record| matches_strike(record, filters))
        .filter(|record| matches_dte(record, filters))
        .filter(|record| matches_open_interest(record, filters))
        .cloned()
        .collect()
}

pub fn analyze_market_structure(
    records: &[MarketStructureOptionRecord],
) -> MarketStructureAnalysis {
    let records_count = records.len();
    let underlying_price = records
        .iter()
        .filter_map(|record| record.underlying_price.and_then(finite_positive))
        .next();
    let open_interest_count = records
        .iter()
        .filter(|record| record.open_interest.and_then(finite).is_some())
        .count();

    let mut warnings = Vec::new();
    if records.is_empty() {
        warnings.push("no_records".to_string());
    }
    if underlying_price.is_none() {
        warnings.push("missing_underlying_price".to_string());
    }
    if open_interest_count < records_count {
        warnings.push("incomplete_open_interest".to_string());
    }

    let mut accumulators = Vec::<LevelAccumulator>::new();
    if let Some(spot) = underlying_price {
        for record in records {
            let strike = match finite(record.strike) {
                Some(value) => value,
                None => continue,
            };
            let open_interest = record
                .open_interest
                .and_then(finite)
                .unwrap_or(0.0)
                .max(0.0);
            let volume = activity_volume(record);
            let exposure = gamma_exposure(record, spot).unwrap_or(0.0);

            let index = accumulators
                .iter()
                .position(|level| same_strike(level.strike, strike))
                .unwrap_or_else(|| {
                    accumulators.push(LevelAccumulator {
                        strike,
                        ..LevelAccumulator::default()
                    });
                    accumulators.len() - 1
                });
            let level = &mut accumulators[index];

            match record.option_right {
                OptionRight::Call => {
                    level.call_open_interest += open_interest;
                    level.call_gamma_exposure += exposure;
                    level.call_volume = level.call_volume.saturating_add(volume);
                }
                OptionRight::Put => {
                    level.put_open_interest += open_interest;
                    level.put_gamma_exposure += exposure;
                    level.put_volume = level.put_volume.saturating_add(volume);
                }
            }
        }
    }

    accumulators.sort_by(|left, right| compare_f64(left.strike, right.strike));

    let mut levels: Vec<MarketStructureLevel> = accumulators
        .into_iter()
        .map(|level| level.into_level(Vec::new()))
        .collect();

    if records_count > 0
        && (levels.is_empty()
            || !levels
                .iter()
                .any(|level| level.absolute_gamma_exposure > 0.0))
    {
        warnings.push("no_gamma_exposure_records".to_string());
    }

    let call_wall_strike = levels
        .iter()
        .filter(|level| level.call_gamma_exposure > 0.0)
        .max_by(|left, right| compare_f64(left.call_gamma_exposure, right.call_gamma_exposure))
        .map(|level| level.strike)
        .or_else(|| {
            levels
                .iter()
                .filter(|level| level.call_open_interest > 0.0)
                .max_by(|left, right| {
                    compare_f64(left.call_open_interest, right.call_open_interest)
                })
                .map(|level| level.strike)
        });
    let put_wall_strike = levels
        .iter()
        .filter(|level| level.put_gamma_exposure < 0.0)
        .min_by(|left, right| compare_f64(left.put_gamma_exposure, right.put_gamma_exposure))
        .map(|level| level.strike)
        .or_else(|| {
            levels
                .iter()
                .filter(|level| level.put_open_interest > 0.0)
                .max_by(|left, right| {
                    compare_f64(left.put_open_interest, right.put_open_interest)
                })
                .map(|level| level.strike)
        });
    let absolute_wall_strike = levels
        .iter()
        .filter(|level| level.absolute_gamma_exposure > 0.0)
        .max_by(|left, right| {
            compare_f64(left.absolute_gamma_exposure, right.absolute_gamma_exposure)
        })
        .map(|level| level.strike)
        .or_else(|| {
            levels
                .iter()
                .filter(|level| level.total_open_interest > 0.0)
                .max_by(|left, right| {
                    compare_f64(left.total_open_interest, right.total_open_interest)
                })
                .map(|level| level.strike)
        });

    for level in &mut levels {
        if call_wall_strike
            .map(|strike| same_strike(level.strike, strike))
            .unwrap_or(false)
        {
            level.labels.push("call_wall".to_string());
        }
        if put_wall_strike
            .map(|strike| same_strike(level.strike, strike))
            .unwrap_or(false)
        {
            level.labels.push("put_wall".to_string());
        }
        if absolute_wall_strike
            .map(|strike| same_strike(level.strike, strike))
            .unwrap_or(false)
        {
            level
                .labels
                .push("absolute_gamma_exposure_wall".to_string());
        }
    }

    let call_wall = level_by_strike(&levels, call_wall_strike);
    let put_wall = level_by_strike(&levels, put_wall_strike);
    let absolute_gamma_exposure_wall = level_by_strike(&levels, absolute_wall_strike);
    let net_gamma_exposure = levels.iter().map(|level| level.net_gamma_exposure).sum();
    let absolute_gamma_exposure = levels
        .iter()
        .map(|level| level.absolute_gamma_exposure)
        .sum();
    let open_interest_coverage = if records_count == 0 {
        0.0
    } else {
        open_interest_count as f64 / records_count as f64
    };

    MarketStructureAnalysis {
        underlying_price,
        records_count,
        open_interest_coverage,
        call_wall,
        put_wall,
        absolute_gamma_exposure_wall,
        net_gamma_exposure,
        absolute_gamma_exposure,
        levels,
        warnings,
    }
}

fn matches_expiration(
    record: &MarketStructureOptionRecord,
    filters: &MarketStructureFilters,
) -> bool {
    filters
        .expiration_date
        .as_ref()
        .map(|expiration| record.expiration_date == *expiration)
        .unwrap_or(true)
}

fn matches_option_right(
    record: &MarketStructureOptionRecord,
    filters: &MarketStructureFilters,
) -> bool {
    filters
        .option_right
        .as_ref()
        .map(|right| record.option_right == *right)
        .unwrap_or(true)
}

fn matches_strike(record: &MarketStructureOptionRecord, filters: &MarketStructureFilters) -> bool {
    let Some(strike) = finite(record.strike) else {
        return false;
    };

    if filters
        .strike_price_gte
        .and_then(finite)
        .map(|min| strike < min)
        .unwrap_or(false)
    {
        return false;
    }
    if filters
        .strike_price_lte
        .and_then(finite)
        .map(|max| strike > max)
        .unwrap_or(false)
    {
        return false;
    }
    true
}

fn matches_dte(record: &MarketStructureOptionRecord, filters: &MarketStructureFilters) -> bool {
    if filters.dte_min.is_none() && filters.dte_max.is_none() {
        return true;
    }

    let Some(dte) = days_to_expiration(record) else {
        return false;
    };
    if filters
        .dte_min
        .and_then(finite)
        .map(|min| dte < min)
        .unwrap_or(false)
    {
        return false;
    }
    if filters
        .dte_max
        .and_then(finite)
        .map(|max| dte > max)
        .unwrap_or(false)
    {
        return false;
    }
    true
}

fn matches_open_interest(
    record: &MarketStructureOptionRecord,
    filters: &MarketStructureFilters,
) -> bool {
    if !filters.require_open_interest {
        return true;
    }
    record
        .open_interest
        .and_then(finite)
        .map(|open_interest| open_interest > 0.0)
        .unwrap_or(false)
}

fn days_to_expiration(record: &MarketStructureOptionRecord) -> Option<f64> {
    let as_of_date = record.as_of.get(0..10).unwrap_or(&record.as_of);
    clock::fractional_days_between(as_of_date, &record.expiration_date)
        .ok()
        .and_then(finite)
}

fn activity_volume(record: &MarketStructureOptionRecord) -> u64 {
    record
        .daily_volume
        .or(record.minute_volume)
        .or(record.latest_trade_size)
        .unwrap_or(0)
}

fn level_by_strike(
    levels: &[MarketStructureLevel],
    strike: Option<f64>,
) -> Option<MarketStructureLevel> {
    let strike = strike?;
    levels
        .iter()
        .find(|level| same_strike(level.strike, strike))
        .cloned()
}

fn finite(value: f64) -> Option<f64> {
    if value.is_finite() { Some(value) } else { None }
}

fn finite_positive(value: f64) -> Option<f64> {
    finite(value).filter(|value| *value > 0.0)
}

fn same_strike(left: f64, right: f64) -> bool {
    (left - right).abs() <= 1e-8
}

fn compare_f64(left: f64, right: f64) -> Ordering {
    left.partial_cmp(&right).unwrap_or(Ordering::Equal)
}