repos

Phase 6: symbol-page distillation + live intraday chart

aa8c5f37 by Isaac Bythewood · 1 month ago

Phase 6: symbol-page distillation + live intraday chart

Add 1D/1W intraday range buttons serving 15m bars from intraday_bars on a
minute axis (ts is epoch-ms, divided to lightweight-charts' UNIX seconds; a
BarTime untagged enum lets the candles field carry a date string or a number).
1D resolves the most recent NY trading day present; 1W is a rolling 7 days.
Daily-only overlays/toggles suppress on intraday, leaving Volume, with a dashed
prev-close reference line.

Live-tick the trailing intraday bar from the existing SSE quotes: stream.js
re-broadcasts each quote as a finance:quote window event and chart.js moves the
last bar in place, plus a 60s local-DB re-fetch folds in new bars (no second
connection, no Yahoo call).

Mobile above-the-fold reorder: promote the full chart, make the health/quality
donut desktop-only, and add a flattened full-width verdict line under the price
(verdict, percent, trajectory) so the phone order is price then verdict then
chart.

Fix a latent bug found in passing: .ind-btn's display:inline-flex overrode the
[hidden] attribute, so the benchmark toggle never hid on benchmark-less symbols;
an .ind-btn[hidden] rule re-asserts display:none.
modified PLAN.md
@@ -34,7 +34,7 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-05-30 (Phase 5 done + deployed at `656e21d`)__Last updated: 2026-05-30 (Phase 6 done on dev; commit + deploy pending)_**Major refactor in progress (the "distill + ETF-first" rewrite).** This planwas fully rewritten 2026-05-30 from a sprawling 3,700-line resume doc into this
@@ -55,8 +55,50 @@ focused roadmap. The decisions driving it are in the Decisions log under  desktop) design while keeping the futuristic-clean "Paper Ledger" look.**Current work:** Phases 1–5 are **done, committed, and deployed** (Phase 5 islive at `656e21d`; production verified rendering the hero, breadth, and ETFbands). Next: **Phase 6 (symbol-page distillation + live intraday on chart).**live at `656e21d`). **Phase 6 is done on dev** (symbol-page distillation + liveintraday); commit + deploy pending. Next: **Phase 7 (health/systems pagedistillation + footer expansion + final polish).**Phase 6 outcome (symbol-page distillation + live intraday on chart):- **1D / 1W intraday range buttons.** Two new ranges at the front of the chart's  range bar serve today's / the week's 15-minute bars from `intraday_bars` on a  minute axis (the rest stay daily). `history_api` branches to `intraday_history`  for them: bars are stored as epoch-**ms** so each `ts` is ÷1000 to the UNIX  *seconds* lightweight-charts wants; a new `BarTime` untagged enum lets the  `candles` field carry either a `YYYY-MM-DD` string (daily) or a number  (intraday). 1D resolves the most recent NY trading day present (so a weekend  shows Friday); 1W is a rolling 7 days (well inside the 14-day retention). The  daily-only overlays (SMA/EMA/RSI/benchmark) come back empty and their toggles  hide on intraday — only Volume stays. A dashed **prev-close** reference line  (the latest daily close) anchors the session move.- **Live, without a second connection.** `stream.js` now re-broadcasts each SSE  quote as a `finance:quote` window event; `chart.js` listens and, while an  intraday range is shown, ticks the trailing bar's close/high/low in place. A  60s background re-fetch (local DB only, no Yahoo call) folds in newly-stored  15m bars so new bars appear without a click. (The live tick itself couldn't be  exercised on dev — today is a closed-market weekend so no quotes stream; the  wiring is structurally verified, like Phase 4's NAV fetch. It runs first in  prod during market hours.)- **Mobile above-the-fold reorder.** Per the user's call: the full interactive  chart is **promoted** above the fold (no separate mini-chart), and the  top-right health/quality **donut is desktop-only** — on mobile a flattened  full-width **verdict line** sits right under the price (`● Verdict · NN% ·  ↑ trajectory`, coloured by overall grade; the trajectory clause shows for a  stock, not an ETF). So the phone order is price/change → verdict → chart.- **Fixed a latent bug in passing:** `.ind-btn { display: inline-flex }` was  overriding the `[hidden]` attribute, so the benchmark toggle never actually  hid on benchmark-less symbols (and wouldn't hide the daily-only toggles on  intraday). Added an `.ind-btn[hidden] { display: none }` re-assert.- **Verified on dev:** `cargo build` + `vite build` clean; `/api/.../history?  range=1D|1W` returns `intraday:true` with numeric times + empty overlays +  prev_close, while `range=1Y` is unchanged (`intraday:false`, SMAs populated).  Screenshots reviewed (then deleted) at mobile (390) + desktop (1280): stock +  ETF; 1D shows intraday candles, the prev-close line, a "over 16 hours" span  caption, and only the Volume toggle; switching back to a daily range restores  the toggles; the mobile verdict line reads correctly (stock with trajectory,  ETF without) with the donut hidden, and desktop shows the donut with the  verdict line hidden. No new console errors (only the benign restart/stream  chunked-encoding noise).Phase 5 outcome (dashboard redesign → "how is the market doing TODAY"):- **Hero verdict.** A two-line plain read at the top blending the broad index
@@ -340,11 +382,18 @@ Kill the rate-limit problem at the root.- ✅ Clearly labeled bands, dual-first density: Hero · Indexes · Breadth · ETFs ·  Stock movers · Industries · Risk & commodities · Quality leaderboard.### Phase 6 — Symbol-page distillation + live intraday on chart- Mobile above-the-fold order: price/change → health verdict → mini chart →  trajectory. Desktop denser; health read is the clear hero.- A viewed fund during market hours shows today's real-time intraday on its  chart (current day), stitched onto the daily series.### Phase 6 — Symbol-page distillation + live intraday on chart  ✅ DONE on dev (commit + deploy pending)- ✅ Live intraday surfaced as **1D / 1W range buttons** (the user's call over a  single stitched-on candle): 15m bars from `intraday_bars` on a minute axis,  daily-only overlays/toggles suppressed, a dashed prev-close reference line.- ✅ Live tick of the trailing bar via a re-broadcast `finance:quote` event +  a 60s local-DB re-fetch — no second EventSource. (Live path runs first in  prod; a closed-market weekend can't stream quotes on dev.)- ✅ Mobile above-the-fold reorder: full chart **promoted** (no separate mini  chart, user's call); donut is desktop-only and a flattened full-width  **verdict line** (verdict · % · trajectory) sits under the price on mobile.  Order: price/change → verdict → chart.- ✅ Fixed a latent `.ind-btn[hidden]` override bug found in passing.### Phase 7 — Health/systems page distillation + final polish pass- Distill `/health` and overall cross-page cohesion; one closing UI polish pass.
@@ -405,6 +454,30 @@ Kill the rate-limit problem at the root.## Decisions log**2026-05-30 — Phase 6 (symbol-page distillation + live intraday).** Answered 3design forks before building:1. **Intraday = dedicated 1D + 1W range buttons** (not a single live candle   stitched onto every daily range, and not both). 15m bars on their own minute   axis, the conventional Yahoo/Google shape; the daily ranges stay daily. The   plan's "stitched onto the daily series" wording was reconciled this way —   15m granularity is invisible on a 1Y view, so a separate intraday tab is the   honest surface, with a prev-close reference line tying it to the daily basis.2. **Mobile = promote the full chart** above the fold (no separate mini chart),   so there is one chart with its range/indicator controls, just lifted higher.3. **Mobile health = a full-width verdict line** under the price (`● Verdict ·   NN% · trajectory ↑`); the donut ring stays the desktop treatment. Works for a   stock (with the trajectory clause) or an ETF (without).Implementation notes worth keeping: `intraday_bars.ts` is epoch-**ms**, so it is÷1000 for lightweight-charts' UNIX-**seconds** intraday axis; a `BarTime`untagged enum lets one `candles` field serialise a date string (daily) or anumber (intraday). The live tick reuses the existing SSE quotes via are-broadcast `finance:quote` window event — no second connection — plus a 60slocal-DB re-fetch for new bars. Found + fixed in passing: `.ind-btn`'s`display: inline-flex` was beating the `[hidden]` attribute, so neither thebenchmark toggle (on benchmark-less symbols) nor the daily-only toggles (onintraday) actually hid; an `.ind-btn[hidden]` rule re-asserts it. The live tickcouldn't be exercised on a closed-market weekend; it runs first in prod.**2026-05-30 — Phase 5 (dashboard redesign).** Answered 4 design forks beforebuilding:1. **Hero verdict = blended + a touch more.** A two-line read: a punchy lead
modified frontend/static_src/base/scripts/stream.js
@@ -154,6 +154,11 @@ function applyQuote(q) {    }  });  // Re-broadcast the quote as a window event (Phase 6) so the symbol chart can  // live-tick its last intraday bar without opening a second EventSource. The  // chart filters by ticker and only reacts while an intraday range is shown.  window.dispatchEvent(new CustomEvent("finance:quote", { detail: q }));  // A dashboard sparkline card just took a live quote, so its section's  // freshness caption ("prices as of …") is now current (Phase 22). Both live  // sections poll together each cycle, so refreshing them as one is honest.
modified frontend/static_src/symbol/scripts/chart.js
@@ -66,13 +66,45 @@ function fmtMoney(n) {  return `${sign}$${Math.abs(n).toFixed(2)}`;}// A bar's `time` is a `YYYY-MM-DD` string on the daily ranges and a UNIX-// seconds number on the intraday ranges (1D / 1W). `barMs` returns epoch-ms// for either, and `fmtBarTime` a human label — a plain date for daily bars, a// New-York date+time for intraday ones (so the measure readout reads sensibly// in both worlds).function barMs(t) {  return typeof t === "number" ? t * 1000 : Date.parse(t);}function fmtBarTime(t) {  if (typeof t !== "number") return t;  return new Date(t * 1000).toLocaleString("en-US", {    timeZone: "America/New_York",    month: "short",    day: "numeric",    hour: "numeric",    minute: "2-digit",  });}/** * A human caption for a visible span, e.g. "over 6 months", "over 8 years". * Derived from the actual dates shown rather than the range button, so a * deep MAX history clamped to what fits is described honestly. * Derived from the actual bars shown rather than the range button, so a deep * MAX history clamped to what fits is described honestly. Handles both the * daily date strings and the intraday UNIX-seconds times, scaling its unit * from hours (an intraday session) up to years. */function spanLabel(fromISO, toISO) {  const months = (Date.parse(toISO) - Date.parse(fromISO)) / 2.6298e9; // 30.44dfunction spanLabel(from, to) {  const ms = barMs(to) - barMs(from);  const hours = ms / 3.6e6;  if (hours < 20) {    const h = Math.max(1, Math.round(hours));    return `over ${h} hour${h === 1 ? "" : "s"}`;  }  const days = ms / 8.64e7;  if (days < 11) {    const d = Math.round(days);    return `over ${d} day${d === 1 ? "" : "s"}`;  }  const months = ms / 2.6298e9; // 30.44d  if (months < 1.6) return "over 1 month";  if (months < 11.5) return `over ${Math.round(months)} months`;  const years = months / 12;
@@ -146,6 +178,31 @@ export function initChart() {  let bars = []; // loaded candles, ascending by time  let latest = null; // last loaded payload, kept so RSI can attach on demand  // Prior-close reference line (Phase 6). The intraday ranges draw the previous  // daily close as a dashed guide so the session's move is legible at a glance;  // the daily ranges carry no `prev_close`, so the line is cleared on those.  let prevCloseLine = null;  function setPrevCloseLine(price) {    if (prevCloseLine) {      series.removePriceLine(prevCloseLine);      prevCloseLine = null;    }    if (price == null) return;    prevCloseLine = series.createPriceLine({      price,      color: "rgba(33,31,26,0.42)",      lineWidth: 1,      lineStyle: 2,      axisLabelVisible: true,      title: "prev close",    });  }  // 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"];  // 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.  let rsiSeries = null;
@@ -255,7 +312,7 @@ export function initChart() {      `<span class="chart-readout__pct">${up ? "▲" : "▼"} ` +      `${up ? "+" : ""}${pct.toFixed(2)}%</span>` +      `<span class="chart-readout__sub">${fmtMoney(absChange)} · ` +      `${bars[a].time} → ${bars[b].time}</span>`;      `${fmtBarTime(bars[a].time)} → ${fmtBarTime(bars[b].time)}</span>`;    readout.hidden = false;    // Center the readout over the band, clamped to the chart's width.
@@ -377,6 +434,20 @@ export function initChart() {      }))      .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));    earningsMarkers.setMarkers(markers);    // Phase 6: intraday ranges (1D / 1W) draw the prior close and drop the    // daily-only overlays + their toggles. Returning to a daily range restores    // them (RSI rebuilds its pane only if its toggle is still on).    setPrevCloseLine(d.intraday ? (d.prev_close ?? null) : null);    DAILY_ONLY_INDS.forEach((key) => {      const btn = document.querySelector(`[data-ind="${key}"]`);      if (btn) btn.hidden = !!d.intraday;    });    if (d.intraday) {      destroyRsi();    } else if (document.querySelector('[data-ind="rsi"]')?.classList.contains("is-active")) {      buildRsi();    }  }  // ── indicator toggles ──────────────────────────────────────────────────
@@ -412,12 +483,13 @@ export function initChart() {  });  // ── range buttons ──────────────────────────────────────────────────────  let loaded = null;  async function load(range) {    if (loaded === range) return;    loaded = range;    clearSelection();    el.classList.add("is-loading");  const isIntraday = (range) => range === "1D" || range === "1W";  // Fetch a range and paint it. `quiet` is the 60s intraday refresh: it skips  // the loading dim and keeps any measure selection, since it is just folding  // in newly-stored bars rather than answering a click.  async function reload(range, quiet) {    if (!quiet) el.classList.add("is-loading");    try {      const res = await fetch(        `/api/symbols/${encodeURIComponent(ticker)}/history?range=${range}`,
@@ -427,13 +499,65 @@ export function initChart() {      chart.timeScale().fitContent();      renderRangeSummary();    } catch (err) {      loaded = null;      if (!quiet) loaded = null;      console.error("chart load failed", err);    } finally {      el.classList.remove("is-loading");      if (!quiet) el.classList.remove("is-loading");    }  }  // While an intraday range is shown, re-pull every 60s so freshly-stored 15m  // bars appear without a click. The fetch only touches the local DB (no Yahoo  // call), and the live quote stream keeps the trailing bar moving in between.  let refreshTimer = null;  function stopRefresh() {    if (refreshTimer) {      clearInterval(refreshTimer);      refreshTimer = null;    }  }  let loaded = null;  async function load(range) {    if (loaded === range) return;    loaded = range;    clearSelection();    stopRefresh();    await reload(range, false);    if (isIntraday(range)) {      refreshTimer = setInterval(() => reload(range, true), 60000);    }  }  // Live-tick the trailing intraday bar from the shared quote stream (Phase 6):  // stream.js re-broadcasts each quote as a `finance:quote` event, so the chart  // moves the last bar's close/high/low in place without a second EventSource.  window.addEventListener("finance:quote", (e) => {    const q = e.detail;    if (!q || q.ticker !== ticker || q.price == null) return;    if (!latest || !latest.intraday || !bars.length) return;    const last = bars[bars.length - 1];    last.close = q.price;    if (q.price > last.high) last.high = q.price;    if (q.price < last.low) last.low = q.price;    series.update({      time: last.time,      open: last.open,      high: last.high,      low: last.low,      close: last.close,    });    volumeSeries.update({      time: last.time,      value: last.volume,      color: last.close >= last.open ? VOLUME_UP : VOLUME_DOWN,    });    renderRangeSummary();    if (anchorIdx !== null && curIdx !== null) renderSelection();  });  window.addEventListener("pagehide", stopRefresh);  const buttons = Array.from(document.querySelectorAll("[data-range]"));  buttons.forEach((btn) => {    btn.addEventListener("click", () => {
modified frontend/static_src/symbol/styles/symbol.scss
@@ -110,6 +110,64 @@  margin-left: 2px;}/* ---------- mobile health verdict line (Phase 6) ----------   The donut's read, flattened to a single "● Verdict · NN% · trajectory ↑"   row sitting directly under the price, where a phone reads it first. Hidden   at $bp-md, where the donut (below) is the desktop treatment. Coloured by the   overall grade; works for a stock (with a trajectory clause) or an ETF. */.sym-verdict {  grid-column: 1 / -1;  display: flex;  align-items: baseline;  flex-wrap: wrap;  gap: 4px var(--sp-3);  margin-top: -2px;  font-size: var(--fs-sm);}.sym-verdict__dot {  align-self: center;  width: 9px;  height: 9px;  border-radius: 50%;  background: var(--ink-faint);}.sym-verdict--good .sym-verdict__dot { background: var(--up); }.sym-verdict--ok   .sym-verdict__dot { background: var(--warn); }.sym-verdict--bad  .sym-verdict__dot { background: var(--down); }.sym-verdict__text {  @include eyebrow;  font-size: var(--fs-xs);  font-weight: 700;  color: var(--ink-dim);}.sym-verdict--good .sym-verdict__text { color: var(--up); }.sym-verdict--ok   .sym-verdict__text { color: var(--warn); }.sym-verdict--bad  .sym-verdict__text { color: var(--down); }.sym-verdict__pct {  @include mono;  font-weight: 700;  color: var(--ink-dim);}/* trajectory clause: a quiet annotation, divided off like .sym-head__asof */.sym-verdict__traj {  font-size: var(--fs-xs);  color: var(--ink-faint);  padding-left: var(--sp-3);  border-left: 1px solid var(--rule);}.sym-verdict__traj--good { color: var(--up); }.sym-verdict__traj--bad  { color: var(--down); }.sym-verdict__arrow {  @include mono;  margin-right: 3px;  font-weight: 700;}/* ---------- header health badge (Phase 17 redesign) ----------   A circular % donut anchors the header's top-right. The previous   full-width Stock health section is gone; the three sub-readings
@@ -125,6 +183,9 @@  align-self: center;  justify-self: end;  position: relative;  /* Phase 6: on phones the flattened .sym-verdict line carries the read; the     donut is the desktop treatment, switched back on at $bp-md below. */  display: none;}.health-badge {
@@ -310,9 +371,26 @@.range-bar {  display: flex;  flex-wrap: wrap;  align-items: center;  gap: var(--sp-1);}/* Phase 6: a hairline after 1W sets the intraday ranges (1D / 1W) apart from   the daily ones without a heavier divider element. */.range-btn--group-end {  margin-right: var(--sp-2);  position: relative;}.range-btn--group-end::after {  content: "";  position: absolute;  right: calc(var(--sp-1) * -1);  top: 50%;  transform: translateY(-50%);  height: 60%;  border-right: 1px solid var(--rule-strong);}/* the range-change chip: % and absolute move over the whole visible range */.range-summary {  display: flex;
@@ -381,6 +459,14 @@  transition: color 0.13s, border-color 0.13s;}/* `display: inline-flex` above would override the [hidden] attribute's   display:none, so re-assert it. Without this the benchmark toggle (hidden on   symbols with no benchmark) and the daily-only toggles (hidden on the 1D / 1W   intraday ranges, Phase 6) would still show. */.ind-btn[hidden] {  display: none;}.ind-btn:hover {  color: var(--ink-dim);  border-color: var(--rule-strong);
@@ -1670,11 +1756,17 @@ a.anomaly__link:hover .anomaly__body {      grid-column: 1;    }    .sym-head__health {      display: block;      grid-row: 1 / span 3;      align-self: start;    }  }  /* Desktop carries the donut, so the flattened mobile verdict line hides. */  .sym-verdict {    display: none;  }  .range-btn,  .ind-btn {    min-height: 34px;
modified src/routes/symbols.rs
@@ -1535,10 +1535,22 @@ struct HistoryQuery {    range: Option<String>,}/// One OHLCV point shaped for lightweight-charts (a `YYYY-MM-DD` `time`).#[derive(sqlx::FromRow, Serialize)]/// A bar's time on the chart axis. Daily bars are calendar dates/// (`YYYY-MM-DD`); intraday bars (the 1D / 1W ranges) are UNIX seconds, which/// is the other form lightweight-charts accepts for an intraday time scale./// `#[serde(untagged)]` so each variant serialises as a bare string or number/// — no tag wrapper the chart would have to unpick.#[derive(Serialize)]#[serde(untagged)]enum BarTime {    Date(String),    Unix(i64),}/// One OHLCV point shaped for lightweight-charts.#[derive(Serialize)]struct Candle {    time: String,    time: BarTime,    open: f64,    high: f64,    low: f64,
@@ -1591,6 +1603,15 @@ struct HistoryResponse {    /// a small ink dot above each matching bar. Stocks only; empty otherwise.    #[serde(skip_serializing_if = "Vec::is_empty")]    earnings: Vec<EarningsMarker>,    /// The prior daily close (Phase 6). Carried only for the intraday ranges,    /// where the chart draws it as a dashed reference line so the day's move is    /// legible against where the symbol opened the session.    #[serde(skip_serializing_if = "Option::is_none")]    prev_close: Option<f64>,    /// True when `candles` carry intraday (UNIX-seconds) times rather than    /// daily dates (Phase 6). The chart switches its axis/labels accordingly    /// and suppresses the daily-only overlays.    intraday: bool,}/// Earliest `YYYY-MM-DD` to *show* for a range button. `None` means no limit.
@@ -1620,6 +1641,15 @@ async fn history_api() -> Response {    let ticker = ticker.to_uppercase();    let range = q.range.unwrap_or_else(|| "1Y".to_string());    // The 1D / 1W ranges (Phase 6) draw today's real-time 15-minute bars on an    // intraday axis, live-ticked by the quote stream — a different data source    // (`intraday_bars`) and time format from the daily candles, so they take    // their own path and the daily indicator machinery never runs for them.    if range == "1D" || range == "1W" {        return intraday_history(&state.pool, &ticker, &range).await;    }    let display_cutoff = range_cutoff(&range);    // Indicators need history *before* the visible window or their first
@@ -1632,10 +1662,14 @@ async fn history_api(            .unwrap_or_else(|_| c.to_string())    });    let candles: Vec<Candle> = match &fetch_cutoff {    // Daily rows as tuples (date, OHLCV). The indicator maths and the benchmark    // overlay key off the date strings, so they are kept in a parallel `dates`    // vec and the `Candle`s (whose `time` is now the `BarTime` enum) are built    // from the same rows at the end.    let rows: Vec<(String, f64, f64, f64, f64, i64)> = match &fetch_cutoff {        Some(cutoff) => {            sqlx::query_as(                "SELECT d AS time, open, high, low, close, volume FROM daily_prices \                "SELECT d, open, high, low, close, volume FROM daily_prices \                 WHERE ticker = ? AND d >= ? ORDER BY d ASC",            )            .bind(&ticker)
@@ -1645,7 +1679,7 @@ async fn history_api(        }        None => {            sqlx::query_as(                "SELECT d AS time, open, high, low, close, volume FROM daily_prices \                "SELECT d, open, high, low, close, volume FROM daily_prices \                 WHERE ticker = ? ORDER BY d ASC",            )            .bind(&ticker)
@@ -1655,17 +1689,19 @@ async fn history_api(    }    .unwrap_or_default();    let dates: Vec<String> = rows.iter().map(|r| r.0.clone()).collect();    let closes: Vec<f64> = rows.iter().map(|r| r.4).collect();    // First bar inside the visible window; everything before it is lookback,    // fetched only so the indicators are correct from the very first shown bar.    let start = match &display_cutoff {        Some(c) => candles        Some(c) => dates            .iter()            .position(|b| b.time.as_str() >= c.as_str())            .unwrap_or(candles.len()),            .position(|d| d.as_str() >= c.as_str())            .unwrap_or(dates.len()),        None => 0,    };    let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();    // Zip a raw indicator series to its bar dates, dropping warm-up `None`s    // and the lookback bars, leaving only points inside the visible window.    let line = |series: Vec<Option<f64>>| -> Vec<LinePoint> {
@@ -1675,7 +1711,7 @@ async fn history_api(            .skip(start)            .filter_map(|(i, v)| {                v.map(|value| LinePoint {                    time: candles[i].time.clone(),                    time: dates[i].clone(),                    value,                })            })
@@ -1696,9 +1732,9 @@ async fn history_api(    .ok()    .flatten()    .flatten();    let benchmark = match (&benchmark_ticker, candles.get(start)) {        (Some(bench), Some(first_visible)) => {            load_benchmark_series(&state.pool, bench, &candles[start..], first_visible.close).await    let benchmark = match (&benchmark_ticker, dates.get(start)) {        (Some(bench), Some(_)) => {            load_benchmark_series(&state.pool, bench, &dates[start..], closes[start]).await        }        _ => Vec::new(),    };
@@ -1727,17 +1763,124 @@ async fn history_api(        sma200: line(compute::sma(&closes, 200)),        ema21: line(compute::ema(&closes, 21)),        rsi14: line(compute::rsi(&closes, 14)),        candles: candles.into_iter().skip(start).collect(),        candles: rows            .into_iter()            .skip(start)            .map(|(d, open, high, low, close, volume)| Candle {                time: BarTime::Date(d),                open,                high,                low,                close,                volume,            })            .collect(),        benchmark,        benchmark_ticker,        earnings,        prev_close: None,        intraday: false,    };    Json(resp).into_response()}/// Calendar days of intraday history the 1W range shows. `intraday_bars` is/// pruned to a 14-day window (see `INTRADAY_RETENTION_DAYS`), so a week sits/// comfortably inside what is stored.const INTRADAY_WEEK_DAYS: i64 = 7;/// Serve the 1D / 1W intraday ranges (Phase 6) from `intraday_bars`. The bars/// are stored as UTC epoch-*milliseconds*; lightweight-charts wants UNIX/// *seconds* for an intraday axis, so each `ts` is divided by 1000. 1D shows/// the most recent trading day present (so a weekend correctly shows Friday);/// 1W shows a rolling seven days. None of the daily-only overlays apply, so the/// indicator series come back empty and the chart hides their toggles.async fn intraday_history(pool: &sqlx::SqlitePool, ticker: &str, range: &str) -> Response {    use chrono::{TimeZone as _, Utc};    use chrono_tz::America::New_York;    let now_ms = chrono::Utc::now().timestamp_millis();    let cutoff_ms: i64 = if range == "1W" {        now_ms - INTRADAY_WEEK_DAYS * 86_400_000    } else {        // 1D: the New-York midnight that opens the most recent day with bars,        // so the view is exactly that session (and weekends show Friday).        let latest: Option<i64> =            sqlx::query_scalar("SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?")                .bind(ticker)                .fetch_optional(pool)                .await                .ok()                .flatten();        match latest {            Some(ms) => Utc                .timestamp_millis_opt(ms)                .single()                .map(|dt| {                    let day = dt.with_timezone(&New_York).date_naive();                    New_York                        .from_local_datetime(&day.and_hms_opt(0, 0, 0).unwrap())                        .single()                        .map(|midnight| midnight.timestamp_millis())                        .unwrap_or(ms)                })                .unwrap_or(now_ms),            None => now_ms,        }    };    let rows: Vec<(i64, f64, f64, f64, f64, i64)> = sqlx::query_as(        "SELECT ts, open, high, low, close, volume FROM intraday_bars \         WHERE ticker = ? AND ts >= ? ORDER BY ts ASC",    )    .bind(ticker)    .bind(cutoff_ms)    .fetch_all(pool)    .await    .unwrap_or_default();    let candles = rows        .into_iter()        .map(|(ts, open, high, low, close, volume)| Candle {            time: BarTime::Unix(ts / 1000),            open,            high,            low,            close,            volume,        })        .collect();    // The prior daily close anchors the session reference line. During a live    // session today's bar is not yet in `daily_prices`, so the most recent row    // is genuinely the previous close.    let prev_close: Option<f64> =        sqlx::query_scalar("SELECT close FROM daily_prices WHERE ticker = ? ORDER BY d DESC LIMIT 1")            .bind(ticker)            .fetch_optional(pool)            .await            .ok()            .flatten();    let resp = HistoryResponse {        candles,        sma50: Vec::new(),        sma200: Vec::new(),        ema21: Vec::new(),        rsi14: Vec::new(),        benchmark: Vec::new(),        benchmark_ticker: None,        earnings: Vec::new(),        prev_close,        intraday: true,    };    Json(resp).into_response()}/// Load a benchmark index's daily closes across the same date span as/// `visible` (the fund's visible candle slice), then scale each close so the/// `visible_dates` (the fund's visible candle dates), then scale each close so the/// series starts at `fund_anchor` — the fund's first visible close — and/// only the *relative* movement past that point is plotted. An empty/// benchmark history or no overlap returns an empty vec, which the
@@ -1745,20 +1888,20 @@ async fn history_api(async fn load_benchmark_series(    pool: &sqlx::SqlitePool,    benchmark: &str,    visible: &[Candle],    visible_dates: &[String],    fund_anchor: f64,) -> Vec<LinePoint> {    let Some(first) = visible.first() else {    let Some(first) = visible_dates.first() else {        return Vec::new();    };    let last = visible.last().unwrap_or(first);    let last = visible_dates.last().unwrap_or(first);    let rows: Vec<(String, f64)> = sqlx::query_as(        "SELECT d, close FROM daily_prices \         WHERE ticker = ? AND d >= ? AND d <= ? ORDER BY d ASC",    )    .bind(benchmark)    .bind(&first.time)    .bind(&last.time)    .bind(first)    .bind(last)    .fetch_all(pool)    .await    .unwrap_or_default();
modified templates/pages/symbol.html
@@ -59,6 +59,26 @@    </div>    {% endif %}    {# Mobile health verdict line (Phase 6): the same read the donut carries on       desktop, flattened into a one-line "verdict · % · trajectory" band that       sits directly under the price where a phone reads it first. Hidden on       desktop, where the donut (below) is the treatment. Works for a stock       (`health`, with a trajectory arrow) or an ETF (`etf_quality`, no       trajectory); a symbol only ever has one. #}    {% if health or etf_quality %}    {% set verdict = health if health else etf_quality %}    <div class="sym-verdict sym-verdict--{{ verdict.overall }}">      <span class="sym-verdict__dot" aria-hidden="true"></span>      <span class="sym-verdict__text">{{ verdict.verdict }}</span>      <span class="sym-verdict__pct num">{{ verdict.percent }}%</span>      {% if health and health.trajectory != 'unknown' %}      <span class="sym-verdict__traj sym-verdict__traj--{{ health.trajectory }}">        <span class="sym-verdict__arrow" aria-hidden="true">{% if health.trajectory == 'good' %}&uarr;{% elif health.trajectory == 'bad' %}&darr;{% else %}&rarr;{% endif %}</span>{{ health.trajectory_label }}      </span>      {% endif %}    </div>    {% endif %}    {# Stock health badge (Phase 17, redesigned): a circular % donut anchors       the top-right of the header. Hovering or focusing reveals the three       sub-readings (fundamentals / trajectory / leadership) — the previous
@@ -160,9 +180,12 @@  {% if stats %}  <section class="panel chart-panel">    <div class="chart-bar">      {# 1D / 1W are the intraday ranges (Phase 6): today's / the week's 15m         bars on a minute axis, live-ticked from the quote stream. The rest are         daily. A hairline after 1W sets the two groups apart. #}      <div class="range-bar">        {% for r in ["1M", "6M", "YTD", "1Y", "5Y", "MAX"] %}        <button type="button" class="range-btn{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button>        {% for r in ["1D", "1W", "1M", "6M", "YTD", "1Y", "5Y", "MAX"] %}        <button type="button" class="range-btn{% if r == '1W' %} range-btn--group-end{% endif %}{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button>        {% endfor %}      </div>      {# Filled by chart.js after each load: the % / absolute move over the