From 5c00355c2ccd94195bdb90e1c1e6f51acbcdd7a5 Mon Sep 17 00:00:00 2001 From: JSDurand Date: Mon, 12 Oct 2026 18:41:33 +0800 Subject: added monthly and weekly analyses * src/main.rs: * src/resample.rs: * src/signals.rs: * web/template.html (Stock RSI / KD): --- src/main.rs | 61 +++++++++++++++------- src/resample.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/signals.rs | 4 +- 3 files changed, 203 insertions(+), 21 deletions(-) create mode 100644 src/resample.rs (limited to 'src') diff --git a/src/main.rs b/src/main.rs index 8ca9df5..f48b35a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,15 @@ 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]"; +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. @@ -19,12 +21,13 @@ struct Args { 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) = (2.5, None, 0); + 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() { @@ -34,6 +37,10 @@ fn parse_args() -> Result { "--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)), @@ -42,39 +49,55 @@ fn parse_args() -> Result { 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 }) + Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, atr_k, swing, smooth, interval }) } -fn run() -> Result { - let args = parse_args()?; - let candles = data::load_csv(&args.input)?; - let rsi = indicators::rsi(&candles, args.rsi); - let (k, d) = indicators::kd(&candles, args.kd); - +/// 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) }, + 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) + let found = signals::zigzag(&indicators::ema_candles(candles, args.smooth), &thr); + signals::snap_swings(candles, &found, args.smooth) } else { - signals::zigzag(&candles, &thr) + 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())); + 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); - print!("{}", signals::forward_stats(&candles, &divs, &[5, 10, 20])); + let stats = format!("[{}] {} bars\n{}", interval.name(), candles.len(), signals::forward_stats(candles, &divs, &[5, 10, 20])); 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, "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(" &'static str { + match self { + Interval::Day => "day", + Interval::Week => "week", + Interval::Month => "month", + } + } + + pub fn parse(s: &str) -> Option { + Interval::ALL.into_iter().find(|i| i.name() == s) + } +} + +/// Days since 1970-01-01 for a `YYYY-MM-DD` date (Howard Hinnant's civil-days algorithm). +fn days_from_date(date: &str) -> i64 { + let (y, m, d): (i64, i64, i64) = ( + date[0..4].parse().unwrap_or(1970), + date[5..7].parse().unwrap_or(1), + date[8..10].parse().unwrap_or(1), + ); + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +/// Monday = 0 ... Sunday = 6. +fn weekday(date: &str) -> i64 { + (days_from_date(date) + 3).rem_euclid(7) +} + +/// Bars are grouped by Monday-based week (1970-01-01 was a Thursday) or by calendar month. +fn group_key(date: &str, interval: Interval) -> i64 { + match interval { + Interval::Day => days_from_date(date), + Interval::Week => (days_from_date(date) + 3).div_euclid(7), + Interval::Month => date[0..4].parse::().unwrap_or(0) * 12 + date[5..7].parse::().unwrap_or(1), + } +} + +/// Aggregate daily candles: first open, highest high, lowest low, last close, summed volume; +/// `date` is the last trading day of the group. +pub fn resample(candles: &[Candle], interval: Interval) -> Vec { + if interval == Interval::Day { + return candles.to_vec(); + } + let mut out: Vec = Vec::new(); + let mut last_key = None; + for c in candles { + let key = group_key(&c.date, interval); + match (last_key == Some(key), out.last_mut()) { + (true, Some(bar)) => { + bar.high = bar.high.max(c.high); + bar.low = bar.low.min(c.low); + bar.close = c.close; + bar.volume += c.volume; + bar.date = c.date.clone(); + } + _ => out.push(c.clone()), + } + last_key = Some(key); + } + out +} + +/// Whether the last bar may still be incomplete: a week whose last trading day is before +/// Friday, or a month whose last trading day is not on or after the 28th-ish last weekday. +/// (Exchange holidays are not known, so this is a hint, not a guarantee.) +pub fn last_bar_partial(candles: &[Candle], interval: Interval) -> bool { + let Some(last) = candles.last() else { return false }; + match interval { + Interval::Day => false, + Interval::Week => weekday(&last.date) < 4, + Interval::Month => { + // last weekday of the month: step back from the month's last day + let (y, m): (i64, i64) = (last.date[0..4].parse().unwrap_or(0), last.date[5..7].parse().unwrap_or(1)); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + let first_next = days_from_date(&format!("{ny:04}-{nm:02}-01")); + let mut d = first_next - 1; + while (d + 3).rem_euclid(7) >= 5 { + d -= 1; + } + days_from_date(&last.date) < d - 3 // allow a few holiday days before the last weekday + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(date: &str, o: f64, h: f64, l: f64, cl: f64, v: f64) -> Candle { + Candle { date: date.into(), open: o, high: h, low: l, close: cl, volume: v } + } + + #[test] + fn weekday_known_dates() { + assert_eq!(weekday("1970-01-01"), 3); // Thursday + assert_eq!(weekday("2024-01-01"), 0); // Monday + assert_eq!(weekday("2026-10-12"), 0); // Monday + assert_eq!(weekday("2024-02-29"), 3); // leap day, Thursday + } + + #[test] + fn weekly_spans_year_boundary_and_holiday() { + // 2023-12-28 Thu, 12-29 Fri | 2024-01-01 Mon (holiday, absent), 01-02 Tue, 01-05 Fri | 01-08 Mon + let days = [ + c("2023-12-28", 10.0, 12.0, 9.0, 11.0, 100.0), + c("2023-12-29", 11.0, 13.0, 10.0, 12.0, 200.0), + c("2024-01-02", 12.0, 14.0, 11.0, 13.0, 300.0), + c("2024-01-05", 13.0, 15.0, 8.0, 9.0, 400.0), + c("2024-01-08", 9.0, 10.0, 8.5, 9.5, 500.0), + ]; + let w = resample(&days, Interval::Week); + assert_eq!(w.len(), 3); + assert_eq!((w[0].open, w[0].high, w[0].low, w[0].close, w[0].volume), (10.0, 13.0, 9.0, 12.0, 300.0)); + assert_eq!(w[0].date, "2023-12-29"); + assert_eq!((w[1].open, w[1].high, w[1].low, w[1].close, w[1].volume), (12.0, 15.0, 8.0, 9.0, 700.0)); + assert_eq!(w[1].date, "2024-01-05"); + assert_eq!(w[2].date, "2024-01-08"); + } + + #[test] + fn monthly_groups_by_calendar_month() { + let days = [ + c("2024-01-30", 1.0, 2.0, 0.5, 1.5, 10.0), + c("2024-01-31", 1.5, 3.0, 1.0, 2.0, 10.0), + c("2024-02-01", 2.0, 2.5, 1.5, 2.2, 10.0), + ]; + let m = resample(&days, Interval::Month); + assert_eq!(m.len(), 2); + assert_eq!((m[0].high, m[0].low, m[0].close, m[0].volume), (3.0, 0.5, 2.0, 20.0)); + assert_eq!(m[0].date, "2024-01-31"); + } + + #[test] + fn partial_flags() { + let thu = [c("2026-10-08", 1.0, 1.0, 1.0, 1.0, 1.0)]; + let fri = [c("2026-10-09", 1.0, 1.0, 1.0, 1.0, 1.0)]; + assert!(last_bar_partial(&thu, Interval::Week)); + assert!(!last_bar_partial(&fri, Interval::Week)); + assert!(last_bar_partial(&thu, Interval::Month)); // 2026-10-30 is the last weekday + assert!(!last_bar_partial(&[c("2026-10-30", 1.0, 1.0, 1.0, 1.0, 1.0)], Interval::Month)); + } +} diff --git a/src/signals.rs b/src/signals.rs index 3019c5c..00072b8 100644 --- a/src/signals.rs +++ b/src/signals.rs @@ -273,11 +273,11 @@ pub fn forward_stats(candles: &[Candle], divs: &[Divergence], horizons: &[usize] }) .collect(); if rets.is_empty() { - out += &format!(" +{h}d: n/a"); + out += &format!(" +{h}b: n/a"); } else { let mean = rets.iter().sum::() / rets.len() as f64 * 100.0; let win = rets.iter().filter(|&&r| r > 0.0).count() as f64 / rets.len() as f64 * 100.0; - out += &format!(" +{h}d: {mean:+.2}% win {win:.0}% (n={})", rets.len()); + out += &format!(" +{h}b: {mean:+.2}% win {win:.0}% (n={})", rets.len()); } } out.push('\n'); -- cgit v1.2.3-18-g5258