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 clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "yf-options")]
#[command(about = "Download Yahoo Finance options chain data with optional Greeks calculation", long_about = None)]
pub struct Cli {
    /// Stock symbols to fetch options for (e.g., AAPL, MSFT)
    #[arg(required = true)]
    pub symbols: Vec<String>,

    /// Filter by expiration date (YYYY-MM-DD)
    #[arg(short, long)]
    pub expiration: Option<String>,

    /// Filter option type: calls, puts, or all
    #[arg(short = 't', long, value_parser = ["calls", "puts", "all"], default_value = "all")]
    pub option_type: String,

    /// Filter strike range (e.g., 150-200)
    #[arg(long)]
    pub strikes: Option<String>,

    /// Filter for in-the-money options only
    #[arg(long, conflicts_with = "otm")]
    pub itm: bool,

    /// Filter for out-of-the-money options only
    #[arg(long, conflicts_with = "itm")]
    pub otm: bool,

    /// Calculate and include Greeks
    #[arg(long)]
    pub greeks: bool,

    /// Risk-free rate for Greeks calculation (default: 0.05)
    #[arg(long, default_value = "0.05")]
    pub risk_free_rate: f64,

    /// Output directory (default: current directory)
    #[arg(short, long, default_value = ".")]
    pub output_dir: String,

    /// Output format: json or csv
    #[arg(long, value_parser = ["json", "csv"], default_value = "json")]
    pub format: String,

    /// Pretty-print JSON output
    #[arg(long)]
    pub pretty: bool,

    /// Combine all symbols into one file
    #[arg(long)]
    pub combine: bool,

    /// Filename for combined output (used with --combine)
    #[arg(long, default_value = "combined_options")]
    pub combined_filename: String,

    /// Requests per minute (default: 5)
    #[arg(long, default_value = "5")]
    pub rate_limit: u32,

    /// Verbose logging
    #[arg(short, long)]
    pub verbose: bool,
}

impl Cli {
    /// Parse strike range string (e.g., "150-200") into (min, max)
    pub fn parse_strike_range(&self) -> Option<(f64, f64)> {
        self.strikes.as_ref().and_then(|s| {
            let parts: Vec<&str> = s.split('-').collect();
            if parts.len() == 2 {
                let min = parts[0].trim().parse::<f64>().ok()?;
                let max = parts[1].trim().parse::<f64>().ok()?;
                Some((min, max))
            } else {
                None
            }
        })
    }

