mod data; mod indicators; mod resample; mod signals; use resample::Interval; use serde_json::json; use std::path::PathBuf; use std::process::ExitCode; const TEMPLATE: &str = include_str!("../web/template.html"); const USAGE: &str = "usage: stock [-o out.html] [--rsi 14] [--kd 9] [--atr 2.5 | --swing PCT] [--smooth N] [--interval day|week|month]"; // Swing detection for divergences: ZigZag with a reversal threshold of k*ATR(14) (--atr, default 2.5) // or a fixed percentage (--swing). --smooth N locates swings on an N-bar EMA, then snaps them to real highs/lows. struct Args { input: PathBuf, output: Option, rsi: usize, kd: usize, atr_k: f64, swing: Option, smooth: usize, interval: Interval, } fn parse_args() -> Result { let mut it = std::env::args().skip(1); let (mut input, mut output, mut rsi, mut kd) = (None, None, 14, 9); let (mut atr_k, mut swing, mut smooth, mut interval) = (2.5, None, 0, Interval::Day); while let Some(a) = it.next() { let mut val = |name: &str| it.next().ok_or(format!("{name} needs a value")); match a.as_str() { "-o" => output = Some(PathBuf::from(val("-o")?)), "--rsi" => rsi = val("--rsi")?.parse().map_err(|e| format!("--rsi: {e}"))?, "--kd" => kd = val("--kd")?.parse().map_err(|e| format!("--kd: {e}"))?, "--swing" => swing = Some(val("--swing")?.parse().map_err(|e| format!("--swing: {e}"))?), "--atr" => atr_k = val("--atr")?.parse().map_err(|e| format!("--atr: {e}"))?, "--smooth" => smooth = val("--smooth")?.parse().map_err(|e| format!("--smooth: {e}"))?, "--interval" => { let v = val("--interval")?; interval = Interval::parse(&v).ok_or(format!("--interval: expected day, week or month, got '{v}'"))?; } "-h" | "--help" => return Err(String::new()), _ if a.starts_with('-') => return Err(format!("unknown option {a}")), _ => input = Some(PathBuf::from(a)), } } if rsi == 0 || kd == 0 || atr_k <= 0.0 || swing.is_some_and(|p| !(p > 0.0 && p < 100.0)) { return Err("periods must be positive, --atr > 0, --swing within (0, 100)".into()); } Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, atr_k, swing, smooth, interval }) } /// Indicators, signals and divergences for one bar interval; also returns a stats text. fn analyse(candles: &[data::Candle], interval: Interval, args: &Args) -> (serde_json::Value, String) { let rsi = indicators::rsi(candles, args.rsi); let (k, d) = indicators::kd(candles, args.kd); let thr = match args.swing { Some(p) => signals::Threshold::Percent(p), None => signals::Threshold::Atr { k: args.atr_k, atr: indicators::atr(candles, 14) }, }; let swings = if args.smooth > 0 { let found = signals::zigzag(&indicators::ema_candles(candles, args.smooth), &thr); signals::snap_swings(candles, &found, args.smooth) } else { signals::zigzag(candles, &thr) }; let mut divs = signals::divergences(candles, &swings, &thr, &rsi, "RSI", &signals::Filters::rsi()); divs.extend(signals::divergences(candles, &swings, &thr, &k, "K", &signals::Filters::k())); divs.sort_by_key(|d| d.confirmed); let stats = format!("[{}] {} bars\n{}", interval.name(), candles.len(), signals::forward_stats(candles, &divs, &[5, 10, 20])); let payload = json!({ "candles": candles, "rsi": rsi, "k": k, "d": d, "crosses": signals::crosses(&k, &d), "divergences": divs, "partial": resample::last_bar_partial(candles, interval), }); (payload, stats) } fn run() -> Result { let args = parse_args()?; let daily = data::load_csv(&args.input)?; let mut intervals = serde_json::Map::new(); for interval in Interval::ALL { let (payload, stats) = analyse(&resample::resample(&daily, interval), interval, &args); print!("{stats}"); intervals.insert(interval.name().into(), payload); } let payload = json!({ "name": args.input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), "rsiPeriod": args.rsi, "kdPeriod": args.kd, "initial": args.interval.name(), "intervals": intervals, }); // " element early. let json = payload.to_string().replace(" ExitCode { match run() { Ok(out) => { println!("wrote {}", out.display()); ExitCode::SUCCESS } Err(e) => { if !e.is_empty() { eprintln!("error: {e}"); } eprintln!("{USAGE}"); ExitCode::FAILURE } } }