summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJSDurand <mmemmew@gmail.com>2026-10-09 16:12:46 +0800
committerJSDurand <mmemmew@gmail.com>2026-10-09 16:12:46 +0800
commite02b8c1369799eee31fed5c5d8b44a1b044e2780 (patch)
tree0f559ea030c6b814e36f04501c2dbdd15427d418 /src
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.
Diffstat (limited to 'src')
-rw-r--r--src/data.rs52
-rw-r--r--src/indicators.rs98
-rw-r--r--src/main.rs87
-rw-r--r--src/signals.rs248
4 files changed, 485 insertions, 0 deletions
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<Vec<Candle>, 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::<Candle>().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<Option<f64>> {
+ 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<Option<f64>>, Vec<Option<f64>>) {
+ 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 <input.csv> [-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<PathBuf>,
+ rsi: usize,
+ kd: usize,
+ swing: f64,
+}
+
+fn parse_args() -> Result<Args, String> {
+ 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<PathBuf, String> {
+ 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,
+ });
+ // "</" would end the <script> element early.
+ let json = payload.to_string().replace("</", "<\\/");
+ let html = TEMPLATE.replace("/*__DATA__*/null", &json);
+
+ let out = args.output.unwrap_or_else(|| args.input.with_extension("html"));
+ std::fs::write(&out, html).map_err(|e| format!("{}: {e}", out.display()))?;
+ Ok(out)
+}
+
+fn main() -> 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<f64>], d: &[Option<f64>]) -> Vec<Cross> {
+ 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<Swing> {
+ 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<bool> = 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<f64>], 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<f64>],
+ name: &'static str,
+) -> Vec<Divergence> {
+ 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<f64> = 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::<f64>() / 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<Candle> {
+ 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"));
+ }
+}
+