summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJSDurand <mmemmew@gmail.com>2026-10-12 18:24:30 +0800
committerJSDurand <mmemmew@gmail.com>2026-10-12 18:24:30 +0800
commit8c93a80302cee7ac73b9d4f2e90e47278164663f (patch)
treef24f825b3751922b1d5023773afa71c353ac8e22
parent03d191a66ff66b6db80222f0f4dcde0b45c36d4e (diff)
updates to the indicators and fix minor bugs
-rw-r--r--src/indicators.rs39
-rw-r--r--src/main.rs37
-rw-r--r--src/signals.rs174
-rw-r--r--web/template.html8
4 files changed, 225 insertions, 33 deletions
diff --git a/src/indicators.rs b/src/indicators.rs
index 4378757..ec52f2a 100644
--- a/src/indicators.rs
+++ b/src/indicators.rs
@@ -27,6 +27,39 @@ pub fn rsi(candles: &[Candle], period: usize) -> Vec<Option<f64>> {
out
}
+/// Average true range (Wilder). Before `period` bars it is the mean true range so far,
+/// so every bar has a value.
+pub fn atr(candles: &[Candle], period: usize) -> Vec<f64> {
+ let n = period as f64;
+ let mut out = Vec::with_capacity(candles.len());
+ let mut avg = 0.0;
+ for (i, c) in candles.iter().enumerate() {
+ let tr = if i == 0 {
+ c.high - c.low
+ } else {
+ let pc = candles[i - 1].close;
+ (c.high - c.low).max((c.high - pc).abs()).max((c.low - pc).abs())
+ };
+ avg = if i < period { (avg * i as f64 + tr) / (i as f64 + 1.0) } else { (avg * (n - 1.0) + tr) / n };
+ out.push(avg);
+ }
+ out
+}
+
+/// Causal exponential moving average of closes, returned as flat candles
+/// (used only to locate candidate swings).
+pub fn ema_candles(candles: &[Candle], n: usize) -> Vec<Candle> {
+ let alpha = 2.0 / (n as f64 + 1.0);
+ let mut e = candles[0].close;
+ candles
+ .iter()
+ .map(|c| {
+ e = alpha * c.close + (1.0 - alpha) * e;
+ Candle { open: e, high: e, low: e, close: e, ..c.clone() }
+ })
+ .collect()
+}
+
/// 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>>) {
@@ -88,6 +121,12 @@ mod tests {
}
#[test]
+ fn atr_constant_range() {
+ let cs = [candle(12.0, 10.0, 11.0), candle(12.0, 10.0, 11.0), candle(12.0, 10.0, 11.0)];
+ assert!(atr(&cs, 14).iter().all(|&a| (a - 2.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);
diff --git a/src/main.rs b/src/main.rs
index 24f4ccc..8ca9df5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,36 +7,42 @@ 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
+const USAGE: &str = "usage: stock <input.csv> [-o out.html] [--rsi 14] [--kd 9] [--atr 2.5 | --swing PCT] [--smooth N]";
+// Swing detection for divergences: ZigZag with a reversal threshold of k*ATR(14) (--atr, default 2.5)
+// or a fixed percentage (--swing). --smooth N locates swings on an N-bar EMA, then snaps them to real highs/lows.
struct Args {
input: PathBuf,
output: Option<PathBuf>,
rsi: usize,
kd: usize,
- swing: f64,
+ atr_k: f64,
+ swing: Option<f64>,
+ smooth: usize,
}
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);
+ let (mut input, mut output, mut rsi, mut kd) = (None, None, 14, 9);
+ let (mut atr_k, mut swing, mut smooth) = (2.5, None, 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}"))?,
+ "--swing" => swing = Some(val("--swing")?.parse().map_err(|e| format!("--swing: {e}"))?),
+ "--atr" => atr_k = val("--atr")?.parse().map_err(|e| format!("--atr: {e}"))?,
+ "--smooth" => smooth = val("--smooth")?.parse().map_err(|e| format!("--smooth: {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());
+ if rsi == 0 || kd == 0 || atr_k <= 0.0 || swing.is_some_and(|p| !(p > 0.0 && p < 100.0)) {
+ return Err("periods must be positive, --atr > 0, --swing within (0, 100)".into());
}
- Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, swing })
+ Ok(Args { input: input.ok_or("missing input.csv")?, output, rsi, kd, atr_k, swing, smooth })
}
fn run() -> Result<PathBuf, String> {
@@ -45,9 +51,18 @@ fn run() -> Result<PathBuf, String> {
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"));
+ let thr = match args.swing {
+ Some(p) => signals::Threshold::Percent(p),
+ None => signals::Threshold::Atr { k: args.atr_k, atr: indicators::atr(&candles, 14) },
+ };
+ let swings = if args.smooth > 0 {
+ let found = signals::zigzag(&indicators::ema_candles(&candles, args.smooth), &thr);
+ signals::snap_swings(&candles, &found, args.smooth)
+ } else {
+ signals::zigzag(&candles, &thr)
+ };
+ let mut divs = signals::divergences(&candles, &swings, &thr, &rsi, "RSI", &signals::Filters::rsi());
+ divs.extend(signals::divergences(&candles, &swings, &thr, &k, "K", &signals::Filters::k()));
divs.sort_by_key(|d| d.confirmed);
print!("{}", signals::forward_stats(&candles, &divs, &[5, 10, 20]));
let payload = json!({
diff --git a/src/signals.rs b/src/signals.rs
index 4b91d8e..3019c5c 100644
--- a/src/signals.rs
+++ b/src/signals.rs
@@ -50,14 +50,31 @@ pub struct Swing {
pub index: usize,
pub is_high: bool,
pub price: f64,
- /// bar where price had reversed by `pct` from the extreme
+ /// bar where price had reversed by the threshold from the extreme
pub confirmed_at: usize,
}
+/// Minimum reversal that confirms a swing.
+pub enum Threshold {
+ /// fixed percentage of the extreme price
+ Percent(f64),
+ /// `k` times the ATR at the confirming bar (adapts to each stock's volatility)
+ Atr { k: f64, atr: Vec<f64> },
+}
+
+impl Threshold {
+ /// Reversal size in price units at bar `i` for an extreme at `price`.
+ pub fn at(&self, i: usize, price: f64) -> f64 {
+ match self {
+ Threshold::Percent(p) => price * p / 100.0,
+ Threshold::Atr { k, atr } => k * atr[i],
+ }
+ }
+}
+
/// 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;
+/// least the threshold from the running extreme. The last, unconfirmed extreme is dropped.
+pub fn zigzag(candles: &[Candle], thr: &Threshold) -> Vec<Swing> {
let mut out = Vec::new();
if candles.is_empty() {
return out;
@@ -71,11 +88,11 @@ pub fn zigzag(candles: &[Candle], pct: f64) -> Vec<Swing> {
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 {
+ if c.low <= hi.1 - thr.at(i, hi.1) && 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 {
+ } else if c.high >= lo.1 + thr.at(i, lo.1) && 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);
@@ -84,7 +101,7 @@ pub fn zigzag(candles: &[Candle], pct: f64) -> Vec<Swing> {
Some(true) => {
if c.high > hi.1 {
hi = (i, c.high);
- } else if c.low <= hi.1 * (1.0 - f) {
+ } else if c.low <= hi.1 - thr.at(i, hi.1) {
out.push(Swing { index: hi.0, is_high: true, price: hi.1, confirmed_at: i });
dir = Some(false);
lo = (i, c.low);
@@ -93,7 +110,7 @@ pub fn zigzag(candles: &[Candle], pct: f64) -> Vec<Swing> {
Some(false) => {
if c.low < lo.1 {
lo = (i, c.low);
- } else if c.high >= lo.1 * (1.0 + f) {
+ } else if c.high >= lo.1 + thr.at(i, lo.1) {
out.push(Swing { index: lo.0, is_high: false, price: lo.1, confirmed_at: i });
dir = Some(true);
hi = (i, c.high);
@@ -104,6 +121,53 @@ pub fn zigzag(candles: &[Candle], pct: f64) -> Vec<Swing> {
out
}
+/// Move each swing to the real extreme (highest high / lowest low) within +-`n` bars,
+/// never past its confirmation bar (no look-ahead). Used after zigzag on smoothed prices.
+pub fn snap_swings(candles: &[Candle], swings: &[Swing], n: usize) -> Vec<Swing> {
+ swings
+ .iter()
+ .map(|s| {
+ let lo = s.index.saturating_sub(n);
+ let hi = (s.index + n).min(s.confirmed_at);
+ let pick = (lo..=hi).reduce(|a, b| {
+ let better = if s.is_high { candles[b].high > candles[a].high } else { candles[b].low < candles[a].low };
+ if better { b } else { a }
+ });
+ let index = pick.unwrap_or(s.index);
+ let price = if s.is_high { candles[index].high } else { candles[index].low };
+ Swing { index, price, ..*s }
+ })
+ .collect()
+}
+
+/// Quality filters that reject noisy divergences.
+pub struct Filters {
+ pub min_bars: usize,
+ pub max_bars: usize,
+ /// minimum indicator difference between the two swings (indicator points)
+ pub min_ind_diff: f64,
+ /// minimum price difference, as a multiple of the swing threshold
+ pub min_price_k: f64,
+ /// reject if price or indicator crosses the line joining the two swings in between
+ pub check_line: bool,
+ /// regular divergences only: (bullish needs first-swing indicator below .0,
+ /// bearish needs it above .1)
+ pub zone: Option<(f64, f64)>,
+}
+
+impl Filters {
+ pub fn rsi() -> Self {
+ Filters { min_bars: 5, max_bars: 60, min_ind_diff: 3.0, min_price_k: 0.3, check_line: true, zone: Some((45.0, 55.0)) }
+ }
+ pub fn k() -> Self {
+ Filters { min_bars: 5, max_bars: 60, min_ind_diff: 5.0, min_price_k: 0.3, check_line: true, zone: Some((30.0, 70.0)) }
+ }
+ #[cfg(test)]
+ pub fn none() -> Self {
+ Filters { min_bars: 0, max_bars: usize::MAX, min_ind_diff: 0.0, min_price_k: 0.0, check_line: false, zone: None }
+ }
+}
+
/// 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);
@@ -113,6 +177,15 @@ fn indicator_extreme(ind: &[Option<f64>], idx: usize, is_high: bool, slack: usiz
})
}
+/// True if no bar strictly between the endpoints lies beyond the straight line
+/// joining them (below it for swing lows, above it for swing highs).
+fn line_clear(a: (usize, f64), b: (usize, f64), is_high: bool, value: impl Fn(usize) -> Option<f64>) -> bool {
+ (a.0 + 1..b.0).all(|j| {
+ let line = a.1 + (b.1 - a.1) * (j - a.0) as f64 / (b.0 - a.0) as f64;
+ value(j).is_none_or(|v| if is_high { v <= line } else { v >= line })
+ })
+}
+
/// 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)
@@ -120,15 +193,22 @@ fn indicator_extreme(ind: &[Option<f64>], idx: usize, is_high: bool, slack: usiz
/// hidden bullish: price higher low, indicator lower low (uptrend continues)
/// hidden bearish: price lower high, indicator higher high (downtrend continues)
pub fn divergences(
+ candles: &[Candle],
swings: &[Swing],
+ thr: &Threshold,
ind: &[Option<f64>],
name: &'static str,
+ f: &Filters,
) -> 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 gap = b.index - a.index;
+ if gap < f.min_bars || gap > f.max_bars {
+ continue;
+ }
let (Some((ja, ia)), Some((jb, ib))) = (
indicator_extreme(ind, a.index, is_high, 2),
indicator_extreme(ind, b.index, is_high, 2),
@@ -137,8 +217,27 @@ pub fn divergences(
if price_up == ind_up || b.price == a.price || ib == ia {
continue;
}
+ if (ib - ia).abs() < f.min_ind_diff
+ || (b.price - a.price).abs() < f.min_price_k * thr.at(b.confirmed_at, b.price)
+ {
+ 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 };
+ if regular {
+ if let Some((lo_max, hi_min)) = f.zone {
+ if (!is_high && ia >= lo_max) || (is_high && ia <= hi_min) {
+ continue;
+ }
+ }
+ }
+ if f.check_line
+ && !(line_clear((a.index, a.price), (b.index, b.price), is_high, |j| {
+ Some(if is_high { candles[j].high } else { candles[j].low })
+ }) && line_clear((ja, ia), (jb, ib), is_high, |j| ind[j]))
+ {
+ continue;
+ }
out.push(Divergence {
kind: if is_high { "bearish" } else { "bullish" },
class: if regular { "regular" } else { "hidden" },
@@ -198,10 +297,17 @@ mod tests {
}
const PRICES: [f64; 9] = [100.0, 95.0, 90.0, 95.0, 100.0, 97.0, 95.0, 100.0, 105.0];
+ const PCT: Threshold = Threshold::Percent(5.0);
+
+ fn run(p: &[f64], ind: &[Option<f64>], f: &Filters) -> Vec<Divergence> {
+ let c = flat(p);
+ let sw = zigzag(&c, &PCT);
+ divergences(&c, &sw, &PCT, ind, "RSI", f)
+ }
#[test]
fn zigzag_finds_confirmed_swings() {
- let sw = zigzag(&flat(&PRICES), 5.0);
+ let sw = zigzag(&flat(&PRICES), &PCT);
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));
@@ -210,39 +316,71 @@ mod tests {
#[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");
+ let d = run(&PRICES, &ind, &Filters::none());
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");
+ let d = run(&p, &ind, &Filters::none());
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");
+ let d = run(&p, &ind, &Filters::none());
assert_eq!(d.len(), 1);
assert_eq!((d[0].class, d[0].kind), ("hidden", "bearish"));
}
-}
+ #[test]
+ fn filters_reject_close_swings_but_keep_clean_ones() {
+ // swings only 4 bars apart: unfiltered fires, filtered does not
+ let mut ind = vec![Some(50.0); PRICES.len()];
+ ind[2] = Some(30.0);
+ ind[6] = Some(20.0);
+ assert_eq!(run(&PRICES, &ind, &Filters::none()).len(), 1);
+ assert!(run(&PRICES, &ind, &Filters::rsi()).is_empty());
+
+ // swing lows 9 bars apart (idx 2: 90, idx 11: 95), nothing between dips below the line
+ let p = [100.0, 95.0, 90.0, 95.0, 100.0, 105.0, 110.0, 105.0, 100.0, 98.0, 96.0, 95.0, 100.0, 105.0];
+ let mut ind = vec![Some(50.0); p.len()];
+ ind[2] = Some(30.0);
+ ind[11] = Some(20.0);
+ let d = run(&p, &ind, &Filters::rsi());
+ assert_eq!(d.len(), 1);
+ assert_eq!((d[0].class, d[0].i1, d[0].i2), ("hidden", 2, 11));
+
+ // the indicator dips below the joining line in between -> rejected
+ ind[6] = Some(5.0);
+ assert!(run(&p, &ind, &Filters::rsi()).is_empty());
+ }
+
+ #[test]
+ fn atr_threshold_scales_with_volatility() {
+ let t = Threshold::Atr { k: 2.0, atr: vec![1.0, 3.0] };
+ assert_eq!(t.at(1, 100.0), 6.0);
+ }
+
+ #[test]
+ fn snap_moves_to_real_extreme_without_lookahead() {
+ let mut c = flat(&[10.0, 9.0, 8.0, 9.0, 10.0]);
+ c[1].low = 7.0; // real low one bar before the swing
+ let sw = [Swing { index: 2, is_high: false, price: 8.0, confirmed_at: 3 }];
+ let s = snap_swings(&c, &sw, 2);
+ assert_eq!((s[0].index, s[0].price), (1, 7.0));
+ }
+}
diff --git a/web/template.html b/web/template.html
index 5349e38..56bbea5 100644
--- a/web/template.html
+++ b/web/template.html
@@ -56,7 +56,7 @@ 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}`;
+$('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);
@@ -131,7 +131,7 @@ function render() {
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>`;
+ `<text x="${x}" y="${K.y+K.h+14}" text-anchor="middle">${D.candles[i].Date.slice(every > 30 ? 0 : 2)}</text>`;
}
// ---- candles + volume
@@ -205,7 +205,7 @@ function updateInfo() {
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; ` +
+ 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>`;
@@ -213,7 +213,7 @@ function updateInfo() {
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`;
+ 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;
}