yf-options 0.2.1

A fast, reliable command-line tool for downloading options chain data from Yahoo Finance. Features include Black-Scholes Greeks calculation (Delta, Gamma, Theta, Vega, Rho), filtering by expiration date, strike range, and ITM/OTM status. Supports multiple symbols with combined output, JSON/CSV export formats, and built-in rate limiting. Ideal for options analysis, volatility screening, and quantitative trading workflows.
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse {
    #[serde(rename = "optionChain")]
    pub option_chain: OptionChainWrapper,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionChainWrapper {
    pub result: Vec<OptionChainResult>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionChainResult {
    #[serde(rename = "underlyingSymbol")]
    pub underlying_symbol: String,

    #[serde(rename = "expirationDates")]
    pub expiration_dates: Vec<i64>,

    pub strikes: Vec<f64>,

    #[serde(rename = "hasMiniOptions")]
    pub has_mini_options: Option<bool>,

    pub quote: Quote,
    pub options: Vec<OptionData>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Quote {
    #[serde(rename = "regularMarketPrice")]
    pub regular_market_price: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionData {
    #[serde(rename = "expirationDate")]
    pub expiration_date: i64,

    #[serde(rename = "hasMiniOptions")]
    pub has_mini_options: Option<bool>,

    pub calls: Vec<OptionContract>,
    pub puts: Vec<OptionContract>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionContract {
    #[serde(rename = "contractSymbol")]
    pub contract_symbol: String,

    pub strike: f64,
    pub currency: Option<String>,

    #[serde(rename = "lastPrice")]
    pub last_price: f64,

    pub change: Option<f64>,

    #[serde(rename = "percentChange")]
    pub percent_change: Option<f64>,

    pub volume: Option<i64>,

    #[serde(rename = "openInterest")]
    pub open_interest: Option<i64>,

    pub bid: Option<f64>,
    pub ask: Option<f64>,

    #[serde(rename = "contractSize")]
    pub contract_size: Option<String>,

    pub expiration: i64,

    #[serde(rename = "lastTradeDate")]
    pub last_trade_date: Option<i64>,

    #[serde(rename = "impliedVolatility")]
    pub implied_volatility: f64,

    #[serde(rename = "inTheMoney")]
    pub in_the_money: bool,

    #[serde(skip)]
    pub greeks: Option<Greeks>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Greeks {
    pub delta: f64,
    pub gamma: f64,
    pub theta: f64,
    pub vega: f64,
    pub rho: f64,
}

#[derive(Debug, Clone, Serialize)]
pub struct OptionChain {
    pub symbol: String,
    pub underlying_price: f64,
    pub expiration_dates: Vec<i64>,
    pub strikes: Vec<f64>,
    pub options: Vec<ExpirationData>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ExpirationData {
    pub expiration_date: i64,
    pub calls: Vec<OptionContract>,
    pub puts: Vec<OptionContract>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_option_contract_serialization() {
        let contract = OptionContract {
            contract_symbol: "AAPL240315C00150000".to_string(),
            strike: 150.0,
            currency: Some("USD".to_string()),
            last_price: 5.50,
            change: Some(0.25),
            percent_change: Some(4.76),
            volume: Some(1000),
            open_interest: Some(5000),
            bid: Some(5.40),
            ask: Some(5.60),
            contract_size: Some("REGULAR".to_string()),
            expiration: 1710460800,
            last_trade_date: Some(1710374400),
            implied_volatility: 0.25,
            in_the_money: false,
            greeks: None,
        };

        let json = serde_json::to_string(&contract).unwrap();
        assert!(json.contains("AAPL240315C00150000"));
        assert!(json.contains("150"));
        assert!(json.contains("5.5"));
    }

    #[test]
    fn test_option_contract_with_greeks() {
        let greeks = Greeks {
            delta: 0.55,
            gamma: 0.02,
            theta: -0.05,
            vega: 0.15,
            rho: 0.10,
        };

        let contract = OptionContract {
            contract_symbol: "AAPL240315C00150000".to_string(),
            strike: 150.0,
            currency: Some("USD".to_string()),
            last_price: 5.50,
            change: None,
            percent_change: None,
            volume: Some(1000),
            open_interest: Some(5000),
            bid: Some(5.40),
            ask: Some(5.60),
            contract_size: Some("REGULAR".to_string()),
            expiration: 1710460800,
            last_trade_date: Some(1710374400),
            implied_volatility: 0.25,
            in_the_money: true,
            greeks: Some(greeks.clone()),
        };

        // Greeks should not be serialized due to #[serde(skip)]
        let json = serde_json::to_string(&contract).unwrap();
        assert!(!json.contains("delta"));
        assert!(!json.contains("gamma"));
    }

    #[test]
    fn test_deserialize_yahoo_response() {
        let json = r#"{
            "optionChain": {
                "result": [{
                    "underlyingSymbol": "AAPL",
                    "expirationDates": [1708646400],
                    "strikes": [150.0, 155.0, 160.0],
                    "quote": {"regularMarketPrice": 175.50},
                    "options": []
                }]
            }
        }"#;

        let response: ApiResponse = serde_json::from_str(json).unwrap();
        assert_eq!(response.option_chain.result.len(), 1);
        assert_eq!(response.option_chain.result[0].underlying_symbol, "AAPL");
        assert_eq!(response.option_chain.result[0].quote.regular_market_price, 175.50);
        assert_eq!(response.option_chain.result[0].expiration_dates, vec![1708646400]);
        assert_eq!(response.option_chain.result[0].strikes.len(), 3);
    }

    #[test]
    fn test_deserialize_option_contract() {
        let json = r#"{
            "contractSymbol": "AAPL240315C00150000",
            "strike": 150.0,
            "lastPrice": 26.50,
            "bid": 26.30,
            "ask": 26.70,
            "volume": 500,
            "openInterest": 2500,
            "impliedVolatility": 0.28,
            "inTheMoney": true,
            "expiration": 1710460800
        }"#;

        let contract: OptionContract = serde_json::from_str(json).unwrap();
        assert_eq!(contract.contract_symbol, "AAPL240315C00150000");
        assert_eq!(contract.strike, 150.0);
        assert_eq!(contract.last_price, 26.50);
        assert_eq!(contract.bid, Some(26.30));
        assert_eq!(contract.ask, Some(26.70));
        assert_eq!(contract.volume, Some(500));
        assert_eq!(contract.open_interest, Some(2500));
        assert_eq!(contract.implied_volatility, 0.28);
        assert!(contract.in_the_money);
    }

    #[test]
    fn test_greeks_serialization() {
        let greeks = Greeks {
            delta: 0.55,
            gamma: 0.02,
            theta: -0.05,
            vega: 0.15,
            rho: 0.10,
        };

        let json = serde_json::to_string(&greeks).unwrap();
        assert!(json.contains("0.55"));
        assert!(json.contains("0.02"));
        assert!(json.contains("-0.05"));
        assert!(json.contains("0.15"));
        assert!(json.contains("0.1")); // Could be 0.1 or 0.10 depending on serialization
    }

    #[test]
    fn test_filter_itm_calls() {
        let contracts = [
            OptionContract {
                contract_symbol: "ITM".to_string(),
                strike: 90.0,
                in_the_money: true,
                currency: None,
                last_price: 10.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                greeks: None,
            },
            OptionContract {
                contract_symbol: "OTM".to_string(),
                strike: 110.0,
                in_the_money: false,
                currency: None,
                last_price: 1.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                greeks: None,
            },
        ];

        let itm: Vec<_> = contracts.iter().filter(|c| c.in_the_money).collect();
        assert_eq!(itm.len(), 1);
        assert_eq!(itm[0].strike, 90.0);
    }

    #[test]
    fn test_filter_strike_range() {
        let contracts = [
            OptionContract {
                contract_symbol: "C1".to_string(),
                strike: 140.0,
                currency: None,
                last_price: 1.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                in_the_money: false,
                greeks: None,
            },
            OptionContract {
                contract_symbol: "C2".to_string(),
                strike: 150.0,
                currency: None,
                last_price: 1.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                in_the_money: false,
                greeks: None,
            },
            OptionContract {
                contract_symbol: "C3".to_string(),
                strike: 160.0,
                currency: None,
                last_price: 1.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                in_the_money: false,
                greeks: None,
            },
            OptionContract {
                contract_symbol: "C4".to_string(),
                strike: 170.0,
                currency: None,
                last_price: 1.0,
                change: None,
                percent_change: None,
                volume: None,
                open_interest: None,
                bid: None,
                ask: None,
                contract_size: None,
                expiration: 0,
                last_trade_date: None,
                implied_volatility: 0.2,
                in_the_money: false,
                greeks: None,
            },
        ];

        let filtered: Vec<_> = contracts
            .iter()
            .filter(|c| c.strike >= 150.0 && c.strike <= 160.0)
            .collect();
        assert_eq!(filtered.len(), 2);
        assert_eq!(filtered[0].strike, 150.0);
        assert_eq!(filtered[1].strike, 160.0);
    }

    #[test]
    fn test_option_chain_serialization() {
        let chain = OptionChain {
            symbol: "AAPL".to_string(),
            underlying_price: 175.50,
            expiration_dates: vec![1710460800, 1711065600],
            strikes: vec![150.0, 155.0, 160.0],
            options: vec![],
        };

        let json = serde_json::to_string(&chain).unwrap();
        assert!(json.contains("AAPL"));
        assert!(json.contains("175.5"));
    }
}