@@ -34,7 +34,7 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-05-30 (Phase 6 done on dev; commit + deploy pending)__Last updated: 2026-05-30 (Phase 7 done on dev; commit + deploy pending)_**Major refactor in progress (the "distill + ETF-first" rewrite).** This planwas fully rewritten 2026-05-30 from a sprawling 3,700-line resume doc into this
@@ -54,10 +54,56 @@ focused roadmap. The decisions driving it are in the Decisions log under- **Everything gets distilled** into a fast-scannable, dual-first (mobile + desktop) design while keeping the futuristic-clean "Paper Ledger" look.**Current work:** Phases 1–5 are **done, committed, and deployed** (Phase 5 islive at `656e21d`). **Phase 6 is done on dev** (symbol-page distillation + liveintraday); commit + deploy pending. Next: **Phase 7 (health/systems pagedistillation + footer expansion + final polish).****Current work:** Phases 1–6 are **done, committed, and deployed** (Phase 6 islive at `aa8c5f3`). **Phase 7 is done on dev** (health verdict + two-tier footer+ live market summary); commit + deploy pending. With Phase 7 the roadmap is**complete** — remaining items are all in Backlog / parked.Phase 7 outcome (health distillation + footer + live breadth):- **Systems verdict on `/health`.** A one-line plain read distilling the whole page — `● All systems normal · both data sources healthy · N jobs on schedule · last fetch 4m ago` — sits above the Endpoints/Jobs/Log detail. Built client-side in `health.js` (`renderVerdict`) from the same snapshot that drives the detail, so it stays live and never disagrees with it. Tone is the worst thing on the page: a tripped breaker or errored job → bad (red); a recovering breaker or stale job → warn (amber, pulsing dot); else ok (green). A mid-fetch job is normal and doesn't darken the tone (the existing "Fetching now —" banner names it). Verified on dev: reads green "All systems normal · both data sources healthy · 10 jobs on schedule · fetching now" at desktop + mobile.- **Two-tier footer (Paper Ledger).** Replaced the single-line footer with the house pattern: a `footer__grid` (About + data attribution / Pages / Elsewhere, `// LABEL` mono headers) over a slim `.footer-bar` (`© {{ now.year }} Isaac Bythewood · Some rights reserved` + a GitHub icon link to `github.com/overshard/finance`). Modeled on `repos` but in Paper Ledger tokens (warm-paper wash, hairline rules, ink text). The stale-Stooq attribution (fixed early in Phase 4) folds into the About column as "Market data via Yahoo Finance · fundamentals & filings via SEC EDGAR · prices are delayed · not investment advice." The `.footer-bar` now carries the mobile bottom-nav clearance (it's the last element before the fixed nav); the desktop block trims it back. Verified: 3-up at desktop, About-spans-full + 2-up links at mobile, copyright bar clears the bottom nav.- **Live market summary (the Phase-5 deferral).** The hero verdict + headline figures + breadth were page-load snapshots; now a new `src/summary.rs` owns the verdict vocabulary (`market_verdict` + `vix_tone`, moved out of `home.rs` so the page render and the live push share one wording) and a cheap `market_summary(pool, session)` (two aggregate reads: breadth over the curated stocks + the lead index / VIX, resolving last-vs-prev exactly as the home queries do). A new `StreamEvent::Summary` carries it; the scheduler publishes it after an intraday sweep that touched a **pulse ticker** (the broad index or ^VIX — the curated stocks behind breadth only move at the daily close, so a sweep that hit only a viewed stock page is skipped), after `daily_close`, and on a session flip (the lead index swaps cash↔future). `base/stream.js` re-broadcasts it as a `finance:summary` window event; a new `home.js` patches the hero verdict/detail/figures + breadth counts/bar in place — no second EventSource. **The live push couldn't be exercised on dev** (closed-market weekend, no quotes stream — same caveat as Phase 6's live tick); it runs first in prod during market hours. The patcher itself was verified structurally by dispatching a synthetic `finance:summary`: the verdict, all three figures, the breadth counts, and the proportion-bar widths all updated correctly.- **Verified on dev:** `cargo build` + `vite build` clean; home + health render at desktop (1280) and mobile (390) with no console errors; the new footer and health verdict read correctly; the synthetic-summary patch updates every hero + breadth node (screenshots reviewed, then deleted).Phase 6 outcome (symbol-page distillation + live intraday on chart):- **1D / 1W intraday range buttons.** Two new ranges at the front of the chart's
@@ -395,7 +441,18 @@ Kill the rate-limit problem at the root. Order: price/change → verdict → chart.- ✅ Fixed a latent `.ind-btn[hidden]` override bug found in passing.### Phase 7 — Health/systems page distillation + final polish pass### Phase 7 — Health/systems page distillation + final polish pass ✅ DONE on dev (commit + deploy pending)- ✅ **`/health` distilled with a top systems verdict** (the chosen shape): a one-line plain read above the existing detail, computed live in `health.js`.- ✅ **Two-tier footer built** (`footer__grid` + `.footer-bar`, Paper Ledger), data attribution folded into the About column.- ✅ **Live market summary** (the Phase-5 hero/breadth deferral): `src/summary.rs` + `StreamEvent::Summary` + scheduler publishes + `home.js` patches. Live path runs first in prod (closed-market weekend can't stream on dev).- Light cohesion only (per the scope decision); no full cross-page audit.- See Status + the 2026-05-30 Phase 7 decisions log for the full outcome.Original Phase 7 brief (kept for reference):- Distill `/health` and overall cross-page cohesion; one closing UI polish pass.- **Expand the footer to match the sibling-project pattern.** Finance currently has a single-line footer; the user wants it grown "a lot" to match how the
@@ -454,6 +511,32 @@ Kill the rate-limit problem at the root.## Decisions log**2026-05-30 — Phase 7 (health distillation + footer + live breadth) design forksresolved.** Answered 3 clarifying questions before building:1. **/health distillation = add a top "verdict" summary** (like the dashboard hero): a one-line plain-language systems read at the top (`● All systems normal · both data sources healthy · N jobs on schedule · last fetch 4m ago`), keeping the existing Endpoints / Background jobs / Activity-log detail below. Computed client-side in `health.js` from the snapshot so it stays live.2. **Build live breadth now** (not parked). The Phase-5 hero verdict + breadth were page-load snapshots; add a server-pushed market-summary event on the stream hub so the verdict sentence, the headline figures, and the breadth counts/bar recompute intraday instead of going stale beside the live-ticking index chips. (Runs first in prod during market hours — a closed-market weekend can't stream quotes on dev, like Phase 6's live tick.)3. **Polish scope = footer + health properly, light cohesion only.** Build the two-tier footer (modeled on `repos`' `footer__grid`, adapted to Paper Ledger) and the health verdict; do a light spacing/heading cohesion sweep only where quick, rather than a full cross-page audit.Implementation shape: a new `src/summary.rs` owns the verdict vocabulary(`market_verdict` + `vix_tone`, moved out of `home.rs` so page-load and live pushshare one wording) and a cheap `market_summary(pool, session)` (two aggregatereads: breadth over curated stocks + the lead index / VIX). A new`StreamEvent::Summary` carries it; the scheduler publishes it after an intradaysweep that touched a pulse ticker (the broad index or ^VIX) and after daily_close.`base/stream.js` re-broadcasts it as a `finance:summary` window event; a new`home.js` patches the hero + breadth DOM in place.**2026-05-30 — Phase 6 (symbol-page distillation + live intraday).** Answered 3design forks before building:1. **Intraday = dedicated 1D + 1W range buttons** (not a single live candle
modified
frontend/static_src/base/scripts/stream.js
@@ -244,6 +244,18 @@ export function initStream() { // it is purely a nudge to re-pull /api/health. window.dispatchEvent(new Event("finance:health")); }); es.addEventListener("summary", (e) => { // Re-broadcast the recomputed dashboard summary (Phase 7) so the home // page can patch its hero verdict + breadth without a second connection. // Only the dashboard listens; every other page ignores it. try { window.dispatchEvent( new CustomEvent("finance:summary", { detail: JSON.parse(e.data) }), ); } catch { /* ignore a malformed frame */ } }); } connect();
modified
frontend/static_src/base/styles/base.scss
@@ -437,22 +437,111 @@ main { flex: 1 0 auto;}/* Two-tier footer (Phase 7): a multi-column upper block (About / Pages / Elsewhere) over a slim copyright bar. Mirrors the sibling apps' shape in Paper Ledger tokens — warm-paper wash, hairline rules, `// LABEL` mono column headers. */.footer { border-top: 1px solid var(--rule); background: var(--paper-edge); margin-top: 52px; /* the extra bottom room clears the fixed bottom nav on phones; the desktop block below trims it back since desktop has no bottom nav */ padding: 22px 16px calc(22px + 64px + env(safe-area-inset-bottom)); margin-top: 56px; padding-top: 40px; padding-bottom: 36px;}.footer__grid { display: grid; gap: 36px; grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr); @media (max-width: 640px) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px 24px; }}.footer__label { font-family: var(--font-mono); font-size: var(--fs-2xs); font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); font-size: 0.8rem; text-align: center; margin-bottom: 14px;}.footer a { color: var(--ink-dim); text-decoration: underline; text-underline-offset: 2px;.footer__about { /* the lede column spans the full width before the link columns wrap under it */ @media (max-width: 640px) { grid-column: 1 / -1; } p { color: var(--ink-dim); font-size: var(--fs-sm); line-height: 1.65; margin: 0 0 12px; max-width: 52ch; }}.footer__attrib { color: var(--ink-faint); font-size: var(--fs-xs); span { color: var(--ink-dim); } strong { color: var(--ink-dim); font-weight: 600; }}.footer__col { ul { list-style: none; margin: 0; padding: 0; } li { margin-bottom: 9px; } a { color: var(--ink-dim); font-size: var(--fs-sm); text-decoration: none; transition: color 150ms ease; &:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; } }}/* Slim copyright bar, like blog/status/analytics/repos. It is the last element before the fixed mobile bottom-nav, so it carries the nav clearance on phones; the desktop block below trims it back. */.footer-bar { border-top: 1px solid var(--rule); background: var(--paper-edge); padding: 14px 0 calc(14px + 64px + env(safe-area-inset-bottom)); color: var(--ink-faint); font-size: var(--fs-xs);}.footer-bar__row { display: flex; align-items: center; justify-content: space-between; gap: 16px; @media (max-width: 480px) { flex-direction: column; gap: 10px; text-align: center; }}.footer-bar__link { display: inline-flex; color: var(--ink-faint); transition: color 150ms ease; &:hover { color: var(--ink); }}/* ---------- empty state ---------- */
@@ -766,9 +855,9 @@ button:focus-visible { display: none; } /* no bottom nav on desktop, so the footer needs no clearance for it */ .footer { padding-bottom: 22px; /* no bottom nav on desktop, so the copyright bar needs no clearance for it */ .footer-bar { padding-bottom: 14px; } .wrap {
modified
frontend/static_src/health/scripts/health.js
@@ -169,6 +169,68 @@ function renderLog(list) { return `<div class="log"><ul class="logrows">${rows.join("")}</ul></div>`;}// The top systems verdict (Phase 7): distil the whole snapshot into one plain// read — overall tone, a headline, and a supporting clause. Tone is the worst// thing on the page: a tripped breaker or an errored job is bad; a recovering// breaker or a stale job is working; otherwise all-clear. A mid-fetch job is// normal and does not darken the tone (the live banner below names it).function renderVerdict(snap) { const el = $('[data-role="verdict"]'); if (!el) return; const eps = snap.endpoints || []; const jobs = snap.jobs || []; const log = snap.log || []; const epOpen = eps.filter((e) => e.state === "open").length; const epHalf = eps.filter((e) => e.state === "half_open").length; const epHealthy = eps.filter((e) => e.state === "closed").length; const jobErr = jobs.filter((j) => j.state === "error").length; const jobStale = jobs.filter((j) => j.state === "stale").length; const fetching = jobs.filter((j) => j.state === "fetching").length; let tone, head; if (epOpen || jobErr) { tone = "bad"; head = "Data flow degraded"; } else if (epHalf || jobStale) { tone = "warn"; head = "Recovering"; } else { tone = "ok"; head = "All systems normal"; } // Sources clause: "both data sources healthy" reads best at the usual two // (Yahoo + SEC), with a fraction when any is down. let srcPart; if (!eps.length) { srcPart = "no sources contacted yet"; } else if (epHealthy === eps.length) { srcPart = eps.length === 2 ? "both data sources healthy" : `all ${eps.length} data sources healthy`; } else { srcPart = `${epHealthy}/${eps.length} data sources healthy`; } // Jobs clause: how many are on schedule (anything not errored or stale). let jobPart; if (!jobs.length) { jobPart = "no jobs yet"; } else if (jobErr || jobStale) { jobPart = `${jobs.length - jobErr - jobStale}/${jobs.length} jobs on schedule`; } else { jobPart = `${jobs.length} jobs on schedule`; } const parts = [srcPart, jobPart]; if (fetching) parts.push("fetching now"); else if (log.length) parts.push(`last fetch ${ago(log[0].started_at)}`); el.dataset.tone = tone; el.hidden = false; $('[data-role="verdict-head"]').textContent = head; $('[data-role="verdict-detail"]').textContent = parts.join(` ${DASH} `);}function renderBanner(jobs) { const banner = $('[data-role="banner"]'); if (!banner) return;
@@ -194,6 +256,7 @@ export function initHealth() { function render(snap) { current = snap; renderVerdict(snap); $('[data-role="endpoints"]').innerHTML = renderEndpoints(snap.endpoints); $('[data-role="jobs"]').innerHTML = renderJobs(snap.jobs); $('[data-role="log"]').innerHTML = renderLog(snap.log);
modified
frontend/static_src/health/styles/health.scss
@@ -14,6 +14,65 @@ font-size: 0.6rem;}/* ---------- systems verdict (Phase 7) ---------- The distilled top read: a coloured dot + headline + one-line clause. Tones reuse the semantic good/ok/bad tokens; filled live by the page script. */.health-verdict { display: flex; align-items: center; gap: 14px; margin: 18px 0 6px; padding: 14px 16px; border: 1px solid var(--rule); border-radius: var(--radius); background: var(--surface);}.health-verdict[hidden] { display: none;}.health-verdict__dot { flex: none; width: 11px; height: 11px; border-radius: 50%; background: var(--ink-faint);}.health-verdict__head { margin: 0; font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em;}.health-verdict__detail { margin: 2px 0 0; color: var(--ink-dim); font-size: 0.85rem;}.health-verdict[data-tone="ok"] { border-color: var(--up-soft); .health-verdict__dot { background: var(--up); }}.health-verdict[data-tone="warn"] { border-color: var(--warn); background: var(--warn-soft); .health-verdict__dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }}.health-verdict[data-tone="bad"] { border-color: var(--down); background: var(--down-soft); .health-verdict__dot { background: var(--down); }}/* ---------- "fetching now" banner ---------- */.health-banner { display: flex;
modified
frontend/static_src/home/index.js
@@ -1,4 +1,7 @@// Markets dashboard. The live stream runs from the base entry and patches// the sparkline cards in place; this page entry only ships the dashboard's// styles.// Markets dashboard. The live stream runs from the base entry and patches the// sparkline cards in place; this entry ships the dashboard styles and the// Phase-7 live patcher for the hero verdict + breadth band.import "./styles/home.scss";import { initSummary } from "./scripts/home.js";initSummary();
added
frontend/static_src/home/scripts/home.js
@@ -0,0 +1,89 @@// Markets dashboard — live hero verdict + breadth (Phase 7).//// The hero's plain-language verdict, its headline figures, and the breadth band// are server-rendered at page load and then kept live: the scheduler recomputes// a market `summary` as the indexes tick intraday, base/stream.js re-broadcasts// it as a `finance:summary` window event, and this patches those nodes in place// from it — no second EventSource. The sparkline cards already stream live via// the base quote patcher; this closes the gap the verdict + breadth left (they// used to be frozen page-load snapshots, see PLAN.md Phase 5's known limitation).//// Patch targets carry data-role hooks in home.html. A node that wasn't rendered// at load (a figure that was null then) is simply skipped — the patcher refines// what's on the page, it doesn't build missing rows.const DASH = "·";// Mirrors the server-side `pct` filter and base/stream.js's fmtPct.function fmtPct(n) { if (n == null || Number.isNaN(n)) return DASH; return ( n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "exceptZero", }) + "%" );}function fmtInt(n) { return (n ?? 0).toLocaleString("en-US");}function setText(role, text) { const el = document.querySelector(`[data-role="${role}"]`); if (el) el.textContent = text;}function setWidth(role, pct) { const el = document.querySelector(`[data-role="${role}"]`); if (el && pct != null) el.style.width = `${pct}%`;}// Set the semantic move class (green/red/flat) from a percentage change.function setMove(el, pct) { el.classList.remove("is-up", "is-down", "is-flat"); if (pct == null || Number.isNaN(pct)) el.classList.add("is-flat"); else el.classList.add(pct >= 0 ? "is-up" : "is-down");}function applySummary(s) { if (!s) return; // Hero verdict + supporting clause. setText("hero-verdict", s.verdict); setText("hero-detail", s.detail); // Headline figures. const broad = document.querySelector('[data-role="hero-broad"]'); if (broad && s.broad_pct != null) { broad.textContent = fmtPct(s.broad_pct); setMove(broad, s.broad_pct); } const b = s.breadth || {}; if (b.pct_green != null) setText("hero-green", `${b.pct_green}%`); if (s.vix_label) setText("hero-vix", s.vix_label); // Breadth band: the counts, the share-green label, and the proportion bar. setText("breadth-adv", fmtInt(b.advancers)); setText("breadth-dec", fmtInt(b.decliners)); setText("breadth-flat", fmtInt(b.unchanged)); if (b.pct_green != null) setText("breadth-pct", `${b.pct_green}% green`); setWidth("breadth-up", b.up_w); setWidth("breadth-flat-seg", b.flat_w); setWidth("breadth-down", b.down_w); const bar = document.querySelector('[data-role="breadth-bar"]'); if (bar && b.total != null) { bar.setAttribute( "aria-label", `${b.advancers} advancing, ${b.decliners} declining of ${b.total} stocks`, ); }}export function initSummary() { // Only the dashboard carries the hero; elsewhere this is a no-op. if (!document.querySelector('[data-role="hero"]')) return; window.addEventListener("finance:summary", (e) => applySummary(e.detail));}
@@ -11,6 +11,7 @@ mod routes;mod scheduler;mod seed;mod stream;mod summary;mod templates;pub use app::{AppState, Config};
modified
src/routes/home.rs
@@ -17,6 +17,7 @@ use crate::compute::{self, Sparkline};use crate::market;use crate::models;use crate::render::render;use crate::summary::{market_verdict, vix_tone};use crate::AppState;pub fn router() -> Router<AppState> {
@@ -467,79 +468,6 @@ fn breadth(stocks: &[StockRow]) -> Breadth { b}/// A VIX level read into one plain word. The bands suit the ^VIX cash gauge:/// sub-14 is a placid tape, the teens are normal, the low-20s start to show/// stress, and 28+ is outright fear.fn vix_tone(level: f64) -> &'static str { match level { v if v < 14.0 => "calm", v if v < 20.0 => "steady", v if v < 28.0 => "elevated", _ => "stressed", }}/// Blend the broad-market move, breadth, and the VIX read into the hero's/// two-line verdict. `broad_pct` is the lead index card's day change (the cash/// S&P during the regular session, its future outside it); `green_pct` the/// share of curated stocks green; `vix_level` / `vix_pct` the volatility gauge/// and its move. Returns `(lead, detail)`. A descriptive read of the tape, not/// a forecast — direction comes from the broad move, falling back to breadth/// when the index is flat; width and risk tone colour the wording.fn market_verdict( broad_pct: Option<f64>, green_pct: Option<u8>, vix_level: Option<f64>, vix_pct: Option<f64>,) -> (String, String) { // Direction: the broad index move is the headline truth, so the verdict's // direction tracks its sign — never the opposite of the "S&P +x%" figure // shown beside it. A near-flat index (|move| < 0.05%) reads as mixed even if // breadth skews, since that is genuinely a directionless tape. Breadth only // sets direction when there is no index price at all (e.g. it never quoted). // 1 up / -1 down / 0 flat. let dir = match broad_pct { Some(p) if p > 0.05 => 1, Some(p) if p < -0.05 => -1, Some(_) => 0, None => match green_pct { Some(g) if g >= 55 => 1, Some(g) if g <= 45 => -1, _ => 0, }, }; // Breadth width: 2 broad / 1 split / 0 narrow. let width = match green_pct { Some(g) if g >= 60 => 2, Some(g) if g <= 40 => 0, _ => 1, }; let vix_rising = vix_pct.is_some_and(|p| p > 4.0); let vix_elevated = vix_level.is_some_and(|v| v >= 20.0); let lead = match (dir, width) { (1, 2) if !vix_elevated => "Risk-on, and broad.", (1, 2) => "Higher across the board.", (1, 0) => "Higher, but narrow.", (1, _) => "Modestly higher.", (-1, _) if vix_rising || vix_elevated => "Risk-off.", (-1, 0) => "Broadly lower.", (-1, _) => "Softer today.", _ => "Quiet, mixed tape.", }; let move_word = match dir { 1 => "higher", -1 => "lower", _ => "little changed", }; let part_word = match width { 2 => "wide participation", 0 => "narrow participation", _ => "mixed participation", }; (lead.to_string(), format!("Markets {move_word} with {part_word}."))}/// Assemble the hero from the already-loaded index + commodity cards and the/// breadth read. The broad-market move is the lead index card (resolved to the/// cash S&P or its future by `dashboard_cards`); the VIX read is the lead
modified
src/routes/stream.rs
@@ -99,6 +99,9 @@ async fn stream( Ok(StreamEvent::Market { session }) => { yield Ok(sse_market(&session)); } Ok(StreamEvent::Summary(s)) => { yield Ok(sse_summary(&s)); } Ok(StreamEvent::Health) => { yield Ok(sse_health()); }
@@ -167,6 +170,14 @@ fn sse_market(session: &str) -> Event { .data(format!("{{\"session\":\"{session}\"}}"))}/// The recomputed dashboard market summary (Phase 7). An open `/` page patches/// its hero verdict + headline figures + breadth from this payload.fn sse_summary(s: &crate::summary::MarketSummary) -> Event { Event::default() .event("summary") .data(serde_json::to_string(s).unwrap_or_default())}/// A content-free nudge: an open `/health` page answers it by pulling a fresh/// snapshot from `/api/health`. See `routes::health`.fn sse_health() -> Event {
modified
src/scheduler.rs
@@ -40,6 +40,7 @@ use crate::providers::{ PortfolioData, Quote, QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::summary;use crate::{seed, Config};/// How often the loop wakes to check whether a job is due. The jobs themselves
@@ -248,6 +249,12 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle hub.publish(StreamEvent::Market { session: session.as_str().to_string(), }); // The lead index swaps between the cash S&P and its future on a // session change, so the verdict's basis moves — push a fresh // summary so an open dashboard re-reads against the new lead. hub.publish(StreamEvent::Summary( summary::market_summary(&pool, session).await, )); last_session = Some(session); }
@@ -742,6 +749,19 @@ async fn run_intraday( } mark_ok(pool, "intraday", None).await?; } // If this sweep touched a symbol that moves the dashboard verdict (the broad // index or ^VIX), recompute and push the market summary so an open dashboard // re-reads its hero + figures live. The curated stocks behind breadth don't // move intraday (they only price at the daily close), so a sweep that hit // only a viewed stock page leaves the summary unchanged and we skip it. if ok > 0 { let pulse = summary::pulse_tickers(session); if targets.iter().any(|t| pulse.contains(&t.as_str())) { hub.publish(StreamEvent::Summary(summary::market_summary(pool, session).await)); } } notify_health(hub); Ok(())}
@@ -860,6 +880,16 @@ async fn run_daily_close_if_due( mark_ok(pool, "daily_close", None).await?; } } // The close just re-priced the whole universe, so breadth and the verdict // have moved — push a fresh summary for any dashboard still open after the // bell. Use the live session so the lead index resolves the same way the // page would (the close runs just after 16:00 ET, in the post session). if ok > 0 { let session = market::session_at(chrono::Utc::now()); hub.publish(StreamEvent::Summary(summary::market_summary(pool, session).await)); } notify_health(hub); Ok(())}
@@ -21,6 +21,8 @@ use std::sync::Mutex;use serde::Serialize;use tokio::sync::broadcast;use crate::summary::MarketSummary;/// Broadcast channel depth. One full universe sweep (~144 quote events) sits/// well under this; a subscriber that still lags is handled by skipping the/// gap (see `routes::stream`), never by stalling a publisher.
@@ -34,6 +36,11 @@ const CHANNEL_CAPACITY: usize = 1024;pub enum StreamEvent { Quote(QuoteUpdate), Market { session: String }, /// A recomputed dashboard market summary — the hero verdict, headline /// figures, and breadth — pushed as index quotes tick intraday so the /// home page's verdict and breadth stay live (Phase 7). Published by the /// scheduler; an open `/` page patches the hero + breadth DOM in place. Summary(MarketSummary), /// A background-data state change — a job started or finished, a /// `fetch_log` row landed. Carries no payload: it is a nudge telling an /// open `/health` page to pull a fresh snapshot from `/api/health`.
@@ -0,0 +1,264 @@//! Live market-summary snapshot — the dashboard hero verdict + market breadth,//! computed straight from stored prices.//!//! This is the one source of truth for the dashboard's plain-language read of//! the day. It is used two ways://! - the home route renders it at page load (via the verdict vocabulary below,//! [`market_verdict`] / [`vix_tone`]); and//! - the scheduler recomputes it as index quotes tick intraday and pushes it//! over the stream as a [`crate::stream::StreamEvent::Summary`], so the//! verdict sentence, the headline figures, and the breadth counts stay live//! instead of going stale beside the live-ticking index chips (Phase 7).//!//! For a Python reader: think of [`market_summary`] as a small read-model query//! — two cheap aggregate reads (breadth across the curated stocks, plus the//! lead index and ^VIX) folded into one serializable struct the browser patches//! into the page.use serde::Serialize;use sqlx::SqlitePool;use crate::compute;use crate::market::Session;/// The broad-market index the hero verdict reads: the cash S&P during the/// regular session, its E-mini future outside it (so the verdict stays live/// overnight, matching how `home::dashboard_cards` resolves the lead card).const BROAD_CASH: &str = "^SPX";const BROAD_FUTURE: &str = "ES=F";/// The volatility gauge folded into the risk tone. No tradable future on Yahoo,/// so it is always the cash level.const VIX: &str = "^VIX";/// The dashboard hero verdict + market breadth in one payload, shaped for the/// browser to patch in place. Mirrors the fields the home template renders at/// load so a pushed update and the server render agree.#[derive(Debug, Clone, Serialize, Default)]pub struct MarketSummary { /// The punchy lead, e.g. "Higher, but narrow." pub verdict: String, /// The supporting clause, e.g. "Markets higher with narrow participation." pub detail: String, /// The broad-market day change (the lead index move); drives the "S&P +x%" /// stat. `None` until that symbol prices. pub broad_pct: Option<f64>, /// The VIX read folded into one phrase, e.g. "calm at 13.2". `None` until /// ^VIX prices. pub vix_label: Option<String>, /// Advancers vs decliners across the curated large-cap stocks. pub breadth: BreadthCounts,}/// Market breadth across the curated large-cap stocks: how many are advancing/// vs declining today, the share green, and the proportion-bar segment widths./// Field names match the home template's `breadth.*` so the page render and a/// pushed update patch the same nodes.#[derive(Debug, Clone, Serialize, Default)]pub struct BreadthCounts { pub advancers: usize, pub decliners: usize, pub unchanged: usize, /// Stocks with a computable day change (advancers + decliners + unchanged). pub total: usize, /// Advancers as a percent of `total`, rounded; `None` when `total` is 0. pub pct_green: Option<u8>, /// Proportion-bar segment widths (percent of `total`): green, flat, red. pub up_w: f64, pub flat_w: f64, pub down_w: f64,}/// The dashboard symbols whose live quotes move the hero verdict: the broad/// index (cash or its future, by session) and ^VIX. The scheduler uses this to/// decide whether an intraday sweep touched anything worth re-pushing a summary/// for (the curated stocks behind breadth only move at the daily close).pub fn pulse_tickers(session: Session) -> [&'static str; 2] { let broad = if matches!(session, Session::Regular) { BROAD_CASH } else { BROAD_FUTURE }; [broad, VIX]}/// Build the full market summary from stored prices. Two cheap reads: a breadth/// scan over the curated stocks and a two-symbol pull for the lead index + VIX./// Both resolve the live last price, falling back to the latest stored daily/// close, exactly as the home route's card/breadth queries do — so a pushed/// update reconciles with what the page rendered.pub async fn market_summary(pool: &SqlitePool, session: Session) -> MarketSummary { let breadth = breadth_snapshot(pool).await; let [broad_ticker, _] = pulse_tickers(session); let broad_pct = day_change_pct(pool, broad_ticker).await; let vix_level = latest_price(pool, VIX).await; let vix_pct = day_change_pct(pool, VIX).await; let (verdict, detail) = market_verdict(broad_pct, breadth.pct_green, vix_level, vix_pct); let vix_label = vix_level.map(|v| format!("{} at {:.1}", vix_tone(v), v)); MarketSummary { verdict, detail, broad_pct, vix_label, breadth, }}/// Market breadth across the curated large-cap stocks. One read of each stock's/// resolved last/prev price (live quote, else latest daily close), folded the/// same way [`crate::routes::home`]'s in-memory breadth does: a stock without a/// computable change (no price, or a non-positive prior close) is left out of/// every count so a missing quote never reads as "flat".async fn breadth_snapshot(pool: &SqlitePool) -> BreadthCounts { let rows: Vec<(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.is_seeded = 1 AND s.kind = 'stock'", ) .fetch_all(pool) .await .unwrap_or_default(); let mut b = BreadthCounts::default(); for (last, prev) in rows { let (Some(last), Some(prev)) = (last, prev) else { continue; }; if prev <= 0.0 { continue; } b.total += 1; if last > prev { b.advancers += 1; } else if last < prev { b.decliners += 1; } else { b.unchanged += 1; } } if b.total > 0 { let total = b.total as f64; b.pct_green = Some((b.advancers as f64 / total * 100.0).round() as u8); b.up_w = b.advancers as f64 / total * 100.0; b.down_w = b.decliners as f64 / total * 100.0; b.flat_w = (100.0 - b.up_w - b.down_w).max(0.0); } b}/// One symbol's resolved last price (live quote, else latest daily close).async fn latest_price(pool: &SqlitePool, ticker: &str) -> Option<f64> { sqlx::query_scalar( "SELECT COALESCE(s.last_price, \ (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)) \ FROM symbols s WHERE s.ticker = ?", ) .bind(ticker) .fetch_optional(pool) .await .ok() .flatten() .flatten()}/// One symbol's day change percent from its resolved last vs prior close.async fn day_change_pct(pool: &SqlitePool, ticker: &str) -> Option<f64> { let row: 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(ticker) .fetch_optional(pool) .await .ok() .flatten(); match row { Some((Some(last), Some(prev))) => Some(compute::change(last, prev).pct), _ => None, }}/// A VIX level read into one plain word. The bands suit the ^VIX cash gauge:/// sub-14 is a placid tape, the teens are normal, the low-20s start to show/// stress, and 28+ is outright fear.pub fn vix_tone(level: f64) -> &'static str { match level { v if v < 14.0 => "calm", v if v < 20.0 => "steady", v if v < 28.0 => "elevated", _ => "stressed", }}/// Blend the broad-market move, breadth, and the VIX read into the hero's/// two-line verdict. `broad_pct` is the lead index card's day change (the cash/// S&P during the regular session, its future outside it); `green_pct` the/// share of curated stocks green; `vix_level` / `vix_pct` the volatility gauge/// and its move. Returns `(lead, detail)`. A descriptive read of the tape, not/// a forecast — direction comes from the broad move, falling back to breadth/// when the index is flat; width and risk tone colour the wording.pub fn market_verdict( broad_pct: Option<f64>, green_pct: Option<u8>, vix_level: Option<f64>, vix_pct: Option<f64>,) -> (String, String) { // Direction: the broad index move is the headline truth, so the verdict's // direction tracks its sign — never the opposite of the "S&P +x%" figure // shown beside it. A near-flat index (|move| < 0.05%) reads as mixed even if // breadth skews, since that is genuinely a directionless tape. Breadth only // sets direction when there is no index price at all (e.g. it never quoted). // 1 up / -1 down / 0 flat. let dir = match broad_pct { Some(p) if p > 0.05 => 1, Some(p) if p < -0.05 => -1, Some(_) => 0, None => match green_pct { Some(g) if g >= 55 => 1, Some(g) if g <= 45 => -1, _ => 0, }, }; // Breadth width: 2 broad / 1 split / 0 narrow. let width = match green_pct { Some(g) if g >= 60 => 2, Some(g) if g <= 40 => 0, _ => 1, }; let vix_rising = vix_pct.is_some_and(|p| p > 4.0); let vix_elevated = vix_level.is_some_and(|v| v >= 20.0); let lead = match (dir, width) { (1, 2) if !vix_elevated => "Risk-on, and broad.", (1, 2) => "Higher across the board.", (1, 0) => "Higher, but narrow.", (1, _) => "Modestly higher.", (-1, _) if vix_rising || vix_elevated => "Risk-off.", (-1, 0) => "Broadly lower.", (-1, _) => "Softer today.", _ => "Quiet, mixed tape.", }; let move_word = match dir { 1 => "higher", -1 => "lower", _ => "little changed", }; let part_word = match width { 2 => "wide participation", 0 => "narrow participation", _ => "mixed participation", }; ( lead.to_string(), format!("Markets {move_word} with {part_word}."), )}
modified
templates/base.html
@@ -57,10 +57,55 @@ </a> </nav> {# Two-tier footer (Phase 7), modeled on the sibling apps' pattern (repos' `footer__grid` with `// LABEL` mono headers) but in Paper Ledger tokens: a multi-column upper block over a slim copyright bar. The data attribution (was the old one-line footer) folds into the About column. #} <footer class="footer"> <p>{{ site.title }} · Market data via Yahoo Finance · Fundamentals via SEC EDGAR · prices are delayed · not investment advice · <a href="/health">data health</a> · © {{ now.year }} Isaac Bythewood</p> <div class="wrap footer__grid"> <div class="footer__about"> <div class="footer__label">// {{ site.title }}</div> <p>A self-hosted market watcher for stocks, ETFs, indexes, and commodities: live charts, key stats, fundamentals, SEC filings, and at-a-glance health reads. For watching the market only — no portfolio, no accounts.</p> <p class="footer__attrib">Market data via <span>Yahoo Finance</span> · fundamentals & filings via <span>SEC EDGAR</span> · prices are delayed · <strong>not investment advice</strong>.</p> </div> <div class="footer__col"> <div class="footer__label">// Pages</div> <ul> <li><a href="/">Markets</a></li> <li><a href="/industries">Industries</a></li> <li><a href="/search">Search</a></li> <li><a href="/health">Data health</a></li> <li><a href="https://github.com/overshard/finance" target="_blank" rel="noopener">Source</a></li> </ul> </div> <div class="footer__col"> <div class="footer__label">// Elsewhere</div> <ul> <li><a href="https://isaacbythewood.com/" target="_blank" rel="noopener">Portfolio</a></li> <li><a href="https://blog.bythewood.me/" target="_blank" rel="noopener">Blog</a></li> <li><a href="https://analytics.bythewood.me/" target="_blank" rel="noopener">Analytics</a></li> <li><a href="https://status.bythewood.me/" target="_blank" rel="noopener">Status</a></li> </ul> </div> </div> </footer> <div class="footer-bar"> <div class="wrap footer-bar__row"> <small>© {{ now.year }} Isaac Bythewood · Some rights reserved</small> <a href="https://github.com/overshard/finance" target="_blank" rel="noopener" class="footer-bar__link" aria-label="Source on GitHub"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> </svg> </a> </div> </div> <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script> {% block extra_js %}{% endblock %}</body>
modified
templates/pages/health.html
@@ -9,6 +9,19 @@ <h1>Data health</h1> <span class="health-asof" data-role="asof"></span> </div> {# Systems verdict (Phase 7): a one-line plain read distilling the whole page — are the upstreams healthy, are the jobs on schedule, how fresh is the last fetch. Filled + kept live by the page script from the same snapshot that drives the detail below, so it never disagrees with it. #} <div class="health-verdict" data-role="verdict" hidden> <span class="health-verdict__dot"></span> <div class="health-verdict__text"> <p class="health-verdict__head" data-role="verdict-head"></p> <p class="health-verdict__detail" data-role="verdict-detail"></p> </div> </div> <p class="health-lede"> Every background fetch this app makes, laid open: each upstream's request guard and hourly budget, what each scheduler job is doing, and a live tail
modified
templates/pages/home.html
@@ -24,16 +24,18 @@ {# Hero (Phase 5): a one-line plain read of the day — the broad move, breadth, and the VIX risk tone blended into a verdict — over a compact index strip. A page-load snapshot: the verdict is descriptive, not a forecast, so the non-advice note rides with it. #} <section class="hero"> Rendered at page load, then kept live (Phase 7): the scheduler pushes a recomputed `summary` over the stream as the indexes tick, and home.js patches the verdict, the figures, and the breadth band below in place. The data-role hooks are those patch targets. #} <section class="hero" data-role="hero"> <div class="hero__read"> <p class="hero__verdict">{{ hero.verdict }}</p> <p class="hero__detail">{{ hero.detail }}</p> <p class="hero__verdict" data-role="hero-verdict">{{ hero.verdict }}</p> <p class="hero__detail" data-role="hero-detail">{{ hero.detail }}</p> <p class="hero__stats"> {% if hero.broad_pct is not none %}<span class="hero__stat">S&P <span class="num {{ 'is-up' if hero.broad_pct >= 0 else 'is-down' }}">{{ hero.broad_pct|pct }}</span></span>{% endif %} {% if hero.green_pct is not none %}<span class="hero__stat"><span class="num">{{ hero.green_pct }}%</span> of the S&P green</span>{% endif %} {% if hero.vix_label %}<span class="hero__stat">VIX {{ hero.vix_label }}</span>{% endif %} {% if hero.broad_pct is not none %}<span class="hero__stat">S&P <span class="num {{ 'is-up' if hero.broad_pct >= 0 else 'is-down' }}" data-role="hero-broad">{{ hero.broad_pct|pct }}</span></span>{% endif %} {% if hero.green_pct is not none %}<span class="hero__stat"><span class="num" data-role="hero-green">{{ hero.green_pct }}%</span> of the S&P green</span>{% endif %} {% if hero.vix_label %}<span class="hero__stat">VIX <span data-role="hero-vix">{{ hero.vix_label }}</span></span>{% endif %} </p> <p class="hero__note">A plain read of today’s tape — not investment advice.</p> </div>
@@ -64,18 +66,18 @@ <h2 class="section-title">Breadth{% if movers_asof %}<span class="section-title__asof">prices as of {{ movers_asof|asof }}</span>{% endif %}</h2> <p class="section-note">How many of the curated large-cap stocks are advancing versus declining today, and the share trading green.</p> <div class="breadth"> <div class="breadth" data-role="breadth"> <div class="breadth__stats"> <span class="breadth__stat breadth__stat--up"><span class="breadth__num num">{{ breadth.advancers }}</span> advancing</span> <span class="breadth__stat breadth__stat--down"><span class="breadth__num num">{{ breadth.decliners }}</span> declining</span> {% if breadth.unchanged %}<span class="breadth__stat breadth__stat--flat"><span class="breadth__num num">{{ breadth.unchanged }}</span> flat</span>{% endif %} {% if breadth.pct_green is not none %}<span class="breadth__pct num">{{ breadth.pct_green }}% green</span>{% endif %} <span class="breadth__stat breadth__stat--up"><span class="breadth__num num" data-role="breadth-adv">{{ breadth.advancers }}</span> advancing</span> <span class="breadth__stat breadth__stat--down"><span class="breadth__num num" data-role="breadth-dec">{{ breadth.decliners }}</span> declining</span> {% if breadth.unchanged %}<span class="breadth__stat breadth__stat--flat"><span class="breadth__num num" data-role="breadth-flat">{{ breadth.unchanged }}</span> flat</span>{% endif %} {% if breadth.pct_green is not none %}<span class="breadth__pct num" data-role="breadth-pct">{{ breadth.pct_green }}% green</span>{% endif %} </div> <div class="breadth__bar" role="img" <div class="breadth__bar" role="img" data-role="breadth-bar" aria-label="{{ breadth.advancers }} advancing, {{ breadth.decliners }} declining of {{ breadth.total }} stocks"> <span class="breadth__seg breadth__seg--up" style="width: {{ breadth.up_w }}%"></span> <span class="breadth__seg breadth__seg--flat" style="width: {{ breadth.flat_w }}%"></span> <span class="breadth__seg breadth__seg--down" style="width: {{ breadth.down_w }}%"></span> <span class="breadth__seg breadth__seg--up" style="width: {{ breadth.up_w }}%" data-role="breadth-up"></span> <span class="breadth__seg breadth__seg--flat" style="width: {{ breadth.flat_w }}%" data-role="breadth-flat-seg"></span> <span class="breadth__seg breadth__seg--down" style="width: {{ breadth.down_w }}%" data-role="breadth-down"></span> </div> </div> {% endif %}
@@ -201,3 +203,5 @@ {% endif %}</div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/home/index.js') }}"></script>{% endblock %}