alpaca-option 0.24.9

Provider-neutral option semantics and math for the alpaca-rust workspace
Documentation
use crate::error::{OptionError, OptionResult};
use crate::pricing;
use crate::pricing::greeks_black_scholes;
use crate::types::{
    AssignmentRiskLevel, BlackScholesInput, MoneynessLabel, OptionPosition, OptionRight,
    PositionSide, ShortItmPosition,
};

const CONTRACT_MULTIPLIER: f64 = 100.0;

fn ensure_finite(name: &str, value: f64) -> OptionResult<()> {
    if value.is_finite() {
        Ok(())
    } else {
        Err(OptionError::new(
            "invalid_analysis_input",
            format!("{name} must be finite: {value}"),
        ))
    }
}

fn ensure_positive(name: &str, value: f64) -> OptionResult<()> {
    ensure_finite(name, value)?;
    if value > 0.0 {
        Ok(())
    } else {
        Err(OptionError::new(
            "invalid_analysis_input",
            format!("{name} must be greater than zero: {value}"),
        ))
    }
}

fn parse_option_right(option_right: &str) -> OptionResult<OptionRight> {
    OptionRight::from_str(option_right).map_err(|_| {
        OptionError::new(
            "invalid_analysis_input",
            format!("invalid option right: {option_right}"),
        )
    })
}

pub fn annualized_premium_yield(premium: f64, capital_base: f64, years: f64) -> OptionResult<f64> {
    ensure_finite("premium", premium)?;
    ensure_positive("capital_base", capital_base)?;
    ensure_positive("years", years)?;
    Ok(premium / capital_base / years)
}

pub fn annualized_premium_yield_days(
    premium: f64,
    capital_base: f64,
    calendar_days: i64,
) -> OptionResult<f64> {
    ensure_positive("calendar_days", calendar_days as f64)?;
    annualized_premium_yield(premium, capital_base, calendar_days as f64 / 365.0)
}

pub fn calendar_forward_factor(
    short_iv: f64,
    long_iv: f64,
    short_years: f64,
    long_years: f64,
) -> OptionResult<f64> {
    ensure_positive("short_iv", short_iv)?;
    ensure_positive("long_iv", long_iv)?;
    ensure_positive("short_years", short_years)?;
    ensure_positive("long_years", long_years)?;
    if long_years <= short_years {
        return Err(OptionError::new(
            "invalid_analysis_input",
            format!("long_years must be greater than short_years: {long_years} <= {short_years}"),
        ));
    }

    let short_variance = short_iv * short_iv;
    let long_variance = long_iv * long_iv;
    let forward_variance =
        (long_variance * long_years - short_variance * short_years) / (long_years - short_years);
    if forward_variance <= 0.0 {
        return Err(OptionError::new(
            "invalid_analysis_input",
            format!("forward variance must be positive: {forward_variance}"),
        ));
    }

    let forward_iv = forward_variance.sqrt();
    Ok((short_iv - forward_iv) / forward_iv)
}

pub fn moneyness_ratio(spot: f64, strike: f64) -> OptionResult<f64> {
    ensure_positive("spot", spot)?;
    ensure_positive("strike", strike)?;
    Ok(spot / strike)
}

pub fn moneyness_label(
    spot: f64,
    strike: f64,
    option_right: &str,
    atm_band: Option<f64>,
) -> OptionResult<MoneynessLabel> {
    let option_right = parse_option_right(option_right)?;
    let atm_band = atm_band.unwrap_or(0.02);
    ensure_finite("atm_band", atm_band)?;
    if atm_band < 0.0 {
        return Err(OptionError::new(
            "invalid_analysis_input",
            format!("atm_band must be non-negative: {atm_band}"),
        ));
    }

    let ratio = moneyness_ratio(spot, strike)?;
    if (ratio - 1.0).abs() <= atm_band {
        return Ok(MoneynessLabel::Atm);
    }

    Ok(match option_right {
        OptionRight::Call => {
            if ratio > 1.0 {
                MoneynessLabel::Itm
            } else {
                MoneynessLabel::Otm
            }
        }
        OptionRight::Put => {
            if ratio < 1.0 {
                MoneynessLabel::Itm
            } else {
                MoneynessLabel::Otm
            }
        }
    })
}

pub fn otm_percent(spot: f64, strike: f64, option_right: &str) -> OptionResult<f64> {
    ensure_positive("spot", spot)?;
    ensure_positive("strike", strike)?;
    let option_right = parse_option_right(option_right)?;

    Ok(match option_right {
        OptionRight::Call => (strike - spot) / spot * 100.0,
        OptionRight::Put => (spot - strike) / spot * 100.0,
    })
}

pub fn position_otm_percent(spot: f64, position: &OptionPosition) -> OptionResult<f64> {
    let contract = position.contract_info();
    otm_percent(spot, contract.strike, contract.option_right.as_str())
}

