@@ -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`.
@@ -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"));
@@ -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' }}"