From e02b8c1369799eee31fed5c5d8b44a1b044e2780 Mon Sep 17 00:00:00 2001 From: JSDurand Date: Fri, 9 Oct 2026 16:12:46 +0800 Subject: first commit This is code written by claude for a static stock analysis page generator. I intend to test difference analysis indicators to see if some claims make any sense. --- src/data.rs | 52 ++++++++++++ src/indicators.rs | 98 +++++++++++++++++++++ src/main.rs | 87 +++++++++++++++++++ src/signals.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 src/data.rs create mode 100644 src/indicators.rs create mode 100644 src/main.rs create mode 100644 src/signals.rs (limited to 'src') diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..c5fc18c --- /dev/null +++ b/src/data.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Candle { + #[serde(rename = "Date")] + pub date: String, + #[serde(rename = "Open")] + pub open: f64, + #[serde(rename = "High")] + pub high: f64, + #[serde(rename = "Low")] + pub low: f64, + #[serde(rename = "Close")] + pub close: f64, + #[serde(rename = "Volume")] + pub volume: f64, +} + +/// Read `Date,Open,High,Low,Close,Volume` rows; dates must be `YYYY-MM-DD`. +/// Rows are sorted ascending by date; duplicate dates are an error. +pub fn load_csv(path: &Path) -> Result, String> { + let mut reader = csv::Reader::from_path(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut candles = Vec::new(); + for (i, row) in reader.deserialize::().enumerate() { + let line = i + 2; // header is line 1 + let c = row.map_err(|e| format!("{}: line {line}: {e}", path.display()))?; + if !valid_date(&c.date) { + return Err(format!("{}: line {line}: bad date '{}', expected YYYY-MM-DD", path.display(), c.date)); + } + if c.high < c.low || !(c.low..=c.high).contains(&c.close) { + return Err(format!("{}: line {line}: inconsistent High/Low/Close", path.display())); + } + candles.push(c); + } + candles.sort_by(|a, b| a.date.cmp(&b.date)); + if let Some(w) = candles.windows(2).find(|w| w[0].date == w[1].date) { + return Err(format!("{}: duplicate date {}", path.display(), w[0].date)); + } + if candles.is_empty() { + return Err(format!("{}: no data rows", path.display())); + } + Ok(candles) +} + +fn valid_date(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()) +} diff --git a/src/indicators.rs b/src/indicators.rs new file mode 100644 index 0000000..4378757 --- /dev/null +++ b/src/indicators.rs @@ -0,0 +1,98 @@ +use crate::data::Candle; +use ta::indicators::FastStochastic; +use ta::{DataItem, Next}; + +/// Wilder RSI from closes: first average = simple mean of the first `period` +/// changes, then avg = (prev * (period-1) + current) / period. (`ta`'s RSI uses a +/// standard EMA instead, which differs from charting software.) The first +/// `period` values are `None`. +pub fn rsi(candles: &[Candle], period: usize) -> Vec> { + let n = period as f64; + let mut out = vec![None; candles.len()]; + let (mut gain, mut loss) = (0.0, 0.0); + for i in 1..candles.len() { + let ch = candles[i].close - candles[i - 1].close; + let (g, l) = (ch.max(0.0), (-ch).max(0.0)); + if i <= period { + gain += g / n; + loss += l / n; + } else { + gain = (gain * (n - 1.0) + g) / n; + loss = (loss * (n - 1.0) + l) / n; + } + if i >= period { + out[i] = Some(if loss == 0.0 { if gain == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + gain / loss) }); + } + } + out +} + +/// KD: RSV from `ta`'s FastStochastic over high/low/close, then +/// K = 2/3 K_prev + 1/3 RSV, D = 2/3 D_prev + 1/3 K, both seeded at 50. +pub fn kd(candles: &[Candle], period: usize) -> (Vec>, Vec>) { + let mut rsv_ind = FastStochastic::new(period).expect("period > 0"); + let (mut k, mut d) = (50.0, 50.0); + let mut ks = Vec::with_capacity(candles.len()); + let mut ds = Vec::with_capacity(candles.len()); + for (i, c) in candles.iter().enumerate() { + let item = DataItem::builder() + .open(c.open) + .high(c.high) + .low(c.low) + .close(c.close) + .volume(c.volume) + .build() + .expect("validated candle"); + let rsv = rsv_ind.next(&item); + if i + 1 < period { + ks.push(None); + ds.push(None); + continue; + } + k = k * 2.0 / 3.0 + rsv / 3.0; + d = d * 2.0 / 3.0 + k / 3.0; + ks.push(Some(k)); + ds.push(Some(d)); + } + (ks, ds) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candle(h: f64, l: f64, c: f64) -> Candle { + Candle { date: "2024-01-01".into(), open: c, high: h, low: l, close: c, volume: 1.0 } + } + + #[test] + fn kd_hand_computed() { + // period 2. i=1: window highs {12,14}->14, lows {8,9}->8, close 13 -> RSV = 5/6*100. + let cs = [candle(12.0, 8.0, 10.0), candle(14.0, 9.0, 13.0)]; + let (k, d) = kd(&cs, 2); + assert_eq!(k[0], None); + let rsv = 5.0 / 6.0 * 100.0; + let k1 = 50.0 * 2.0 / 3.0 + rsv / 3.0; + assert!((k[1].unwrap() - k1).abs() < 1e-9); + assert!((d[1].unwrap() - (50.0 * 2.0 / 3.0 + k1 / 3.0)).abs() < 1e-9); + } + + #[test] + fn rsi_wilder_hand_computed() { + // period 2, closes 10, 11, 10, 12: first avg gain=.5 loss=.5 (i=2) -> RSI 50; + // i=3: gain=(.5*1+2)/2=1.25, loss=(.5*1+0)/2=.25 -> RS 5 -> RSI 100-100/6 + let cs = [10.0, 11.0, 10.0, 12.0].map(|c| candle(c, c, c)); + let r = rsi(&cs, 2); + assert!((r[2].unwrap() - 50.0).abs() < 1e-9); + assert!((r[3].unwrap() - (100.0 - 100.0 / 6.0)).abs() < 1e-9); + } + + #[test] + fn rsi_all_gains_is_100() { + let cs: Vec<_> = (1..=20).map(|i| candle(i as f64 + 1.0, i as f64, i as f64)).collect(); + let r = rsi(&cs, 14); + assert_eq!(r[13], None); + assert!(r[14].is_some()); + assert!((r[19].unwrap() - 100.0).abs() < 1e-9); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..24f4ccc --- /dev/null +++ b/src/main.rs @@ -0,0 +1,87 @@ +mod data; +mod indicators; +mod signals; + +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] [--swing 5]"; +// --swing: minimum reversal in percent for a price swing (ZigZag) used by divergence detection + +struct Args { + input: PathBuf, + output: Option, + rsi: usize, + kd: usize, + swing: f64, +} + +fn parse_args() -> Result { + let mut it = std::env::args().skip(1); + let (mut input, mut output, mut rsi, mut kd, mut swing) = (None, None, 14, 9, 5.0); + 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 = val("--swing")?.parse().map_err(|e| format!("--swing: {e}"))?, + "-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 || !(swing > 0.0 && swing < 100.0) { + return Err("periods must be positive and --swing within (0, 100)".into()); + } + Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, swing }) +} + +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); + + let swings = signals::zigzag(&candles, args.swing); + let mut divs = signals::divergences(&swings, &rsi, "RSI"); + divs.extend(signals::divergences(&swings, &k, "K")); + divs.sort_by_key(|d| d.confirmed); + print!("{}", 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, + }); + // " 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 + } + } +} diff --git a/src/signals.rs b/src/signals.rs new file mode 100644 index 0000000..4b91d8e --- /dev/null +++ b/src/signals.rs @@ -0,0 +1,248 @@ +use crate::data::Candle; +use serde::Serialize; + +#[derive(Serialize)] +pub struct Cross { + pub index: usize, + /// "golden" (K crosses above D) or "death" (K crosses below D) + pub kind: &'static str, + /// true when the cross happens in the extreme zone (<20 golden, >80 death) + pub strong: bool, +} + +#[derive(Serialize)] +pub struct Divergence { + /// "bullish" or "bearish" + pub kind: &'static str, + /// "regular" (reversal signal) or "hidden" (trend-continuation signal) + pub class: &'static str, + /// "RSI" or "K" + pub indicator: &'static str, + /// bars of the two price swings + pub i1: usize, + pub i2: usize, + /// price at the swings (swing low's Low / swing high's High) + pub p1: f64, + pub p2: f64, + /// bars where the indicator has its extreme near each swing + pub j1: usize, + pub j2: usize, + /// bar at which the second swing was confirmed (earliest the signal was knowable) + pub confirmed: usize, +} + +pub fn crosses(k: &[Option], d: &[Option]) -> Vec { + let mut out = Vec::new(); + for i in 1..k.len() { + if let (Some(k0), Some(d0), Some(k1), Some(d1)) = (k[i - 1], d[i - 1], k[i], d[i]) { + if k0 <= d0 && k1 > d1 { + out.push(Cross { index: i, kind: "golden", strong: k1 < 20.0 }); + } else if k0 >= d0 && k1 < d1 { + out.push(Cross { index: i, kind: "death", strong: k1 > 80.0 }); + } + } + } + out +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Swing { + pub index: usize, + pub is_high: bool, + pub price: f64, + /// bar where price had reversed by `pct` from the extreme + pub confirmed_at: usize, +} + +/// ZigZag swings: a swing high (low) is confirmed once price falls (rises) by at +/// least `pct` percent from the running extreme. The last, unconfirmed extreme is dropped. +pub fn zigzag(candles: &[Candle], pct: f64) -> Vec { + let f = pct / 100.0; + let mut out = Vec::new(); + if candles.is_empty() { + return out; + } + // Some(true): tracking a high (rising); Some(false): tracking a low; None: undecided. + let mut dir: Option = None; + let mut hi = (0, candles[0].high); + let mut lo = (0, candles[0].low); + for (i, c) in candles.iter().enumerate().skip(1) { + match dir { + None => { + if c.high > hi.1 { hi = (i, c.high); } + if c.low < lo.1 { lo = (i, c.low); } + if c.low <= hi.1 * (1.0 - f) && hi.0 < i { + out.push(Swing { index: hi.0, is_high: true, price: hi.1, confirmed_at: i }); + dir = Some(false); + lo = (i, c.low); + } else if c.high >= lo.1 * (1.0 + f) && lo.0 < i { + out.push(Swing { index: lo.0, is_high: false, price: lo.1, confirmed_at: i }); + dir = Some(true); + hi = (i, c.high); + } + } + Some(true) => { + if c.high > hi.1 { + hi = (i, c.high); + } else if c.low <= hi.1 * (1.0 - f) { + out.push(Swing { index: hi.0, is_high: true, price: hi.1, confirmed_at: i }); + dir = Some(false); + lo = (i, c.low); + } + } + Some(false) => { + if c.low < lo.1 { + lo = (i, c.low); + } else if c.high >= lo.1 * (1.0 + f) { + out.push(Swing { index: lo.0, is_high: false, price: lo.1, confirmed_at: i }); + dir = Some(true); + hi = (i, c.high); + } + } + } + } + out +} + +/// Indicator extreme (min for lows, max for highs) within +-`slack` bars of `idx`. +fn indicator_extreme(ind: &[Option], idx: usize, is_high: bool, slack: usize) -> Option<(usize, f64)> { + let lo = idx.saturating_sub(slack); + let hi = (idx + slack).min(ind.len() - 1); + (lo..=hi).filter_map(|j| ind[j].map(|v| (j, v))).reduce(|a, b| { + if (b.1 > a.1) == is_high && b.1 != a.1 { b } else { a } + }) +} + +/// Divergences between price swings and an indicator, using consecutive swings of the +/// same kind (low->low, high->high): +/// regular bullish: price lower low, indicator higher low (reversal up) +/// regular bearish: price higher high, indicator lower high (reversal down) +/// hidden bullish: price higher low, indicator lower low (uptrend continues) +/// hidden bearish: price lower high, indicator higher high (downtrend continues) +pub fn divergences( + swings: &[Swing], + ind: &[Option], + name: &'static str, +) -> Vec { + let mut out = Vec::new(); + for is_high in [false, true] { + let same: Vec<&Swing> = swings.iter().filter(|s| s.is_high == is_high).collect(); + for w in same.windows(2) { + let (a, b) = (w[0], w[1]); + let (Some((ja, ia)), Some((jb, ib))) = ( + indicator_extreme(ind, a.index, is_high, 2), + indicator_extreme(ind, b.index, is_high, 2), + ) else { continue }; + let (price_up, ind_up) = (b.price > a.price, ib > ia); + if price_up == ind_up || b.price == a.price || ib == ia { + continue; + } + // lows: price down & ind up is regular; highs: price up & ind down is regular. + let regular = if is_high { price_up } else { !price_up }; + out.push(Divergence { + kind: if is_high { "bearish" } else { "bullish" }, + class: if regular { "regular" } else { "hidden" }, + indicator: name, + i1: a.index, i2: b.index, p1: a.price, p2: b.price, + j1: ja, j2: jb, confirmed: b.confirmed_at, + }); + } + } + out.sort_by_key(|d| d.confirmed); + out +} + +/// Rough forward-return summary per (class, kind, indicator): count, then for each +/// horizon the mean close-to-close return after the confirmation bar and the share of +/// signals that moved in the signal's direction. +pub fn forward_stats(candles: &[Candle], divs: &[Divergence], horizons: &[usize]) -> String { + use std::collections::BTreeMap; + let mut groups: BTreeMap<(&str, &str, &str), Vec<&Divergence>> = BTreeMap::new(); + for d in divs { + groups.entry((d.class, d.kind, d.indicator)).or_default().push(d); + } + let mut out = String::new(); + for ((class, kind, ind), ds) in groups { + out += &format!("{class:8} {kind:8} {ind:4} n={:<3}", ds.len()); + for &h in horizons { + let rets: Vec = ds + .iter() + .filter(|d| d.confirmed + h < candles.len()) + .map(|d| { + let r = candles[d.confirmed + h].close / candles[d.confirmed].close - 1.0; + if kind == "bullish" { r } else { -r } // positive = moved the signalled way + }) + .collect(); + if rets.is_empty() { + out += &format!(" +{h}d: 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.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flat(closes: &[f64]) -> Vec { + closes + .iter() + .map(|&c| Candle { date: "2024-01-01".into(), open: c, high: c, low: c, close: c, volume: 1.0 }) + .collect() + } + + const PRICES: [f64; 9] = [100.0, 95.0, 90.0, 95.0, 100.0, 97.0, 95.0, 100.0, 105.0]; + + #[test] + fn zigzag_finds_confirmed_swings() { + let sw = zigzag(&flat(&PRICES), 5.0); + let lows: Vec<_> = sw.iter().filter(|s| !s.is_high).map(|s| (s.index, s.confirmed_at)).collect(); + assert_eq!(lows, vec![(2, 3), (6, 7)]); + assert!(sw.iter().any(|s| s.is_high && s.index == 4)); + } + + #[test] + fn hidden_bullish_not_regular() { + // price: lows 90 then 95 (higher low); indicator lows 30 then 20 (lower low) + let sw = zigzag(&flat(&PRICES), 5.0); + let mut ind = vec![Some(50.0); PRICES.len()]; + ind[2] = Some(30.0); + ind[6] = Some(20.0); + let d = divergences(&sw, &ind, "RSI"); + assert_eq!(d.len(), 1); + assert_eq!((d[0].class, d[0].kind, d[0].i1, d[0].i2), ("hidden", "bullish", 2, 6)); + } + + #[test] + fn regular_bullish() { + // price: lower low needs 90 then 85 + let p = [100.0, 95.0, 90.0, 95.0, 100.0, 95.0, 85.0, 90.0, 95.0]; + let sw = zigzag(&flat(&p), 5.0); + let mut ind = vec![Some(50.0); p.len()]; + ind[2] = Some(20.0); + ind[6] = Some(30.0); + let d = divergences(&sw, &ind, "K"); + assert_eq!(d.len(), 1); + assert_eq!((d[0].class, d[0].kind), ("regular", "bullish")); + } + + #[test] + fn hidden_bearish() { + // price highs 100 then 95 (lower high); indicator highs 60 then 80 (higher high) + let p = [90.0, 95.0, 100.0, 95.0, 90.0, 92.0, 95.0, 90.0, 85.0]; + let sw = zigzag(&flat(&p), 5.0); + let mut ind = vec![Some(50.0); p.len()]; + ind[2] = Some(60.0); + ind[6] = Some(80.0); + let d = divergences(&sw, &ind, "RSI"); + assert_eq!(d.len(), 1); + assert_eq!((d[0].class, d[0].kind), ("hidden", "bearish")); + } +} + -- cgit v1.2.3-18-g5258