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 {
#[arg(required = true)]
pub symbols: Vec<String>,
#[arg(short, long)]
pub expiration: Option<String>,
#[arg(short = 't', long, value_parser = ["calls", "puts", "all"], default_value = "all")]
pub option_type: String,
#[arg(long)]
pub strikes: Option<String>,
#[arg(long, conflicts_with = "otm")]
pub itm: bool,
#[arg(long, conflicts_with = "itm")]
pub otm: bool,
#[arg(long)]
pub greeks: bool,
#[arg(long, default_value = "0.05")]
pub risk_free_rate: f64,
#[arg(short, long, default_value = ".")]
pub output_dir: String,
#[arg(long, value_parser = ["json", "csv"], default_value = "json")]
pub format: String,
#[arg(long)]
pub pretty: bool,
#[arg(long)]
pub combine: bool,
#[arg(long, default_value = "combined_options")]
pub combined_filename: String,
#[arg(long, default_value = "5")]
pub rate_limit: u32,
#[arg(short, long)]
pub verbose: bool,
}
impl Cli {
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
}
})
}
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() {
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());
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);
}
}