Skip to main content

yf_options/
cli.rs

1use clap::Parser;
2
3#[derive(Parser, Debug)]
4#[command(name = "yf-options")]
5#[command(about = "Download Yahoo Finance options chain data with optional Greeks calculation", long_about = None)]
6pub struct Cli {
7    /// Stock symbols to fetch options for (e.g., AAPL, MSFT)
8    #[arg(required = true)]
9    pub symbols: Vec<String>,
10
11    /// Filter by expiration date (YYYY-MM-DD)
12    #[arg(short, long)]
13    pub expiration: Option<String>,
14
15    /// Filter option type: calls, puts, or all
16    #[arg(short = 't', long, value_parser = ["calls", "puts", "all"], default_value = "all")]
17    pub option_type: String,
18
19    /// Filter strike range (e.g., 150-200)
20    #[arg(long)]
21    pub strikes: Option<String>,
22
23    /// Filter for in-the-money options only
24    #[arg(long, conflicts_with = "otm")]
25    pub itm: bool,
26
27    /// Filter for out-of-the-money options only
28    #[arg(long, conflicts_with = "itm")]
29    pub otm: bool,
30
31    /// Calculate and include Greeks
32    #[arg(long)]
33    pub greeks: bool,
34
35    /// Risk-free rate for Greeks calculation (default: 0.05)
36    #[arg(long, default_value = "0.05")]
37    pub risk_free_rate: f64,
38
39    /// Output directory (default: current directory)
40    #[arg(short, long, default_value = ".")]
41    pub output_dir: String,
42
43    /// Output format: json or csv
44    #[arg(long, value_parser = ["json", "csv"], default_value = "json")]
45    pub format: String,
46
47    /// Pretty-print JSON output
48    #[arg(long)]
49    pub pretty: bool,
50
51    /// Combine all symbols into one file
52    #[arg(long)]
53    pub combine: bool,
54
55    /// Filename for combined output (used with --combine)
56    #[arg(long, default_value = "combined_options")]
57    pub combined_filename: String,
58
59    /// Requests per minute (default: 5)
60    #[arg(long, default_value = "5")]
61    pub rate_limit: u32,
62
63    /// Verbose logging
64    #[arg(short, long)]
65    pub verbose: bool,
66}
67
68impl Cli {
69    /// Parse strike range string (e.g., "150-200") into (min, max)
70    pub fn parse_strike_range(&self) -> Option<(f64, f64)> {
71        self.strikes.as_ref().and_then(|s| {
72            let parts: Vec<&str> = s.split('-').collect();
73            if parts.len() == 2 {
74                let min = parts[0].trim().parse::<f64>().ok()?;
75                let max = parts[1].trim().parse::<f64>().ok()?;
76                Some((min, max))
77            } else {
78                None
79            }
80        })
81    }
82
83    /// Parse expiration date string (YYYY-MM-DD) into Unix timestamp
84    pub fn parse_expiration_timestamp(&self) -> Option<i64> {
85        use chrono::NaiveDate;
86
87        self.expiration.as_ref().and_then(|date_str| {
88            NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
89                .ok()
90                .map(|date| {
91                    date.and_hms_opt(0, 0, 0)
92                        .unwrap()
93                        .and_utc()
94                        .timestamp()
95                })
96        })
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_basic_symbol() {
106        let args = Cli::parse_from(["yf-options", "AAPL"]);
107        assert_eq!(args.symbols, vec!["AAPL"]);
108    }
109
110    #[test]
111    fn test_multiple_symbols() {
112        let args = Cli::parse_from(["yf-options", "AAPL", "MSFT", "GOOGL"]);
113        assert_eq!(args.symbols.len(), 3);
114        assert_eq!(args.symbols, vec!["AAPL", "MSFT", "GOOGL"]);
115    }
116
117    #[test]
118    fn test_expiration_filter() {
119        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-03-15"]);
120        assert_eq!(args.expiration, Some("2024-03-15".to_string()));
121    }
122
123    #[test]
124    fn test_type_filter_calls() {
125        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "calls"]);
126        assert_eq!(args.option_type, "calls");
127    }
128
129    #[test]
130    fn test_type_filter_puts() {
131        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "puts"]);
132        assert_eq!(args.option_type, "puts");
133    }
134
135    #[test]
136    fn test_type_filter_all() {
137        let args = Cli::parse_from(["yf-options", "AAPL", "-t", "all"]);
138        assert_eq!(args.option_type, "all");
139    }
140
141    #[test]
142    fn test_strike_range() {
143        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150-200"]);
144        assert_eq!(args.strikes, Some("150-200".to_string()));
145        let (min, max) = args.parse_strike_range().unwrap();
146        assert_eq!(min, 150.0);
147        assert_eq!(max, 200.0);
148    }
149
150    #[test]
151    fn test_strike_range_with_spaces() {
152        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150 - 200"]);
153        let (min, max) = args.parse_strike_range().unwrap();
154        assert_eq!(min, 150.0);
155        assert_eq!(max, 200.0);
156    }
157
158    #[test]
159    fn test_invalid_strike_range() {
160        let args = Cli::parse_from(["yf-options", "AAPL", "--strikes", "150"]);
161        assert!(args.parse_strike_range().is_none());
162    }
163
164    #[test]
165    fn test_greeks_flag() {
166        let args = Cli::parse_from(["yf-options", "AAPL", "--greeks"]);
167        assert!(args.greeks);
168    }
169
170    #[test]
171    fn test_risk_free_rate() {
172        let args = Cli::parse_from(["yf-options", "AAPL", "--greeks", "--risk-free-rate", "0.045"]);
173        assert_eq!(args.risk_free_rate, 0.045);
174    }
175
176    #[test]
177    fn test_itm_flag() {
178        let args = Cli::parse_from(["yf-options", "AAPL", "--itm"]);
179        assert!(args.itm);
180        assert!(!args.otm);
181    }
182
183    #[test]
184    fn test_otm_flag() {
185        let args = Cli::parse_from(["yf-options", "AAPL", "--otm"]);
186        assert!(args.otm);
187        assert!(!args.itm);
188    }
189
190    #[test]
191    fn test_itm_otm_mutually_exclusive() {
192        // Should fail if both --itm and --otm provided
193        let result = Cli::try_parse_from(["yf-options", "AAPL", "--itm", "--otm"]);
194        assert!(result.is_err());
195    }
196
197    #[test]
198    fn test_default_values() {
199        let args = Cli::parse_from(["yf-options", "AAPL"]);
200        assert_eq!(args.rate_limit, 5);
201        assert!(!args.greeks);
202        assert!(!args.pretty);
203        assert_eq!(args.risk_free_rate, 0.05);
204        assert_eq!(args.output_dir, ".");
205        assert_eq!(args.format, "json");
206        assert_eq!(args.option_type, "all");
207    }
208
209    #[test]
210    fn test_output_dir() {
211        let args = Cli::parse_from(["yf-options", "AAPL", "-o", "/tmp/options"]);
212        assert_eq!(args.output_dir, "/tmp/options");
213    }
214
215    #[test]
216    fn test_format_json() {
217        let args = Cli::parse_from(["yf-options", "AAPL", "--format", "json"]);
218        assert_eq!(args.format, "json");
219    }
220
221    #[test]
222    fn test_format_csv() {
223        let args = Cli::parse_from(["yf-options", "AAPL", "--format", "csv"]);
224        assert_eq!(args.format, "csv");
225    }
226
227    #[test]
228    fn test_pretty_flag() {
229        let args = Cli::parse_from(["yf-options", "AAPL", "--pretty"]);
230        assert!(args.pretty);
231    }
232
233    #[test]
234    fn test_combine_flag() {
235        let args = Cli::parse_from(["yf-options", "AAPL", "MSFT", "--combine"]);
236        assert!(args.combine);
237    }
238
239    #[test]
240    fn test_verbose_flag() {
241        let args = Cli::parse_from(["yf-options", "AAPL", "-v"]);
242        assert!(args.verbose);
243    }
244
245    #[test]
246    fn test_rate_limit_custom() {
247        let args = Cli::parse_from(["yf-options", "AAPL", "--rate-limit", "10"]);
248        assert_eq!(args.rate_limit, 10);
249    }
250
251    #[test]
252    fn test_parse_expiration_timestamp() {
253        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-03-15"]);
254        let timestamp = args.parse_expiration_timestamp();
255        assert!(timestamp.is_some());
256        // March 15, 2024 00:00:00 UTC = 1710460800
257        assert_eq!(timestamp.unwrap(), 1710460800);
258    }
259
260    #[test]
261    fn test_parse_invalid_expiration_date() {
262        let args = Cli::parse_from(["yf-options", "AAPL", "-e", "2024-13-45"]);
263        let timestamp = args.parse_expiration_timestamp();
264        assert!(timestamp.is_none());
265    }
266
267    #[test]
268    fn test_complex_combination() {
269        let args = Cli::parse_from([
270            "yf-options",
271            "AAPL",
272            "MSFT",
273            "-e",
274            "2024-03-15",
275            "-t",
276            "calls",
277            "--strikes",
278            "150-200",
279            "--itm",
280            "--greeks",
281            "--risk-free-rate",
282            "0.045",
283            "-o",
284            "/tmp/out",
285            "--format",
286            "csv",
287            "--pretty",
288            "--combine",
289            "-v",
290            "--rate-limit",
291            "10",
292        ]);
293
294        assert_eq!(args.symbols, vec!["AAPL", "MSFT"]);
295        assert_eq!(args.expiration, Some("2024-03-15".to_string()));
296        assert_eq!(args.option_type, "calls");
297        assert_eq!(args.strikes, Some("150-200".to_string()));
298        assert!(args.itm);
299        assert!(args.greeks);
300        assert_eq!(args.risk_free_rate, 0.045);
301        assert_eq!(args.output_dir, "/tmp/out");
302        assert_eq!(args.format, "csv");
303        assert!(args.pretty);
304        assert!(args.combine);
305        assert!(args.verbose);
306        assert_eq!(args.rate_limit, 10);
307    }
308}