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
//! Output writers for yf-options.
//!
//! Uses yf-common::JsonWriter for JSON output.
//! CSV output remains tool-specific due to the options-specific schema
//! (18 columns including Greeks).

use crate::error::Result;
use crate::models::{OptionChain, OptionContract};
use std::fs;
use std::path::Path;
use yf_common::output::JsonWriter;

/// Write option chain data to file (single symbol).
pub fn write_to_file(
    chain: &OptionChain,
    output_dir: &str,
    format: &str,
    pretty: bool,
) -> Result<String> {
    match format {
        "json" => {
            let writer = JsonWriter::new(output_dir, pretty);
            let filename = format!("{}_options.json", chain.symbol);
            let path = writer.write(chain, &filename)?;
            Ok(path.to_string_lossy().to_string())
        }
        "csv" => {
            let filename = format!("{}_options.csv", chain.symbol);
            let output_path = Path::new(output_dir).join(&filename);
            write_csv(chain, &output_path)?;
            Ok(output_path.to_string_lossy().to_string())
        }
        _ => unreachable!("Invalid format validated by clap"),
    }
}

/// Write combined option chains to file (multiple symbols).
pub fn write_combined_to_file(
    chains: &[OptionChain],
    output_dir: &str,
    format: &str,
    pretty: bool,
    combined_filename: &str,
) -> Result<String> {
    match format {
        "json" => {
            let writer = JsonWriter::new(output_dir, pretty);
            let filename = if combined_filename.ends_with(".json") {
                combined_filename.to_string()
            } else {
                format!("{}.json", combined_filename)
            };
            // Wrap slice in a Vec reference for Sized bound on JsonWriter::write
            let chains_vec: Vec<&OptionChain> = chains.iter().collect();
            let path = writer.write(&chains_vec, &filename)?;
            Ok(path.to_string_lossy().to_string())
        }
        "csv" => {
            let filename = if combined_filename.ends_with(".csv") {
                combined_filename.to_string()
            } else {
                format!("{}.csv", combined_filename)
            };
            let output_path = Path::new(output_dir).join(&filename);

            let mut csv_content = String::new();
            csv_content.push_str(CSV_HEADER);

            for chain in chains {
                write_chain_csv_rows(&mut csv_content, chain);
            }

            fs::write(&output_path, csv_content)?;
            Ok(output_path.to_string_lossy().to_string())
        }
        _ => unreachable!("Invalid format validated by clap"),
    }
}

/// CSV header for options data (18 columns).
const CSV_HEADER: &str = "symbol,underlying_price,expiration_date,option_type,contract_symbol,strike,last_price,bid,ask,volume,open_interest,implied_volatility,in_the_money,delta,gamma,theta,vega,rho\n";

/// Write option chain data as CSV.
fn write_csv(chain: &OptionChain, path: &Path) -> Result<()> {
    let mut csv_content = String::new();
    csv_content.push_str(CSV_HEADER);
    write_chain_csv_rows(&mut csv_content, chain);
    fs::write(path, csv_content)?;
    Ok(())
}

/// Append CSV rows for a single chain to the output buffer.
fn write_chain_csv_rows(buf: &mut String, chain: &OptionChain) {
    for expiration_data in &chain.options {
        let exp_date = expiration_data.expiration_date;

        // Write calls
        for call in &expiration_data.calls {
            buf.push_str(&format_option_row(
                &chain.symbol,
                chain.underlying_price,
                exp_date,
                "call",
                call,
            ));
        }

        // Write puts
        for put in &expiration_data.puts {
            buf.push_str(&format_option_row(
                &chain.symbol,
                chain.underlying_price,
                exp_date,
                "put",
                put,
            ));
        }
    }
}

/// Format a single option contract as a CSV row.
fn format_option_row(
    symbol: &str,
    underlying_price: f64,
    expiration: i64,
    option_type: &str,
    contract: &OptionContract,
) -> String {
    let mut row = format!(
        "{},{},{},{},{},{},{},{},{},{},{},{},{}",
        symbol,
        underlying_price,
        expiration,
        option_type,
        contract.contract_symbol,
        contract.strike,
        contract.last_price,
        contract.bid.map_or(String::new(), |v| v.to_string()),
        contract.ask.map_or(String::new(), |v| v.to_string()),
        contract.volume.map_or(String::new(), |v| v.to_string()),
        contract
            .open_interest
            .map_or(String::new(), |v| v.to_string()),
        contract.implied_volatility,
        contract.in_the_money,
    );

    // Add Greeks if present
    if let Some(greeks) = &contract.greeks {
        row.push_str(&format!(
            ",{},{},{},{},{}",
            greeks.delta, greeks.gamma, greeks.theta, greeks.vega, greeks.rho
        ));
    } else {
        row.push_str(",,,,,");
    }

    row.push('\n');
    row
}