repos

Phase H: Supertrend indicator on symbol pages

48ab1629 by Isaac Bythewood · 1 month ago

Phase H: Supertrend indicator on symbol pages

Add the Supertrend (ATR-banded trend follower) alongside the other
symbol-page indicators.

- compute::supertrend (ATR 10 / 3x, Wilder-smoothed, carry-forward bands)
- /history payload carries a supertrend series (value + trend side)
- chart.js draws it as a single per-point-coloured line (green below
  price in an uptrend, red above in a downtrend), so the two trends can
  never overlap; the band jumps sides at a flip
- "What the indicators say" gains a Supertrend trend tile, folded into
  the overall bullish/bearish tally
- default-on overlays trimmed to SMA 50, SMA 200, Volume, RSI
  (Supertrend, EMA 21, and the benchmark now default off)
modified CLAUDE.md
@@ -36,7 +36,7 @@ There are no tests or linters configured.**Market clock (`src/market.rs`):** The US equity session (Closed/Pre/Regular/Post) in `America/New_York` via `chrono-tz`. No exchange-holiday calendar (deliberate — see the decisions log).**Compute (`src/compute.rs`):** Pure numeric code — indicator maths (`sma`, `ema`, `rsi`), graded fundamental ratios, range-meter marker positions, and the home-page sparkline SVG. The maths lives here, not in SQL or JS.**Compute (`src/compute.rs`):** Pure numeric code — indicator maths (`sma`, `ema`, `rsi`, `supertrend`), graded fundamental ratios, range-meter marker positions, and the home-page sparkline SVG. The maths lives here, not in SQL or JS.**Templates:** Jinja2 templates in `templates/` rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `/` (matches the sibling Rust apps). `vite_asset` resolves hashed asset names from `dist/.vite/manifest.json`.
modified PLAN.md
@@ -78,7 +78,8 @@ dev (cargo + vite clean, zero warnings, zero console errors, desktop + mobile):- **Symbol page:** range buttons are now **YTD 1M 3M 6M 1Y 3Y 5Y MAX (default  1Y)** — the old 1D/1W intraday ranges were dropped (the demand-only model can't  keep enough 15m bars to draw them legibly). **EMA 21 and RSI now default ON**  (with SMA 50/200, Volume). Added the `3Y` cutoff.  (with SMA 50/200, Volume). Added the `3Y` cutoff. *(Phase H later turned EMA 21  back off by default; default-on is now SMA 50/200 + Volume + RSI.)*- **Indicator interpretation blurb under the symbol chart** (`build_indicator_read`  → `indicators` ctx → `templates/pages/symbol.html`): a direct RSI  overbought/oversold/neutral verdict (with a leaning-bullish/bearish middle),
@@ -640,6 +641,35 @@ were already removed in Phase F.)  choice, retention, and guard-budget impact are a design call worth confirming  before building. Surfaced to the user; ready to execute on their go-ahead.### Phase H — Supertrend indicator on symbol pages  ✅ DONE on dev (uncommitted)Add the Supertrend (ATR-banded trend follower) alongside the other symbol-pageindicators, per a user request. See the Status block above for the full outcome.- **Maths:** `compute::supertrend(highs, lows, closes, period, mult)` →  `Vec<Option<SuperTrend>>` (band value + `up` trend side). Standard ATR(10)×3  (`SUPERTREND_PERIOD` / `SUPERTREND_MULT`), Wilder-smoothed ATR like `rsi`, with  the carry-forward final-band rule.- **Chart:** `/api/.../history` gains a `supertrend` array (value + `up` per bar,  trimmed to the visible window like the other overlays); `chart.js` draws it as a  **single line series coloured per point** (green when the band trails below price,  red when it rides above), so one bar is one value/one colour and the trends can  never overlap; the band jumps sides at a flip. One toggle (`data-ind="supertrend"`,  **off by default** — see the decisions log); its swatch is a split green/red dot.  Daily-only (empty on intraday). *(First tried two whitespace-gapped series; the  line connected straight across the gaps and drew both colours at once — see the  lessons.)*- **Default-toggle trim (same round, user call):** Supertrend, **EMA 21**, and the  **benchmark (^SPX)** all now default **off** — the on-by-default set was too busy.  Default-on overlays are now just SMA 50, SMA 200, Volume, and RSI. (This reverses  the Phase F "EMA 21 default on" choice.)- **Written read:** a "Supertrend" tile joins the "What the indicators say" signal  grid (Uptrend/Downtrend, green/red) and folds into the overall bullish/bearish  tally; `build_indicator_read` now takes highs/lows too.- **Verified on dev:** `cargo build` + `vite build` clean, zero warnings; the  `/history` payload returns 250 supertrend points over 1Y (6 flips on AAPL);  Playwright at 1280 + 390 shows the green/red band trailing price with clean flips,  the toggle hides/shows both series, the read tile renders and the tally reads "5 of  5 bullish", zero console errors.### Backlog / parked- Named multiple watchlists (only a single session list is planned for now).- A "popular non-S&P" quick-add set on the dashboard, if the curated catalog feels
@@ -652,6 +682,24 @@ were already removed in Phase F.)## Decisions log**2026-06-05 — Phase H: Supertrend indicator on symbol pages.** User asked forSupertrend next to the other tracked indicators. Three design calls (all theuser's, given the convention tension):1. **Green/red trend colouring** (chosen over the muted non-semantic palette or a   two-muted-ink compromise). It's Supertrend's signature and up=green/down=red   matches the app's price-move semantics, so it reuses the candle green/red — a   deliberate, documented exception to the "indicator lines are non-semantic" rule.2. **Initially on by default; then reversed to off** after the user found the   on-by-default overlay set too busy ("the green bar doesn't go away"). In the same   round the user also turned **EMA 21** and the **benchmark (^SPX)** off by default   (reversing Phase F's EMA-on choice). Default-on overlays are now SMA 50 / SMA 200   / Volume / RSI only.3. **Folded into the written read** as a colour-coded trend tile + the overall tally.Standard params ATR(10)×3, no prompt. Rendered as a single per-point-coloured line(after a two-whitespace-gapped-series first attempt drew both colours at once — the"constantly green" bug; see the lessons). Verified on MSFT that the band flipsgreen→red→green correctly through its declines, one colour per bar.**2026-06-04 — Phase F: per-instrument overview + symbol chart upgrades.** Livingwith the single normalized overview overlay, the user found "everything on onenormalized graph" confusing and wanted each instrument as its own chart showing
@@ -802,7 +850,14 @@ v1, top picks + backtest, UI polish). Blow-by-blow lives in git history.  survives restarts. A boot-time breaker trip is normal after a deploy; it recovers  via the half-open probe.- **Chart indicator lines use a non-semantic palette on purpose** — green/amber/red  are reserved for good/ok/bad; candles own green/red.  are reserved for good/ok/bad; candles own green/red. **Supertrend is the one  deliberate exception** (a user call, Phase H): its trend colour *is* the signal  and up=green/down=red matches the price-move semantics, so it reuses the candle  green/red. Drawn as a **single line series coloured per data point** (one  value/one colour per bar), so the two trends can never render at the same x; the  band jumps sides at a flip. **Do not** use two whitespace-gapped series for this —  lightweight-charts connected the line straight across the whitespace gaps and drew  both the green and red lines at once (the band appeared "constantly green").- **Yahoo NAV is only as fresh as you keep it.** Comparing a live price to a  weeks-old NAV yields a meaningless premium/discount. The ETF quality read's  tracking factor must gate on `nav_synced_at` freshness. In the demand-only model
modified frontend/static_src/symbol/scripts/chart.js
@@ -51,6 +51,12 @@ const RSI_INK = "#3f6f9c";// together and the divergence is the relative performance the eye should// follow.const BENCH_INK = "#7a5237";// Supertrend is the deliberate green/red exception (a user call): the band's// whole point is its trend colour, and up=green / down=red matches the app's// price-move semantics. It reuses the candle green/red exactly so the overlay// reads as part of the price, not a separate wayfinding ink.const SUPERTREND_UP = "#2f7d4f";const SUPERTREND_DOWN = "#b23b32";const VOLUME_UP = "rgba(47,125,79,0.38)";const VOLUME_DOWN = "rgba(178,59,50,0.38)";// Phase 25: earnings-date markers. A small ink dot above each candle that
@@ -168,6 +174,20 @@ export function initChart() {    visible: false,  });  // Supertrend overlay: a single line whose colour is set per point — green  // while the band trails below price (uptrend), red while it rides above  // (downtrend). One line means one value and one colour per bar, so the two  // trends can never draw at the same time; the band simply jumps to the other  // side at a flip. (Two whitespace-gapped series were tried first but the line  // connected straight across the gaps, drawing both colours at once.)  const supertrendSeries = chart.addSeries(LineSeries, {    color: SUPERTREND_UP,    lineWidth: 2,    priceLineVisible: false,    lastValueVisible: false,    crosshairMarkerVisible: false,  });  // Earnings-date markers (Phase 25). Stocks only; the payload carries an  // `earnings` array of `YYYY-MM-DD` past dates that match candle times.  // Each draws a small ink dot above the matching bar. v5's
@@ -201,7 +221,7 @@ export function initChart() {  // The moving-average, RSI and benchmark overlays are all derived from the  // daily series, so they are meaningless on the intraday ranges. Their toggle  // buttons hide there (benchmark hides on its own when the payload has none).  const DAILY_ONLY_INDS = ["sma50", "sma200", "ema21", "rsi"];  const DAILY_ONLY_INDS = ["sma50", "sma200", "ema21", "rsi", "supertrend"];  // RSI lives in its own pane below the price pane and is created only while  // toggled on, so an empty second pane never lingers when it is off.
@@ -408,11 +428,21 @@ export function initChart() {    overlays.sma200.setData(d.sma200);    overlays.ema21.setData(d.ema21);    if (rsiSeries) rsiSeries.setData(d.rsi14);    // Supertrend: one line, coloured per bar by its trend side.    supertrendSeries.setData(      (d.supertrend || []).map((p) => ({        time: p.time,        value: p.value,        color: p.up ? SUPERTREND_UP : SUPERTREND_DOWN,      })),    );    // Phase 28: benchmark overlay rides on the price pane when present.    const bench = d.benchmark || [];    benchmarkSeries.setData(bench);    // Only show the benchmark series — and the toggle for it — when the    // payload actually has one. The button defaults to on when shown.    // payload actually has one; its visibility then follows the toggle's    // is-active state (off by default).    const benchBtn = document.querySelector('[data-ind="benchmark"]');    if (benchBtn) benchBtn.hidden = bench.length === 0;    const benchOn = bench.length > 0 && (!benchBtn || benchBtn.classList.contains("is-active"));
@@ -459,6 +489,8 @@ export function initChart() {      volumeSeries.applyOptions({ visible: on });    } else if (key === "benchmark") {      benchmarkSeries.applyOptions({ visible: on });    } else if (key === "supertrend") {      supertrendSeries.applyOptions({ visible: on });    } else if (overlays[key]) {      overlays[key].applyOptions({ visible: on });    }
@@ -469,8 +501,16 @@ export function initChart() {    // Paint the swatch from the JS palette so the inks live in one place.    const dot = btn.querySelector(".ind-btn__dot");    if (dot) {      // Supertrend's swatch is split green/red to telegraph its two-tone line;      // the rest take their single ink from the palette.      dot.style.background =        key === "rsi" ? RSI_INK : key === "benchmark" ? BENCH_INK : OVERLAY_INK[key];        key === "supertrend"          ? `linear-gradient(90deg, ${SUPERTREND_UP} 50%, ${SUPERTREND_DOWN} 50%)`          : key === "rsi"            ? RSI_INK            : key === "benchmark"              ? BENCH_INK              : OVERLAY_INK[key];    }    // The template's is-active class is the initial visibility.    applyIndicator(key, btn.classList.contains("is-active"));
modified src/compute.rs
@@ -443,6 +443,91 @@ fn rsi_from(avg_gain: f64, avg_loss: f64) -> f64 {    100.0 - 100.0 / (1.0 + rs)}/// One Supertrend bar: the band value to plot and which side of price it sits/// on. `up` is an uptrend — the band is trailing *below* price as support;/// `!up` is a downtrend, the band riding *above* price as resistance. The line/// flips sides when price closes through it, which is the whole signal.#[derive(Debug, Clone, Copy)]pub struct SuperTrend {    pub value: f64,    pub up: bool,}/// The standard Supertrend defaults: a 10-bar ATR scaled by 3. Used by both the/// chart overlay and the symbol page's indicator read so the two always agree.pub const SUPERTREND_PERIOD: usize = 10;pub const SUPERTREND_MULT: f64 = 3.0;/// Supertrend over `period` bars at `mult`× ATR (classically 10 / 3.0). An/// ATR-banded trend follower: a single line that trails below price in an/// uptrend and above it in a downtrend, flipping when a close breaks through./// Takes parallel high / low / close slices (oldest first) and returns one/// `Option<SuperTrend>` per bar — `None` until the ATR has warmed up — so the/// caller aligns it to the bar list by index like the other indicators.////// ATR uses Wilder smoothing (matching `rsi`), and the bands carry forward with/// the standard rule: each final band only tightens toward price unless the/// prior close pierced it, which keeps the line from whipping on a quiet bar.pub fn supertrend(highs: &[f64], lows: &[f64], closes: &[f64], period: usize, mult: f64) -> Vec<Option<SuperTrend>> {    let n = closes.len();    let mut out = vec![None; n];    // Need `period` true ranges (each from a bar and its predecessor) to seed    // the ATR, so the first reading lands at index `period`.    if period == 0 || n <= period {        return out;    }    // True range at bar `i`: the greatest of today's span and the two gaps to    // yesterday's close. `i == 0` has no predecessor, so it is the bar span.    let tr = |i: usize| -> f64 {        let hl = highs[i] - lows[i];        if i == 0 {            return hl;        }        hl.max((highs[i] - closes[i - 1]).abs())            .max((lows[i] - closes[i - 1]).abs())    };    let hl2 = |i: usize| (highs[i] + lows[i]) / 2.0;    // Wilder-seeded ATR: the mean of the first `period` true ranges, then    // smoothed one bar at a time.    let mut atr = (1..=period).map(tr).sum::<f64>() / period as f64;    let mut final_upper = hl2(period) + mult * atr;    let mut final_lower = hl2(period) - mult * atr;    // Seed the trend from where the close sits in its first band: above the    // upper band reads as an uptrend, otherwise a downtrend.    let mut up = closes[period] > final_upper;    let mut st = if up { final_lower } else { final_upper };    out[period] = Some(SuperTrend { value: st, up });    for i in period + 1..n {        atr = (atr * (period as f64 - 1.0) + tr(i)) / period as f64;        let basic_upper = hl2(i) + mult * atr;        let basic_lower = hl2(i) - mult * atr;        // A final band only moves toward price unless the *prior* close pierced        // it, in which case it resets to the fresh basic band.        final_upper = if basic_upper < final_upper || closes[i - 1] > final_upper {            basic_upper        } else {            final_upper        };        final_lower = if basic_lower > final_lower || closes[i - 1] < final_lower {            basic_lower        } else {            final_lower        };        // Stay in the current trend until a close crosses the trailing band;        // then flip to the opposite band.        up = if up {            closes[i] >= final_lower        } else {            closes[i] > final_upper        };        st = if up { final_lower } else { final_upper };        out[i] = Some(SuperTrend { value: st, up });    }    out}fn earnings_growth(net_income: Option<f64>, prev_net_income: Option<f64>) -> Ratio {    const KEY: &str = "earnings_growth";    const LABEL: &str = "Earnings growth";
modified src/routes/symbols.rs
@@ -99,7 +99,13 @@ struct IndicatorSignal {/// Build the colour-coded indicator read from the daily closes (oldest first),/// the current price, and whether the symbol is dollar-priced. Needs enough/// history for RSI(14)/EMA(21); the 50/200 averages join once they exist.fn build_indicator_read(closes: &[f64], price: f64, dollar: bool) -> Option<IndicatorRead> {fn build_indicator_read(    highs: &[f64],    lows: &[f64],    closes: &[f64],    price: f64,    dollar: bool,) -> Option<IndicatorRead> {    if closes.len() < 30 || price <= 0.0 {        return None;    }
@@ -184,6 +190,30 @@ fn build_indicator_read(closes: &[f64], price: f64, dollar: bool) -> Option<Indi        });    }    // Supertrend posture: which side of the ATR band price closed on. Folds    // into the same bullish/bearish tally as the moving-average signals.    let st = compute::supertrend(highs, lows, closes, compute::SUPERTREND_PERIOD, compute::SUPERTREND_MULT)        .last()        .copied()        .flatten();    if let Some(p) = st {        if p.up {            bull += 1;        }        total += 1;        signals.push(IndicatorSignal {            label: "Supertrend".to_string(),            value: fmt(p.value),            status: if p.up { "Uptrend" } else { "Downtrend" }.to_string(),            tone: if p.up { "up" } else { "down" }.to_string(),            note: if p.up {                "Price is holding above the Supertrend band — bullish".to_string()            } else {                "Price is below the Supertrend band — bearish".to_string()            },        });    }    // Overall verdict from the trend tally.    let (verdict, verdict_tone) = if total == 0 {        ("No signal", "steady")
@@ -1391,8 +1421,11 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    // moving average), shown beneath the chart. Built from the daily closes    // (oldest first) against the current price; `None` without enough history.    let indicators = price.and_then(|p| {        // `bars` is newest-first; the indicator maths want oldest-first.        let highs: Vec<f64> = bars.iter().rev().map(|b| b.2).collect();        let lows: Vec<f64> = bars.iter().rev().map(|b| b.3).collect();        let closes: Vec<f64> = bars.iter().rev().map(|b| b.4).collect();        build_indicator_read(&closes, p, symbol.kind != "index")        build_indicator_read(&highs, &lows, &closes, p, symbol.kind != "index")    });    // Stock fundamentals are loaded once and shared by the ratio cards
@@ -1717,6 +1750,16 @@ struct EarningsMarker {    time: String,}/// One Supertrend point for the chart: the band value plus whether the trend is/// up, so the client can split the single line into a green (uptrend) and a red/// (downtrend) series with a clean break at each flip.#[derive(Serialize)]struct SuperTrendPoint {    time: String,    value: f64,    up: bool,}/// The symbol chart payload (Phase 8 + Phase 28): the candles for the/// selected range plus the indicator overlays, each already trimmed to the/// visible window. Phase 28 adds an optional benchmark series — the
@@ -1730,6 +1773,11 @@ struct HistoryResponse {    sma200: Vec<LinePoint>,    ema21: Vec<LinePoint>,    rsi14: Vec<LinePoint>,    /// Supertrend band (ATR 10 / 3×). Each point carries its trend side so the    /// client draws it green below price in an uptrend, red above in a    /// downtrend. Empty on the intraday ranges (a daily-only overlay).    #[serde(skip_serializing_if = "Vec::is_empty")]    supertrend: Vec<SuperTrendPoint>,    /// Benchmark closes scaled to the same starting price as the visible    /// candles, so the two lines start together and drift apart on relative    /// performance. Empty when no benchmark is configured or no benchmark
@@ -1833,6 +1881,8 @@ async fn history_api(    .unwrap_or_default();    let dates: Vec<String> = rows.iter().map(|r| r.0.clone()).collect();    let highs: Vec<f64> = rows.iter().map(|r| r.2).collect();    let lows: Vec<f64> = rows.iter().map(|r| r.3).collect();    let closes: Vec<f64> = rows.iter().map(|r| r.4).collect();    // First bar inside the visible window; everything before it is lookback,
@@ -1901,11 +1951,28 @@ async fn history_api(        Vec::new()    };    // Supertrend, trimmed to the visible window the same way as the lines but    // keeping each bar's trend side so the client can colour it.    let supertrend: Vec<SuperTrendPoint> =        compute::supertrend(&highs, &lows, &closes, compute::SUPERTREND_PERIOD, compute::SUPERTREND_MULT)            .into_iter()            .enumerate()            .skip(start)            .filter_map(|(i, p)| {                p.map(|p| SuperTrendPoint {                    time: dates[i].clone(),                    value: p.value,                    up: p.up,                })            })            .collect();    let resp = HistoryResponse {        sma50: line(compute::sma(&closes, 50)),        sma200: line(compute::sma(&closes, 200)),        ema21: line(compute::ema(&closes, 21)),        rsi14: line(compute::rsi(&closes, 14)),        supertrend,        candles: rows            .into_iter()            .skip(start)
@@ -2012,6 +2079,7 @@ async fn intraday_history(pool: &sqlx::SqlitePool, ticker: &str, range: &str) ->        sma200: Vec::new(),        ema21: Vec::new(),        rsi14: Vec::new(),        supertrend: Vec::new(),        benchmark: Vec::new(),        benchmark_ticker: None,        earnings: Vec::new(),
modified templates/pages/symbol.html
@@ -210,10 +210,11 @@      {% for ind in [        {"key": "sma50",  "label": "SMA 50",  "on": true,  "dot": true,  "hide": false},        {"key": "sma200", "label": "SMA 200", "on": true,  "dot": true,  "hide": false},        {"key": "ema21",  "label": "EMA 21",  "on": true,  "dot": true,  "hide": false},        {"key": "ema21",  "label": "EMA 21",  "on": false, "dot": true,  "hide": false},        {"key": "supertrend", "label": "Supertrend", "on": false, "dot": true, "hide": false},        {"key": "volume", "label": "Volume",  "on": true,  "dot": false, "hide": false},        {"key": "rsi",    "label": "RSI",     "on": true,  "dot": true,  "hide": false},        {"key": "benchmark", "label": (symbol.benchmark or "Benchmark"), "on": true, "dot": true, "hide": (not symbol.benchmark)}        {"key": "benchmark", "label": (symbol.benchmark or "Benchmark"), "on": false, "dot": true, "hide": (not symbol.benchmark)}      ] %}      <button type="button" class="ind-btn{% if ind.on %} is-active{% endif %}"              data-ind="{{ ind.key }}" aria-pressed="{{ 'true' if ind.on else 'false' }}"