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
mod cli;
mod client;
mod error;
mod greeks;
mod models;
mod output;

use chrono::Utc;
use clap::Parser;
use cli::Cli;
use client::OptionsClient;
use error::{Result, YfOptionsError};
use models::{ExpirationData, OptionChain, OptionContract};
use tracing::{info, warn};

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    // Initialize logging
    let log_level = if cli.verbose { "debug" } else { "info" };
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level)),
        )
        .init();

    if let Err(e) = run(cli).await {
        eprintln!("Error: {}", e);
        std::process::exit(1);
    }
}

async fn run(cli: Cli) -> Result<()> {
    // Validate strike range if provided
    if cli.strikes.is_some() && cli.parse_strike_range().is_none() {
        return Err(YfOptionsError::StrikeRangeError(
            "Invalid strike range format. Use: MIN-MAX (e.g., 150-200)".to_string(),
        ));
    }

    // Create options client (uses yf-common CrumbAuth + rate limiting)
    let client = OptionsClient::new(cli.rate_limit)?;

    // Authenticate with Yahoo Finance to get cookies and crumb token
    info!("Authenticating with Yahoo Finance...");
    client.authenticate().await?;
    info!("Authentication successful");

    // Fetch options for all symbols
    let mut option_chains = Vec::new();

    for symbol in &cli.symbols {
        info!("Fetching options for {}", symbol);

        match fetch_and_process_symbol(&client, symbol, &cli).await {
            Ok(chain) => {
                info!(
                    "Fetched {} options for {} ({} expirations)",
                    chain.options.iter().map(|e| e.calls.len() + e.puts.len()).sum::<usize>(),
                    symbol,
                    chain.options.len()
                );
                option_chains.push(chain);
            }
            Err(e) => {
                warn!("Failed to fetch options for {}: {}", symbol, e);
            }
        }
    }

    if option_chains.is_empty() {
        return Err(YfOptionsError::NoDataError("No data fetched".to_string()));
    }

    // Write output
    if cli.combine {
        let output_path = output::write_combined_to_file(
            &option_chains,
            &cli.output_dir,
            &cli.format,
            cli.pretty,
            &cli.combined_filename,
        )?;
        info!("Combined output written to: {}", output_path);
    } else {
        for chain in &option_chains {
            let output_path =
                output::write_to_file(chain, &cli.output_dir, &cli.format, cli.pretty)?;
            info!("Output written to: {}", output_path);
        }
    }

    Ok(())
}

async fn fetch_and_process_symbol(
    client: &OptionsClient,
    symbol: &str,
    cli: &Cli,
) -> Result<OptionChain> {
    // If specific expiration is requested, fetch only that expiration
    let expiration_timestamp = cli.parse_expiration_timestamp();

    let response = if let Some(timestamp) = expiration_timestamp {
        client
            .fetch_options_for_expiration(symbol, timestamp)
            .await?
    } else {
        client.fetch_options_chain(symbol, None).await?
    };

    let result = response
        .option_chain
        .result
        .first()
        .ok_or_else(|| YfOptionsError::NoDataError(symbol.to_string()))?;

    let underlying_price = result.quote.regular_market_price;

    // Process options data with filters
    let mut processed_options = Vec::new();

    for option_data in &result.options {
        let expiration_date = option_data.expiration_date;

        // Filter and process calls
        let mut calls = filter_contracts(
            &option_data.calls,
            underlying_price,
            expiration_date,
            cli,
            true, // is_call
        );

        // Filter and process puts
        let mut puts = filter_contracts(
            &option_data.puts,
            underlying_price,
            expiration_date,
            cli,
            false, // is_put
        );

        // Apply option type filter
        match cli.option_type.as_str() {
            "calls" => puts.clear(),
            "puts" => calls.clear(),
            _ => {} // "all" - keep both
        }

        // Only include if we have contracts left after filtering
        if !calls.is_empty() || !puts.is_empty() {
            processed_options.push(ExpirationData {
                expiration_date,
                calls,
                puts,
            });
        }
    }

    Ok(OptionChain {
        symbol: symbol.to_string(),
        underlying_price,
        expiration_dates: result.expiration_dates.clone(),
        strikes: result.strikes.clone(),
        options: processed_options,
    })
}

fn filter_contracts(
    contracts: &[OptionContract],
    underlying_price: f64,
    expiration_date: i64,
    cli: &Cli,
    is_call: bool,
) -> Vec<OptionContract> {
    contracts
        .iter()
        .filter(|contract| {
            // Strike range filter
            if let Some((min, max)) = cli.parse_strike_range() {
                if contract.strike < min || contract.strike > max {
                    return false;
                }
            }

            // ITM/OTM filter
            if cli.itm && !contract.in_the_money {
                return false;
            }
            if cli.otm && contract.in_the_money {
                return false;
            }

            true
        })
        .map(|contract| {
            let mut contract = contract.clone();

            // Calculate Greeks if requested
            if cli.greeks {
                contract.greeks = calculate_greeks_for_contract(
                    &contract,
                    underlying_price,
                    expiration_date,
                    cli.risk_free_rate,
                    is_call,
                );
            }

            contract
        })
        .collect()
}

fn calculate_greeks_for_contract(
    contract: &OptionContract,
    underlying_price: f64,
    expiration_date: i64,
    risk_free_rate: f64,
    is_call: bool,
) -> Option<models::Greeks> {
    // Calculate time to expiry in years
    let now = Utc::now().timestamp();
    let time_to_expiry = (expiration_date - now) as f64 / (365.25 * 24.0 * 3600.0);

    if time_to_expiry <= 0.0 {
        return None;
    }

    greeks::calculate_greeks(
        is_call,
        underlying_price,
        contract.strike,
        time_to_expiry,
        risk_free_rate,
        contract.implied_volatility,
    )
}