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 #[arg(required = true)]
9 pub symbols: Vec<String>,
10
11 #[arg(short, long)]
13 pub expiration: Option<String>,
14
15 #[arg(short = 't', long, value_parser = ["calls", "puts", "all"], default_value = "all")]
17 pub option_type: String,
18
19 #[arg(long)]
21 pub strikes: Option<String>,
22
23 #[arg(long, conflicts_with = "otm")]
25 pub itm: bool,
26
27 #[arg(long, conflicts_with = "itm")]
29 pub otm: bool,
30
31 #[arg(long)]
33 pub greeks: bool,
34
35 #[arg(long, default_value = "0.05")]
37 pub risk_free_rate: f64,
38
39 #[arg(short, long, default_value = ".")]
41 pub output_dir: String,
42
43 #[arg(long, value_parser = ["json", "csv"], default_value = "json")]
45 pub format: String,
46
47 #[arg(long)]
49 pub pretty: bool,
50
51 #[arg(long)]
53 pub combine: bool,
54
55 #[arg(long, default_value = "combined_options")]
57 pub combined_filename: String,
58
59 #[arg(long, default_value = "5")]
61 pub rate_limit: u32,
62
63 #[arg(short, long)]
65 pub verbose: bool,
66}
67
68impl Cli {
69 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 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 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 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}