summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xscripts/cron_daily.sh62
-rwxr-xr-xscripts/fetch.sh3
-rwxr-xr-xscripts/fetch_tw_daily.sh62
-rw-r--r--scripts/lib.sh1
-rwxr-xr-xscripts/run.sh53
-rwxr-xr-xscripts/sync_from_vps.sh60
-rw-r--r--src/data.rs49
-rw-r--r--web/template.html8
8 files changed, 278 insertions, 20 deletions
diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh
new file mode 100755
index 0000000..9fb0b44
--- /dev/null
+++ b/scripts/cron_daily.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# VPS wrapper for cron: download the latest trading day into a daily snapshot file, keep only
+# recent snapshots, and send an e-mail when something goes wrong.
+#
+# crontab (UTC; Taipei = UTC+8):
+# ALERT_TO=you@example.com
+# 30 6 * * 1-5 $HOME/stock/scripts/cron_daily.sh
+# 0 11 * * 1-5 $HOME/stock/scripts/cron_daily.sh
+#
+# Environment: STOCK_DIR (default ~/stock), KEEP_DAYS (default 30), ALERT_TO (e-mail address;
+# needs msmtp or mail). Snapshots go to $STOCK_DIR/updates/YYYY-MM-DD.csv, the log to
+# $STOCK_DIR/logs/daily.log. Failures: download failed after all retries, or no new data for 10 days.
+set -u
+root=${STOCK_DIR:-$HOME/stock}
+keep_days=${KEEP_DAYS:-30}
+updates="$root/updates"; logs="$root/logs"; log="$logs/daily.log"
+mkdir -p "$updates" "$logs"
+
+notify() { # SUBJECT BODY
+ if [ -z "${ALERT_TO:-}" ]; then echo "ALERT_TO is not set; cannot send e-mail: $1" >> "$log"; return; fi
+ if command -v msmtp >/dev/null; then
+ printf 'To: %s\nSubject: %s\n\n%s\n' "$ALERT_TO" "$1" "$2" | msmtp "$ALERT_TO" || echo "msmtp failed" >> "$log"
+ elif command -v mail >/dev/null; then
+ printf '%s\n' "$2" | mail -s "$1" "$ALERT_TO" || echo "mail failed" >> "$log"
+ else
+ echo "no msmtp/mail command; cannot send e-mail: $1" >> "$log"
+ fi
+}
+
+# One run at a time (mkdir is atomic); a lock older than 3 hours is treated as stale.
+lock="$root/.daily.lock"
+if ! mkdir "$lock" 2>/dev/null; then
+ if [ -n "$(find "$lock" -maxdepth 0 -mmin +180 2>/dev/null)" ]; then
+ rmdir "$lock" 2>/dev/null; mkdir "$lock" 2>/dev/null || exit 0
+ else
+ echo "$(date -u +%FT%TZ) another run is in progress, skipping" >> "$log"; exit 0
+ fi
+fi
+out=$(mktemp)
+trap 'rmdir "$lock" 2>/dev/null; rm -f "$out"' EXIT
+
+"$(dirname "$0")/fetch_tw_daily.sh" --snapshot "$updates" > "$out" 2>&1
+rc=$?
+{ echo "== $(date -u +%FT%TZ) exit $rc"; cat "$out"; } >> "$log"
+find "$updates" -name '????-??-??.csv' -mtime +"$keep_days" -delete
+tail -n 3000 "$log" > "$log.tmp" && mv "$log.tmp" "$log" # keep the log small
+
+if [ $rc -ne 0 ]; then
+ notify "stock daily update FAILED on $(hostname) ($(date -u +%F))" "$(tail -n 40 "$out")"
+ exit $rc
+fi
+if grep -qE '^(new|updated):' "$out"; then
+ echo " -> new data written" >> "$log"
+else
+ echo " -> no new trading day" >> "$log"
+ # A long silence (beyond the longest market holiday) means the exchange feed has probably changed.
+ if ! find "$updates" -name '????-??-??.csv' -mtime -10 2>/dev/null | grep -q .; then
+ notify "stock daily update: no new data for 10 days on $(hostname)" "No snapshot file has changed in $updates for 10 days.
+Last log lines:
+$(tail -n 20 "$log")"
+ fi
+fi
diff --git a/scripts/fetch.sh b/scripts/fetch.sh
index 33b1da2..1ad05a3 100755
--- a/scripts/fetch.sh
+++ b/scripts/fetch.sh
@@ -17,6 +17,9 @@
# trade on one of those days (halted) is fetched again, which is harmless. Skipped stocks are
# not backfilled when START is moved earlier: use -F for that.
# A >1% close mismatch on overlapping days is reported (probable split / capital reduction).
+
+
+
set -u
source "$(dirname "$0")/lib.sh"
diff --git a/scripts/fetch_tw_daily.sh b/scripts/fetch_tw_daily.sh
index 3055f01..68792cd 100755
--- a/scripts/fetch_tw_daily.sh
+++ b/scripts/fetch_tw_daily.sh
@@ -1,19 +1,25 @@
#!/usr/bin/env bash
# Daily update of Taiwan stock CSVs from the exchanges' official open data (2 requests in total).
#
-# scripts/fetch_tw_daily.sh [-d outdir] (default outdir: data/tw)
+# scripts/fetch_tw_daily.sh [-d outdir] merge the latest day into each CODE.TW.csv
+# scripts/fetch_tw_daily.sh --snapshot DIR write DIR/YYYY-MM-DD.csv (all stocks, one day)
#
-# Downloads the latest trading day of every TWSE-listed and TPEx (OTC) security and merges the row
-# into each existing CODE.TW.csv / CODE.TWO.csv in outdir (create those first with fetch.sh).
-# Only the latest day is available from these endpoints: run it at least once per trading day, and
-# use fetch.sh (which repairs the last ~10 days from Yahoo) to fill any days you missed.
+# Downloads the latest trading day of every TWSE-listed and TPEx (OTC) security (2 requests).
+# Merge mode (default outdir: data/tw) adds the rows to each existing CODE.TW.csv / CODE.TWO.csv
+# (create those first with fetch.sh). Snapshot mode needs no per-stock files: it writes one
+# "Symbol,Date,Open,High,Low,Close,Volume" file per trading day, which the VPS publishes for
+# scripts/sync_from_vps.sh to apply. Only the latest day is available from these endpoints.
# Prices are raw, volume is in shares.
set -u
source "$(dirname "$0")/lib.sh"
-outdir=data/tw
-while getopts "d:" o; do case $o in d) outdir=$OPTARG;; *) sed -n '2,11p' "$0"; exit 2;; esac; done
+outdir=data/tw; snapshot=
+args=(); for a in "$@"; do [ "$a" = "--snapshot" ] && a=-s; args+=("$a"); done # getopts has no long options
+set -- ${args[@]+"${args[@]}"}
+while getopts "d:s:" o; do
+ case $o in d) outdir=$OPTARG;; s) snapshot=$OPTARG;; *) sed -n '2,12p' "$0"; exit 2;; esac
+done
for cmd in curl jq; do command -v $cmd >/dev/null || { echo "$cmd is required" >&2; exit 1; }; done
-[ -d "$outdir" ] || { echo "$outdir does not exist; run fetch.sh first" >&2; exit 1; }
+[ -n "$snapshot" ] || [ -d "$outdir" ] || { echo "$outdir does not exist; run fetch.sh first" >&2; exit 1; }
# Private scratch folder (always a fresh mktemp directory); removed again when the script ends.
work=$(mktemp -d) && [ -n "$work" ] && [ -d "$work" ] || { echo "cannot create a temporary directory" >&2; exit 1; }
@@ -21,32 +27,56 @@ trap 'rm -rf "$work"' EXIT
get_json() { # URL OUTFILE; retries until the response parses as a JSON array
local try
- for try in 1 2 3 4 5; do
- curl -s --http1.1 -m 120 -A "Mozilla/5.0" "$1" -o "$2" && jq -e 'type == "array" and length > 0' "$2" >/dev/null 2>&1 && return 0
- echo " $1: bad response (try $try), retrying" >&2; sleep $((try * 3))
+ # The exchanges sometimes cut a large response short (curl exit 56) or return an empty array
+ # before the day is published; wait longer between tries (2, 4, ... 30 s).
+ for try in 1 2 3 4 5 6 7 8; do
+ rc=0; curl -s --http1.1 -m 120 -A "Mozilla/5.0" "$1" -o "$2" || rc=$?
+ if [ $rc -eq 0 ] && jq -e 'type == "array" and length > 0' "$2" >/dev/null 2>&1; then return 0; fi
+ echo " $1: bad response (try $try, curl exit $rc), retrying" >&2; sleep $(( (try * 2 > 30) ? 30 : try * 2 ))
done
echo "failed to download $1" >&2; return 1
}
# The jq programs below turn ROC dates (1151008 -> 2026-10-08) into ISO dates and keep only rows with
-# positive numeric OHLC, emitting "SYMBOL,Date,O,H,L,C,V".
+# positive numeric OHLC and volume (a missing value becomes null, never a dropped field), emitting "SYMBOL,Date,O,H,L,C,V".
rows="$work/rows.csv"; : > "$rows"
get_json "https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL" "$work/twse.json" || exit 1
get_json "https://www.tpex.org.tw/openapi/v1/tpex_mainboard_daily_close_quotes" "$work/tpex.json" || exit 1
jq -r '
- def num: if type == "string" then (gsub(",";"") | select(test("^[0-9]+(\\.[0-9]+)?$")) | tonumber | select(. > 0)) else empty end;
+ def num: if type == "string" then (gsub(",";"") | if test("^[0-9]+(\\.[0-9]+)?$") then tonumber else null end) else null end | if . != null and . > 0 then . else null end;
.[] | ((.Date[0:3] | tonumber + 1911 | tostring) + "-" + .Date[3:5] + "-" + .Date[5:7]) as $d
| [(.Code + ".TW"), $d, (.OpeningPrice|num), (.HighestPrice|num), (.LowestPrice|num), (.ClosingPrice|num), (.TradeVolume|num)]
- | select(all(.[2:6][]; . != null)) | @csv' "$work/twse.json" | tr -d '"' >> "$rows"
+ | select(all(.[2:7][]; . != null)) | @csv' "$work/twse.json" | tr -d '"' >> "$rows"
jq -r '
- def num: if type == "string" then (gsub(",";"") | select(test("^[0-9]+(\\.[0-9]+)?$")) | tonumber | select(. > 0)) else empty end;
+ def num: if type == "string" then (gsub(",";"") | if test("^[0-9]+(\\.[0-9]+)?$") then tonumber else null end) else null end | if . != null and . > 0 then . else null end;
.[] | ((.Date[0:3] | tonumber + 1911 | tostring) + "-" + .Date[3:5] + "-" + .Date[5:7]) as $d
| [(.SecuritiesCompanyCode + ".TWO"), $d, (.Open|num), (.High|num), (.Low|num), (.Close|num), (.TradingShares|num)]
- | select(all(.[2:6][]; . != null)) | @csv' "$work/tpex.json" | tr -d '"' >> "$rows"
+ | select(all(.[2:7][]; . != null)) | @csv' "$work/tpex.json" | tr -d '"' >> "$rows"
echo "TWSE date: $(jq -r '.[0].Date' "$work/twse.json") TPEx date: $(jq -r '.[0].Date' "$work/tpex.json") (ROC calendar)"
echo "usable rows: $(wc -l < "$rows" | tr -d ' ')"
+[ -s "$rows" ] || { echo "no usable rows in either response" >&2; exit 1; }
+
+if [ -n "$snapshot" ]; then
+ mkdir -p "$snapshot"
+ # TWSE and TPEx publish at different times, so one date can arrive in two runs: merge by symbol
+ # into an existing snapshot instead of overwriting it.
+ for d in $(cut -d, -f2 "$rows" | sort -u); do
+ f="$snapshot/$d.csv"
+ if [ -s "$f" ]; then state="updated"; else state="new"; fi
+ awk -F, -v d="$d" '
+ FNR == 1 && FILENAME != ARGV[ARGC-1] { next } # header of the existing snapshot
+ FILENAME == ARGV[ARGC-1] { if ($2 == d) r[$1] = $0; next } # new rows win
+ { if (!($1 in r)) r[$1] = $0 }
+ END { for (k in r) print r[k] }' "$f" "$rows" 2>/dev/null | sort > "$work/snap.body"
+ if [ ! -s "$f" ]; then awk -F, -v d="$d" '$2 == d' "$rows" | sort > "$work/snap.body"; fi
+ { echo "Symbol,Date,Open,High,Low,Close,Volume"; cat "$work/snap.body"; } > "$f.tmp"
+ if [ -s "$f" ] && cmp -s "$f" "$f.tmp"; then state="unchanged"; rm -f "$f.tmp"; else mv "$f.tmp" "$f"; fi
+ echo "$state: $f ($(wc -l < "$work/snap.body" | tr -d ' ') rows)"
+ done
+ exit 0
+fi
updated=0; skipped=0
while IFS= read -r sym; do
diff --git a/scripts/lib.sh b/scripts/lib.sh
index 3a28552..efe5556 100644
--- a/scripts/lib.sh
+++ b/scripts/lib.sh
@@ -1,4 +1,5 @@
# Shared helpers for fetch.sh and fetch_tw_daily.sh (source this file).
+export LC_ALL=C # awk/sort numeric and ordering behaviour must not depend on the user locale (e.g. comma decimals)
header="Date,Open,High,Low,Close,Volume"
# merge_into FILE NEWROWS: merge by date (new wins), sorted, warn on >1% close mismatch.
diff --git a/scripts/run.sh b/scripts/run.sh
new file mode 100755
index 0000000..463740b
--- /dev/null
+++ b/scripts/run.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Input a number and the script picks the right data and runs the
+# executable to produce the HTML web page, and then opens the page
+# with the default web browser.
+#
+# scripts/run.sh SYMBOL
+#
+# The SYMBOL does not have to contain TW or TWO; the script finds the
+# correct one. If both options exist, do both.
+#
+# Extra options are passed on to the analyser, e.g.
+# scripts/run.sh 2330 --interval week
+#
+# Environment: DATA_DIR (default data/tw), HTML_DIR (default data/html), OPEN_CMD (default `open`
+# on macOS, `xdg-open` elsewhere). The page is written to $HTML_DIR/CODE.TW.html (or .TWO.html).
+set -u
+cd "$(dirname "$0")/.." || exit 1 # project root, so it works from any directory
+data=${DATA_DIR:-data/tw}; htmldir=${HTML_DIR:-data/html}
+
+[ $# -ge 1 ] || { sed -n '2,15p' "$0"; exit 2; }
+sym=$1; shift
+
+# An explicit suffix (2330.TW / 6488.TWO) selects that market only; otherwise try both.
+case $sym in
+ *.[Tt][Ww][Oo]) code=${sym%.*}; candidates=("$data/$code.TWO.csv");;
+ *.[Tt][Ww]) code=${sym%.*}; candidates=("$data/$code.TW.csv");;
+ *) code=$sym; candidates=("$data/$code.TW.csv" "$data/$code.TWO.csv");;
+esac
+
+files=(); for f in "${candidates[@]}"; do [ -s "$f" ] && files+=("$f"); done
+if [ ${#files[@]} -eq 0 ]; then
+ echo "no data for '$sym' in $data (looked for: ${candidates[*]})" >&2
+ echo "download it first, e.g.: scripts/fetch.sh -d $data 2010-01-01 \$(date +%F) $code" >&2
+ exit 1
+fi
+
+cargo build --release -q || { echo "build failed" >&2; exit 1; }
+bin=target/release/stock
+mkdir -p "$htmldir"
+
+pages=()
+for f in "${files[@]}"; do
+ page="$htmldir/$(basename "$f" .csv).html"
+ echo "== $(basename "$f" .csv)"
+ "$bin" "$f" -o "$page" "$@" || { echo "analysis failed for $f" >&2; exit 1; }
+ pages+=("$page")
+done
+
+if [ -n "${OPEN_CMD:-}" ]; then opener=$OPEN_CMD
+elif command -v open >/dev/null; then opener=open
+elif command -v xdg-open >/dev/null; then opener=xdg-open
+else echo "no program to open pages with; open them yourself: ${pages[*]}"; exit 0; fi
+for page in "${pages[@]}"; do $opener "$page"; done
diff --git a/scripts/sync_from_vps.sh b/scripts/sync_from_vps.sh
new file mode 100755
index 0000000..20ba9d1
--- /dev/null
+++ b/scripts/sync_from_vps.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Mac side: pull the VPS's daily snapshot files and merge them into the per-stock CSVs.
+#
+# scripts/sync_from_vps.sh [--dry-run] [-r user@host:stock/updates/] [-d data/tw] [-u data/updates]
+#
+# The remote can also be given as $VPS_UPDATES. The VPS keeps only recent daily files
+# (Symbol,Date,Open,High,Low,Close,Volume). They are copied to the local cache (-u), which mirrors
+# the remote folder, and all cached snapshots are merged by date into the CSVs in -d. Merging is
+# idempotent (re-applying a day changes nothing), which also repairs a day that was published in
+# two parts. Stocks without a local CSV are ignored. Days you missed entirely (the VPS only keeps
+# a few weeks) can be filled from Yahoo with fetch.sh.
+set -u
+source "$(dirname "$0")/lib.sh"
+remote=${VPS_UPDATES:-}; outdir=data/tw; cache=data/updates; dry=0
+args=(); for a in "$@"; do [ "$a" = "--dry-run" ] && a=-n; args+=("$a"); done # getopts has no long options
+set -- ${args[@]+"${args[@]}"}
+while getopts "r:d:u:n" o; do
+ case $o in r) remote=$OPTARG;; d) outdir=$OPTARG;; u) cache=$OPTARG;; n) dry=1;; *) sed -n '2,13p' "$0"; exit 2;; esac
+done
+[ -n "$remote" ] || { echo "no remote given: use -r user@host:stock/updates/ or set VPS_UPDATES" >&2; exit 2; }
+[ -d "$outdir" ] || { echo "$outdir does not exist" >&2; exit 1; }
+for cmd in rsync awk sort; do command -v $cmd >/dev/null || { echo "$cmd is required" >&2; exit 1; }; done
+mkdir -p "$cache"
+
+latest_local() {
+ for f in "$outdir"/*.csv; do tail -n 1 "$f" | cut -d, -f1; done | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' | sort | tail -n 1
+}
+echo "latest date in $outdir before sync: $(latest_local)"
+
+remote=${remote%/}/
+if [ $dry -eq 1 ]; then
+ echo "--- files that would be downloaded:"
+ rsync -avn --delete "$remote" "$cache/"
+ exit 0
+fi
+rsync -av --partial --delete "$remote" "$cache/" || { echo "rsync failed" >&2; exit 1; }
+
+shopt -s nullglob
+snaps=("$cache"/????-??-??.csv)
+[ ${#snaps[@]} -gt 0 ] || { echo "no snapshot files in $cache"; exit 0; }
+
+work=$(mktemp -d) && [ -n "$work" ] && [ -d "$work" ] || { echo "cannot create a temporary directory" >&2; exit 1; }
+trap 'rm -rf "$work"' EXIT
+
+# All cached rows sorted by symbol then date, restricted to stocks we have, split into one file each.
+for f in "${snaps[@]}"; do tail -n +2 "$f"; done | sort -t, -k1,1 -k2,2 > "$work/all.csv"
+ls "$outdir" | sed -n 's/\.csv$//p' > "$work/have.txt"
+mkdir "$work/split"
+awk -F, -v dir="$work/split" '
+ FNR == NR { want[$1]; next }
+ !($1 in want) { next }
+ $1 != prev { if (out != "") close(out); prev = $1; out = dir "/" $1 ".csv" }
+ { print $2 "," $3 "," $4 "," $5 "," $6 "," $7 > out }' "$work/have.txt" "$work/all.csv"
+
+n=0
+for f in "$work"/split/*.csv; do
+ merge_into "$outdir/$(basename "$f")" "$f" && n=$((n + 1))
+done
+echo "merged ${#snaps[@]} snapshot days into $n stock files"
+echo "latest date in $outdir after sync: $(latest_local)"
diff --git a/src/data.rs b/src/data.rs
index c5fc18c..24a80ef 100644
--- a/src/data.rs
+++ b/src/data.rs
@@ -18,21 +18,35 @@ pub struct Candle {
}
/// 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.
+/// Rows are sorted ascending by date; duplicate dates are an error. Rows whose Open/Close lie
+/// outside High/Low are repaired (High/Low widened) rather than rejected.
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();
+ let mut repaired = 0usize;
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()))?;
+ let mut 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()));
+ let prices = [c.open, c.high, c.low, c.close];
+ if prices.iter().any(|p| !p.is_finite() || *p <= 0.0) {
+ return Err(format!("{}: line {line}: prices must be positive numbers", path.display()));
+ }
+ // Yahoo history has stray rows (e.g. the close outside the day's range). Keep the day but
+ // widen High/Low so they include Open and Close; the count is reported below.
+ let (hi, lo) = (prices.iter().cloned().fold(f64::MIN, f64::max), prices.iter().cloned().fold(f64::MAX, f64::min));
+ if hi != c.high || lo != c.low {
+ repaired += 1;
+ c.high = hi;
+ c.low = lo;
}
candles.push(c);
}
+ if repaired > 0 {
+ eprintln!("note: {}: {repaired} rows had Open/Close outside High/Low; High/Low widened to include them", path.display());
+ }
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));
@@ -50,3 +64,30 @@ fn valid_date(s: &str) -> bool {
&& b[7] == b'-'
&& b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+
+ fn load(body: &str) -> Result<Vec<Candle>, String> {
+ let path = std::env::temp_dir().join(format!("stock-test-{}-{}.csv", std::process::id(), body.len()));
+ std::fs::File::create(&path).unwrap().write_all(body.as_bytes()).unwrap();
+ let r = load_csv(&path);
+ let _ = std::fs::remove_file(&path);
+ r
+ }
+
+ #[test]
+ fn widens_high_low_instead_of_rejecting() {
+ // close 67 below low 71.6, as in real Yahoo data
+ let c = load("Date,Open,High,Low,Close,Volume\n2016-07-12,71.9,72.4,71.6,67,100\n").unwrap();
+ assert_eq!((c[0].high, c[0].low, c[0].close), (72.4, 67.0, 67.0));
+ }
+
+ #[test]
+ fn rejects_non_positive_prices_and_bad_dates() {
+ assert!(load("Date,Open,High,Low,Close,Volume\n2016-07-12,0,1,1,1,100\n").is_err());
+ assert!(load("Date,Open,High,Low,Close,Volume\n12/07/2016,1,1,1,1,100\n").is_err());
+ }
+}
diff --git a/web/template.html b/web/template.html
index 45fa874..966a584 100644
--- a/web/template.html
+++ b/web/template.html
@@ -206,6 +206,14 @@ function render() {
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>`;
+ // same horizontal crosshair for the indicator panels, at the hovered bar's values
+ for (const [p, arr, color] of [[R, D.rsi, css('--accent')], [K, D.k, css('--line1')], [K, D.d, css('--line2')]]) {
+ const v = arr[hover];
+ if (v == null) continue;
+ const y = ind(p, v);
+ s += `<line x1="${M.l}" x2="${W-M.r}" y1="${y}" y2="${y}" stroke="${color}" stroke-width="1" stroke-dasharray="4 3"/>` +
+ `<text x="${W-M.r+4}" y="${y+4}" style="fill:${color};font-weight:600">${fmt(v, 1)}</text>`;
+ }
}
svg.innerHTML = s;
updateInfo();