repos

Accuracy overhaul, sparkline overview, S&P-500 movers, tests

09005f6a by Isaac Bythewood · 10 days ago

Accuracy overhaul, sparkline overview, S&P-500 movers, tests

Accuracy:
- SEC: pin one us-gaap concept per metric so YoY growth never compares two
  definitions (the main "fundamentals are wrong" bug); extract pure select_facts.
- Honest data-age: gate ETF NAV premium on NAV freshness, drive the symbol
  header label off the quote's real age (Delayed vs false Live), MIN not MAX for
  the dashboard "prices as of" caption.
- Volume read prorated by elapsed session fraction (market::volume_session_fraction).
- TTM EPS requires four consecutive quarters; latest-FY advances only on
  income-statement metrics; prev_close falls back to the close before today.
- P/E reading text matches its one-decimal display.

Dead-ticker handling (SPCX, renamed to SPCK and delisted):
- tri-state resolve_one_cik (Found/Absent/Unavailable) stamps a genuinely-absent
  SEC ticker checked, so the page shows honest "no data / may be delisted" instead
  of a Refresh loop that never clears.
- "no recent trading data" banner when the last daily bar is >7 days old.

UI:
- Market overview redesigned to a Yahoo-style SVG sparkline grid (frontend-only;
  drops lightweight-charts from the home bundle). /api/dashboard unchanged.
- Market movers restricted to the S&P 500 (embedded universe/sp500.txt + src/sp500.rs).

