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();
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<()> {
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(),
));
}
let client = OptionsClient::new(cli.rate_limit)?;
info!("Authenticating with Yahoo Finance...");
client.authenticate().await?;
info!("Authentication successful");
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()));
}
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> {
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;
let mut processed_options = Vec::new();
for option_data in &result.options {
let expiration_date = option_data.expiration_date;
let mut calls = filter_contracts(
&option_data.calls,
underlying_price,
expiration_date,
cli,
true, );
let mut puts = filter_contracts(
&option_data.puts,
underlying_price,
expiration_date,
cli,
false, );
match cli.option_type.as_str() {
"calls" => puts.clear(),
"puts" => calls.clear(),
_ => {} }
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| {
if let Some((min, max)) = cli.parse_strike_range() {
if contract.strike < min || contract.strike > max {
return false;
}
}
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();
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> {
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,
)
}