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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 {
@@ -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 }}
@@ -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(())}
@@ -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}"); }}
@@ -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
@@ -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 & % change vs prev close</span> <span class="hero-graph__note">value & % 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&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 — 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 — 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 — 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 — 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 — hit <strong>Refresh</strong> above to fetch it now. {% endif %} </div> {% endif %} {% endif %}
@@ -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