diff options
| author | JSDurand <mmemmew@gmail.com> | 2026-10-09 16:12:46 +0800 |
|---|---|---|
| committer | JSDurand <mmemmew@gmail.com> | 2026-10-09 16:12:46 +0800 |
| commit | e02b8c1369799eee31fed5c5d8b44a1b044e2780 (patch) | |
| tree | 0f559ea030c6b814e36f04501c2dbdd15427d418 | |
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-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.lock | 239 | ||||
| -rw-r--r-- | Cargo.toml | 10 | ||||
| -rwxr-xr-x | scripts/fetch.sh | 83 | ||||
| -rw-r--r-- | src/data.rs | 52 | ||||
| -rw-r--r-- | src/indicators.rs | 98 | ||||
| -rw-r--r-- | src/main.rs | 87 | ||||
| -rw-r--r-- | src/signals.rs | 248 | ||||
| -rw-r--r-- | web/template.html | 269 |
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> O ${fmt(c.Open)} H ${fmt(c.High)} L ${fmt(c.Low)} ` + + `C <b ${cc}>${fmt(c.Close)}</b> <span ${cc}>(${chg == null ? '–' : (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'})</span> ` + + `Vol ${fmtVol(c.Volume)} <span style="color:${css('--accent')}">RSI ${fmt(D.rsi[i], 1)}</span> ` + + `<span style="color:${css('--line1')}">K ${fmt(D.k[i], 1)}</span> <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 += ` | 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> |