Tests: 10 -> 31 (compute ratios/indicators, sec fiscal-year/classify/concept
pinning, market session+volume, sp500 membership, TTM consecutive-quarter guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
modified frontend/static_src/home/scripts/hero.js
@@ -1,31 +1,27 @@// The dashboard's market-overview + watchlist charts (Phase F).// The dashboard's market overview + watchlist (redesigned to a sparkline grid).//// Both the fixed market overview and the personal watchlist are drawn as the// same per-instrument chart card: one Schwab trading day (7 AM–8 PM ET), an// area line coloured green/red by the day's direction, pre-market / after-hours// shaded, and a headline value + % change vs the previous close (the// universally-quoted number). Everything comes from /api/dashboard, re-fetched// ~every minute and on tab focus. Overview cards are built here; watchlist cards// are server-rendered shells (for the link + remove button) that we draw into.import { createChart, BaselineSeries, ColorType } from "lightweight-charts";// Semantic day-direction colours (the Paper Ledger up/down inks) + soft fills.// The day chart is drawn as a BASELINE series anchored at the previous close, so// the line is green where price sits above yesterday's close and red where it// sits below — the Google-Finance / Robinhood read, far more honest than tinting// the whole line by the net day move.// Goal: a glanceable, Yahoo-Finance-style read of "how are the markets doing"// at one glance. Each instrument (the major indexes + gold, crude, bitcoin) and// each watchlist symbol is a small card: its name, its live value, the day's %// change, and a tiny non-interactive SVG sparkline of the day's path coloured// green/red vs the previous close. No interactive charts, no session badges, no// per-card chrome — the calm overview Isaac actually trusts. Everything comes// from /api/dashboard, re-fetched ~every 20s and on tab focus. The same card// shape is used for the fixed overview (built here) and the watchlist// (server-rendered shells we draw the sparkline into).// Semantic day-direction inks (Paper Ledger up/down) + soft area fills. The// sparkline is coloured by the day's direction vs the previous close, the// Google-Finance / Yahoo read.const UP = "#2f7d4f";const DOWN = "#b23b32";const UP_FILL_NEAR = "rgba(47, 125, 79, 0.16)";const UP_FILL_FAR = "rgba(47, 125, 79, 0)";const DOWN_FILL_NEAR = "rgba(178, 59, 50, 0.16)";const DOWN_FILL_FAR = "rgba(178, 59, 50, 0)";const REF = "rgba(33, 31, 26, 0.28)"; // dashed previous-close lineconst UP_FILL = "rgba(47, 125, 79, 0.13)";const DOWN_FILL = "rgba(178, 59, 50, 0.13)";const REF = "rgba(33, 31, 26, 0.28)"; // dashed previous-close baselineconst DASH = "·";// Arrow + sign + colour together: a colourblind-safe change indicator (WCAG 1.4.1// wants a second channel beyond colour). "▲ +1.96%" reads at a glance in greyscale.// Arrow + sign + colour together: a colourblind-safe change indicator (WCAG// 1.4.1 wants a second channel beyond colour). "▲ +1.96%" reads in greyscale.function fmtPctArrow(n) {  if (n == null || Number.isNaN(n)) return DASH;  const a = n > 0 ? "▲ " : n < 0 ? "▼ " : "";
@@ -55,12 +51,6 @@ function fmtPct(n) {    }) + "%"  );}function fmtSigned(n, unit) {  if (n == null || Number.isNaN(n)) return DASH;  const s = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });  const sign = n >= 0 ? "+" : "-";  return unit === "$" ? `${sign}$${s}` : `${sign}${s}`;}function fmtCompact(n) {  if (n == null || Number.isNaN(n)) return DASH;  const abs = Math.abs(n);
@@ -71,47 +61,6 @@ function fmtCompact(n) {}const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);// Sector-tile background: a green/red wash whose strength scales with the move,// clamped at ±3% (the de-facto heatmap scale Yahoo/Finviz use), so a +0.3% tile// is a faint tint and a +3%+ tile is saturated. Neutral wash when unknown.function sectorColor(pct) {  if (pct == null || Number.isNaN(pct)) return "var(--ink-wash, rgba(33, 31, 26, 0.05))";  const t = Math.max(-1, Math.min(1, pct / 3));  const a = (0.1 + 0.62 * Math.abs(t)).toFixed(3);  return t >= 0 ? `rgba(47, 125, 79, ${a})` : `rgba(178, 59, 50, ${a})`;}// 12-hour AM/PM ET clock for the axis ticks and crosshair (never 24-hour).function fmtAxisTime(tSec) {  return new Date(tSec * 1000).toLocaleTimeString("en-US", {    timeZone: "America/New_York",    hour: "numeric",    minute: "2-digit",    hour12: true,  });}function fmtWeekday(tSec) {  return new Date(tSec * 1000).toLocaleDateString("en-US", {    timeZone: "America/New_York",    weekday: "short",  });}// Axis tick formatter for the end-of-week full-week frame: label day-boundary// ticks (DayOfMonth and coarser) with the weekday, intraday ticks with the time,// so a Mon→Fri frame reads "Mon … 12 PM … Tue …" instead of repeating times.function fmtWeekTick(tSec, tickMarkType) {  return tickMarkType <= 2 ? fmtWeekday(tSec) : fmtAxisTime(tSec);}function fmtCrosshairTime(tSec) {  return new Date(tSec * 1000).toLocaleString("en-US", {    timeZone: "America/New_York",    month: "short",    day: "numeric",    hour: "numeric",    minute: "2-digit",    hour12: true,  });}function fmtClock(ms) {  if (!ms) return null;  return new Date(ms)
@@ -119,20 +68,6 @@ function fmtClock(ms) {    .replace(/\s/g, "")    .toLowerCase();}// ── freshness ────────────────────────────────────────────────────────────────// Per-card data age, as a short chip + a tone. The whole point of this is that a// quote is never *silently* stale: the card always says how old its number is, so// landing on an out-of-date dashboard reads as "2m ago", not a mystery.function fmtAge(ms) {  if (!ms) return { text: "—", tone: "stale" };  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));  if (s < 45) return { text: "live", tone: "live" };  if (s < 90) return { text: "1m ago", tone: "fresh" };  if (s < 3600) return { text: `${Math.round(s / 60)}m ago`, tone: s < 600 ? "fresh" : "stale" };  if (s < 86400) return { text: `${Math.round(s / 3600)}h ago`, tone: "stale" };  return { text: "stale", tone: "stale" };}function fmtAgo(ms) {  if (!ms) return "";  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
@@ -142,23 +77,83 @@ function fmtAgo(ms) {  return `${Math.round(s / 3600)}h ago`;}// Repaint one card's freshness chip from the asof epoch-ms stashed on it.function paintFresh(el) {  const ms = parseInt(el.dataset.asof || "", 10);  const { text, tone } = fmtAge(Number.isNaN(ms) ? 0 : ms);  el.textContent = text;  el.dataset.tone = tone;// Sector-tile background: a green/red wash whose strength scales with the move,// clamped at ±3% (the de-facto heatmap scale Yahoo/Finviz use). Neutral when// unknown.function sectorColor(pct) {  if (pct == null || Number.isNaN(pct)) return "var(--ink-wash, rgba(33, 31, 26, 0.05))";  const t = Math.max(-1, Math.min(1, pct / 3));  const a = (0.1 + 0.62 * Math.abs(t)).toFixed(3);  return t >= 0 ? `rgba(47, 125, 79, ${a})` : `rgba(178, 59, 50, ${a})`;}// Last shown value per ticker, so a card flashes when its number actually moves.const lastShown = new Map();// The freshest reads quote time (epoch-ms), so the header "updated Ns ago" ticks.let readsAsofMs = null;// ── sparkline ────────────────────────────────────────────────────────────────// Build a small, non-interactive SVG sparkline of one instrument's day: the// intraday line over the day's grid, coloured by direction vs the previous// close (`base`), with a faint dashed baseline at that close and a soft area// fill. preserveAspectRatio="none" stretches the fixed viewBox to the card; the// line keeps a crisp 1.5px stroke via vector-effect. Returns "" when there are// too few points to draw (the card then shows just its value + %).function sparkSvg(s) {  const pts = s.points || [];  if (pts.length < 2) return "";  const W = 100;  const H = 34;  const PAD = 2;  // Value range, widened to include the baseline so the dashed line always sits  // inside the frame.  let lo = Infinity;  let hi = -Infinity;  for (const p of pts) {    if (p.v < lo) lo = p.v;    if (p.v > hi) hi = p.v;  }  if (s.base != null) {    lo = Math.min(lo, s.base);    hi = Math.max(hi, s.base);  }  const span = hi - lo || 1;  // x by the bar's position in the day window so a half-day plots from the left  // rather than stretching across the full width.  const dt = s.end_t > s.start_t ? s.end_t - s.start_t : 1;  const x = (t) => (PAD + ((t - s.start_t) / dt) * (W - 2 * PAD)).toFixed(2);  const y = (v) => (PAD + (1 - (v - lo) / span) * (H - 2 * PAD)).toFixed(2);  const line = pts.map((p, i) => `${i ? "L" : "M"}${x(p.t)} ${y(p.v)}`).join(" ");  const first = x(pts[0].t);  const last = x(pts[pts.length - 1].t);  const area = `${line} L${last} ${H - PAD} L${first} ${H - PAD} Z`;  const color = s.up ? UP : DOWN;  const fill = s.up ? UP_FILL : DOWN_FILL;  const baseY = s.base != null ? y(s.base) : null;  const baseline =    baseY != null      ? `<line x1="${PAD}" y1="${baseY}" x2="${W - PAD}" y2="${baseY}" stroke="${REF}" stroke-width="0.5" stroke-dasharray="2 2" vector-effect="non-scaling-stroke"/>`      : "";  return (    `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">` +    `<path class="spark__area" d="${area}" fill="${fill}"/>` +    baseline +    `<path class="spark__line" d="${line}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>` +    `</svg>`  );}function setText(role, text) {  const el = document.querySelector(`[data-role="${role}"]`);  if (el && text != null) el.textContent = text;}function setTone(role, tone, prefix) {  const el = document.querySelector(`[data-role="${role}"]`);  if (!el || !tone) return;  [...el.classList].forEach((c) => {    if (c.startsWith(prefix)) el.classList.remove(c);  });  el.classList.add(prefix + tone);}// ── session countdown ────────────────────────────────────────────────────────// "Market closes in 2h 14m" in the banner: the next boundary on the fixed ET// schedule (no holiday calendar, by design — mirrors market.rs): weekdays// Pre 4:00 → Regular 9:30 → Post 16:00 → Closed 20:00; weekends closed.// schedule (no holiday calendar, by design — mirrors market.rs).const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };const PRE_OPEN = 4 * 60;const REG_OPEN = 9 * 60 + 30;
@@ -204,357 +199,21 @@ function nextSessionRead() {    ].find(([at]) => minutes < at);    if (next) return `${next[1]} in ${fmtSpan(next[0] - minutes)}`;  }  // Past today's last boundary, or a weekend: count to the next weekday's  // pre-market open (Friday evening → Monday).  const days = wd === 5 ? 3 : wd === 6 ? 2 : 1;  const span = (days - 1) * 1440 + (1440 - minutes) + PRE_OPEN;  return `Pre-market opens in ${fmtSpan(span)}`;}function setText(role, text) {  const el = document.querySelector(`[data-role="${role}"]`);  if (el && text != null) el.textContent = text;}function setTone(role, tone, prefix) {  const el = document.querySelector(`[data-role="${role}"]`);  if (!el || !tone) return;  [...el.classList].forEach((c) => {    if (c.startsWith(prefix)) el.classList.remove(c);  });  el.classList.add(prefix + tone);}// ── extended-hours shading ───────────────────────────────────────────────────// US regular session in ET minutes-of-day (9:30 AM – 4:00 PM). Everything else// is "extended" (pre-market / after-hours / overnight) and gets shaded.const REG_START = 9 * 60 + 30;const REG_END = 16 * 60;function etMinutes(tSec) {  const parts = new Intl.DateTimeFormat("en-US", {    timeZone: "America/New_York",    hour: "2-digit",    minute: "2-digit",    hour12: false,  }).formatToParts(new Date(tSec * 1000));  let h = 0;  let m = 0;  for (const p of parts) {    if (p.type === "hour") h = parseInt(p.value, 10);    else if (p.type === "minute") m = parseInt(p.value, 10);  }  if (h === 24) h = 0;  return h * 60 + m;}const isExtended = (tSec) => {  const x = etMinutes(tSec);  return x < REG_START || x >= REG_END;};// Shade the extended-hours spans behind a chart's line (pointer-transparent// overlay divs), recomputed on every relayout.function renderBands(entry) {  const box = entry.bandsEl;  if (!box) return;  box.innerHTML = "";  const times = entry.times;  if (!times || times.length < 2) return;  const ts = entry.chart.timeScale();  const w = entry.chartEl.clientWidth;  const coords = times.map((p) => ts.timeToCoordinate(p.t));  let sum = 0;  let n = 0;  for (let k = 1; k < coords.length; k++) {    if (coords[k] != null && coords[k - 1] != null) {      sum += coords[k] - coords[k - 1];      n++;    }  }  const half = (n ? sum / n : 6) / 2;  let i = 0;  while (i < times.length) {    if (!times[i].ext || coords[i] == null) {      i++;      continue;    }    let j = i;    while (j + 1 < times.length && times[j + 1].ext && coords[j + 1] != null) j++;    const left = Math.max(0, coords[i] - half);    const right = Math.min(w, coords[j] + half);    if (right > left) {      const band = document.createElement("div");      band.className = "ov-band";      band.style.left = `${left}px`;      band.style.width = `${right - left}px`;      box.appendChild(band);    }    i = j + 1;  }}// ── chart card ───────────────────────────────────────────────────────────────// Attach a lightweight-charts area chart to a card's `.ov-card__chart` mount.function attachChart(chartEl) {  const bandsEl = document.createElement("div");  bandsEl.className = "ov-bands";  chartEl.appendChild(bandsEl);  const chart = createChart(chartEl, {    autoSize: true,    handleScroll: false,    handleScale: false,    layout: {      background: { type: ColorType.Solid, color: "transparent" },      textColor: "#8a8372",      fontFamily: "'JetBrains Mono', monospace",      fontSize: 10,      attributionLogo: false,    },    grid: {      vertLines: { visible: false },      horzLines: { color: "rgba(33,31,26,0.05)" },    },    rightPriceScale: {      borderVisible: false,      scaleMargins: { top: 0.16, bottom: 0.08 },    },    timeScale: {      borderColor: "rgba(33,31,26,0.12)",      timeVisible: true,      secondsVisible: false,      tickMarkFormatter: (t) => fmtAxisTime(t),      // We pin the visible range to the full Schwab-day grid ourselves (see      // drawSeries); keep that pin across resizes instead of letting the chart      // re-fit to wherever the real data happens to sit.      lockVisibleTimeRangeOnResize: true,    },    crosshair: {      mode: 1,      vertLine: { labelVisible: true, width: 1, color: "rgba(33,31,26,0.25)", style: 3 },      horzLine: { labelVisible: true, color: "rgba(33,31,26,0.25)", style: 3 },    },    localization: { timeFormatter: (t) => fmtCrosshairTime(t) },  });  const series = chart.addSeries(BaselineSeries, {    // baseValue (the previous close) is set per-card in drawSeries.    baseValue: { type: "price", price: 0 },    topLineColor: UP,    topFillColor1: UP_FILL_NEAR,    topFillColor2: UP_FILL_FAR,    bottomLineColor: DOWN,    bottomFillColor1: DOWN_FILL_FAR,    bottomFillColor2: DOWN_FILL_NEAR,    lineWidth: 2,    priceLineVisible: false,    lastValueVisible: false,    crosshairMarkerRadius: 3,    crosshairMarkerBorderWidth: 0,    crosshairMarkerBackgroundColor: "#211f1a",  });  const entry = { chart, series, chartEl, bandsEl, refLine: null, times: [], points: [], unit: "pts" };  attachMeasure(entry);  // Keep the shading + measure band glued to the data on every relayout.  chart.timeScale().subscribeVisibleLogicalRangeChange(() => {    renderBands(entry);    if (entry.renderMeasure) entry.renderMeasure();  });  return entry;}// Click-drag measure tool: a shaded band + a readout chip showing the % and// value change between the two bars under the drag (the symbol chart's gesture,// ported to the mini charts). Snaps to real bars; suppresses the navigating// click on watchlist cards when a drag actually happened.function attachMeasure(entry) {  const el = entry.chartEl;  const band = document.createElement("div");  band.className = "ov-measure-band";  band.hidden = true;  const readout = document.createElement("div");  readout.className = "ov-measure-readout";  readout.hidden = true;  el.append(band, readout);  let dragging = false;  let anchorX = null;  let curX = null;  let moved = false;  const localX = (e) => e.clientX - el.getBoundingClientRect().left;  // Real (valued) bars and their current x-coordinates; whitespace is ignored.  function realCoords() {    const ts = entry.chart.timeScale();    const out = [];    for (const p of entry.points) {      const x = ts.timeToCoordinate(p.t);      if (x != null) out.push({ p, x });    }    return out;  }  function nearest(coords, x) {    let best = null;    let bd = Infinity;    for (const o of coords) {      const d = Math.abs(o.x - x);      if (d < bd) {        bd = d;        best = o;      }    }    return best;  }  function render() {    if (anchorX == null || curX == null) {      band.hidden = true;      readout.hidden = true;      return;    }    const coords = realCoords();    const a = nearest(coords, anchorX);    const b = nearest(coords, curX);    if (!a || !b || a.p.t === b.p.t) {      band.hidden = true;      readout.hidden = true;      return;    }    const left = Math.min(a.x, b.x);    const right = Math.max(a.x, b.x);    band.style.left = `${left}px`;    band.style.width = `${right - left}px`;    band.hidden = false;    const start = a.p.t < b.p.t ? a.p : b.p;    const end = a.p.t < b.p.t ? b.p : a.p;    const abs = end.v - start.v;    const pct = start.v !== 0 ? (abs / start.v) * 100 : 0;    const up = abs >= 0;    readout.dataset.dir = up ? "up" : "down";    readout.innerHTML =      `<span class="ov-measure__pct">${up ? "▲" : "▼"} ${up ? "+" : ""}${pct.toFixed(2)}%</span>` +      `<span class="ov-measure__sub">${fmtSigned(abs, entry.unit)}</span>`;    readout.hidden = false;    const mid = (left + right) / 2;    const rw = readout.offsetWidth;    const max = el.clientWidth - rw - 4;    readout.style.left = `${Math.min(max, Math.max(4, mid - rw / 2))}px`;  }  entry.renderMeasure = render;  el.addEventListener("pointerdown", (e) => {    if (!entry.points || entry.points.length < 2) return;    dragging = true;    moved = false;    anchorX = localX(e);    curX = anchorX;    try {      el.setPointerCapture(e.pointerId);    } catch {      /* ignore */    }    render();  });  el.addEventListener("pointermove", (e) => {    if (!dragging) return;    curX = localX(e);    if (Math.abs(curX - anchorX) > 3) moved = true;    render();  });  function end(e) {    if (!dragging) return;    dragging = false;    try {      el.releasePointerCapture(e.pointerId);    } catch {      /* ignore */    }    // A click with no drag clears the selection.    if (!moved) {      anchorX = null;      curX = null;      render();    }  }  el.addEventListener("pointerup", end);  el.addEventListener("pointercancel", end);  // Suppress the watchlist card's navigation when the pointerup ended a drag.  el.addEventListener(    "click",    (e) => {      if (moved) {        e.preventDefault();        e.stopPropagation();        moved = false;      }    },    true,  );}// Draw/update a series into an attached chart entry.function drawSeries(entry, s) {  entry.points = s.points; // real bars, for the measure tool  entry.unit = s.unit;  // Anchor the green/red split at the previous close, so the line reads above /  // below yesterday's close rather than one flat colour for the whole day.  entry.series.applyOptions({ baseValue: { type: "price", price: s.base } });  entry.chart.applyOptions({    // A full-week frame labels the axis by weekday; a single day, by time.    timeScale: { tickMarkFormatter: s.week ? fmtWeekTick : (t) => fmtAxisTime(t) },    localization: { priceFormatter: (v) => fmtValue(v, s.unit), timeFormatter: (t) => fmtCrosshairTime(t) },  });  // Frame every card on ONE identical x-axis so the small multiples line up: a  // fixed 15-minute grid spanning the whole Schwab day [start_t, end_t] (7 AM–8 PM  // ET). Each real 15m bar drops onto its grid slot (Yahoo's 15m bars land exactly  // on this grid); every empty slot stays whitespace — not just before the first  // bar and after the last, but ALSO any interior gap where Yahoo skipped an  // illiquid pre-/after-hours bar. Filling those interior gaps is the point:  // otherwise lightweight-charts collapses consecutive bars into adjacent slots,  // so a card missing a few extended-hours bars ends up with fewer slots and  // fitContent() stretches it differently — the same clock time would sit at a  // different x on each card. With the full grid every card has the same slot  // count and the same time at the same horizontal position.  const pts = s.points;  const GRID_STEP = 900; // 15 minutes, the intraday bar interval — shared by all cards  const slots = Math.max(0, Math.round((s.end_t - s.start_t) / GRID_STEP));  const valueAt = new Map();  for (const p of pts) {    const idx = Math.round((p.t - s.start_t) / GRID_STEP);    if (idx >= 0 && idx <= slots) valueAt.set(idx, p.v);  }  const data = [];  for (let idx = 0; idx <= slots; idx++) {    const t = s.start_t + idx * GRID_STEP;    const v = valueAt.get(idx);    data.push(v == null ? { time: t } : { time: t, value: v });  }  entry.series.setData(data);  entry.times = data.map((d) => ({ t: d.time, ext: isExtended(d.time) }));  if (entry.refLine) entry.series.removePriceLine(entry.refLine);  entry.refLine = entry.series.createPriceLine({    price: s.base,    color: REF,    lineWidth: 1,    lineStyle: 2,    axisLabelVisible: false,  });// Last shown value per ticker, so a card flashes when its number actually moves.const lastShown = new Map();// The freshest reads quote time (epoch-ms), so the header "updated Ns ago" ticks.let readsAsofMs = null;  // Pin the visible range to the WHOLE grid, not fitContent(): fitContent frames  // to wherever the real values sit, so a sparse card (e.g. BTC with only a few  // recent bars) zooms in differently than a full one — the exact drift we're  // killing. With every card showing the identical logical range [0 .. last slot],  // 7 AM (slot 0) sits flush at the left edge and 8 PM (last slot) at the right on  // every card, regardless of how many real bars it has.  entry.chart.timeScale().setVisibleLogicalRange({ from: 0, to: data.length - 1 });  renderBands(entry);}const escapeHtml = (s) =>  String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);// Update a card's header value + % pill, its session label, the week-to-date// move, and the freshness chip — and flash the card when the value actually moves.function setHead(root, s) {// Update a card's value + % pill and its sparkline, flashing when the value moves.function paintCard(root, s) {  const prev = lastShown.get(s.ticker);  const v = root.querySelector(".ov-card__value");
@@ -565,35 +224,9 @@ function setHead(root, s) {    c.classList.remove("is-up", "is-down", "is-flat");    c.classList.add(s.change_pct == null ? "is-flat" : s.change_pct >= 0 ? "is-up" : "is-down");  }  const chart = root.querySelector(".ov-card__chart");  if (chart) chart.innerHTML = sparkSvg(s);  // Session label badge ("Futures" / "Pre-market" / "After hours" / …): present  // only off-hours, so the regular-session number stays unlabelled.  const lab = root.querySelector(".ov-card__label");  if (lab) {    lab.textContent = s.headline_label || "";    lab.hidden = !s.headline_label;  }  // Week-to-date move beside the day move.  const wk = root.querySelector(".ov-card__week");  if (wk) {    wk.hidden = s.week_pct == null;    if (s.week_pct != null) {      wk.textContent = "wk " + fmtPct(s.week_pct);      wk.classList.remove("is-up", "is-down", "is-flat");      wk.classList.add(s.week_pct >= 0 ? "is-up" : "is-down");    }  }  // Freshness chip: stash the asof epoch-ms so the ticker can age it in place.  const fr = root.querySelector(".ov-card__fresh");  if (fr) {    fr.dataset.asof = s.asof || "";    paintFresh(fr);  }  // Flash the card when the displayed value genuinely changed between polls, so  // a live move is felt, not just silently swapped in.  if (prev != null && s.last != null && prev !== s.last) {    root.classList.remove("ov-flash-up", "ov-flash-down");    void root.offsetWidth; // reflow so the animation re-triggers
@@ -602,32 +235,23 @@ function setHead(root, s) {  if (s.last != null) lastShown.set(s.ticker, s.last);}const escapeHtml = (s) =>  String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);export function initHero() {  const overviewGrid = document.querySelector('[data-role="overview-grid"]');  const overviewCards = new Map(); // ticker -> { root, entry }  const watchCards = new Map(); // ticker -> { root, entry }  const overviewCards = new Map(); // ticker -> root  function makeOverviewCard(s) {    const root = document.createElement("div");    root.className = "ov-card";    root.dataset.ticker = s.ticker;    root.innerHTML =      `<div class="ov-card__head">` +      `<div class="ov-card__id"><span class="ov-card__name">${escapeHtml(s.name)}</span></div>` +      `<div class="ov-card__nums"><span class="ov-card__value num"></span>` +      `<span class="ov-card__chg num"></span></div></div>` +      `<div class="ov-card__meta">` +      `<span class="ov-card__label" hidden></span>` +      `<span class="ov-card__week num" hidden></span>` +      `<span class="ov-card__fresh" data-asof=""></span>` +      `<div class="ov-card__name">${escapeHtml(s.name)}</div>` +      `<div class="ov-card__nums">` +      `<span class="ov-card__value num"></span>` +      `<span class="ov-card__chg num"></span>` +      `</div>` +      `<div class="ov-card__chart"></div>`;    overviewGrid.appendChild(root);    const entry = attachChart(root.querySelector(".ov-card__chart"));    return { root, entry };    return root;  }  function drawOverview(list) {
@@ -641,44 +265,33 @@ export function initHero() {    const seen = new Set();    for (const s of list) {      seen.add(s.ticker);      let c = overviewCards.get(s.ticker);      if (!c) {        c = makeOverviewCard(s);        overviewCards.set(s.ticker, c);      let root = overviewCards.get(s.ticker);      if (!root) {        root = makeOverviewCard(s);        overviewCards.set(s.ticker, root);      }      setHead(c.root, s);      drawSeries(c.entry, s);      paintCard(root, s);    }    for (const [t, c] of overviewCards) {    for (const [t, root] of overviewCards) {      if (!seen.has(t)) {        c.entry.chart.remove();        c.root.remove();        root.remove();        overviewCards.delete(t);      }    }  }  // Watchlist cards are server-rendered shells; draw the chart into each and  // Watchlist cards are server-rendered shells; draw the sparkline into each and  // refresh its value/%. A card with no series (no intraday bars) keeps its  // server-rendered figures and simply shows no line.  function drawWatchlist(list) {    const byTicker = new Map((list || []).map((s) => [s.ticker, s]));    document.querySelectorAll(".watch-grid .ov-card").forEach((root) => {      const s = byTicker.get(root.dataset.ticker);      if (!s) return;      let c = watchCards.get(root.dataset.ticker);      if (!c) {        c = { root, entry: attachChart(root.querySelector(".ov-card__chart")) };        watchCards.set(root.dataset.ticker, c);      }      setHead(root, s);      drawSeries(c.entry, s);      if (s) paintCard(root, s);    });  }  // The sector heatmap: 11 tiles, each a link to the ETF, coloured by its move.  // Cheap to rebuild wholesale (11 nodes), and there is no per-tile animation to  // preserve, so a fresh innerHTML each poll keeps it simple.  function drawSectors(list) {    const grid = document.querySelector('[data-role="sectors-grid"]');    if (!grid || !list) return;
@@ -699,9 +312,6 @@ export function initHero() {  }  // ── market movers ──────────────────────────────────────────────────────────  // Top gainers / losers / most active from /api/movers (server-cached 8 min).  // Fetched independently of the dashboard poll, after first paint, so a cold  // pull never blocks the page.  function moverRow(m) {    const pct = m.change_pct;    const cls = pct == null ? "is-flat" : pct >= 0 ? "is-up" : "is-down";
@@ -712,7 +322,7 @@ export function initHero() {      `<span class="mv-row__name">${escapeHtml(m.name)}</span></span>` +      `<span class="mv-row__nums">` +      `<span class="mv-row__pct num ${cls}">${fmtPctArrow(pct)}</span>` +      `<span class="mv-row__sub num">${fmtValue(m.price, "$")} ${DASH} ${fmtCompact(m.volume)}</span>` +      `<span class="mv-row__sub num">${fmtValue(m.price, "$")}</span>` +      `</span></a>`    );  }
@@ -744,11 +354,9 @@ export function initHero() {  function patchReads(r) {    if (!r) return;    // Crash-response lead: S&P drawdown from its record close.    setText("drawdown-pct", r.drawdown_pct != null ? fmtPct(r.drawdown_pct) : DASH);    setTone("drawdown-pct", r.drawdown_tone || "steady", "read__tone--");    if (r.drawdown_label) setText("drawdown-label", r.drawdown_label);    // Credit stress (HYG day move).    setText("credit-pct", r.credit_pct != null ? fmtPct(r.credit_pct) : DASH);    setTone("credit-pct", r.credit_tone || "steady", "read__tone--");    if (r.credit_label) setText("credit-label", r.credit_label);
@@ -767,23 +375,13 @@ export function initHero() {    paintAsof();  }  // The header "Prices as of 3:42pm · updated 12s ago" caption, ticked in place  // so the "updated …" part counts up between polls instead of looking frozen.  // The header "Prices as of 3:42pm · updated 12s ago" caption, ticked in place.  function paintAsof() {    if (!readsAsofMs) return;    const clock = fmtClock(readsAsofMs);    if (clock) setText("reads-asof", `Prices as of ${clock} ${DASH} updated ${fmtAgo(readsAsofMs)}`);  }  // Age every card's freshness chip + the header caption, so a static dashboard  // visibly counts up rather than sitting at "live" forever.  function tickFreshness() {    document.querySelectorAll(".ov-card__fresh").forEach(paintFresh);    paintAsof();  }  // Toggle the "Refreshing…" indicator while an on-open / on-focus pull is in  // flight, so the wait for fresh quotes is visible instead of a silent stall.  function setRefreshing(on) {    const el = document.querySelector('[data-role="refresh-state"]');    if (el) el.hidden = !on;
@@ -816,9 +414,8 @@ export function initHero() {    patchSession(data.session);  }  // Land → show the stored figures at once, then kick a guarded refresh (which  // only re-hits Yahoo for anything older than the scheduler's throttle) with a  // visible "Refreshing…" state so the wait is never a mystery.  // Land → show stored figures at once, then kick a guarded refresh with a  // visible "Refreshing…" state so the wait for fresh quotes is never a mystery.  patchCountdown();  refresh();  setRefreshing(true);
@@ -829,23 +426,16 @@ export function initHero() {      refresh();    });  // Movers load after first paint (server-cached, so this is usually a cache hit)  // and refresh on a slow cadence aligned with the 8-minute server cache.  loadMovers();  // The /api/dashboard poll is a local DB read (no Yahoo call), so a tighter 20s  // cadence is free and keeps the cards close to the freshest stored quote.  const timer = setInterval(refresh, 20000);  // The countdown drifts a minute at a time; a 30s repaint keeps it honest.  const clockTimer = setInterval(patchCountdown, 30000);  // Age the freshness chips + header caption every few seconds.  const freshTimer = setInterval(tickFreshness, 5000);  // Movers change slowly and are server-cached; a 4-minute refresh is plenty.  const asofTimer = setInterval(paintAsof, 5000);  const moversTimer = setInterval(loadMovers, 240000);  document.addEventListener("visibilitychange", () => {    if (!document.hidden) {      patchCountdown();      tickFreshness();      paintAsof();      loadMovers();      setRefreshing(true);      fetch("/api/dashboard/refresh")
@@ -859,7 +449,7 @@ export function initHero() {  window.addEventListener("pagehide", () => {    clearInterval(timer);    clearInterval(clockTimer);    clearInterval(freshTimer);    clearInterval(asofTimer);    clearInterval(moversTimer);  });}
modified frontend/static_src/home/styles/home.scss
@@ -185,19 +185,6 @@  color: inherit;}.ov-card__head {  display: flex;  align-items: flex-start;  justify-content: space-between;  gap: var(--sp-2);}.ov-card__id {  display: flex;  flex-direction: column;  gap: 1px;  min-width: 0;}.ov-card__name {  @include serif;  font-size: var(--fs-md);
@@ -214,16 +201,29 @@  overflow: hidden;  text-overflow: ellipsis;}/* Watchlist card name: ticker bold with the company name trailing small on one   line, so the card head matches the overview cards' single-line name. */.ov-card__name--watch {  display: flex;  align-items: baseline;  gap: 6px;  min-width: 0;  color: var(--ink);}.ov-card__name--watch .ov-card__sub {  font-weight: 400;  flex: 0 1 auto;}/* Value + % on one baseline-aligned row — the at-a-glance read. */.ov-card__nums {  display: flex;  flex-direction: column;  align-items: flex-end;  gap: 4px;  flex: none;  align-items: baseline;  justify-content: space-between;  gap: var(--sp-2);}.ov-card__value {  font-size: var(--fs-md);  font-size: var(--fs-lg);  font-weight: 700;  line-height: 1;}
@@ -246,78 +246,6 @@  background: var(--down-soft);}/* ---------- card meta row: session label · week move · freshness ---------- */.ov-card__meta {  display: flex;  align-items: center;  gap: var(--sp-2);  margin-top: 6px;  min-height: 1em;  font-size: var(--fs-2xs);  line-height: 1;}/* Session badge ("Futures" / "Pre-market" / …) — neutral, informative, present   only off-hours so the regular-session number reads unlabelled. */.ov-card__label {  text-transform: uppercase;  letter-spacing: 0.04em;  font-weight: 700;  font-size: var(--fs-3xs, 0.625rem);  padding: 2px 6px;  border-radius: 999px;  background: var(--ink-wash, rgba(33, 31, 26, 0.06));  color: var(--ink-dim);  white-space: nowrap;}.ov-card__week {  font-weight: 600;  color: var(--ink-faint);  white-space: nowrap;}.ov-card__week.is-up {  color: var(--up);}.ov-card__week.is-down {  color: var(--down);}/* Freshness chip — pushed to the right; tone keys off data-tone. */.ov-card__fresh {  margin-left: auto;  color: var(--ink-faint);  white-space: nowrap;  display: inline-flex;  align-items: center;  gap: 4px;}.ov-card__fresh::before {  content: "";  width: 5px;  height: 5px;  border-radius: 50%;  background: currentColor;}.ov-card__fresh[data-tone="live"] {  color: var(--up);}.ov-card__fresh[data-tone="live"]::before {  animation: ov-fresh-pulse 1.8s ease-in-out infinite;}.ov-card__fresh[data-tone="fresh"] {  color: var(--ink-dim);}.ov-card__fresh[data-tone="stale"] {  color: var(--ink-faint);}@keyframes ov-fresh-pulse {  0%,  100% {    opacity: 1;  }  50% {    opacity: 0.25;  }}/* A real value move flashes the card briefly in the move's direction. */.ov-card.ov-flash-up {  animation: ov-flash-up 0.6s ease-out;
@@ -346,15 +274,18 @@  .ov-card.ov-flash-down {    animation: none;  }  .ov-card__fresh[data-tone="live"]::before {    animation: none;  }}/* The sparkline mount: a short, full-width SVG of the day's path. */.ov-card__chart {  position: relative;  width: 100%;  height: 156px;  height: 42px;}.spark {  display: block;  width: 100%;  height: 100%;}/* ---------- watchlist card extras (link + remove) ---------- */
@@ -396,75 +327,6 @@  opacity: 0.4;}/* Extended-hours (pre-market / after-hours / overnight) shading. A subtle,   non-semantic ink tint over those spans so the clear regular-session window   stands out. Pointer-transparent so the crosshair still reads through. The   bottom inset keeps the tint off the time-axis labels. */.ov-bands {  position: absolute;  top: 0;  right: 0;  bottom: 22px;  left: 0;  pointer-events: none;  overflow: hidden;}.ov-band {  position: absolute;  top: 0;  bottom: 0;  background: rgba(33, 31, 26, 0.05);}/* Click-drag measure tool (mirrors the symbol chart): a shaded band between two   bars + a readout chip with the % and value change. Both pointer-transparent. */.ov-measure-band {  position: absolute;  top: 0;  bottom: 22px;  z-index: 3;  background: rgba(33, 31, 26, 0.08);  border-left: 1px solid var(--rule-strong);  border-right: 1px solid var(--rule-strong);  pointer-events: none;}.ov-measure-band[hidden] {  display: none;}.ov-measure-readout {  position: absolute;  top: 4px;  z-index: 4;  display: flex;  flex-direction: column;  gap: 1px;  padding: 3px 7px;  background: var(--surface);  border: 1px solid var(--rule-strong);  border-radius: var(--radius-sm);  box-shadow: var(--lift-hover);  white-space: nowrap;  pointer-events: none;}.ov-measure-readout[hidden] {  display: none;}.ov-measure__pct {  @include mono;  font-size: var(--fs-xs);  font-weight: 700;}.ov-measure-readout[data-dir="up"] .ov-measure__pct {  color: var(--up);}.ov-measure-readout[data-dir="down"] .ov-measure__pct {  color: var(--down);}.ov-measure__sub {  @include mono;  font-size: var(--fs-2xs);  color: var(--ink-faint);}/* ---------- sector heatmap ("what's moving") ---------- */.sectors {
@@ -708,6 +570,18 @@  font-size: var(--fs-2xs);  color: var(--ink-faint);}/* "S&P 500" scope chip beside the Market movers title. */.movers__scope {  margin-left: 6px;  font-size: var(--fs-2xs);  font-weight: 600;  letter-spacing: 0.02em;  vertical-align: middle;  padding: 2px 7px;  border-radius: 999px;  background: var(--ink-wash, rgba(33, 31, 26, 0.06));  color: var(--ink-dim);}.movers__cols {  display: grid;  grid-template-columns: 1fr;
modified frontend/static_src/symbol/styles/symbol.scss
@@ -721,6 +721,22 @@  font-size: 0.82rem;}/* "no recent trading data" banner for a dormant / delisted / renamed symbol,   sitting above the header so it is read first (see STALE_DATA_DAYS). */.stale-banner {  margin: 4px 0 16px;  padding: 10px 14px;  border: 1px solid var(--warn);  border-left-width: 3px;  border-radius: 4px;  background: var(--warn-soft);  color: var(--ink);  font-size: 0.86rem;  line-height: 1.45;  strong { color: var(--warn); }}/* the rolled-up strong / fair / weak standing badge with a one-line reading,   sitting above the per-ratio grid (Phase 20) */.fund-standing {
modified src/compute.rs
@@ -204,13 +204,13 @@ fn pe(price: Option<f64>, eps: Option<f64>) -> Ratio {    // Below 10x the stock is cheap (a bargain, or a warning); 10-25x is the    // healthy band; 25-40x is paying up for growth; above 40x is steep.    let (grade, reading) = if v < 10.0 {        (Grade::Ok, format!("At {v:.0}x, the stock is priced cheaply against its profits: sometimes a bargain, sometimes a sign of trouble ahead."))        (Grade::Ok, format!("At {v:.1}x, the stock is priced cheaply against its profits: sometimes a bargain, sometimes a sign of trouble ahead."))    } else if v <= 25.0 {        (Grade::Good, format!("At {v:.0}x, the price is a reasonable multiple of the company's annual profit."))        (Grade::Good, format!("At {v:.1}x, the price is a reasonable multiple of the company's annual profit."))    } else if v < 40.0 {        (Grade::Ok, format!("At {v:.0}x, investors are paying up; a fair amount of future growth is already in the price."))        (Grade::Ok, format!("At {v:.1}x, investors are paying up; a fair amount of future growth is already in the price."))    } else {        (Grade::Bad, format!("At {v:.0}x, the price is steep relative to profit; the stock leans heavily on growth that has yet to arrive."))        (Grade::Bad, format!("At {v:.1}x, the price is steep relative to profit; the stock leans heavily on growth that has yet to arrive."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.1}x"), grade, reading)}
@@ -1842,4 +1842,79 @@ mod phase28_tests {        let evs = drawdown_anomalies(&closes, &date_refs);        assert!(evs.len() <= 5, "expected dedupe to keep events sparse, got {}", evs.len());    }    // ── fundamental ratios (the figures the owner most distrusts) ──────────────    #[test]    fn change_is_signed_percent_of_prior() {        let c = change(110.0, 100.0);        assert!((c.abs - 10.0).abs() < 1e-9);        assert!((c.pct - 10.0).abs() < 1e-9);        // A zero prior never divides by zero.        assert_eq!(change(5.0, 0.0).pct, 0.0);    }    #[test]    fn pe_bands_and_reading_precision() {        // A healthy multiple grades Good; the display carries one decimal.        let r = pe(Some(192.0), Some(9.6)); // 20.0x        assert!(matches!(r.grade, Grade::Good));        assert_eq!(r.display, "20.0x");        // Negative earnings → no P/E at all.        assert!(matches!(pe(Some(100.0), Some(-1.0)).grade, Grade::Unknown));        // The plain-English reading echoes the one-decimal value, not a rounded        // whole multiple (the bug where 9.6x read "At 10x …").        let cheap = pe(Some(96.0), Some(10.0)); // 9.6x        assert!(cheap.reading.contains("9.6x"), "reading was: {}", cheap.reading);    }    #[test]    fn revenue_growth_grades_direction() {        assert!(matches!(revenue_growth(Some(120.0), Some(100.0)).grade, Grade::Good)); // +20%        assert!(matches!(revenue_growth(Some(95.0), Some(100.0)).grade, Grade::Bad)); // shrinking        assert!(matches!(revenue_growth(Some(120.0), None).grade, Grade::Unknown));        assert!(matches!(revenue_growth(Some(120.0), Some(0.0)).grade, Grade::Unknown));    }    #[test]    fn earnings_growth_handles_loss_bases() {        // A growth % off a loss-making prior year is meaningless → Unknown.        assert!(matches!(earnings_growth(Some(50.0), Some(-10.0)).grade, Grade::Unknown));        // A swing to a loss from a profitable year is Bad.        assert!(matches!(earnings_growth(Some(-5.0), Some(100.0)).grade, Grade::Bad));        // Healthy profit growth is Good.        assert!(matches!(earnings_growth(Some(130.0), Some(100.0)).grade, Grade::Good));    }    #[test]    fn profit_margin_needs_positive_revenue() {        assert!(matches!(profit_margin(Some(10.0), Some(0.0)).grade, Grade::Unknown));        assert!(matches!(profit_margin(Some(20.0), Some(100.0)).grade, Grade::Good)); // 20%        assert!(matches!(profit_margin(Some(2.0), Some(100.0)).grade, Grade::Bad)); // 2%    }    // ── chart indicators ──────────────────────────────────────────────────────    #[test]    fn moving_averages_warm_up_then_track() {        let xs = [1.0, 2.0, 3.0, 4.0, 5.0];        let s = sma(&xs, 3);        assert_eq!(s[0], None);        assert_eq!(s[1], None);        assert_eq!(s[2], Some(2.0)); // (1+2+3)/3        assert_eq!(s[4], Some(4.0)); // (3+4+5)/3        // EMA seeds at the first full window's simple mean, then rises with the        // (monotonically increasing) series.        let e = ema(&xs, 3);        assert_eq!(e[1], None);        assert_eq!(e[2], Some(2.0));        assert!(e[4].unwrap() > e[2].unwrap());    }    #[test]    fn rsi_pegs_at_100_on_an_all_gains_window() {        let xs: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();        let r = rsi(&xs, 14);        assert_eq!(r[14], Some(100.0)); // no losses → RSI is 100    }}
modified src/main.rs
@@ -10,6 +10,7 @@ mod render;mod routes;mod scheduler;mod seed;mod sp500;mod stream;mod templates;mod watchlist;
@@ -79,7 +80,10 @@ async fn serve() -> anyhow::Result<()> {    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("finance listening on http://{addr}");    tracing::info!(        "finance listening on http://{addr} ({} S&P 500 movers names loaded)",        sp500::count()    );    axum::serve(listener, router).await?;    Ok(())}
modified src/market.rs
@@ -65,6 +65,26 @@ pub fn session_at(now: DateTime<Utc>) -> Session {    }}/// The share of a full trading day's volume that should have accumulated by/// `now`, for proration. During the regular session it is the fraction of the/// 09:30–16:00 ET session elapsed (floored at 0.02 so the first minutes do not/// divide by ~0); at every other time it is 1.0, because Yahoo's/// `regularMarketVolume` then reflects a *complete* session (the prior day's in/// pre-market, today's after the close). Dividing today's cumulative volume by/// `avg_full_day * this_fraction` compares it to the volume typically seen by/// this point in the day, instead of reading "light" all morning.pub fn volume_session_fraction(now: DateTime<Utc>) -> f64 {    match session_at(now) {        Session::Regular => {            let t = now.with_timezone(&New_York).time();            let elapsed = (t - at(9, 30)).num_seconds() as f64;            let total = (at(16, 0) - at(9, 30)).num_seconds() as f64;            (elapsed / total).clamp(0.02, 1.0)        }        _ => 1.0,    }}/// The `America/New_York` calendar date (`YYYY-MM-DD`) at `now`.// Retained past the Phase-A removal of the daily-close job: the Phase-C// dashboard resolves "today" / the most-recent trading day for the day graph.
@@ -89,3 +109,44 @@ pub fn is_et_weekday(now: DateTime<Utc>) -> bool {pub fn after_close(now: DateTime<Utc>) -> bool {    now.with_timezone(&New_York).time() >= at(16, 5)}#[cfg(test)]mod tests {    use super::*;    use chrono::TimeZone;    // June 2026 is EDT (UTC-4), so ET = UTC - 4h. 2026-06-24 is a Wednesday.    fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {        Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()    }    #[test]    fn session_at_maps_the_trading_day() {        assert_eq!(session_at(utc(2026, 6, 24, 12, 0)), Session::Pre); // 08:00 ET        assert_eq!(session_at(utc(2026, 6, 24, 13, 30)), Session::Regular); // 09:30 ET open        assert_eq!(session_at(utc(2026, 6, 24, 17, 0)), Session::Regular); // 13:00 ET        assert_eq!(session_at(utc(2026, 6, 24, 20, 0)), Session::Post); // 16:00 ET close        assert_eq!(session_at(utc(2026, 6, 24, 1, 0)), Session::Closed); // overnight        assert_eq!(session_at(utc(2026, 6, 27, 17, 0)), Session::Closed); // Saturday    }    #[test]    fn volume_fraction_prorates_only_during_the_regular_session() {        // Pre-market: regularMarketVolume is the prior full session → 1.0.        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 12, 0)), 1.0);        // Midday (13:00 ET): 3.5h of a 6.5h session elapsed ≈ 0.538.        let mid = volume_session_fraction(utc(2026, 6, 24, 17, 0));        assert!((mid - 3.5 / 6.5).abs() < 1e-6, "midday fraction was {mid}");        // After hours and weekends are a complete session → 1.0.        assert_eq!(volume_session_fraction(utc(2026, 6, 24, 21, 0)), 1.0);        assert_eq!(volume_session_fraction(utc(2026, 6, 27, 17, 0)), 1.0);    }    #[test]    fn volume_fraction_floors_at_the_open() {        // Right at the open the elapsed fraction is floored (not ~0) so the        // morning ratio does not divide by near-zero and explode.        let at_open = volume_session_fraction(utc(2026, 6, 24, 13, 30));        assert!(at_open >= 0.02, "expected a floor, got {at_open}");    }}
modified src/models.rs
@@ -191,7 +191,14 @@ fn latest_annual_inputs_filtered(    for f in facts {        if f.fiscal_qtr.is_none() && keep(f) {            annual.insert((f.metric.as_str(), f.fiscal_year), f.value);            latest_fy = Some(latest_fy.map_or(f.fiscal_year, |y| y.max(f.fiscal_year)));            // Only an income-statement metric may advance the "latest fiscal            // year" the ratios key off. A stray balance-sheet or dividend figure            // tagged a year ahead (common right around a filing) would otherwise            // make `latest_fy` a year the core figures aren't in yet, blanking            // every ratio instead of reading the most recent complete year.            if matches!(f.metric.as_str(), "revenue" | "net_income" | "eps_diluted") {                latest_fy = Some(latest_fy.map_or(f.fiscal_year, |y| y.max(f.fiscal_year)));            }        }    }    let fy = latest_fy?;
modified src/providers/sec.rs
@@ -290,6 +290,27 @@ fn classify(e: &UnitEntry, fye_month: u32) -> Option<(String, i64, Option<i64>)>    }}/// One candidate us-gaap concept's classified series for a single metric, used/// while choosing which concept to pin in `facts`. `rank` is the concept's/// index in the metric's candidate list (lower = more preferred); `facts` maps/// each period label to the chosen fact for that period.struct ConceptSeries {    rank: usize,    facts: HashMap<String, Fact>,}impl ConceptSeries {    /// The newest `period_end` across the series, for picking the concept whose    /// data reaches furthest forward. Only built for non-empty series.    fn newest_end(&self) -> &str {        self.facts            .values()            .map(|f| f.period_end.as_str())            .max()            .unwrap_or("")    }}// ── submissions ────────────────────────────────────────────────────────────#[derive(Deserialize)]
@@ -368,20 +389,48 @@ impl FundamentalsProvider for SecProvider {            return Ok(Vec::new()); // 404: company has no XBRL facts        };        let body: CompanyFacts = resp.json().await?;        Ok(select_facts(&body, chrono::Utc::now().year() as i64))    }        let fye_month = fiscal_year_end_month(&body);        let this_year = chrono::Utc::now().year() as i64;        // Collapse to one fact per (metric, period): a metric can be reported        // under several concepts and restated across filings, so the latest        // `filed` wins.        let mut chosen: HashMap<(String, String), Fact> = HashMap::new();    async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>> {        let url = format!("https://data.sec.gov/submissions/CIK{cik}.json");        let Some(resp) = self.get(&url).await? else {            return Ok(Vec::new()); // 404: no submission history        };        let body: Submissions = resp.json().await?;        Ok(select_filings(body, cik))    }}/// The pure core of [`SecProvider::facts`]: turn a parsed `companyfacts` body/// into our normalised facts, given the current year (passed in for test/// determinism). Pins one us-gaap concept per metric so the whole series shares/// one definition — see the loop below. Extracted so the concept-pinning logic/// is unit-testable without a network round trip.fn select_facts(body: &CompanyFacts, this_year: i64) -> Vec<Fact> {        let fye_month = fiscal_year_end_month(body);        // For each metric, pin ONE us-gaap concept and read the whole series        // from it. The candidate concepts for a metric are NOT interchangeable        // (e.g. `Revenues` can bundle items the contract-revenue tag excludes),        // so picking the newest-filed value per period across all candidates can        // silently source FY2024 from one concept and FY2023 from another, and        // the year-over-year growth then compares two different definitions. By        // emitting only the pinned concept's facts, every period of a metric        // shares one definition: a year the company did not report under the        // pinned concept is left absent (growth reads "no data") rather than        // computed against a mismatched figure. Restatements within the pinned        // concept are still collapsed to the latest `filed`. See `ConceptSeries`.        let mut out: Vec<Fact> = Vec::new();        for (metric, concepts) in METRIC_CONCEPTS {            for concept in *concepts {            let mut candidates: Vec<ConceptSeries> = Vec::new();            for (rank, concept) in concepts.iter().enumerate() {                let Some(concept_data) = body.facts.us_gaap.get(*concept) else {                    continue;                };                // This concept's in-window series, deduped by period (a later                // filing's restated value wins, by `filed`).                let mut series: HashMap<String, Fact> = HashMap::new();                for (unit, entries) in &concept_data.units {                    for e in entries {                        let Some((period, fiscal_year, fiscal_qtr)) = classify(e, fye_month)
@@ -397,13 +446,13 @@ impl FundamentalsProvider for SecProvider {                        if fiscal_year < keep_since {                            continue;                        }                        let key = (metric.to_string(), period.clone());                        let newer = chosen.get(&key).map_or(true, |prev| {                            e.filed.as_deref().unwrap_or("") > prev.filed_at.as_deref().unwrap_or("")                        let newer = series.get(&period).map_or(true, |prev: &Fact| {                            e.filed.as_deref().unwrap_or("")                                > prev.filed_at.as_deref().unwrap_or("")                        });                        if newer {                            chosen.insert(                                key,                            series.insert(                                period.clone(),                                Fact {                                    metric: metric.to_string(),                                    period,
@@ -419,19 +468,31 @@ impl FundamentalsProvider for SecProvider {                        }                    }                }                if !series.is_empty() {                    candidates.push(ConceptSeries { rank, facts: series });                }            }            // Pin the best candidate: the concept whose series reaches the most            // recent period, then the one covering the most periods, then the            // earliest-listed (most-preferred) concept. Emit only its facts.            if let Some(best) = candidates.into_iter().max_by(|a, b| {                a.newest_end()                    .cmp(b.newest_end())                    .then_with(|| a.facts.len().cmp(&b.facts.len()))                    .then_with(|| b.rank.cmp(&a.rank)) // lower rank (preferred) wins ties            }) {                out.extend(best.facts.into_values());            }        }        Ok(chosen.into_values().collect())    }        out}    async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>> {        let url = format!("https://data.sec.gov/submissions/CIK{cik}.json");        let Some(resp) = self.get(&url).await? else {            return Ok(Vec::new()); // 404: no submission history        };        let body: Submissions = resp.json().await?;        let r = body.filings.recent;/// The pure core of [`SecProvider::filings`]: pick the material filings out of a/// parsed submissions body, newest first, capped at `MAX_FILINGS`. Extracted to/// mirror `select_facts` (the network fetch lives in the method).fn select_filings(body: Submissions, cik: &str) -> Vec<FilingRecord> {    let r = body.filings.recent;        // EDGAR pads the CIK to 10 digits; the Archives path uses it unpadded.        let cik_int = cik.trim_start_matches('0');
@@ -481,8 +542,7 @@ impl FundamentalsProvider for SecProvider {                break;            }        }        Ok(out)    }    out}// ── ETF fund profiles: N-PORT holdings, AUM, filing history (Phase 18) ─────
@@ -1169,3 +1229,145 @@ fn parse_ownership(xml: &[u8]) -> Vec<OwnershipPerson> {        })        .collect()}#[cfg(test)]mod tests {    use super::*;    fn d(s: &str) -> NaiveDate {        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()    }    #[test]    fn normalize_ticker_strips_punctuation() {        assert_eq!(normalize_ticker("BRK.B"), "BRKB");        assert_eq!(normalize_ticker("BF-B"), "BFB");        assert_eq!(normalize_ticker("aapl"), "AAPL");    }    #[test]    fn fiscal_year_of_handles_non_calendar_year_end() {        // September fiscal-year end: an Oct–Dec quarter is Q1 of the NEXT FY.        assert_eq!(fiscal_year_of(d("2024-11-30"), 9), 2025);        assert_eq!(fiscal_year_of(d("2024-08-31"), 9), 2024);        // Calendar fiscal year: the period's calendar year is its fiscal year.        assert_eq!(fiscal_year_of(d("2024-12-31"), 12), 2024);    }    /// Build a duration UnitEntry (an income-statement fact).    fn dur(start: &str, end: &str, val: f64, fp: &str, form: Option<&str>) -> UnitEntry {        UnitEntry {            start: Some(start.to_string()),            end: end.to_string(),            val,            fp: Some(fp.to_string()),            form: form.map(str::to_string),            filed: Some("2025-02-01".to_string()),        }    }    /// Build an instantaneous UnitEntry (a balance-sheet snapshot).    fn inst(end: &str, val: f64, fp: &str, form: Option<&str>) -> UnitEntry {        UnitEntry {            start: None,            end: end.to_string(),            val,            fp: Some(fp.to_string()),            form: form.map(str::to_string),            filed: Some("2025-02-01".to_string()),        }    }    #[test]    fn classify_keeps_full_years_and_discrete_quarters() {        // Full fiscal year (≈365-day duration, fp FY).        assert_eq!(            classify(&dur("2024-01-01", "2024-12-31", 100.0, "FY", Some("10-K")), 12),            Some(("FY2024".to_string(), 2024, None))        );        // Discrete quarter (≈90-day duration).        assert_eq!(            classify(&dur("2024-01-01", "2024-03-31", 25.0, "Q1", Some("10-Q")), 12),            Some(("Q1-2024".to_string(), 2024, Some(1)))        );        // Year-end balance from an annual report (instantaneous, fp FY, 10-K).        assert_eq!(            classify(&inst("2024-12-31", 500.0, "FY", Some("10-K")), 12),            Some(("FY2024".to_string(), 2024, None))        );    }    #[test]    fn classify_drops_ytd_rollups_and_quarterly_balances() {        // A 6-month year-to-date roll-up is neither a quarter nor a full year.        assert_eq!(            classify(&dur("2024-01-01", "2024-06-30", 50.0, "Q2", Some("10-Q")), 12),            None        );        // A balance-sheet snapshot from a 10-Q is dropped (it would mislabel a        // prior year-end comparative as the filing's own quarter).        assert_eq!(            classify(&inst("2024-03-31", 480.0, "Q1", Some("10-Q")), 12),            None        );    }    /// Parse a `companyfacts`-shaped JSON body for the select_facts tests.    fn body(json: &str) -> CompanyFacts {        serde_json::from_str(json).expect("valid companyfacts json")    }    #[test]    fn select_facts_pins_one_concept_per_metric() {        // Revenue is reported under TWO non-interchangeable concepts. The newer        // concept (RevenueFromContract…) covers FY2023 AND FY2024; the legacy        // `Revenues` covers FY2023 only, with a DIFFERENT value. The pinned        // concept must be the one reaching the most recent period, and BOTH its        // years must come from it — so FY2023 is 1000 (its value), never 999.        let b = body(            r#"{              "facts": { "us-gaap": {                "RevenueFromContractWithCustomerExcludingAssessedTax": { "units": { "USD": [                  {"start":"2023-01-01","end":"2023-12-31","val":1000,"fp":"FY","form":"10-K","filed":"2024-02-01"},                  {"start":"2024-01-01","end":"2024-12-31","val":1200,"fp":"FY","form":"10-K","filed":"2025-02-01"}                ] } },                "Revenues": { "units": { "USD": [                  {"start":"2023-01-01","end":"2023-12-31","val":999,"fp":"FY","form":"10-K","filed":"2024-02-01"}                ] } }              } }            }"#,        );        let facts = select_facts(&b, 2025);        let rev: std::collections::HashMap<i64, f64> = facts            .iter()            .filter(|f| f.metric == "revenue" && f.fiscal_qtr.is_none())            .map(|f| (f.fiscal_year, f.value))            .collect();        assert_eq!(rev.get(&2024), Some(&1200.0));        assert_eq!(            rev.get(&2023),            Some(&1000.0),            "FY2023 must come from the SAME pinned concept, not the mismatched 999"        );        // Year-over-year growth is then a like-for-like comparison.        let growth: f64 = (1200.0 - 1000.0) / 1000.0 * 100.0;        assert!((growth - 20.0).abs() < 1e-9);    }    #[test]    fn select_facts_takes_latest_restatement_within_a_concept() {        // The same period reported twice; the later `filed` wins.        let b = body(            r#"{              "facts": { "us-gaap": {                "NetIncomeLoss": { "units": { "USD": [                  {"start":"2024-01-01","end":"2024-12-31","val":300,"fp":"FY","form":"10-K","filed":"2025-02-01"},                  {"start":"2024-01-01","end":"2024-12-31","val":280,"fp":"FY","form":"10-K","filed":"2025-06-01"}                ] } }              } }            }"#,        );        let facts = select_facts(&b, 2025);        let ni = facts.iter().find(|f| f.metric == "net_income" && f.fiscal_year == 2024);        assert_eq!(ni.map(|f| f.value), Some(280.0), "the later-filed restatement (280) wins over 300");    }}
modified src/routes/home.rs
@@ -527,8 +527,14 @@ async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) ->/// fetched when the dashboard is open and the cache has aged out.const MOVERS_TTL_MS: i64 = 8 * 60 * 1000;const MOVERS_META_KEY: &str = "movers_json";/// Rows per movers list.const MOVERS_COUNT: u32 = 10;/// Rows shown per movers list.const MOVERS_COUNT: usize = 10;/// Rows pulled per Yahoo screener before filtering to the S&P 500. Yahoo's/// predefined screeners rank the *whole* market, most of it micro-caps the user/// has never heard of, so we pull a wide slice and keep only the S&P 500 names/// (then the top `MOVERS_COUNT`). Wide enough that a normal day still yields ten/// large-cap movers per list.const MOVERS_FETCH_COUNT: u32 = 100;/// The three market-movers lists for the dashboard's "what's driving it" tables.#[derive(Serialize, Deserialize)]
@@ -592,10 +598,14 @@ async fn fetch_movers_fresh(state: &AppState, now: i64) -> Option<MoversData> {            // caller serve the cache rather than push against the guard.            _ => break,        }        match yahoo.fetch_movers(scr, MOVERS_COUNT).await {            Ok(rows) => {        match yahoo.fetch_movers(scr, MOVERS_FETCH_COUNT).await {            Ok(mut rows) => {                let _ = guard.record_success().await;                any = true;                // Keep only S&P 500 names, then the top MOVERS_COUNT, so the                // lists read as recognizable large caps rather than micro-caps.                rows.retain(|m| crate::sp500::is_member(&m.symbol));                rows.truncate(MOVERS_COUNT);                match slot {                    0 => out.gainers = rows,                    1 => out.losers = rows,
@@ -631,15 +641,19 @@ fn session_label(s: market::Session) -> &'static str {/// (else the latest stored daily close), the close before it, and the epoch-ms/// the quote was sourced at (for the freshness chip).async fn quote_row(state: &AppState, ticker: &str) -> (Option<f64>, Option<f64>, Option<i64>) {    let today = market::et_date(chrono::Utc::now());    sqlx::query_as(        "SELECT \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \              ORDER BY d DESC LIMIT 1)), \           s.last_quote_at \         FROM symbols s WHERE s.ticker = ?",    )    .bind(&today)    .bind(ticker)    .fetch_optional(&state.pool)    .await
@@ -866,7 +880,11 @@ async fn market_reads(state: &AppState) -> MarketReads {            .ok()            .flatten();            if let Some(avg) = avg.filter(|a| *a > 0.0) {                let ratio = today as f64 / avg;                // `today` is cumulative volume *so far*. Compare it to the volume                // typically seen by this point in the session, not the whole-day                // average, so the read is not structurally "Light" all morning.                let frac = market::volume_session_fraction(chrono::Utc::now());                let ratio = today as f64 / (avg * frac);                r.volume_ratio = Some(ratio);                r.volume_label = Some(                    if ratio >= 1.15 {
@@ -938,9 +956,11 @@ async fn market_reads(state: &AppState) -> MarketReads {        }    }    // Freshest quote across the baseline reads, for the "prices as of" caption.    // Age of the *oldest* baseline read, for the "prices as of" caption. MIN,    // not MAX: the caption asserts every read is at least this fresh, so a just-    // refreshed SPY must not make a stale VIX or drawdown read as current.    r.asof = sqlx::query_scalar(        "SELECT MAX(fetched_at) FROM quotes WHERE ticker IN (?, ?, ?)",        "SELECT MIN(fetched_at) FROM quotes WHERE ticker IN (?, ?, ?)",    )    .bind(BASELINE)    .bind(VIX)
@@ -957,14 +977,18 @@ async fn market_reads(state: &AppState) -> MarketReads {/// The latest price and the prior close for one symbol: the live last price/// (else the latest stored daily close) and the close before it.async fn last_and_prev(state: &AppState, ticker: &str) -> Option<(Option<f64>, Option<f64>)> {    let today = market::et_date(chrono::Utc::now());    sqlx::query_as(        "SELECT \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \              ORDER BY d DESC LIMIT 1)) \         FROM symbols s WHERE s.ticker = ?",    )    .bind(&today)    .bind(ticker)    .fetch_optional(&state.pool)    .await
@@ -982,16 +1006,21 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> {    }    // One query for the price rows; the `IN` placeholder count matches `tickers`.    type SparkRow = (String, String, String, Option<f64>, Option<f64>);    let today = market::et_date(chrono::Utc::now());    let placeholders = vec!["?"; tickers.len()].join(",");    let sql = format!(        "SELECT s.ticker, s.name, s.kind, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \              ORDER BY d DESC LIMIT 1)) \         FROM symbols s WHERE s.ticker IN ({placeholders})"    );    let mut q = sqlx::query_as::<_, SparkRow>(&sql);    // The `?` in the prev-close subquery precedes the IN-list placeholders, so    // bind today's ET date first, then the tickers.    let mut q = sqlx::query_as::<_, SparkRow>(&sql).bind(&today);    for t in tickers {        q = q.bind(*t);    }
modified src/routes/symbols.rs
@@ -255,17 +255,32 @@ struct HeaderQuote {struct QuoteRow {    price: f64,    prev_close: Option<f64>,}/// A short freshness label for the symbol header. Yahoo's chart endpoint does/// not carry a market-state field, so this comes from our own session clock/// (`market.rs`) rather than the quote.fn quote_state_label() -> &'static str {    /// When this quote was sourced (epoch-ms), so the header's freshness label    /// reflects the quote's real age, not just the wall-clock session.    fetched_at: Option<i64>,}/// How recent a stored quote must be to read as current during an open session./// The intraday poll refreshes watched symbols about every 5 minutes and the/// home sweep every 15; past this a quote is no longer "live", and the header/// says so rather than asserting a freshness the number does not have.const QUOTE_FRESH_MS: i64 = 15 * 60 * 1000;/// A short freshness label for the symbol header, honest about the quote's age./// Yahoo's chart endpoint carries no market-state field, so the *session* comes/// from our own clock (`market.rs`); the *freshness* comes from how long ago the/// quote was actually sourced (`quoted_at`, epoch-ms). A stale quote during an/// open session reads "Delayed" instead of a false "Live". After the close the/// shown number IS the day's close, so its age does not change the "At close"/// reading.fn quote_state_label(quoted_at: Option<i64>) -> &'static str {    use market::Session::{Closed, Post, Pre, Regular};    let fresh = quoted_at.is_some_and(|t| now_ms() - t <= QUOTE_FRESH_MS);    match market::session_at(chrono::Utc::now()) {        market::Session::Pre => "Pre-market",        market::Session::Regular => "Live",        market::Session::Post => "After hours",        market::Session::Closed => "At close",        Pre => if fresh { "Pre-market" } else { "Delayed" },        Regular => if fresh { "Live" } else { "Delayed" },        Post => if fresh { "After hours" } else { "Delayed" },        Closed => "At close",    }}
@@ -275,6 +290,29 @@ fn quote_state_label() -> &'static str {/// unambiguous "no data" mark (a middle dot read as a stray decimal point).const DASH: &str = "\u{2014}";/// How fresh a struck NAV must be before a price-vs-NAV premium/discount is/// trustworthy. NAV is struck once per trading day, so a NAV older than this is/// stale and any "premium" against it is really just price drift since then; the/// premium drops to `None` rather than assert a bogus figure. Shared by the/// "About this fund" premium line and the ETF quality read's tracking factor.const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;/// A symbol whose most recent daily bar is older than this (calendar days) is/// treated as dormant: likely delisted, renamed, or halted. Comfortably past a/// stacked holiday weekend so a normally-trading symbol never trips it. Yahoo/// still serves a frozen chart for a dead ticker (so the page would otherwise/// look live), which is exactly why this banner exists.const STALE_DATA_DAYS: i64 = 7;/// A "no recent trading data" banner for the symbol header: the date of the last/// bar we hold and how many days stale it is, so a dormant/delisted symbol reads/// honestly instead of showing a frozen chart as if it were current.#[derive(Serialize)]struct StaleData {    last_date: String,    days: i64,}/// Whether a period-over-period rise in a metric is good news, for the/// financials-table growth cue.#[derive(Clone, Copy)]
@@ -374,7 +412,20 @@ fn ttm_eps_diluted(facts: &[models::FundFact]) -> Option<(f64, String)> {    if q.len() < 4 {        return None;    }    Some((q[..4].iter().map(|(_, v)| *v).sum(), q[0].0.to_string()))    let four = &q[..4];    // The four quarters must be *consecutive*, or their sum is not a real    // trailing twelve months: a missing quarter would splice together periods    // spanning more than a year and still label it "TTM". Require each adjacent    // pair of period-ends to sit about one quarter apart (~80–100 days); when    // they don't, return None so the caller falls back to full-year EPS.    let parse = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok();    for pair in four.windows(2) {        let gap = (parse(pair[0].0)? - parse(pair[1].0)?).num_days();        if !(80..=100).contains(&gap) {            return None;        }    }    Some((four.iter().map(|(_, v)| *v).sum(), four[0].0.to_string()))}/// A `YYYY-MM-DD` period end as a short `Mon YYYY` label (e.g. `Jun 2025`).
@@ -848,11 +899,19 @@ fn build_fund_meta(row: FundMetadataRow, price: Option<f64>) -> FundMetaView {    let pct = |v: Option<f64>, dp: usize| -> String {        v.map_or_else(|| DASH.to_string(), |x| format!("{:.*}%", dp, x * 100.0))    };    // Premium / discount: live price against the latest NAV. Live price falls    // Premium / discount: live price against the latest NAV, but only when that    // NAV is fresh (struck within NAV_FRESH_MS). Comparing a live price to a    // days-old NAV yields a meaningless premium, so a stale NAV drops the line to    // `None` rather than show drift as a premium — the same gate the ETF quality    // read's tracking factor uses, so the two never disagree. Live price falls    // back to the daily close when no quote yet, just as the ratio cards do.    let nav_fresh = row        .nav_synced_at        .is_some_and(|t| now_ms() - t <= NAV_FRESH_MS);    let premium = price        .and_then(|p| compute::premium_discount_pct(p, row.nav_price).map(|pct| (p, pct)))        .map(|(_, pct)| PremiumView {        .filter(|_| nav_fresh)        .and_then(|p| compute::premium_discount_pct(p, row.nav_price))        .map(|pct| PremiumView {            text: format!("{:+.2}%", pct),            grade: compute::premium_grade(pct),        });
@@ -1411,7 +1470,9 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    // The latest stored live quote, if the symbol has ever been quoted. The    // header prefers it over the last daily close.    let quote = sqlx::query_as::<_, QuoteRow>("SELECT price, prev_close FROM quotes WHERE ticker = ?")    let quote = sqlx::query_as::<_, QuoteRow>(        "SELECT price, prev_close, fetched_at FROM quotes WHERE ticker = ?",    )        .bind(&ticker)        .fetch_optional(&state.pool)        .await
@@ -1423,7 +1484,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)                price: q.price,                change_abs: change.map(|c| c.abs),                change_pct: change.map(|c| c.pct),                state_label: quote_state_label().to_string(),                state_label: quote_state_label(q.fetched_at).to_string(),            }        });
@@ -1667,8 +1728,8 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        // meaningless. We let the factor drop out rather than assert a bogus        // tracking verdict. NAV is re-fetched on demand when an ETF page is        // viewed and stale; when it is behind (fresh deploy, guard tripped),        // tracking simply reads "—".        const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;        // tracking simply reads "—". The freshness window is shared with the        // "About this fund" premium line (see NAV_FRESH_MS) so they agree.        let nav_fresh =            etf_nav_synced_at.is_some_and(|t| crate::db::now_ms() - t <= NAV_FRESH_MS);        let premium_pct = if nav_fresh {
@@ -1751,9 +1812,22 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        None    };    // Dormant / delisted signal: the most recent daily bar is well in the past.    // Yahoo serves a frozen chart for a dead ticker, so without this the page    // would look live. `None` for a normally-trading symbol.    let stale_data = symbol.history_last_date.as_deref().and_then(|d| {        let last = chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?;        let days = (chrono::Utc::now().date_naive() - last).num_days();        (days > STALE_DATA_DAYS).then(|| StaleData {            last_date: d.to_string(),            days,        })    });    let extra = minijinja::context! {        title => ticker,        symbol => symbol,        stale_data => stale_data,        stats => stats,        indicators => indicators,        quote => quote,
@@ -2580,3 +2654,51 @@ fn sse_json(event: &str, data: &str) -> Result<axum::response::sse::Event, std::fn json_str(s: &str) -> String {    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())}#[cfg(test)]mod tests {    use super::*;    fn q(period_end: &str, value: f64) -> models::FundFact {        models::FundFact {            metric: "eps_diluted".to_string(),            period: format!("Q-{period_end}"),            fiscal_year: 2024,            fiscal_qtr: Some(1),            value,            period_end: period_end.to_string(),        }    }    #[test]    fn ttm_eps_sums_four_consecutive_quarters() {        let facts = vec![            q("2024-12-31", 1.0),            q("2024-09-30", 0.9),            q("2024-06-30", 0.8),            q("2024-03-31", 0.7),        ];        let ttm = ttm_eps_diluted(&facts).expect("four consecutive quarters → a TTM");        assert!((ttm.0 - 3.4).abs() < 1e-9);        assert_eq!(ttm.1, "2024-12-31"); // labelled through the newest quarter    }    #[test]    fn ttm_eps_rejects_a_gap() {        // A missing quarter (jump from 2024-06-30 back to 2023-09-30) spans more        // than a year, so it must NOT be summed as a trailing twelve months.        let facts = vec![            q("2024-12-31", 1.0),            q("2024-09-30", 0.9),            q("2024-06-30", 0.8),            q("2023-09-30", 0.6),        ];        assert!(ttm_eps_diluted(&facts).is_none(), "a non-consecutive run is not a TTM");    }    #[test]    fn ttm_eps_needs_four_quarters() {        let facts = vec![q("2024-12-31", 1.0), q("2024-09-30", 0.9)];        assert!(ttm_eps_diluted(&facts).is_none());    }}
modified src/scheduler.rs
@@ -1144,9 +1144,21 @@ async fn backfill_stock_sec(    guard: &EndpointGuard,    ticker: &str,) {    let Some(cik) = resolve_one_cik(pool, sec, guard, ticker, false).await else {        tracing::info!("[backfill] {ticker}: no SEC CIK, leaving it for the sec job");        return;    let cik = match resolve_one_cik(pool, sec, guard, ticker, false).await {        CikResolution::Found(c) => c,        CikResolution::Absent => {            // Not in SEC's company map: a non-filer, a foreign issuer, or a            // delisted/renamed ticker. Stamp the sections checked so the page            // shows an honest "no data" rather than a perpetual pending note.            let _ = mark_sec_synced(pool, ticker, "fundamentals_synced_at").await;            let _ = mark_sec_synced(pool, ticker, "filings_synced_at").await;            tracing::info!("[backfill] {ticker}: not in SEC company map, marked checked");            return;        }        CikResolution::Unavailable => {            tracing::info!("[backfill] {ticker}: SEC CIK map unavailable, leaving it for the sec job");            return;        }    };    if let Some(Ok(facts)) = guarded(guard, sec.facts(&cik)).await {        match store_fundamentals(pool, ticker, &facts).await {
@@ -1209,9 +1221,21 @@ async fn backfill_leadership(/// Backfill an ETF's fund profile: resolve its fund CIK, pull the filing list,/// then either the N-PORT portfolio or a commodity trust's AUM.async fn backfill_etf_sec(pool: &SqlitePool, sec: &SecProvider, guard: &EndpointGuard, ticker: &str) {    let Some(cik) = resolve_one_cik(pool, sec, guard, ticker, true).await else {        tracing::info!("[backfill] {ticker}: no SEC fund CIK, leaving it for the sec job");        return;    let cik = match resolve_one_cik(pool, sec, guard, ticker, true).await {        CikResolution::Found(c) => c,        CikResolution::Absent => {            // Not in SEC's mutual-fund map: a delisted/renamed fund (e.g. SPCX,            // which renamed to SPCK) or one that does not file N-PORT. Stamp it            // checked so the page shows an honest "no fund profile available"            // rather than a pending note Refresh can never clear.            let _ = mark_fund_synced(pool, ticker).await;            tracing::info!("[backfill] {ticker}: not in SEC fund map (delisted/renamed?), marked checked");            return;        }        CikResolution::Unavailable => {            tracing::info!("[backfill] {ticker}: SEC fund map unavailable, leaving it for the sec job");            return;        }    };    // `resolve_fund_ciks` stored the series id alongside the CIK.    let series_id: Option<String> =
@@ -1251,29 +1275,63 @@ async fn backfill_etf_sec(pool: &SqlitePool, sec: &SecProvider, guard: &Endpoint    }}/// Outcome of resolving a symbol's SEC CIK from the bulk ticker map.enum CikResolution {    /// A CIK is on file for this symbol (resolved now or on a prior run).    Found(String),    /// The bulk map was fetched successfully but does not list this ticker:    /// the company/fund genuinely has no SEC entry (delisted, renamed, a    /// foreign issuer, or a non-filer). The caller stamps the affected section    /// *checked* so the page shows an honest "no data available" instead of a    /// perpetual "not synced yet, hit Refresh".    Absent,    /// The map could not be fetched (guard denied / network error): nothing was    /// learned, so the caller leaves the section unsynced for a later retry.    Unavailable,}/// Resolve and store a freshly-added symbol's SEC CIK from the bulk ticker map./// `fund` selects the mutual-fund map (ETFs) over the operating-company map/// (stocks). Returns the stored CIK on success./// (stocks). Distinguishes a genuinely-absent ticker from an unreachable map so/// the caller can render an honest empty state (see [`CikResolution`]).async fn resolve_one_cik(    pool: &SqlitePool,    sec: &SecProvider,    guard: &EndpointGuard,    ticker: &str,    fund: bool,) -> Option<String> {    if fund {        if let Some(Ok(map)) = guarded(guard, sec.fund_ticker_map()).await {            let _ = resolve_fund_ciks(pool, &map).await;) -> CikResolution {    // Whether the bulk map was actually fetched this call (vs guard-denied /    // errored). A symbol may already carry a CIK from a prior run regardless.    let fetched = if fund {        match guarded(guard, sec.fund_ticker_map()).await {            Some(Ok(map)) => {                let _ = resolve_fund_ciks(pool, &map).await;                true            }            _ => false,        }    } else {        match guarded(guard, sec.cik_map()).await {            Some(Ok(map)) => {                let _ = resolve_ciks(pool, &map).await;                true            }            _ => false,        }    } else if let Some(Ok(map)) = guarded(guard, sec.cik_map()).await {        let _ = resolve_ciks(pool, &map).await;    };    let cik: Option<String> =        sqlx::query_scalar::<_, Option<String>>("SELECT cik FROM symbols WHERE ticker = ?")            .bind(ticker)            .fetch_one(pool)            .await            .ok()            .flatten();    match (cik, fetched) {        (Some(c), _) => CikResolution::Found(c),        (None, true) => CikResolution::Absent,        (None, false) => CikResolution::Unavailable,    }    sqlx::query_scalar::<_, Option<String>>("SELECT cik FROM symbols WHERE ticker = ?")        .bind(ticker)        .fetch_one(pool)        .await        .ok()        .flatten()}/// Prune aged rows once per `PRUNE_INTERVAL_SECS`. `intraday_bars` keeps a
added src/sp500.rs
@@ -0,0 +1,60 @@//! S&P 500 membership, used to restrict the dashboard's market-movers lists to//! recognizable large-cap names rather than the whole-market micro-caps Yahoo's//! predefined screeners return (a user request: "market movers has a lot of//! companies I've literally never heard of").//!//! The constituent list lives in `universe/sp500.txt` (one ticker per line, in//! Yahoo symbology so `BRK.B` is `BRK-B`; `#` comments and blank lines allowed)//! and is embedded at compile time, so there is no runtime file IO and it ships//! inside the binary. Refresh by editing that file and redeploying (the file's//! header documents the one-liner that regenerates it).use std::collections::HashSet;use std::sync::LazyLock;/// The raw constituent list, embedded at build time.const SP500_RAW: &str = include_str!("../universe/sp500.txt");/// The membership set, parsed once on first use. Tickers are uppercased so the/// lookup is case-insensitive; blank lines and `#` comments are skipped.static SP500: LazyLock<HashSet<String>> = LazyLock::new(|| {    SP500_RAW        .lines()        .map(str::trim)        .filter(|l| !l.is_empty() && !l.starts_with('#'))        .map(str::to_uppercase)        .collect()});/// Whether `ticker` is an S&P 500 constituent (case-insensitive).pub fn is_member(ticker: &str) -> bool {    SP500.contains(&ticker.to_uppercase())}/// How many constituents are loaded — for a boot log / sanity check.pub fn count() -> usize {    SP500.len()}#[cfg(test)]mod tests {    use super::*;    #[test]    fn loads_a_full_roster() {        // The S&P 500 hovers around 500-505 names; guard against an empty or        // truncated embed (e.g. a botched refresh).        assert!(count() >= 490, "expected ~500 constituents, got {}", count());    }    #[test]    fn known_members_and_non_members() {        assert!(is_member("AAPL"));        assert!(is_member("aapl"), "lookup is case-insensitive");        assert!(is_member("BRK-B"), "dotted tickers stored in Yahoo dash form");        assert!(is_member("JPM"));        // A real ticker that is not in the index, and obvious junk.        assert!(!is_member("SPCX"), "a delisted micro-cap fund is not a member");        assert!(!is_member("NOTATICKER"));    }}
modified templates/pages/home.html
@@ -22,20 +22,18 @@  <section class="hero-graph">    <div class="hero-graph__head">      <h1 class="hero-graph__title">Market overview</h1>      <span class="hero-graph__note">live value &amp; % change vs prev close</span>      <span class="hero-graph__note">value &amp; % change vs prev close</span>    </div>    {# Until /api/dashboard answers, the grid shows one shimmer-skeleton card       per overview slot — same shape as the real cards, so the page doesn't       jump when they land. hero.js hides this wrapper (display: contents, so       the skeletons are real grid items) on the first draw. #}       per overview slot — same shape as the real sparkline cards, so the page       doesn't jump when they land. hero.js hides this wrapper (display: contents,       so the skeletons are real grid items) on the first draw. #}    <div class="overview-grid" data-role="overview-grid">      <div class="ov-skels" data-role="hero-empty" aria-hidden="true">        {% for t in overview_tickers %}{% if loop.index <= 6 %}        <div class="ov-card ov-card--skel">          <div class="ov-card__head">            <span class="ov-skel ov-skel--name"></span>            <span class="ov-skel ov-skel--num"></span>          </div>          <span class="ov-skel ov-skel--name"></span>          <span class="ov-skel ov-skel--num"></span>          <div class="ov-card__chart ov-skel ov-skel--chart"></div>        </div>        {% endif %}{% endfor %}
@@ -125,20 +123,10 @@      {% for c in cards %}      <div class="ov-card ov-card--watch" data-ticker="{{ c.ticker }}" data-unit="{{ c.unit }}">        <a class="ov-card__link" href="/s/{{ c.ticker|urlencode }}">          <div class="ov-card__head">            <div class="ov-card__id">              <span class="ov-card__name">{{ c.ticker }}</span>              <span class="ov-card__sub">{{ c.name }}</span>            </div>            <div class="ov-card__nums">              <span class="ov-card__value num">{{ c.price|money }}</span>              <span class="ov-card__chg num {% if c.change_pct is none %}is-flat{% elif c.change_pct >= 0 %}is-up{% else %}is-down{% endif %}">{{ c.change_pct|pct }}</span>            </div>          </div>          <div class="ov-card__meta">            <span class="ov-card__label" hidden></span>            <span class="ov-card__week num" hidden></span>            <span class="ov-card__fresh" data-asof=""></span>          <div class="ov-card__name ov-card__name--watch">{{ c.ticker }}<span class="ov-card__sub">{{ c.name }}</span></div>          <div class="ov-card__nums">            <span class="ov-card__value num">{{ c.price|money }}</span>            <span class="ov-card__chg num {% if c.change_pct is none %}is-flat{% elif c.change_pct >= 0 %}is-up{% else %}is-down{% endif %}">{{ c.change_pct|pct }}</span>          </div>          <div class="ov-card__chart"></div>        </a>
@@ -157,7 +145,7 @@     8-minute server-side cache), after first paint so it never blocks the page. #}  <section class="movers">    <div class="movers__head">      <h2 class="section-title">Market movers</h2>      <h2 class="section-title">Market movers <span class="movers__scope">S&amp;P 500</span></h2>      <span class="movers__asof" data-role="movers-asof"></span>    </div>    <div class="movers__cols" data-role="movers" aria-busy="true">
modified templates/pages/symbol.html
@@ -45,6 +45,14 @@    <span class="refresh__status" data-refresh-status aria-live="polite"></span>    <div class="refresh__bar" data-refresh-bar hidden><div class="refresh__fill" data-refresh-fill></div></div>  </div>  {% if stale_data %}  <div class="stale-banner" role="note">    <strong>No recent trading data.</strong> The last price we hold for {{ symbol.ticker }}    is from {{ stale_data.last_date|shortdate }} ({{ stale_data.days }} days ago). This symbol    may be delisted, renamed, or halted &mdash; the figures below reflect its last active    session, not the current market.  </div>  {% endif %}  <header class="sym-head{% if health or etf_quality %} sym-head--has-health{% endif %}">    <div class="sym-head__id">      <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1>
@@ -439,7 +447,7 @@  </section>  {% endif %}  <h2 class="section-title">Fundamentals{% if symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>  <h2 class="section-title">Fundamentals{% if fundamentals and fundamentals.ratios and symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>  {% if fundamentals and fundamentals.ratios %}  {# The rolled-up standing badge sits above the per-ratio cards (Phase 20). #}  {% if standing %}
@@ -468,8 +476,14 @@  </section>  {% else %}  <div class="fund-pending">    {% if symbol.fundamentals_synced_at %}    No SEC fundamentals are available for {{ symbol.ticker }}. It may not file    financial reports with the SEC (a foreign issuer or non-filer), or the    symbol may be delisted. (Checked {{ symbol.fundamentals_synced_at|ago }}.)    {% else %}    SEC fundamentals for {{ symbol.ticker }} have not synced yet. They are pulled    on demand &mdash; hit <strong>Refresh</strong> above to fetch them now.    {% endif %}  </div>  {% endif %}
@@ -563,7 +577,7 @@  {# About this fund: expense ratio, yield, NAV + premium/discount, family, #}  {# category, inception, and the issuer's strategy paragraph (Phase 28). #}  <h2 class="section-title">About this fund{% if symbol.fund_metadata_synced_at %}<span class="section-title__asof">synced from Yahoo {{ symbol.fund_metadata_synced_at|ago }}</span>{% endif %}</h2>  <h2 class="section-title">About this fund{% if fund_meta and symbol.fund_metadata_synced_at %}<span class="section-title__asof">synced from Yahoo {{ symbol.fund_metadata_synced_at|ago }}</span>{% endif %}</h2>  {% if fund_meta %}  <section class="panel fund-about">    <dl class="fund-about__stats">
@@ -606,8 +620,14 @@  </section>  {% else %}  <div class="fund-pending">    Fund metadata for {{ symbol.ticker }} has not synced yet. It is pulled on    demand &mdash; hit <strong>Refresh</strong> above to fetch it now.    {% if symbol.fund_metadata_synced_at %}    No fund details are available for {{ symbol.ticker }} from Yahoo. The fund may    be delisted or renamed, or Yahoo may not carry details for it.    (Checked {{ symbol.fund_metadata_synced_at|ago }}.)    {% else %}    Fund details for {{ symbol.ticker }} have not synced yet. They are pulled on    demand &mdash; hit <strong>Refresh</strong> above to fetch them now.    {% endif %}  </div>  {% endif %}
@@ -641,7 +661,7 @@  </section>  {% endif %}  <h2 class="section-title">Fund profile{% if symbol.fund_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fund_synced_at|ago }}</span>{% endif %}</h2>  <h2 class="section-title">Fund profile{% if fund and symbol.fund_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fund_synced_at|ago }}</span>{% endif %}</h2>  {% if fund %}  <section class="panel fund">    <dl class="fund__stats">
@@ -741,8 +761,14 @@  {% endif %}  {% else %}  <div class="fund-pending">    {% if symbol.fund_synced_at %}    No fund profile is available for {{ symbol.ticker }} from the SEC. The fund may    be delisted or renamed, or it does not file N-PORT portfolio reports.    (Checked {{ symbol.fund_synced_at|ago }}.)    {% else %}    The fund profile for {{ symbol.ticker }} has not synced yet. It is pulled on    demand &mdash; hit <strong>Refresh</strong> above to fetch it now.    {% endif %}  </div>  {% endif %}  {% endif %}
added universe/sp500.txt
@@ -0,0 +1,509 @@# S&P 500 constituents, one ticker per line (Yahoo symbology, dots -> dashes).# Used to restrict the dashboard market-movers lists to recognizable names.# Refresh: curl the datasets CSV and rebuild this file, then redeploy:#   curl -s https://raw.githubusercontent.com/datasets/s-and-p-500-companies/main/data/constituents.csv \#     | tail -n +2 | cut -d, -f1 | tr "." "-" | sort -u >> universe/sp500.txt# Lines starting with # and blank lines are ignored.AAAPLABBVABNBABTACGLACNADBEADIADMADPADSKAEEAEPAESAFLAIGAIZAJGAKAMALBALGNALLALLEAMATAMCRAMDAMEAMGNAMPAMTAMZNANETAONAOSAPAAPDAPHAPOAPPAPTVAREARESATOAVBAVGOAVYAWKAXONAXPAZOBABACBALLBAXBBYBDXBENBF-BBGBIIBBKNGBKRBLDRBLKBMYBNYBRBRK-BBROBSXBXBXPCCAGCAHCARRCASYCATCBCBOECBRECCICCLCDNSCDWCEGCFCFGCHDCHRWCHTRCICIENCINFCLCLXCMCSACMECMGCMICMSCNCCNPCOFCOHRCOINCOOCOPCORCOSTCPAYCPRTCPTCRHCRLCRMCRWDCSCOCSGPCSXCTASCTSHCTVACVNACVSCVXDDALDASHDDDDOGDEDECKDELLDGDGXDHIDHRDISDLRDLTRDOCDOVDOWDPZDRIDTEDUKDVADVNDXCMEAEBAYECHOECLEDEFXEGEIXELELVEMEEMREOGEQIXEQREQTERIEESESSETNETREVRGEWEXCEXEEXPDEXPEEXRFFANGFASTFCXFDSFDXFDXFFEFFIVFICOFISFISVFITBFIXFLEXFOXFOXAFRTFSLRFTNTFTVGDGDDYGEGEHCGENGEVGILDGISGLGLWGMGNRCGOOGGOOGLGPCGPNGRMNGSGWWHALHASHBANHCAHDHIGHIIHLTHONHOODHPEHPQHRLHSICHSTHSYHUBBHUMHWMIBKRIBMICEIDXXIEXIFFINCYINTCINTUINVHIPIQVIRIRMISRGITITWIVZJJBHTJBLJCIJKHYJNJJPMKDPKEYKEYSKHCKIMKKRKLACKMBKMIKOKRKVUELLDOSLENLHLHXLIILINLITELLYLMTLNTLOWLRCXLULULUVLVSLYBLYVMAMAAMARMASMCDMCHPMCKMCOMDLZMDTMETMETAMGMMKCMLMMMMMNSTMOMOSMPCMPWRMRKMRNAMRSHMRVLMSMSCIMSFTMSIMTBMTDMUNCLHNDAQNDSNNEENEMNFLXNINKENOCNOWNRGNSCNTAPNTRSNUENVDANVRNWSNWSANXPIOODFLOKEOMCONORCLORLYOTISOXYPANWPAYXPCARPCGPEGPEPPFEPFGPGPGRPHPHMPKGPLDPLTRPMPNCPNRPNWPODDPPGPPLPRUPSAPSKYPSXPTCPWRPYPLQQCOMRCLREGREGNRFRJFRLRMDROKROLROPROSTRSGRTXRVTYSBACSBUXSCHWSHWSJMSLBSMCISNASNDKSNPSSOSOLVSPGSPGISRESTESTLDSTTSTXSTZSWSWKSWKSSYFSYKSYYTTAPTDGTDYTECHTELTERTFCTGTTJXTKOTMOTMUSTPLTPRTRGPTRMBTROWTRVTSCOTSLATSNTTTTDTTWOTXNTXTTYLUALUBERUDRUHSULTAUNHUNPUPSURIUSBVVEEVVICIVLOVLTOVMCVRSKVRSNVRTVRTXVSTVTRVTRSVZWABWATWBDWDAYWDCWECWELLWFCWMWMBWMTWRBWSMWSTWTWWYWYNNXELXOMXYLXYZYUMZBHZBRAZTS