@@ -22,7 +22,7 @@ There are no tests or linters configured.## The demand-only model (read this first)Data is pulled **on demand** the first time a symbol is viewed (or when its stored copy is stale), and live intraday quotes are polled **only** for the symbols a browser is currently watching. **One deliberate exception** (user call, 2026-06-10): the scheduler's *home sweep* re-quotes the home dashboard's instruments (overview set + every watchlist symbol) every 15 minutes whether or not anyone is on the site, so the dashboard always opens fresh; it is session-aware (off-hours only the ~24h futures/crypto are polled) and guard-routed like everything else. Beyond that the architecture is unchanged: no other timed sweeps, history fills in lazily, and every figure on the page carries an honest data-age read ("live", "2m ago", "stale, refreshing", "as of Fri close") — a stale figure is never shown as if fresh.Data is pulled **on demand** the first time a symbol is viewed (or when its stored copy is stale), and live intraday quotes are polled **only** for the symbols a browser is currently watching. **One deliberate exception** (user call, 2026-06-10): the scheduler's *home sweep* re-quotes the home dashboard's instruments every 15 minutes whether or not anyone is on the site, so the dashboard always opens fresh; it is session-aware (off-hours only the ~24h futures/crypto are polled) and guard-routed like everything else. The swept set is `dashboard_tickers()` in `routes/home.rs`: the overview cash + futures tickers, the VIX, the SPY volume proxy, the **HYG credit proxy**, the **11 SPDR sector ETFs** (the "what's moving" heatmap), plus the union of all watchlist symbols. Beyond that the architecture is unchanged: no other timed sweeps, history fills in lazily, and every figure on the page carries an honest data-age read ("live", "2m ago", "stale, refreshing", "as of Fri close") — a stale figure is never shown as if fresh.The user considers **never hitting a rate limit** critical. Every outbound call passes through the persistent `EndpointGuard` (see Architecture). Treat the rate-limit-critical path with care; don't add eager fetching.
@@ -104,9 +104,10 @@ The binary reads `templates/`, `dist/`, `migrations/`, and `universe/` from cwd## Key Routes- `/`: home dashboard — a session-aware market-overview strip (one interactive chart per instrument: S&P, Dow, Nasdaq-100, gold, crude, BTC, plus VIX/volume/SMA reads) over the user's editable watchlist- `/`: home dashboard. A session-aware market-overview strip (one interactive chart per instrument: S&P, Dow, Nasdaq-100, gold, crude, BTC, each with day + week %, a session badge, and a freshness chip), then the **"What's moving" sector heatmap** (11 SPDR sector ETFs, cells coloured by move with a ±3% clamp), then the **crash-response pulse row** (S&P drawdown-from-record-high with DCA add-zone labels, VIX, HYG credit stress, market volume, 50/200 trend), over the user's editable watchlist, then the **Market movers** tables (top gainers / losers / most active). The page polls `/api/dashboard` every 20s (a local DB read) and shows a live "updated Ns ago" / "Refreshing…" state so it never looks silently stale.- `/s/{ticker}`: symbol page — candlestick chart with indicators, key stats; a stock also shows fundamentals and a leadership roster, an ETF a fund profile (holdings, AUM), both show SEC filings- `/api/dashboard`, `/api/dashboard/refresh`: dashboard data JSON + manual refresh- `/api/movers`: top gainers / losers / most active. Served from an 8-minute `meta` cache (`movers_json`); a miss does one guarded, paced pull of Yahoo's three predefined screeners. The page fetches it after first paint, so a cold pull never blocks the dashboard- `/api/watchlist` (POST), `/api/watchlist/remove` (POST): edit the session watchlist- `/api/symbols/{ticker}/history`: candle + indicator series JSON for the chart- `/api/symbols/{ticker}/growth`, `/api/symbols/{ticker}/refresh`: growth series + on-demand refresh stream
@@ -121,7 +122,7 @@ The binary reads `templates/`, `dist/`, `migrations/`, and `universe/` from cwdAll free, no account, no API key. Every outbound call goes through the `EndpointGuard` (see Architecture); data is fetched on demand for viewed symbols when stale or absent.- **Historical daily OHLCV + intraday bars + live quotes — Yahoo Finance.** `v8/finance/chart`; no key, just a browser User-Agent. `interval=1d&range=max` returns a symbol's entire daily history in one call (the per-symbol backfill); `interval=15m&range=1d` serves live quotes + intraday. ETF/fundamentals metadata comes from `v10/finance/quoteSummary` (crumb-gated — see Gotchas). There is no separate history source.- **Historical daily OHLCV + intraday bars + live quotes — Yahoo Finance.** `v8/finance/chart`; no key, just a browser User-Agent. `interval=1d&range=max` returns a symbol's entire daily history in one call (the per-symbol backfill); `interval=15m&range=1d` serves live quotes + intraday. ETF/fundamentals metadata comes from `v10/finance/quoteSummary` (crumb-gated — see Gotchas). There is no separate history source. The dashboard's **market movers** come from `v1/finance/screener/predefined/saved` (`scrIds=day_gainers`/`day_losers`/`most_actives`); this predefined-screener endpoint is *not* crumb-gated (a plain GET), unlike `quoteSummary`.- **Fundamentals, filings, leadership + ETF profiles — SEC EDGAR.** `company_tickers.json` / `companyfacts` / `submissions` for stock fundamentals and filings; Form 3/4/5 ownership XML for the officer/board roster; `company_tickers_mf.json` plus quarterly N-PORT filings for ETF fund profiles (holdings, AUM, asset mix). No key; a contact email (`SEC_CONTACT_EMAIL`) rides in the User-Agent. Indexes do not file with the SEC.## Gotchas (don't relearn these)
@@ -134,7 +135,7 @@ All free, no account, no API key. Every outbound call goes through the `Endpoint- **Guard state is shared across server + `seed` subcommand** via SQLite, and survives restarts. A boot-time breaker trip is normal after a deploy; it recovers via the half-open probe.- **Supertrend is drawn as a single line series coloured per data point** (one value/one colour per bar), so the two trend colours can never render at the same x; the band jumps sides at a flip. **Do not** use two whitespace-gapped series — lightweight-charts connects the line straight across the gaps and draws both colours at once (the "constantly green" bug).- **Yahoo NAV is only as fresh as you keep it.** Comparing a live price to a weeks-old NAV yields a meaningless premium/discount. The ETF quality read's tracking factor gates on `nav_synced_at` freshness and drops to `—` rather than assert a bogus premium. NAV is fetched on demand when an ETF page is viewed and stale.- **The index overview slots are a hybrid by design.** The chart *line* is the E-mini future (so pre/regular/after-hours all move), while the headline value + % are the **cash index** quote (`regularMarketPrice` vs `chartPreviousClose`) so the number matches everyone (e.g. S&P 500 +0.41% = `^GSPC`). On big-basis days the line (futures) can diverge from the headline (cash) after 4pm — that's expected, not a bug. Each chart normally frames exactly one Schwab day (regular + extended hours, 7:00 AM–8:00 PM ET) of the most recent session.- **The index overview headline is session-aware (the futures off-hours).** The chart *line* is always the E-mini future (so pre/regular/after-hours all move). The headline value + % switch by the market clock (`headline()` in `routes/home.rs`): the **cash index** during the regular session (unlabelled, e.g. S&P 500 +0.41% = `^GSPC`), the **future** vs its prior settlement pre-market (badge "Futures") and overnight (badge "Overnight"), and the cash **close** after hours (badge "At close"). A stock shows its pre-market bar pre-open ("Pre-market"). This is so the number matches Yahoo/MarketWatch at 7am and at night instead of a frozen cash close; the per-card freshness chip and `headline_label` keep it honest. Each card also carries `week_pct` (week-to-date) beside the day move. The day denominator is `previousClose` (use `chartPreviousClose` only at `range=1d`, where they coincide). Each chart normally frames exactly one Schwab day (7:00 AM–8:00 PM ET) of the most recent session; on a big-basis day the futures line can still sit a few points off the cash headline, which is expected.- **The dashboard switches to a full-week frame after Friday's close.** From Friday 4:00 PM ET through the weekend until Monday 7:00 AM ET (`week_window` in `routes/home.rs`), every overview + watchlist chart spans the whole trading week (Mon 7 AM → Fri 8 PM ET) and the headline % is the full-week move (prior Friday's daily close → Friday's close), not the one-day move. Because the routine intraday poll only stores one day of 15-minute bars at a time, a one-off guarded `range=5d` backfill (`scheduler::backfill_intraday_week`, fired detached from `/api/dashboard/refresh`) fills any week-days the dashboard was not open for; it skips symbols already covering the week, so it is a no-op once filled.- **`cargo` isn't on PATH in this dev container** — use `~/.cargo/bin/cargo`, and run the dev binary with `FINANCE_ROOT=/home/dev/code/finance` (or from the project dir so paths resolve from cwd).
modified
frontend/static_src/home/scripts/hero.js
@@ -8,17 +8,30 @@// ~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, AreaSeries, ColorType } from "lightweight-charts";import { createChart, BaselineSeries, ColorType } from "lightweight-charts";// Semantic day-direction colours (the Paper Ledger up/down inks) + soft fills// that match the watchlist spark-card aesthetic.// 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.const UP = "#2f7d4f";const DOWN = "#b23b32";const UP_FILL = "rgba(47, 125, 79, 0.15)";const DOWN_FILL = "rgba(178, 59, 50, 0.15)";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 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.function fmtPctArrow(n) { if (n == null || Number.isNaN(n)) return DASH; const a = n > 0 ? "▲ " : n < 0 ? "▼ " : ""; return a + fmtPct(n);}const SESSION_LABELS = { regular: "Regular session", pre: "Pre-market",
@@ -58,6 +71,16 @@ 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", {
@@ -97,6 +120,41 @@ function fmtClock(ms) { .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)); if (s < 5) return "just now"; if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; 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;}// 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;// ── 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
@@ -277,12 +335,21 @@ function attachChart(chartEl) { localization: { timeFormatter: (t) => fmtCrosshairTime(t) }, }); const series = chart.addSeries(AreaSeries, { 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" };
@@ -429,13 +496,9 @@ function attachMeasure(entry) {function drawSeries(entry, s) { entry.points = s.points; // real bars, for the measure tool entry.unit = s.unit; const color = s.up ? UP : DOWN; entry.series.applyOptions({ lineColor: color, topColor: s.up ? UP_FILL : DOWN_FILL, bottomColor: "transparent", crosshairMarkerBackgroundColor: color, }); // 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) },
@@ -489,16 +552,54 @@ function drawSeries(entry, s) { renderBands(entry);}// Update a card's header value + % pill.// 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) { const prev = lastShown.get(s.ticker); const v = root.querySelector(".ov-card__value"); if (v) v.textContent = fmtValue(s.last, s.unit); const c = root.querySelector(".ov-card__chg"); if (c) { c.textContent = fmtPct(s.change_pct); c.textContent = fmtPctArrow(s.change_pct); 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"); } // 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 root.classList.add(s.last >= prev ? "ov-flash-up" : "ov-flash-down"); } if (s.last != null) lastShown.set(s.ticker, s.last);}const escapeHtml = (s) =>
@@ -518,6 +619,11 @@ export function initHero() { `<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>` + `<div class="ov-card__chart"></div>`; overviewGrid.appendChild(root); const entry = attachChart(root.querySelector(".ov-card__chart"));
@@ -570,8 +676,82 @@ export function initHero() { }); } // 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; grid.removeAttribute("aria-busy"); grid.innerHTML = list .map((s) => { const pct = s.change_pct; const cls = pct == null ? "is-flat" : pct >= 0 ? "is-up" : "is-down"; return ( `<a class="sector-tile ${cls}" href="/s/${encodeURIComponent(s.ticker)}" ` + `style="background:${sectorColor(pct)}" title="${escapeHtml(s.ticker)}">` + `<span class="sector-tile__name">${escapeHtml(s.name)}</span>` + `<span class="sector-tile__pct num">${fmtPct(pct)}</span>` + `</a>` ); }) .join(""); } // ── 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"; return ( `<a class="mv-row" href="/s/${encodeURIComponent(m.symbol)}" title="${escapeHtml(m.name)}">` + `<span class="mv-row__id">` + `<span class="mv-row__sym">${escapeHtml(m.symbol)}</span>` + `<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></a>` ); } async function loadMovers() { let data; try { const res = await fetch("/api/movers", { headers: { Accept: "application/json" } }); if (!res.ok) return; data = await res.json(); } catch { return; } const root = document.querySelector('[data-role="movers"]'); if (!root) return; root.removeAttribute("aria-busy"); const fill = (key, rows) => { const list = root.querySelector(`[data-mv="${key}"] .movers__list`); if (list) { list.innerHTML = (rows || []).map(moverRow).join("") || `<p class="movers__empty">${DASH}</p>`; } }; fill("gainers", data.gainers); fill("losers", data.losers); fill("actives", data.actives); const asof = document.querySelector('[data-role="movers-asof"]'); if (asof) asof.textContent = data.asof ? "as of " + fmtClock(data.asof) : ""; } 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); setText("vix-level", r.vix_level != null ? r.vix_level.toFixed(2) : DASH); if (r.vix_tone) { setText("vix-tone", cap(r.vix_tone));
@@ -583,8 +763,30 @@ export function initHero() { setText("sma-read", r.sma_read); setTone("sma-read", r.sma_tone, "read__tone--"); } const clock = fmtClock(r.asof); if (clock) setText("reads-asof", "Prices as of " + clock); readsAsofMs = r.asof || null; 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. 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; } function patchCountdown() {
@@ -608,28 +810,56 @@ export function initHero() { return; } drawOverview(data.series); drawSectors(data.sectors); drawWatchlist(data.watchlist); patchReads(data.reads); 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. patchCountdown(); refresh(); setRefreshing(true); fetch("/api/dashboard/refresh") .catch(() => {}) .finally(() => refresh()); const timer = setInterval(refresh, 60000); // The countdown drifts a minute at a time; a 30s repaint keeps it honest // without waiting on the next dashboard poll. .finally(() => { setRefreshing(false); 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 moversTimer = setInterval(loadMovers, 240000); document.addEventListener("visibilitychange", () => { if (!document.hidden) { patchCountdown(); refresh(); tickFreshness(); loadMovers(); setRefreshing(true); fetch("/api/dashboard/refresh") .catch(() => {}) .finally(() => { setRefreshing(false); refresh(); }); } }); window.addEventListener("pagehide", () => { clearInterval(timer); clearInterval(clockTimer); clearInterval(freshTimer); clearInterval(moversTimer); });}
modified
frontend/static_src/home/styles/home.scss
@@ -246,6 +246,111 @@ 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;}.ov-card.ov-flash-down { animation: ov-flash-down 0.6s ease-out;}@keyframes ov-flash-up { 0% { box-shadow: inset 0 0 0 2px var(--up); } 100% { box-shadow: inset 0 0 0 2px transparent; }}@keyframes ov-flash-down { 0% { box-shadow: inset 0 0 0 2px var(--down); } 100% { box-shadow: inset 0 0 0 2px transparent; }}@media (prefers-reduced-motion: reduce) { .ov-card.ov-flash-up, .ov-card.ov-flash-down { animation: none; } .ov-card__fresh[data-tone="live"]::before { animation: none; }}.ov-card__chart { position: relative; width: 100%;
@@ -361,16 +466,102 @@ color: var(--ink-faint);}/* ---------- sector heatmap ("what's moving") ---------- */.sectors { margin-top: var(--sp-4);}.sectors__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2);}.sectors__note { font-size: var(--fs-2xs); color: var(--ink-faint);}.sectors__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; min-height: 2.5em;}@media (min-width: $bp-sm) { .sectors__grid { /* 11 sector tiles flow into as many columns as fit, so the strip stays one or two tidy rows on desktop without hard-coding a count. */ grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); }}.sector-tile { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: var(--radius-sm, 6px); border: 1px solid var(--rule); text-decoration: none; color: var(--ink); transition: transform 0.12s ease, box-shadow 0.12s ease;}.sector-tile:hover { transform: translateY(-1px); box-shadow: var(--lift-hover); border-color: var(--rule-strong);}.sector-tile__name { font-size: var(--fs-2xs); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.sector-tile__pct { font-size: var(--fs-sm); font-weight: 700; line-height: 1;}/* ---------- headline market reads ---------- */.reads-head { display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-2); margin-bottom: calc(-1 * var(--sp-3) + 4px);}.reads-head__asof { font-size: var(--fs-xs); color: var(--ink-faint);}.reads-head__refresh { display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-xs); color: var(--ink-dim);}.reads-head__spinner { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--rule-strong); border-top-color: var(--ink-dim); animation: reads-spin 0.7s linear infinite;}@keyframes reads-spin { to { transform: rotate(360deg); }}@media (prefers-reduced-motion: reduce) { .reads-head__spinner { animation: none; }}.reads { display: grid;
@@ -379,8 +570,8 @@}@media (min-width: $bp-sm) { .reads { // Three reads since Phase E (VIX, Market volume, S&P trend): the S&P price // tile moved onto the overview graph. // Five reads: the crash-response row (S&P-from-high, VIX, credit) leads, then // market volume + the S&P 50/200 trend. grid-template-columns: repeat(3, minmax(0, 1fr)); }}
@@ -392,6 +583,11 @@ flex-direction: column; gap: 3px;}/* The crash-response lead read carries a hairline accent so the at-a-glance "how far from the high" number reads as the headline of the pulse row. */.read--lead { border-left: 2px solid var(--rule-strong);}.read__label { @include eyebrow;
@@ -496,3 +692,109 @@.watch-grid { margin-top: var(--sp-3);}/* ---------- market movers ---------- */.movers { margin-top: var(--sp-5);}.movers__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2);}.movers__asof { font-size: var(--fs-2xs); color: var(--ink-faint);}.movers__cols { display: grid; grid-template-columns: 1fr; gap: var(--sp-3);}@media (min-width: $bp-sm) { .movers__cols { grid-template-columns: repeat(3, minmax(0, 1fr)); }}.movers__col { @include card; padding: var(--sp-2) var(--sp-3) var(--sp-1);}.movers__title { @include eyebrow; margin-bottom: 4px;}.movers__title--up { color: var(--up);}.movers__title--down { color: var(--down);}.movers__empty { font-size: var(--fs-xs); color: var(--ink-faint); padding: var(--sp-2) 0;}.mv-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: 7px 0; border-top: 1px solid var(--rule); text-decoration: none; color: var(--ink);}.movers__list .mv-row:first-child { border-top: none;}.mv-row:hover { color: var(--ink); background: var(--ink-wash, rgba(33, 31, 26, 0.04)); margin: 0 calc(-1 * var(--sp-2)); padding-left: var(--sp-2); padding-right: var(--sp-2);}.mv-row__id { display: flex; flex-direction: column; gap: 1px; min-width: 0;}.mv-row__sym { font-weight: 700; font-size: var(--fs-sm); line-height: 1.1;}.mv-row__name { font-size: var(--fs-2xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 13ch;}.mv-row__nums { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: none;}.mv-row__pct { font-weight: 700; font-size: var(--fs-sm); line-height: 1;}.mv-row__pct.is-up { color: var(--up);}.mv-row__pct.is-down { color: var(--down);}.mv-row__sub { font-size: var(--fs-2xs); color: var(--ink-faint);}
modified
frontend/static_src/symbol/styles/symbol.scss
@@ -711,6 +711,16 @@ font-size: 0.82rem;}/* Stale-earnings caveat: the latest SEC quarter is old enough that the price-based ratios may lag. Amber, sparing — a confidence flag, not an alarm. */.fund-stale { margin: -8px 0 16px; padding-left: 10px; border-left: 2px solid var(--warn); color: var(--warn); font-size: 0.82rem;}/* the rolled-up strong / fair / weak standing badge with a one-line reading, sitting above the per-ratio grid (Phase 20) */.fund-standing {
@@ -33,6 +33,33 @@ pub fn vix_tone(level: f64) -> &'static str { }}/// The S&P's drawdown from its record close, read into a tone + zone label for/// the dashboard's crash-response gauge. `dd` is the percent below the high/// (`<= 0`). The zones use the usual market vocabulary — a slight dip, a/// pullback, a correction (`-10%`), a bear market (`-20%`). For a DCA investor/// the deeper zones are the *add* zone, and the labels say so, because the/// research is clear that timing damage is done on the sell side, not the buy.pub fn drawdown_read(dd: f64) -> (&'static str, &'static str) { match dd { d if d >= -2.0 => ("up", "At highs"), d if d >= -5.0 => ("steady", "Slight dip"), d if d >= -10.0 => ("warn", "Pullback"), d if d >= -20.0 => ("down", "Correction · add zone"), _ => ("down", "Bear market · add zone"), }}/// A credit-stress read from the high-yield ETF's day move (HYG). Falling/// high-yield = risk-off / widening spreads; rising = risk appetite. A confirming/// gauge beside the VIX and the drawdown — the bond market's stress tell.pub fn credit_read(pct: f64) -> (&'static str, &'static str) { match pct { p if p <= -0.6 => ("down", "Stressing"), p if p >= 0.3 => ("up", "Easing"), _ => ("steady", "Steady"), }}/// Position of `value` along the `[lo, hi]` range, as a 0..100 percent for/// placing a marker on a track. Clamped to the ends; a zero-width range maps/// to the midpoint. Rounded to 2 dp so it inlines cleanly into a `style`.
modified
src/providers/yahoo.rs
@@ -232,7 +232,104 @@ struct OhlcvArrays { volume: Vec<Option<i64>>,}/// One row in a market-movers list (top gainers / losers / most active), from the/// predefined-screener endpoint. A plain snapshot for the dashboard, cached as/// JSON in `meta` (hence `Deserialize` too), not a stored table row.#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]pub struct Mover { pub symbol: String, pub name: String, pub price: Option<f64>, /// Day % move, already a percent (Yahoo returns e.g. 20.08, not 0.2008). pub change_pct: Option<f64>, pub volume: Option<i64>,}#[derive(Deserialize)]struct ScreenerEnvelope { finance: ScreenerFinance,}#[derive(Deserialize)]struct ScreenerFinance { #[serde(default)] result: Vec<ScreenerResult>,}#[derive(Deserialize)]struct ScreenerResult { #[serde(default)] quotes: Vec<ScreenerQuote>,}#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct ScreenerQuote { symbol: String, short_name: Option<String>, long_name: Option<String>, display_name: Option<String>, regular_market_price: Option<f64>, regular_market_change_percent: Option<f64>, regular_market_volume: Option<i64>,}impl YahooProvider { /// Fetch a predefined market-movers screener (`day_gainers` / `day_losers` / /// `most_actives`), `count` rows. One unauthenticated GET (the predefined /// screeners are not crumb-gated). A 429/503/401/403 surfaces as the typed /// [`RateLimited`] so the endpoint guard trips its breaker at once, exactly /// like the chart calls; anything else parses to the rows (empty on a shape /// we don't recognise). pub async fn fetch_movers(&self, scr_id: &str, count: u32) -> Result<Vec<Mover>> { let url = format!( "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved\ ?count={count}&scrIds={scr_id}" ); let resp = self.client.get(&url).send().await?; let status = resp.status(); if matches!( status, StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE | StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN ) { let retry_after_secs = resp .headers() .get(RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|s| s.trim().parse::<i64>().ok()); return Err(anyhow::Error::new(RateLimited { status: status.as_u16(), retry_after_secs, })); } let resp = resp.error_for_status()?; let env: ScreenerEnvelope = resp.json().await?; let quotes = env .finance .result .into_iter() .next() .map(|r| r.quotes) .unwrap_or_default(); Ok(quotes .into_iter() .map(|q| { let name = q .short_name .or(q.display_name) .or(q.long_name) .unwrap_or_else(|| q.symbol.clone()); Mover { symbol: q.symbol, name, price: q.regular_market_price, change_pct: q.regular_market_change_percent, volume: q.regular_market_volume, } }) .collect()) } /// Fetch and parse the v8 chart payload for `ticker`. /// /// `Ok(Some(_))` is a real chart result; `Ok(None)` means Yahoo answered
modified
src/routes/home.rs
@@ -19,18 +19,22 @@ use axum::{ routing::get, Json, Router,};use serde::Serialize;use serde::{Deserialize, Serialize};use crate::compute;use crate::guard::{EndpointGuard, Permit};use crate::market;use crate::providers::http;use crate::providers::yahoo::{Mover, YahooProvider};use crate::render::render_to_string;use crate::{watchlist, AppState};use crate::{db, scheduler, watchlist, AppState};pub fn router() -> Router<AppState> { Router::new() .route("/", get(home)) .route("/api/dashboard", get(dashboard_api)) .route("/api/dashboard/refresh", get(dashboard_refresh)) .route("/api/movers", get(movers_api))}/// The S&P 500 cash index — the SMA-trend read and the day graph's baseline.
@@ -42,6 +46,11 @@ const VIX: &str = "^VIX";/// while the dashboard is open (it carries a `data-ticker`) so it stays fresh.const VOLUME_PROXY: &str = "SPY";/// The high-yield corporate-bond ETF used as the dashboard's credit-stress proxy:/// a falling HYG means widening high-yield spreads (risk-off), the bond market's/// tell that a sell-off is a real credit event, not noise. Already in the seed.const CREDIT: &str = "HYG";/// One slot in the fixed market-overview grid. Two tickers, so each card can show/// the full extended-hours day *and* the universally-quoted number:/// - `chart` draws the line. For an index this is the E-mini **future**, which
@@ -73,6 +82,29 @@ const OVERVIEW: &[OverviewSlot] = &[ OverviewSlot { quote: "BTC-USD", chart: "BTC-USD", name: "Bitcoin", dollar: true },];/// The 11 SPDR Select Sector ETFs, the cheap stand-in for a 500-name S&P heatmap:/// each is a market-cap slice of one GICS sector, so their day moves show *which*/// part of the market is driving the index at a glance, for 11 quotes instead of/// 500. Ordered roughly by S&P weight so the biggest movers read first.const SECTORS: &[(&str, &str)] = &[ ("XLK", "Technology"), ("XLF", "Financials"), ("XLC", "Communication"), ("XLY", "Discretionary"), ("XLV", "Health Care"), ("XLI", "Industrials"), ("XLP", "Staples"), ("XLE", "Energy"), ("XLU", "Utilities"), ("XLRE", "Real Estate"), ("XLB", "Materials"),];/// The sector ETF tickers, for the home sweep / on-open refresh quote set.fn sector_tickers() -> Vec<&'static str> { SECTORS.iter().map(|(t, _)| *t).collect()}/// The overview slots as (quote ticker, chart ticker, display name, dollar unit).fn overview() -> Vec<(&'static str, &'static str, &'static str, bool)> { OVERVIEW.iter().map(|s| (s.quote, s.chart, s.name, s.dollar)).collect()
@@ -98,7 +130,12 @@ fn overview_tickers() -> Vec<&'static str> {/// this set (each adding the watchlist symbols on top).pub(crate) fn dashboard_tickers() -> Vec<&'static str> { let mut out = overview_tickers(); for t in [VIX, VOLUME_PROXY] { for t in [VIX, VOLUME_PROXY, CREDIT] { if !out.contains(&t) { out.push(t); } } for t in sector_tickers() { if !out.contains(&t) { out.push(t); }
@@ -219,6 +256,16 @@ struct MarketReads { /// The S&P's stance vs its 50- and 200-day averages, plus a tone. sma_read: Option<String>, sma_tone: Option<String>, /// The S&P's drawdown from its record close (`<= 0`), the crash-response lead /// read, with a tone and a zone label (slight dip / pullback / correction / /// bear, the deeper zones flagged as the DCA add zone). drawdown_pct: Option<f64>, drawdown_tone: Option<String>, drawdown_label: Option<String>, /// Credit-stress read off the high-yield ETF (HYG) day move, with tone + label. credit_pct: Option<f64>, credit_tone: Option<String>, credit_label: Option<String>, /// Freshest quote time (epoch-ms) across the baseline reads, for the /// "prices as of …" caption. asof: Option<i64>,
@@ -233,13 +280,30 @@ struct Series { name: String, /// "$" for dollar-priced instruments (gold, crude, BTC), "pts" otherwise. unit: &'static str, /// The session's first-bar open — the chart's reference line and the % base. /// The % base / the chart's dashed reference line. During the regular session /// this is the previous close; off-hours it is the reference the headline move /// is measured against (the futures' prior settlement, or the prev close). base: f64, /// The latest value (the card's headline figure). /// The latest value (the card's headline figure). Off-hours this is the live /// extended-hours value (the future for an index, the pre-market bar for a /// stock), not the frozen regular-session close. last: f64, /// % change from `base` (the day move shown beside the value). /// % change from `base` — the headline move, session-appropriate (the cash /// day move during the regular session, the futures move pre-market/overnight, /// the pre-market move for a stock, the close move after hours). change_pct: f64, /// True when the day move is not negative — drives the green/red line colour. /// Week-to-date % move: the cash value vs the cash close before this ET week's /// Monday, so the card can show the day AND the week move at once. `None` when /// there is no prior-week close to anchor to. week_pct: Option<f64>, /// Which session the headline reflects, so an off-hours number is never read /// as the close: `None` during the regular session (the plain cash number), /// else "Futures" / "Pre-market" / "After hours" / "Overnight" / "At close". headline_label: Option<&'static str>, /// Epoch-ms source time of the headline quote, for the per-card freshness chip /// ("live" / "2m ago" / "stale"). `None` when no quote has been stored yet. asof: Option<i64>, /// True when the headline move is not negative — drives the green/red line colour. up: bool, /// UNIX seconds bounding the chart frame (extended-hours open and close). /// Normally a single Schwab day; in end-of-week mode the whole trading week
@@ -261,6 +325,16 @@ struct SeriesPoint { v: f64,}/// One sector tile in the "what's driving the market" heatmap: a sector ETF's/// latest-session % move, the cell coloured green/red by it (clamped at ±3% on/// the client). `change_pct` is `None` until the ETF has been quoted.#[derive(Serialize)]struct SectorTile { ticker: String, name: &'static str, change_pct: Option<f64>,}/// What `/api/dashboard` returns and what `home` seeds the page with.#[derive(Serialize)]struct DashboardData {
@@ -268,11 +342,33 @@ struct DashboardData { reads: MarketReads, /// The fixed market-overview charts. series: Vec<Series>, /// The sector heatmap (11 SPDR sector ETFs), so the dashboard shows which /// part of the market is driving the index, not just the index level. sectors: Vec<SectorTile>, /// The session's watchlist, drawn with the same per-instrument chart /// treatment as the overview (Schwab day, shading, % vs prev close). watchlist: Vec<Series>,}/// Build the sector heatmap: each SPDR sector ETF's most-recent-session % move/// (latest price vs its previous close). A cheap, fixed set of local reads.async fn sector_tiles(state: &AppState) -> Vec<SectorTile> { let mut tiles = Vec::with_capacity(SECTORS.len()); for (ticker, name) in SECTORS { let (last, prev, _asof) = quote_row(state, ticker).await; let change_pct = match (last, prev) { (Some(l), Some(p)) if p > 0.0 => Some((l / p - 1.0) * 100.0), _ => None, }; tiles.push(SectorTile { ticker: (*ticker).to_string(), name, change_pct, }); } tiles}async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response { let session = watchlist::resolve(&state.pool, &headers).await; let tickers = watchlist::list(&state.pool, &session.sid).await;
@@ -297,7 +393,9 @@ async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response { reads => reads, vix => VIX, volume_proxy => VOLUME_PROXY, credit_proxy => CREDIT, overview_tickers => overview_tickers, sector_tickers => sector_tickers(), session => market_session.as_str(), session_label => session_label(market_session), };
@@ -349,6 +447,7 @@ async fn dashboard_api(State(state): State<AppState>, headers: HeaderMap) -> Res session: market_session.as_str().to_string(), reads: market_reads(&state).await, series, sectors: sector_tiles(&state).await, watchlist, };
@@ -423,6 +522,101 @@ async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) -> resp}/// Movers cache: re-fetch the screener at most this often. The pull is a guarded,/// paced 3-call job; in between, the cached blob is served. Demand-driven — only/// 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;/// The three market-movers lists for the dashboard's "what's driving it" tables.#[derive(Serialize, Deserialize)]struct MoversData { /// Epoch-ms the lists were fetched, for the freshness caption. `None` = empty. asof: Option<i64>, gainers: Vec<Mover>, losers: Vec<Mover>, actives: Vec<Mover>,}impl MoversData { fn empty() -> Self { MoversData { asof: None, gainers: vec![], losers: vec![], actives: vec![] } }}/// `GET /api/movers` — top gainers / losers / most active. Served from an 8-minute/// `meta` cache; on a miss it does one guarded, paced pull of the three predefined/// Yahoo screeners and stores the result. On a guard stop or fetch failure it/// falls back to the (stale) cache, else an empty set, so the dashboard degrades/// quietly and never hammers Yahoo. The page fetches this after first paint, so a/// cold pull never blocks the dashboard.async fn movers_api(State(state): State<AppState>) -> Response { let now = db::now_ms(); let cached: Option<MoversData> = db::get_meta(&state.pool, MOVERS_META_KEY) .await .ok() .flatten() .and_then(|raw| serde_json::from_str(&raw).ok()); if let Some(c) = &cached { if c.asof.is_some_and(|t| now - t < MOVERS_TTL_MS) { return Json(c).into_response(); } } if let Some(fresh) = fetch_movers_fresh(&state, now).await { return Json(fresh).into_response(); } // Refresh did not land (guard stop or all calls failed): serve stale, else empty. match cached { Some(c) => Json(c).into_response(), None => Json(MoversData::empty()).into_response(), }}/// One guarded, paced pull of the three movers screeners, stored to the cache./// `None` when the guard denies the first call or every call fails (so the caller/// can fall back to the cache); a partial result (some lists empty) still returns.async fn fetch_movers_fresh(state: &AppState, now: i64) -> Option<MoversData> { let yahoo = YahooProvider::new(http::build_client(&state.config)); let guard = EndpointGuard::with_budget(state.pool.clone(), "yahoo", scheduler::YAHOO_BUDGET); let mut out = MoversData::empty(); let mut any = false; for (scr, slot) in [("day_gainers", 0u8), ("day_losers", 1), ("most_actives", 2)] { match guard.acquire().await { Ok(Permit::Granted) => {} // Denied (breaker/budget/pacing) or an acquire error: stop and let the // caller serve the cache rather than push against the guard. _ => break, } match yahoo.fetch_movers(scr, MOVERS_COUNT).await { Ok(rows) => { let _ = guard.record_success().await; any = true; match slot { 0 => out.gainers = rows, 1 => out.losers = rows, _ => out.actives = rows, } } Err(e) => { let _ = guard.record_failure(&e).await; } } } if !any { return None; } out.asof = Some(now); if let Ok(json) = serde_json::to_string(&out) { let _ = db::set_meta(&state.pool, MOVERS_META_KEY, &json).await; } Some(out)}/// A human session label for the dashboard's market-hours banner.fn session_label(s: market::Session) -> &'static str { match s {
@@ -433,12 +627,93 @@ fn session_label(s: market::Session) -> &'static str { }}/// Build one slot's overview chart over a single Schwab trading day (extended/// open through extended close, framed by `start_t`/`end_t`). The **line** is the/// `chart` ticker's 15-minute bars (the future, so pre-market + regular +/// after-hours all show), while the headline **value + %** come from the `quote`/// ticker (the cash index, the universally-quoted number). `None` when the chart/// ticker has no intraday bars (e.g. never polled, or a holiday)./// One symbol's latest value, prior close, and quote age: the live last price/// (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>) { 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)), \ s.last_quote_at \ FROM symbols s WHERE s.ticker = ?", ) .bind(ticker) .fetch_optional(&state.pool) .await .ok() .flatten() .unwrap_or((None, None, None))}/// The cash close strictly before the current ET week's Monday — the week-to-date/// % base, so "this week" reads as the move since last week ended (the prior/// Friday's close).async fn week_base_close(state: &AppState, ticker: &str, now_ms: i64) -> Option<f64> { use chrono::{Datelike as _, Duration, TimeZone as _}; use chrono_tz::America::New_York; let now = New_York.timestamp_millis_opt(now_ms).single()?; let monday = now.date_naive() - Duration::days(now.weekday().num_days_from_monday() as i64); sqlx::query_scalar( "SELECT close FROM daily_prices WHERE ticker = ? AND d < ? ORDER BY d DESC LIMIT 1", ) .bind(ticker) .bind(monday.to_string()) .fetch_optional(&state.pool) .await .ok() .flatten() .filter(|c: &f64| *c > 0.0)}/// The session-appropriate headline source for a card: which value, which % base,/// and how to label it, so a number is never read as something it isn't.////// - **Regular session:** the cash value vs its previous close, unlabelled — the/// universally-quoted day move./// - **Pre-market:** for an index, the E-mini **future** vs its prior settlement/// ("Futures", what every market site shows at 7am); for a stock, its/// pre-market bar vs the previous close ("Pre-market")./// - **After hours:** the regular-session **close** ("At close") — what everyone/// still quotes as the day's result after 4pm./// - **Overnight (closed):** for an index, the future ("Overnight"); for a stock,/// the last close ("At close").////// `cash` and `fut` are each `(last, prev_close, asof_ms)`; `fut` is `Some` only/// for an index slot (a distinct futures chart ticker). `ext_bar` is the last/// drawn bar's close, the live extended-hours value for a stock pre-market.fn headline( session: market::Session, cash: (Option<f64>, Option<f64>, Option<i64>), fut: Option<(Option<f64>, Option<f64>, Option<i64>)>, ext_bar: Option<f64>,) -> (Option<f64>, Option<f64>, Option<&'static str>, Option<i64>) { use market::Session::{Closed, Post, Pre, Regular}; match session { Regular => (cash.0, cash.1, None, cash.2), Pre => match fut { Some(f) => (f.0, f.1, Some("Futures"), f.2), None => (ext_bar.or(cash.0), cash.1, Some("Pre-market"), cash.2), }, Post => (cash.0, cash.1, Some("At close"), cash.2), Closed => match fut { Some(f) => (f.0, f.1, Some("Overnight"), f.2), None => (cash.0, cash.1, Some("At close"), cash.2), }, }}/// Build one slot's overview chart over a single Schwab trading day (extended open/// through extended close, framed by `start_t`/`end_t`). The **line** is the/// `chart` ticker's 15-minute bars (the future for an index, so pre-market +/// regular + after-hours all show). The headline **value + %** are session-aware/// (see [`headline`]): the cash index during the regular session, the future/// pre-market and overnight, the pre-market bar for a stock, the close after/// hours — so the number matches what Yahoo/MarketWatch show at the moment you/// look, instead of a frozen cash close at 7am or midnight. `week_pct` carries the/// week-to-date move alongside. `None` when the chart ticker has no intraday bars.async fn overview_series( state: &AppState, quote_ticker: &str,
@@ -446,10 +721,15 @@ async fn overview_series( name: &str, dollar: bool,) -> Option<Series> { // After Friday's close the chart frames the whole trading week; the rest of // the time it anchors to the Schwab day the chart ticker's most recent bar // falls in, so it shows just that one day and never the previous one. let now_ms = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now(); let now_ms = now.timestamp_millis(); let session = market::session_at(now); // An index slot pairs a cash quote ticker with a distinct futures chart ticker; // gold/crude/BTC and watchlist symbols are a single ticker (no futures proxy). let is_index_slot = quote_ticker != chart_ticker; // Chart frame: the whole trading week after Friday's close, else the Schwab // day the chart ticker's most recent bar falls in. let (start_ms, end_ms, week_monday) = match week_window(now_ms) { Some((s, e, mon)) => (s, e, Some(mon)), None => {
@@ -478,38 +758,25 @@ async fn overview_series( .await .unwrap_or_default(); // Headline value + % come from the QUOTE ticker, exactly as every market site // shows it: the latest price (`last_price` = Yahoo's regularMarketPrice) // against the previous close (`prev_close` = chartPreviousClose). For the // cash indexes this is the universally-quoted number — live during the // session, frozen at the closing change after the close. `prev_close` is also // the chart's dashed reference line. Both fall back to stored daily closes. let quote: Option<(Option<f64>, Option<f64>)> = 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)) \ FROM symbols s WHERE s.ticker = ?", ) .bind(quote_ticker) .fetch_optional(&state.pool) .await .ok() .flatten(); let (last_price, prev_close) = quote.unwrap_or((None, None)); // Last value: the quote price, else the last drawn bar's close. Over the // weekend the quote price is Yahoo's frozen Friday close — exactly the week's // end value we want. let last = last_price.or_else(|| rows.last().map(|r| r.2))?; // Reference: the session's first-bar open (also the % base's last-resort). // The session's first-bar open (the % base's last resort) and the last drawn // bar's close (a stock's live extended-hours value pre-market). let open = rows.first().map(|r| if r.1 > 0.0 { r.1 } else { r.2 }); // The % base. Single-day mode: the previous close (the universally-quoted // day move). Week mode: the prior Friday's close — the last daily close // strictly before this week's Monday — so the move is the full-week change. let base = if let Some(monday) = week_monday { let prior_close: Option<f64> = sqlx::query_scalar( let ext_bar = rows.last().map(|r| r.2); // The cash quote (regular-session number + week base) and, for an index slot, // the futures quote (the off-hours number). The tuples are Copy. let cash = quote_row(state, quote_ticker).await; let fut = if is_index_slot { Some(quote_row(state, chart_ticker).await) } else { None }; // Headline value / base / label / age. In the end-of-week frame the move is // the whole-week change (prior Friday's close → Friday's close); otherwise it // is the session-aware headline. let (last_opt, base_opt, label, asof) = if let Some(monday) = week_monday { let prior: Option<f64> = sqlx::query_scalar( "SELECT close FROM daily_prices WHERE ticker = ? AND d < ? ORDER BY d DESC LIMIT 1", ) .bind(quote_ticker)
@@ -518,18 +785,34 @@ async fn overview_series( .await .ok() .flatten(); prior_close.filter(|p| *p > 0.0).or(open)? (cash.0.or(ext_bar), prior, Some("This week"), cash.2) } else { prev_close.filter(|p| *p > 0.0).or(open)? headline(session, cash, fut, ext_bar) }; let last = last_opt.or(ext_bar)?; let base = base_opt.filter(|p| *p > 0.0).or(open)?; if base <= 0.0 { return None; } let change_pct = (last / base - 1.0) * 100.0; // Week-to-date move (cash value vs the close before this week's Monday), shown // alongside the day move. In the weekend frame the headline already IS the // week move, so reuse it. let week_pct = if week_monday.is_some() { Some(change_pct) } else { match (cash.0, week_base_close(state, quote_ticker, now_ms).await) { (Some(c), Some(wb)) => Some((c / wb - 1.0) * 100.0), _ => None, } }; let points: Vec<SeriesPoint> = rows .iter() .map(|(ts, _open, close)| SeriesPoint { t: ts / 1000, v: *close }) .collect(); let change_pct = (last / base - 1.0) * 100.0; Some(Series { ticker: quote_ticker.to_string(), name: name.to_string(),
@@ -537,6 +820,9 @@ async fn overview_series( base, last, change_pct, week_pct, headline_label: label, asof, up: change_pct >= 0.0, start_t: start_ms / 1000, end_t: end_ms / 1000,
@@ -621,6 +907,37 @@ async fn market_reads(state: &AppState) -> MarketReads { } } // S&P drawdown from its record close — the crash-response lead read. The // record is the deepest daily history we hold (seeded ~10y, enough for the // recent peak); a live value above it just reads as 0% (at highs). if let Some((Some(last), _)) = last_and_prev(state, BASELINE).await { let ath: Option<f64> = sqlx::query_scalar("SELECT MAX(close) FROM daily_prices WHERE ticker = ?") .bind(BASELINE) .fetch_optional(&state.pool) .await .ok() .flatten(); if let Some(high) = ath.filter(|a| *a > 0.0) { let dd = (last / high.max(last) - 1.0) * 100.0; let (tone, label) = compute::drawdown_read(dd); r.drawdown_pct = Some(dd); r.drawdown_tone = Some(tone.to_string()); r.drawdown_label = Some(label.to_string()); } } // Credit stress via the high-yield ETF's day move. if let Some((Some(last), Some(prev))) = last_and_prev(state, CREDIT).await { if prev > 0.0 { let pct = (last / prev - 1.0) * 100.0; let (tone, label) = compute::credit_read(pct); r.credit_pct = Some(pct); r.credit_tone = Some(tone.to_string()); r.credit_label = Some(label.to_string()); } } // Freshest quote across the baseline reads, for the "prices as of" caption. r.asof = sqlx::query_scalar( "SELECT MAX(fetched_at) FROM quotes WHERE ticker IN (?, ?, ?)",
modified
src/routes/symbols.rs
@@ -339,8 +339,16 @@ struct FundTable {/// Everything the symbol page's fundamentals + financials sections need.#[derive(Serialize)]struct FundamentalsView { /// Fiscal period the ratios are based on, e.g. `FY2024`. /// Fiscal period the annual ratios are based on, e.g. `FY2024`. basis: Option<String>, /// What the P/E's earnings figure is, e.g. `TTM through Jun 2025` (trailing /// twelve months) or `FY2024` when fewer than four quarters are available. pe_basis: Option<String>, /// The most recent reported period (e.g. `Jun 2025`), for the freshness note. earnings_period: Option<String>, /// True when the latest reported quarter is old enough that the price-based /// ratios may lag a more recent quarter, so the page can flag it. earnings_stale: bool, ratios: Vec<compute::Ratio>, annual: FundTable, quarterly: FundTable,
@@ -348,6 +356,43 @@ struct FundamentalsView { has_quarterly: bool,}/// Trailing-twelve-month diluted EPS: the sum of the four most recent quarterly/// diluted-EPS facts (the derived Q4 included), with the latest quarter's period/// end. `None` with fewer than four quarters. Diluted EPS does not decompose/// perfectly quarter to quarter, but a TTM sum tracks current earnings far better/// than a fiscal-year figure that can be 6-12 months stale — which is exactly what/// makes a live-price P/E drift. Returns `(ttm_eps, latest_period_end)`.fn ttm_eps_diluted(facts: &[models::FundFact]) -> Option<(f64, String)> { let mut q: Vec<(&str, f64)> = facts .iter() .filter(|f| f.fiscal_qtr.is_some() && f.metric == "eps_diluted") .map(|f| (f.period_end.as_str(), f.value)) .collect(); // Most recent period first, one row per quarter. q.sort_by(|a, b| b.0.cmp(a.0)); q.dedup_by(|a, b| a.0 == b.0); if q.len() < 4 { return None; } Some((q[..4].iter().map(|(_, v)| *v).sum(), q[0].0.to_string()))}/// A `YYYY-MM-DD` period end as a short `Mon YYYY` label (e.g. `Jun 2025`).fn fmt_period(end: &str) -> String { chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d") .map(|d| d.format("%b %Y").to_string()) .unwrap_or_else(|_| end.to_string())}/// Days since a `YYYY-MM-DD` period end. Large numbers mean a missing or stale/// recent filing. Returns `i64::MAX` when the date does not parse (treated stale).fn period_age_days(end: &str) -> i64 { match chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d") { Ok(d) => (chrono::Utc::now().date_naive() - d).num_days(), Err(_) => i64::MAX, }}/// One SEC filing shaped for the page.#[derive(Serialize)]struct FilingView {
@@ -592,16 +637,38 @@ fn build_fundamentals(facts: &[models::FundFact], price: Option<f64>) -> Option< .map(|(y, _, p)| (y, p)) .collect(); // Ratios run off the most recent full fiscal year; the shared helper in // `models` assembles the inputs so the home ranking grades stocks the // same way this page does. // Balance-sheet + margin ratios run off the most recent full fiscal year (the // shared helper, so the home ranking grades stocks the same way). The P/E, // though, divides a LIVE price, so it gets trailing-twelve-month EPS when we // have four quarters: dividing today's price by a 6-12-month-old fiscal-year // EPS is what made the P/E look wrong after a price move. let latest_fy = annual.last().map(|(y, _)| *y); let ratios = models::latest_annual_inputs(facts, price) .map(|inputs| compute::compute_ratios(&inputs)) .unwrap_or_default(); let ttm = ttm_eps_diluted(facts); let inputs = models::latest_annual_inputs(facts, price).map(|mut i| { if let Some((eps, _)) = ttm { i.eps_diluted = Some(eps); } i }); let ratios = inputs.map(|i| compute::compute_ratios(&i)).unwrap_or_default(); let pe_basis = match &ttm { Some((_, end)) => Some(format!("TTM through {}", fmt_period(end))), None => latest_fy.map(|y| format!("FY{y}")), }; // The most recent reported period across all facts, and whether it is old // enough that the price-based ratios may lag a newer quarter. Companies file // ~45 days after a quarter ends, so a latest period older than ~150 days // means a recent quarter is missing or the SEC pull is stale. let latest_period_end = facts.iter().map(|f| f.period_end.as_str()).max(); let earnings_period = latest_period_end.map(fmt_period); let earnings_stale = latest_period_end.is_some_and(|d| period_age_days(d) > 150); Some(FundamentalsView { basis: latest_fy.map(|y| format!("FY{y}")), pe_basis, earnings_period, earnings_stale, ratios, has_annual: !annual.is_empty(), has_quarterly: !quarterly.is_empty(),
modified
templates/pages/home.html
@@ -51,19 +51,46 @@ {% for t in overview_tickers %}<span data-ticker="{{ t }}"></span>{% endfor %} </div> {# Sector heatmap: the 11 SPDR sector ETFs, coloured by today's move, so the dashboard answers "what part of the market is driving it". hero.js fills the grid from /api/dashboard; the hidden interest nodes keep the ETFs polled. #} <section class="sectors"> <div class="sectors__head"> <h2 class="section-title">What’s moving</h2> <span class="sectors__note">sectors · latest-session % move</span> </div> <div class="sectors__grid" data-role="sectors-grid" aria-busy="true"></div> </section> <div class="sectors-interest" hidden aria-hidden="true"> {% for t in sector_tickers %}<span data-ticker="{{ t }}"></span>{% endfor %} </div> {# Headline market reads (context the graph doesn't carry). VIX and SPY carry data-ticker only (to keep them polled) and are patched by hero.js, which also fills volume + the S&P trend. The S&P price itself lives on the graph now. #} <div class="reads-head"> <span class="reads-head__asof" data-role="reads-asof">{% if reads.asof %}Prices as of {{ reads.asof|asof }}{% else %}Awaiting first quotes…{% endif %}</span> <span class="reads-head__refresh" data-role="refresh-state" hidden><span class="reads-head__spinner" aria-hidden="true"></span>Refreshing…</span> </div> <section class="reads"> <div class="read read--lead"> <div class="read__label">S&P from high</div> <div class="read__value num read__tone--{{ reads.drawdown_tone or 'steady' }}" data-role="drawdown-pct">{{ reads.drawdown_pct|pct if reads.drawdown_pct is not none else "—" }}</div> <div class="read__sub" data-role="drawdown-label">{{ reads.drawdown_label or "Drawdown from record close" }}</div> </div> <div class="read" data-ticker="{{ vix }}"> <div class="read__label">Volatility · VIX</div> <div class="read__value num" data-role="vix-level">{{ reads.vix_level|round(2) if reads.vix_level is not none else "—" }}</div> <div class="read__sub read__tone read__tone--{{ reads.vix_tone or 'steady' }}" data-role="vix-tone">{{ reads.vix_tone|capitalize if reads.vix_tone else "—" }}</div> </div> <div class="read" data-ticker="{{ credit_proxy }}"> <div class="read__label">Credit · high yield</div> <div class="read__value num read__tone--{{ reads.credit_tone or 'steady' }}" data-role="credit-pct">{{ reads.credit_pct|pct if reads.credit_pct is not none else "—" }}</div> <div class="read__sub" data-role="credit-label">{{ reads.credit_label or "HYG vs prev close" }}</div> </div> <div class="read" data-ticker="{{ volume_proxy }}"> <div class="read__label">Market volume</div> <div class="read__value num" data-role="volume">{{ reads.volume|compact if reads.volume is not none else "—" }}</div>
@@ -108,6 +135,11 @@ <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> <div class="ov-card__chart"></div> </a> <button class="ov-card__remove" type="button" data-remove data-ticker="{{ c.ticker }}" aria-label="Remove {{ c.ticker }} from watchlist" title="Remove">×</button>
@@ -119,6 +151,30 @@ <a href="/search">browse the universe</a> to find one.</p> {% endif %} </section> {# Market movers: top gainers / losers / most active, the "what specific names moved and why" detail. hero.js fills the three lists from /api/movers (an 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> <span class="movers__asof" data-role="movers-asof"></span> </div> <div class="movers__cols" data-role="movers" aria-busy="true"> <div class="movers__col" data-mv="gainers"> <h3 class="movers__title movers__title--up">Gainers</h3> <div class="movers__list"></div> </div> <div class="movers__col" data-mv="losers"> <h3 class="movers__title movers__title--down">Losers</h3> <div class="movers__list"></div> </div> <div class="movers__col" data-mv="actives"> <h3 class="movers__title">Most active</h3> <div class="movers__list"></div> </div> </div> </section></div>{% endblock %}
modified
templates/pages/symbol.html
@@ -446,12 +446,13 @@ <div class="fund-standing"> {{ verdict_badge(standing) }} <p class="fund-standing__text">Across the nine graded ratios{% if fundamentals.basis %} ({{ fundamentals.basis }} figures against the latest price){% endif %}, ({{ fundamentals.basis }} balance-sheet figures{% if fundamentals.pe_basis %}, P/E on {{ fundamentals.pe_basis }} earnings{% endif %}, against the latest price){% endif %}, {{ symbol.ticker }}’s fundamentals read as <strong>{{ standing.verdict|lower }}</strong> overall.</p> </div> {% elif fundamentals.basis %} <p class="fund-basis">Each ratio reads {{ fundamentals.basis }} figures against the latest price, graded so the quality shows at a glance.</p> <p class="fund-basis">Balance-sheet ratios read {{ fundamentals.basis }} figures{% if fundamentals.pe_basis %}; the P/E uses {{ fundamentals.pe_basis }} EPS{% endif %}, against the latest price.</p> {% endif %} {% if fundamentals.earnings_stale %}<p class="fund-stale" role="note">Latest SEC earnings are from {{ fundamentals.earnings_period }}; the price-based ratios may lag a more recent quarter.</p>{% endif %} <section class="ratios"> {% for r in fundamentals.ratios %} <div class="ratio ratio--{{ r.grade }}">
modified
universe/starter.csv
@@ -562,3 +562,14 @@ SI=F,Silver Futures,future,COMEX,HG=F,Copper Futures,future,COMEX,NG=F,Natural Gas Futures,future,NYMEX,BTC-USD,Bitcoin,crypto,,XLK,Technology Select Sector SPDR,etf,,^SPXXLF,Financial Select Sector SPDR,etf,,^SPXXLE,Energy Select Sector SPDR,etf,,^SPXXLV,Health Care Select Sector SPDR,etf,,^SPXXLI,Industrial Select Sector SPDR,etf,,^SPXXLP,Consumer Staples Select Sector SPDR,etf,,^SPXXLY,Consumer Discretionary Select Sector SPDR,etf,,^SPXXLU,Utilities Select Sector SPDR,etf,,^SPXXLB,Materials Select Sector SPDR,etf,,^SPXXLRE,Real Estate Select Sector SPDR,etf,,^SPXXLC,Communication Services Select Sector SPDR,etf,,^SPX