pub fn assignment_risk(extrinsic: f64) -> OptionResult<AssignmentRiskLevel> {
    ensure_finite("extrinsic", extrinsic)?;

    Ok(if extrinsic < 0.0 {
        AssignmentRiskLevel::Danger
    } else if extrinsic < 0.05 {
        AssignmentRiskLevel::Critical
    } else if extrinsic < 0.1 {
        AssignmentRiskLevel::High
    } else if extrinsic < 0.3 {
        AssignmentRiskLevel::Medium
    } else if extrinsic < 1.0 {
        AssignmentRiskLevel::Low
    } else {
        AssignmentRiskLevel::Safe
    })
}

pub fn short_extrinsic_amount(
    spot: f64,
    positions: &[OptionPosition],
    structure_quantity: Option<u32>,
) -> OptionResult<Option<f64>> {
    if !spot.is_finite() || spot <= 0.0 {
        return Ok(None);
    }

    let mut total_extrinsic_per_share = 0.0;
    let mut has_short_position = false;

    for position in positions {
        if position.position_side() != PositionSide::Short {
            continue;
        }

        has_short_position = true;
        if position.quantity() == 0 {
            return Ok(None);
        }

        let option_price = position
            .snapshot_ref()
            .and_then(|snapshot| snapshot.quote.mark.or(snapshot.quote.last));
        let Some(option_price) = option_price else {
            return Ok(None);
        };
        let contract = position.contract_info();

        total_extrinsic_per_share += pricing::extrinsic_value(
            option_price,
            spot,
            contract.strike,
            contract.option_right.as_str(),
        )? * f64::from(position.quantity());
    }

    if !has_short_position {
        return Ok(None);
    }

    let structure_quantity = structure_quantity.unwrap_or(1).max(1);
    Ok(Some(
        total_extrinsic_per_share * CONTRACT_MULTIPLIER * f64::from(structure_quantity),
    ))
}

pub fn short_itm_positions(
    spot: f64,
    positions: &[OptionPosition],
) -> OptionResult<Vec<ShortItmPosition>> {
    if !spot.is_finite() || spot <= 0.0 {
        return Ok(Vec::new());
    }

    let mut items = Vec::new();
    for position in positions {
        if position.position_side() != PositionSide::Short || position.quantity() == 0 {
            continue;
        }

        let option_price = position
            .snapshot_ref()
            .and_then(|snapshot| snapshot.quote.mark.or(snapshot.quote.last))
            .unwrap_or(0.0);
        let contract = position.contract_info();
        let intrinsic =
            pricing::intrinsic_value(spot, contract.strike, contract.option_right.as_str())?;
        if intrinsic <= 0.0 {
            continue;
        }

        items.push(ShortItmPosition {
            contract,
            quantity: position.quantity(),
            option_price,
            intrinsic,
            extrinsic: pricing::extrinsic_value(
                option_price,
                spot,
                position.contract_info().strike,
                position.contract_info().option_right.as_str(),
            )?,
        });
    }

    Ok(items)
}

pub fn strike_for_target_delta(
    spot: f64,
    years: f64,
    rate: f64,
    dividend_yield: f64,
    volatility: f64,
    target_delta: f64,
    option_right: &str,
    strike_step: f64,
) -> OptionResult<f64> {
    ensure_positive("spot", spot)?;
    ensure_positive("years", years)?;
    ensure_finite("rate", rate)?;
    ensure_finite("dividend_yield", dividend_yield)?;
    ensure_positive("volatility", volatility)?;
    ensure_finite("target_delta", target_delta)?;
    ensure_positive("strike_step", strike_step)?;
    let option_right = parse_option_right(option_right)?;

    match option_right {
        OptionRight::Call => {
            let mut strike = (spot / strike_step).round() * strike_step;
            while strike < spot * 1.5 + strike_step * 0.5 {
                let greeks = greeks_black_scholes(&BlackScholesInput {
                    spot,
                    strike,
                    years,
                    rate,
                    dividend_yield,
                    volatility,
                    option_right: OptionRight::Call,
                })?;
                if greeks.delta <= target_delta {
                    return Ok(strike);
                }
                strike += strike_step;
            }
        }
        OptionRight::Put => {
            let mut strike = (spot * 0.7 / strike_step).round() * strike_step;
            while strike <= spot * 1.1 + strike_step * 0.5 {
                let greeks = greeks_black_scholes(&BlackScholesInput {
                    spot,
                    strike,
                    years,
                    rate,
                    dividend_yield,
                    volatility,
                    option_right: OptionRight::Put,
                })?;
                if greeks.delta <= target_delta {
                    return Ok(strike);
                }
                strike += strike_step;
            }
        }
    }

    Err(OptionError::new(
        "target_delta_not_found",
        format!("no strike found for target delta: {target_delta}"),
    ))
}