    /// Parse expiration date string (YYYY-MM-DD) into Unix timestamp
    pub fn parse_expiration_timestamp(&self) -> Option<i64> {
        use chrono::NaiveDate;

        self.expiration.as_ref().and_then(|date_str| {
            NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
                .ok()
                .map(|date| {
                    date.and_hms_opt(0, 0, 0)
                        .unwrap()
                        .and_utc()
                        .timestamp()
                })
        })
    }
}

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

    #[test]
    fn test_basic_symbol() {
        let args = Cli::parse_from(["yf-options", "AAPL"]);
        assert_eq!(args.symbols, vec!["AAPL"]);
    }

    #[test]
    fn test_multiple_symbols() {
        let args = Cli::parse_from(["yf-options", "AAPL", "MSFT", "GOOGL"]);
        assert_eq!(args.symbols.len(), 3);
        assert_eq!(args.symbols, vec!["AAPL", "MSFT", "GOOGL"]);
    }

    #[test]
    fn test_expiration_filter() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-03-15"]);
        assert_eq!(args.expiration, Some("2024-03-15".to_string()));
    }

    #[test]
    fn test_type_filter_calls() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "calls"]);
        assert_eq!(args.option_type, "calls");
    }

    #[test]
    fn test_type_filter_puts() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "puts"]);
        assert_eq!(args.option_type, "puts");
    }

    #[test]
    fn test_type_filter_all() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "all"]);
        assert_eq!(args.option_type, "all");
    }

    #[test]
    fn test_strike_range() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150-200"]);
        assert_eq!(args.strikes, Some("150-200".to_string()));
        let (min, max) = args.parse_strike_range().unwrap();
        assert_eq!(min, 150.0);
        assert_eq!(max, 200.0);
    }

    #[test]
    fn test_strike_range_with_spaces() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150 - 200"]);
        let (min, max) = args.parse_strike_range().unwrap();
        assert_eq!(min, 150.0);
        assert_eq!(max, 200.0);
    }

    #[test]
    fn test_invalid_strike_range() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150"]);
        assert!(args.parse_strike_range().is_none());
    }

    #[test]
    fn test_greeks_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--greeks"]);
        assert!(args.greeks);
    }

    #[test]
    fn test_risk_free_rate() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--greeks", "--risk-free-rate", "0.045"]);
        assert_eq!(args.risk_free_rate, 0.045);
    }

    #[test]
    fn test_itm_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--itm"]);
        assert!(args.itm);
        assert!(!args.otm);
    }

    #[test]
    fn test_otm_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--otm"]);
        assert!(args.otm);
        assert!(!args.itm);
    }

    #[test]
    fn test_itm_otm_mutually_exclusive() {
        // Should fail if both --itm and --otm provided
        let result = Cli::try_parse_from(["yf-options", "AAPL", "--itm", "--otm"]);
        assert!(result.is_err());
    }

    #[test]
    fn test_default_values() {
        let args = Cli::parse_from(["yf-options", "AAPL"]);
        assert_eq!(args.rate_limit, 5);
        assert!(!args.greeks);
        assert!(!args.pretty);
        assert_eq!(args.risk_free_rate, 0.05);
        assert_eq!(args.output_dir, ".");
        assert_eq!(args.format, "json");
        assert_eq!(args.option_type, "all");
    }

    #[test]
    fn test_output_dir() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-o", "/tmp/options"]);
        assert_eq!(args.output_dir, "/tmp/options");
    }

    #[test]
    fn test_format_json() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--format", "json"]);
        assert_eq!(args.format, "json");
    }

    #[test]
    fn test_format_csv() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--format", "csv"]);
        assert_eq!(args.format, "csv");
    }

    #[test]
    fn test_pretty_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--pretty"]);
        assert!(args.pretty);
    }

    #[test]
    fn test_combine_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "MSFT", "--combine"]);
        assert!(args.combine);
    }

    #[test]
    fn test_verbose_flag() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-v"]);
        assert!(args.verbose);
    }

    #[test]
    fn test_rate_limit_custom() {
        let args = Cli::parse_from(["yf-options", "AAPL", "--rate-limit", "10"]);
        assert_eq!(args.rate_limit, 10);
    }

    #[test]
    fn test_parse_expiration_timestamp() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-03-15"]);
        let timestamp = args.parse_expiration_timestamp();
        assert!(timestamp.is_some());
        // March 15, 2024 00:00:00 UTC = 1710460800
        assert_eq!(timestamp.unwrap(), 1710460800);
    }

    #[test]
    fn test_parse_invalid_expiration_date() {
        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-13-45"]);
        let timestamp = args.parse_expiration_timestamp();
        assert!(timestamp.is_none());
    }

    #[test]
    fn test_complex_combination() {
        let args = Cli::parse_from([
            "yf-options",
            "AAPL",
            "MSFT",
            "-e",
            "2024-03-15",
            "-t",
            "calls",
            "--strikes",
            "150-200",
            "--itm",
            "--greeks",
            "--risk-free-rate",
            "0.045",
            "-o",
            "/tmp/out",
            "--format",
            "csv",
            "--pretty",
            "--combine",
            "-v",
            "--rate-limit",
            "10",
        ]);

        assert_eq!(args.symbols, vec!["AAPL", "MSFT"]);
        assert_eq!(args.expiration, Some("2024-03-15".to_string()));
        assert_eq!(args.option_type, "calls");
        assert_eq!(args.strikes, Some("150-200".to_string()));
        assert!(args.itm);
        assert!(args.greeks);
        assert_eq!(args.risk_free_rate, 0.045);
        assert_eq!(args.output_dir, "/tmp/out");
        assert_eq!(args.format, "csv");
        assert!(args.pretty);
        assert!(args.combine);
        assert!(args.verbose);
        assert_eq!(args.rate_limit, 10);
    }
}