summaryrefslogtreecommitdiff
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
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.
-rw-r--r--.gitignore2
-rw-r--r--Cargo.lock239
-rw-r--r--Cargo.toml10
-rwxr-xr-xscripts/fetch.sh83
-rw-r--r--src/data.rs52
-rw-r--r--src/indicators.rs98
-rw-r--r--src/main.rs87
-rw-r--r--src/signals.rs248
-rw-r--r--web/template.html269
9 files changed, 1088 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..78f062f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+*.csv
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..45af43c
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,239 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "error-chain"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc"
+dependencies = [
+ "backtrace",
+ "version_check",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "stock"
+version = "0.1.0"
+dependencies = [
+ "csv",
+ "serde",
+ "serde_json",
+ "ta",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "ta"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "870afd24c39cfe0691308f94b8d1f2726c0f93fc1296c971c8c2c308f51cba2d"
+dependencies = [
+ "error-chain",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..772dcee
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "stock"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+ta = "0.4.0"
+serde_json = "1.0"
+csv = "1.4.0"
+serde = { version = "1.0.229", features = ["derive"] }
diff --git a/scripts/fetch.sh b/scripts/fetch.sh
new file mode 100755
index 0000000..5ed4901
--- /dev/null
+++ b/scripts/fetch.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# Download daily OHLCV history from Yahoo Finance into CSV files readable by `stock`.
+#
+# scripts/fetch.sh [-d outdir] [-f symbols.txt] START END SYMBOL...
+#
+# START/END are YYYY-MM-DD (END inclusive). Symbols use Yahoo naming:
+# 2330.TW TWSE-listed 6488.TWO TPEx/OTC ^TWII Taiwan index AAPL US stock
+# A bare 4-6 digit code (e.g. 2330) is treated as 2330.TW.
+# Existing CSVs are extended incrementally (only newer days are fetched).
+set -u
+
+outdir=data; symfile=
+while getopts "d:f:" o; do
+ case $o in d) outdir=$OPTARG;; f) symfile=$OPTARG;; *) sed -n '2,10p' "$0"; exit 2;; esac
+done
+shift $((OPTIND - 1))
+[ $# -ge 2 ] || { sed -n '2,10p' "$0"; exit 2; }
+start=$1; end=$2; shift 2
+symbols=("$@")
+[ -n "$symfile" ] && while IFS= read -r l; do l=${l%%#*}; l=${l//[[:space:]]/}; [ -n "$l" ] && symbols+=("$l"); done < "$symfile"
+[ ${#symbols[@]} -gt 0 ] || { echo "no symbols given" >&2; exit 2; }
+for cmd in curl jq; do command -v $cmd >/dev/null || { echo "$cmd is required" >&2; exit 1; }; done
+
+epoch() { # YYYY-MM-DD -> epoch seconds (UTC); BSD date, then GNU date
+ date -j -u -f "%Y-%m-%d %H:%M:%S" "$1 00:00:00" +%s 2>/dev/null || date -u -d "$1" +%s
+}
+next_day() { date -j -v+1d -f "%Y-%m-%d" "$1" +%Y-%m-%d 2>/dev/null || date -d "$1 +1 day" +%Y-%m-%d; }
+
+mkdir -p "$outdir"
+: > "$outdir/failed.txt"
+header="Date,Open,High,Low,Close,Volume"
+p2=$(( $(epoch "$end") + 86400 )) # make END inclusive
+
+fetch_one() { # prints CSV rows (no header) for $1 between $2 and $3(epoch)
+ local sym=$1 from=$2 to=$3 body code tries=0
+ local url="https://query1.finance.yahoo.com/v8/finance/chart/${sym}?period1=${from}&period2=${to}&interval=1d&events=history"
+ while :; do
+ body=$(curl -s -A "Mozilla/5.0" -w '\n%{http_code}' "$url"); code=${body##*$'\n'}; body=${body%$'\n'*}
+ case $code in
+ 200) break;;
+ 404) echo " $sym: not found (404)" >&2; return 1;;
+ 429|5??) tries=$((tries + 1)); [ $tries -gt 5 ] && { echo " $sym: giving up (HTTP $code)" >&2; return 1; }
+ echo " $sym: HTTP $code, retry $tries in $((5 * 2 ** (tries - 1)))s" >&2; sleep $((5 * 2 ** (tries - 1)));;
+ *) echo " $sym: HTTP $code" >&2; return 1;;
+ esac
+ done
+ printf '%s' "$body" | jq -r '
+ if .chart.error != null then error(.chart.error.description) else . end
+ | .chart.result[0] as $r
+ | if ($r.timestamp // null) == null then empty else
+ ($r.meta.gmtoffset // 0) as $off
+ | $r.indicators.quote[0] as $q
+ | range(0; $r.timestamp | length) as $i
+ | [($r.timestamp[$i] + $off | strftime("%Y-%m-%d")), $q.open[$i], $q.high[$i], $q.low[$i], $q.close[$i], $q.volume[$i]]
+ | select(all(.[]; . != null))
+ | map(if type == "number" then (. * 10000 | round / 10000) else . end)
+ | @csv | gsub("\""; "") end'
+}
+
+n=0
+for raw in "${symbols[@]}"; do
+ sym=$raw; [[ $sym =~ ^[0-9]{4,6}$ ]] && sym=$sym.TW
+ file="$outdir/$sym.csv"
+ from=$start
+ if [ -s "$file" ]; then
+ last=$(tail -n 1 "$file" | cut -d, -f1)
+ [[ $last =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && from=$(next_day "$last")
+ fi
+ [ $n -gt 0 ] && sleep $((1 + RANDOM % 2))
+ n=$((n + 1))
+ p1=$(epoch "$from")
+ if [ "$p1" -ge "$p2" ]; then echo "$sym: up to date"; continue; fi
+ echo "$sym: $from .. $end"
+ if rows=$(fetch_one "$sym" "$p1" "$p2"); then
+ [ -s "$file" ] || echo "$header" > "$file"
+ [ -n "$rows" ] && printf '%s\n' "$rows" >> "$file"
+ echo " $(printf '%s' "$rows" | grep -c .) new rows -> $file"
+ else
+ echo "$sym" >> "$outdir/failed.txt"
+ fi
+done
+[ -s "$outdir/failed.txt" ] && { echo "failed symbols listed in $outdir/failed.txt" >&2; exit 1; }
+rm -f "$outdir/failed.txt"
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"));
+ }
+}
+
diff --git a/web/template.html b/web/template.html
new file mode 100644
index 0000000..5349e38
--- /dev/null
+++ b/web/template.html
@@ -0,0 +1,269 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Stock RSI / KD</title>
+<style>
+ :root {
+ --bg: #ffffff; --fg: #1d2330; --muted: #7a8294; --grid: #e6e9ef; --panel: #f6f7fa;
+ --up: #d6323c; --down: #1f9d55; --line1: #2563eb; --line2: #e08a00; --accent: #6d4aff;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root { --bg: #14171f; --fg: #e4e7ee; --muted: #8a92a6; --grid: #262b38; --panel: #1b1f2a;
+ --up: #ff5d66; --down: #34c77b; --line1: #5b8cff; --line2: #ffb038; --accent: #9a82ff; }
+ }
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
+ font: 13px/1.4 system-ui, sans-serif; }
+ body { display: flex; flex-direction: column; }
+ header { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; padding: 8px 16px;
+ border-bottom: 1px solid var(--grid); }
+ header h1 { font-size: 15px; margin: 0; }
+ #info { flex: 1 1 100%; font-variant-numeric: tabular-nums; min-height: 1.4em; }
+ #info b { font-weight: 600; }
+ label, button { color: var(--muted); }
+ button { background: var(--panel); border: 1px solid var(--grid); border-radius: 4px; padding: 2px 8px; cursor: pointer; }
+ button:hover { color: var(--fg); }
+ #wrap { flex: 1; min-height: 0; position: relative; }
+ svg { width: 100%; height: 100%; display: block; touch-action: none; user-select: none; cursor: crosshair; }
+ svg text { fill: var(--muted); font-size: 11px; }
+ svg text.title { fill: var(--fg); font-weight: 600; }
+ .grid { stroke: var(--grid); stroke-width: 1; }
+ .guide { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 4 3; }
+</style>
+</head>
+<body>
+<header>
+ <h1 id="title"></h1>
+ <label>Colours <select id="scheme"><option value="tw">Red up / green down (TW)</option>
+ <option value="us">Green up / red down (US)</option></select></label>
+ <button id="reset">Reset zoom</button>
+ <button id="clear">Clear pins</button>
+ <span style="color:var(--muted)">wheel: zoom · drag: pan · click: pin a day (shows change vs pin) · triangles: K/D crosses · dashed lines: regular divergence (reversal) · dotted lines: hidden divergence (continuation)</span>
+ <div id="info"></div>
+</header>
+<div id="wrap"><svg id="svg"></svg></div>
+<script>
+const D = /*__DATA__*/null;
+const N = D.candles.length;
+const $ = id => document.getElementById(id);
+const svg = $('svg');
+const M = { l: 12, r: 52, t: 6, b: 20 };
+const MIN_SPAN = Math.min(10, N);
+
+let start = Math.max(0, N - 120), end = N; // visible window, in bar indices (float)
+let hover = null, pins = [];
+let scheme = 'tw';
+try { scheme = localStorage.getItem('scheme') || 'tw'; } catch (e) {}
+$('scheme').value = scheme;
+$('title').textContent = `${D.name} · RSI(${D.rsiPeriod}) KD(${D.kdPeriod}) · ${D.candles[0].date} – ${D.candles[N-1].date}`;
+
+const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
+const fmt = (v, d = 2) => v == null ? '–' : v.toFixed(d);
+const fmtVol = v => v >= 1e8 ? (v/1e8).toFixed(2)+'億' : v >= 1e4 ? (v/1e4).toFixed(1)+'萬' : String(v);
+function colors() {
+ const up = css('--up'), down = css('--down');
+ return scheme === 'tw' ? { up, down } : { up: down, down: up };
+}
+
+let W = 0, H = 0, plotW = 0, panels = [];
+function layout() {
+ W = svg.clientWidth; H = svg.clientHeight;
+ plotW = W - M.l - M.r;
+ const h = H - M.t - M.b, gap = 14;
+ const hp = Math.round((h - 2 * gap) * 0.5), hs = Math.round((h - 2 * gap) * 0.25);
+ panels = [
+ { id: 'price', y: M.t, h: hp },
+ { id: 'rsi', y: M.t + hp + gap, h: hs },
+ { id: 'kd', y: M.t + hp + hs + 2 * gap, h: h - hp - hs - 2 * gap },
+ ];
+}
+const xOf = i => M.l + (i - start + 0.5) * plotW / (end - start);
+const idxAt = px => Math.floor(start + (px - M.l) / plotW * (end - start));
+const clampWin = () => {
+ let span = Math.min(Math.max(end - start, MIN_SPAN), N);
+ start = Math.min(Math.max(start, 0), N - span); end = start + span;
+};
+
+function niceTicks(lo, hi, n) {
+ const step0 = (hi - lo) / n, p = Math.pow(10, Math.floor(Math.log10(step0)));
+ const step = [1, 2, 5, 10].map(m => m * p).find(s => s >= step0);
+ const out = [];
+ for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) out.push(v);
+ return out;
+}
+
+function render() {
+ layout();
+ if (W <= 0) return;
+ const lo = Math.max(0, Math.floor(start)), hi = Math.min(N - 1, Math.ceil(end));
+ const bw = plotW / (end - start);
+ const col = colors();
+ let s = '';
+ const [P, R, K] = panels;
+
+ // ---- price panel scale
+ let pmin = Infinity, pmax = -Infinity, vmax = 0;
+ for (let i = lo; i <= hi; i++) {
+ const c = D.candles[i];
+ pmin = Math.min(pmin, c.Low); pmax = Math.max(pmax, c.High); vmax = Math.max(vmax, c.Volume);
+ }
+ const pad = (pmax - pmin || 1) * 0.06; pmin -= pad; pmax += pad;
+ const volH = P.h * 0.16;
+ const py = v => P.y + (pmax - v) / (pmax - pmin) * (P.h - volH - 4);
+ const ind = (p, v) => p.y + (100 - v) / 100 * p.h;
+
+ // ---- grids / axes
+ for (const t of niceTicks(pmin, pmax, 5)) {
+ s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${py(t)}" y2="${py(t)}"/>` +
+ `<text x="${W-M.r+4}" y="${py(t)+4}">${+t.toFixed(2)}</text>`;
+ }
+ for (const [p, name, lines] of [[R, `RSI(${D.rsiPeriod})`, [30, 70]], [K, `KD(${D.kdPeriod})`, [20, 80]]]) {
+ s += `<rect x="${M.l}" y="${p.y}" width="${plotW}" height="${p.h}" fill="${css('--panel')}" opacity=".5"/>`;
+ for (const t of [0, 50, 100]) s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}"/>`;
+ for (const t of lines) s += `<line x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}" stroke="${css('--muted')}" stroke-dasharray="3 3"/>` +
+ `<text x="${W-M.r+4}" y="${ind(p,t)+4}">${t}</text>`;
+ s += `<text class="title" x="${M.l+4}" y="${p.y+12}">${name}</text>`;
+ }
+ // x ticks
+ const target = Math.max(1, Math.floor(plotW / 90)), every = Math.max(1, Math.round((end - start) / target));
+ for (let i = lo; i <= hi; i++) {
+ if (i % every) continue;
+ const x = xOf(i);
+ s += `<line class="grid" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}"/>` +
+ `<text x="${x}" y="${K.y+K.h+14}" text-anchor="middle">${D.candles[i].date.slice(every > 30 ? 0 : 2)}</text>`;
+ }
+
+ // ---- candles + volume
+ const bodyW = Math.max(1, bw * 0.7);
+ for (let i = lo; i <= hi; i++) {
+ const c = D.candles[i], x = xOf(i);
+ const prev = i > 0 ? D.candles[i-1].Close : c.Open;
+ const colr = c.Close > c.Open || (c.Close === c.Open && c.Close >= prev) ? col.up : col.down;
+ if (bw >= 3) {
+ const top = py(Math.max(c.Open, c.Close)), bot = py(Math.min(c.Open, c.Close));
+ s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>` +
+ `<rect x="${x-bodyW/2}" y="${top}" width="${bodyW}" height="${Math.max(1, bot-top)}" fill="${colr}"/>`;
+ } else {
+ s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>`;
+ }
+ const vh = vmax ? c.Volume / vmax * volH : 0;
+ s += `<rect x="${x-bodyW/2}" y="${P.y+P.h-vh}" width="${bodyW}" height="${vh}" fill="${colr}" opacity=".35"/>`;
+ }
+
+ // ---- indicator lines (break on nulls)
+ const poly = (arr, p, color) => {
+ let d = '', pen = false;
+ for (let i = lo; i <= hi; i++) {
+ const v = arr[i];
+ if (v == null) { pen = false; continue; }
+ d += `${pen ? 'L' : 'M'}${xOf(i).toFixed(1)},${ind(p, v).toFixed(1)}`; pen = true;
+ }
+ return `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>`;
+ };
+ s += poly(D.rsi, R, css('--accent')) + poly(D.k, K, css('--line1')) + poly(D.d, K, css('--line2'));
+ s += `<text x="${M.l+70}" y="${K.y+12}" style="fill:${css('--line1')}">K</text>` +
+ `<text x="${M.l+84}" y="${K.y+12}" style="fill:${css('--line2')}">D</text>`;
+
+ // ---- signals
+ for (const c of D.crosses) {
+ if (c.index < lo || c.index > hi) continue;
+ const x = xOf(c.index), y = ind(K, D.k[c.index]), g = c.kind === 'golden';
+ const sz = c.strong ? 6 : 4;
+ s += `<path d="M${x},${y + (g ? -2 : 2)} l${-sz},${g ? -sz*1.6 : sz*1.6} h${2*sz}z" fill="${g ? css('--line1') : css('--line2')}" opacity="${c.strong ? 1 : .55}"/>`;
+ }
+ for (const dv of D.divergences) {
+ if (dv.i2 < lo || dv.i1 > hi) continue;
+ const bull = dv.kind === 'bullish', color = bull ? css('--line1') : css('--line2');
+ const arr = dv.indicator === 'RSI' ? D.rsi : D.k, p = dv.indicator === 'RSI' ? R : K;
+ // regular (reversal) = dashed, hidden (continuation) = dotted
+ const dash = dv.class === 'hidden'
+ ? `stroke="${color}" stroke-width="2" stroke-dasharray="1 4" stroke-linecap="round"`
+ : `stroke="${color}" stroke-width="1.5" stroke-dasharray="5 3"`;
+ s += `<line x1="${xOf(dv.i1)}" y1="${py(dv.p1)}" x2="${xOf(dv.i2)}" y2="${py(dv.p2)}" ${dash} opacity=".7"/>` +
+ `<line x1="${xOf(dv.j1)}" y1="${ind(p, arr[dv.j1])}" x2="${xOf(dv.j2)}" y2="${ind(p, arr[dv.j2])}" ${dash}/>`;
+ }
+
+ // ---- pins and crosshair
+ const guide = (i, cls, extra = '') => {
+ const x = xOf(i);
+ return `<line class="${cls}" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}" ${extra}/>`;
+ };
+ for (const i of pins) if (i >= lo && i <= hi) s += guide(i, 'guide', `stroke="${css('--accent')}" stroke-dasharray="none"`);
+ if (hover != null) {
+ s += guide(hover, 'guide');
+ const c = D.candles[hover];
+ s += `<line class="guide" x1="${M.l}" x2="${W-M.r}" y1="${py(c.Close)}" y2="${py(c.Close)}"/>` +
+ `<text x="${W-M.r+4}" y="${py(c.Close)+4}" style="fill:${css('--fg')};font-weight:600">${fmt(c.Close)}</text>`;
+ }
+ svg.innerHTML = s;
+ updateInfo();
+}
+
+function updateInfo() {
+ const i = hover != null ? hover : N - 1, c = D.candles[i], col = colors();
+ const prev = i > 0 ? D.candles[i-1].Close : null;
+ const chg = prev ? (c.Close - prev) / prev * 100 : null;
+ const cc = chg == null ? '' : `style="color:${chg >= 0 ? col.up : col.down}"`;
+ let t = `<b>${c.date}</b> &nbsp; O ${fmt(c.Open)} &nbsp; H ${fmt(c.High)} &nbsp; L ${fmt(c.Low)} &nbsp; ` +
+ `C <b ${cc}>${fmt(c.Close)}</b> <span ${cc}>(${chg == null ? '–' : (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'})</span> &nbsp; ` +
+ `Vol ${fmtVol(c.Volume)} &nbsp; <span style="color:${css('--accent')}">RSI ${fmt(D.rsi[i], 1)}</span> &nbsp; ` +
+ `<span style="color:${css('--line1')}">K ${fmt(D.k[i], 1)}</span> &nbsp; <span style="color:${css('--line2')}">D ${fmt(D.d[i], 1)}</span>`;
+ if (pins.length) {
+ const p = pins[pins.length - 1], pc = D.candles[p];
+ const diff = c.Close - pc.Close, pct = diff / pc.Close * 100;
+ const days = Math.abs(i - p);
+ t += ` &nbsp;|&nbsp; vs pin ${pc.date}: <b style="color:${diff >= 0 ? col.up : col.down}">${diff >= 0 ? '+' : ''}${diff.toFixed(2)} (${diff >= 0 ? '+' : ''}${pct.toFixed(2)}%)</b>, ${days} bars`;
+ }
+ $('info').innerHTML = t;
+}
+
+// ---- interaction
+function svgX(e) { return e.clientX - svg.getBoundingClientRect().left; }
+svg.addEventListener('wheel', e => {
+ e.preventDefault();
+ const span = end - start;
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { // horizontal scroll pans
+ const shift = e.deltaX / plotW * span; start += shift; end += shift;
+ } else { // wheel / pinch zooms around cursor
+ const frac = Math.min(1, Math.max(0, (svgX(e) - M.l) / plotW));
+ const anchor = start + frac * span;
+ const ns = Math.min(Math.max(span * Math.exp(e.deltaY * (e.ctrlKey ? 0.01 : 0.002)), MIN_SPAN), N);
+ start = anchor - frac * ns; end = start + ns;
+ }
+ clampWin(); render();
+}, { passive: false });
+
+let drag = null;
+svg.addEventListener('pointerdown', e => { drag = { x: svgX(e), start, moved: false }; svg.setPointerCapture(e.pointerId); });
+svg.addEventListener('pointermove', e => {
+ const x = svgX(e);
+ if (drag) {
+ const dx = x - drag.x;
+ if (Math.abs(dx) > 3) drag.moved = true;
+ if (drag.moved) {
+ const span = end - start; start = drag.start - dx / plotW * span; end = start + span; clampWin();
+ }
+ }
+ const i = idxAt(x);
+ hover = i >= 0 && i < N ? i : null;
+ render();
+});
+svg.addEventListener('pointerup', e => {
+ if (drag && !drag.moved && hover != null) {
+ const k = pins.indexOf(hover);
+ if (k >= 0) pins.splice(k, 1); else pins.push(hover);
+ render();
+ }
+ drag = null;
+});
+svg.addEventListener('pointerleave', () => { if (!drag) { hover = null; render(); } });
+$('reset').onclick = () => { start = 0; end = N; render(); };
+$('clear').onclick = () => { pins = []; render(); };
+$('scheme').onchange = e => { scheme = e.target.value; try { localStorage.setItem('scheme', scheme); } catch (_) {} render(); };
+new ResizeObserver(render).observe($('wrap'));
+matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
+clampWin(); render();
+</script>
+</body>
+</html>