@@ -32,9 +32,88 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-06-04 (Phases A-E all done + deployed. Phases A-D shipped in`818cf58`; **Phase E — market-overview graph — committed + deployed** this roundper new user direction.)__Last updated: 2026-06-04 (**Phase F — overview split into per-instrument charts+ symbol-page chart upgrades — done & verified on dev, uncommitted.** Phases A-Epreviously shipped; A-D in `818cf58`, E committed+deployed.)_Phase F outcome (per-instrument overview + symbol chart upgrades), all verified ondev (cargo + vite clean, zero warnings, zero console errors, desktop + mobile):- **Overview is now one chart per instrument**, not a single normalized overlay. `/api/dashboard` returns, per instrument, its actual values (index points or dollars), the last value, the % change, and the Schwab-day frame bounds; `home/scripts/hero.js` builds a grid of small interactive lightweight-charts (own axis, hover crosshair, no pan/zoom), each header showing the live value + coloured % change. Line is **semantic green/red by day direction**.- **Overview set trimmed to 6** (dropped Russell 2000 per user): S&P, Dow, Nasdaq 100, Gold, Crude, Bitcoin. Grid is **3-up on desktop** (auto-fill minmax 260px), 1-up on mobile.- **Index slots are a HYBRID — futures line + cash headline.** Each `OverviewSlot` now carries two tickers: the **chart** ticker (the E-mini future, e.g. `ES=F`) draws the line, so the chart shows the full Schwab day — pre-market + regular + after-hours movement; the **quote** ticker (the cash index, e.g. `^SPX`) drives the headline value + %, taken from the quote (`last_price`/`regularMarketPrice` vs `prev_close`/`chartPreviousClose`) so the number matches every market site (e.g. S&P 500 +0.41% = 7584.31 / 7553.68). The dashed reference line is the cash prev close. Gold/Crude/BTC use one ticker for both (genuinely 24h). `overview_tickers()` returns both cash+futures so the hidden interest nodes + `/api/dashboard/refresh` poll/refresh both. - *Why hybrid:* "+0.41% (frozen cash close)" and "shows after-hours movement" are mutually exclusive on one instrument — the cash index stops printing at 4pm. The futures line gives the movement; the cash quote gives the universal number. On a big futures-vs-cash-basis day the line and headline can tell slightly different stories, which the user accepted to "see all of it".- **% change is vs the previous close** (`chartPreviousClose`), which is also the chart's dashed reference line. Note copy: "live value & % change vs prev close" (dropped the "index futures (off-hours)" mode tag).- **Each chart frames exactly one Schwab trading day** — extended-hours open (7:00 AM ET) through close (8:00 PM ET) of the most recent session, never the previous day. `overview_series` anchors to the ET date of the latest bar via `schwab_day_window()` and queries only that window; the frontend pads whitespace before/after so a partial day plots from the left.- **Pre-market / after-hours are shaded** on each chart (a subtle neutral ink band over <9:30 and ≥16:00 ET), leaving the regular session clear so the main moves stand out. Drawn as pointer-transparent overlay bands, repositioned on relayout.- **Time axis is 12-hour AM/PM** (tick + crosshair formatters), never 24-hour.- **Symbol page:** range buttons are now **YTD 1M 3M 6M 1Y 3Y 5Y MAX (default 1Y)** — the old 1D/1W intraday ranges were dropped (the demand-only model can't keep enough 15m bars to draw them legibly). **EMA 21 and RSI now default ON** (with SMA 50/200, Volume). Added the `3Y` cutoff.- **Indicator interpretation blurb under the symbol chart** (`build_indicator_read` → `indicators` ctx → `templates/pages/symbol.html`): a direct RSI overbought/oversold/neutral verdict (with a leaning-bullish/bearish middle), then one plain-language line per moving average (price vs the 21-day EMA / 50- / 200-day average, each green/red), and the 50-vs-200 golden/death-cross posture. Closes with a "mechanical reading, not advice" note.- **Watchlist == overview cards.** The watchlist now uses the *same* per-instrument chart card as the overview (Schwab-day frame, pre/after shading, % vs prev close, AM/PM axis). `/api/dashboard` gained a `watchlist` array of the same `Series` shape (single-ticker; unit from kind via `unit_for`); the server still renders the card shells (link + remove + initial value/%), and `hero.js` draws the chart + refreshes the figures into them. The old `SparkCard` SVG sparkline + `compute::sparkline` were removed.- **Card design polish (cohesion).** Unified `ov-card` for both grids: refined header (name/ticker + right-aligned value + a colour-coded **% pill**), an **area fill** under the line (up/down soft tint, matching the Paper Ledger spark aesthetic), softer grid/axis, hover lift on watchlist cards.- **Drag-to-measure on the home charts.** Ported the symbol chart's click-drag measure gesture (shaded band + readout chip with % and value change between two bars) to every overview + watchlist mini chart; snaps to real bars, and a drag on a watchlist card suppresses its navigation click.- **Indicator blurb redesigned** (symbol page). `build_indicator_read` now returns an overall **trend verdict** (Bullish/Mixed/Bearish + tally), an **RSI gauge** (0–100 track with oversold/overbought zones + a coloured marker), and colour-coded **signal tiles** (EMA 21 / SMA 50 / SMA 200 / 50-200 cross), each green/red by bullish/bearish. Replaces the plain bulleted list.- **Git:** added the GitHub `origin` remote `git@github.com:overshard/finance.git` (SSH, matching the sibling projects). Not pushed.Phase F still open — **denser candles for short ranges (1M / 3M)**, see Roadmap"Phase G" below. The user wants finer-than-daily candles on the short ranges(daily gives only ~20 bars at 1M, ~63 at 3M, which they find too sparse). Thisneeds a new Yahoo intraday/hourly fetch + storage and is deferred for a designcall (see the Decisions log entry) rather than built unsupervised, given thestrict rate-limit policy.Phase E outcome (market-overview graph, split from the watchlist):- **Top graph is now a fixed, session-aware market overview**, not the watchlist.
@@ -537,6 +616,30 @@ pluralization. (Industries links were already removed in Phase A; the loading bamarket-hours banner, and mobile hierarchy landed in Phases B/C.) See the Statusblock above for the full outcome. **This completes the demand-only roadmap.**### Phase F — Per-instrument overview + symbol chart upgrades ✅ DONE on dev (uncommitted)Split the single normalized overview overlay into one actual-value chart perinstrument; upgrade the symbol page's ranges, default indicators, and add anindicator-interpretation blurb. See the Status block above for the full outcome.### Phase G — Denser candles for short ranges (1M / 3M) ⏳ DEFERRED (needs a design call)The user finds daily candles too sparse under ~6 months (1M ≈ 20 bars, 3M ≈ 63);they want finer-than-daily candles there. (The old broken 1D/1W intraday rangeswere already removed in Phase F.)- **Source:** Yahoo `v8/finance/chart?interval=1h` (hourly bars, allowed up to a ~730-day range) — one guarded call covers up to ~3 months of hourly candles (~450 bars at 3M, ~150 at 1M). Finer intervals (15m/5m) only reach 60/60 days.- **Plan (recommended):** fetch hourly on demand through the `yahoo` guard (only when a symbol is viewed and its hourly data is stale), store in a new `hourly_bars` table (retention ~120d, pruned), and have `history_api` serve 1M and 3M from it as intraday (UNIX-seconds) candles. **Keep the daily SMA/EMA/RSI overlays** by plotting their daily points (converted to UNIX-seconds) over the hourly candles, so the dense candles and the meaningful *daily* indicators coexist (no conflict with the Phase F "EMA 21 + RSI default on" + blurb).- **Why deferred:** it adds a new outbound fetch type + table + scheduler step, and the user considers *never hitting a rate limit* critical, so the interval choice, retention, and guard-budget impact are a design call worth confirming before building. Surfaced to the user; ready to execute on their go-ahead.### Backlog / parked- Named multiple watchlists (only a single session list is planned for now).- A "popular non-S&P" quick-add set on the dashboard, if the curated catalog feels
@@ -549,6 +652,41 @@ block above for the full outcome. **This completes the demand-only roadmap.**## Decisions log**2026-06-04 — Phase F: per-instrument overview + symbol chart upgrades.** Livingwith the single normalized overview overlay, the user found "everything on onenormalized graph" confusing and wanted each instrument as its own chart showingits **actual value** (points / dollars) alongside the %. Decisions made thisround (mostly via rapid iteration):1. **One interactive chart per instrument** (chosen over compact spark cards), each its own axis + hover crosshair, **green/red line by day direction**.2. **% is vs the previous close** (not the session open); prev close is also the dashed reference line. **Index slots are a hybrid:** the chart line is the E-mini **future** (so pre/regular/after-hours all show — the user wanted to "see all of it"), while the headline value + % are the **cash index** quote (`regularMarketPrice` vs `chartPreviousClose`) so the number matches everyone (S&P 500 +0.41% = `^GSPC` 7584.31/7553.68). Resolution of a back-and-forth: first vs-open → vs-prev-close; then cash-only (matched +0.41% but lost pre/post); then this hybrid (both). The unavoidable truth surfaced to the user: the frozen +0.41% *is* frozen because cash stops at 4pm, so showing after-hours movement means the line (futures) can diverge from the headline (cash) on big-basis days. Gold/Crude/BTC stay single-ticker 24h.3. **Each chart frames exactly one Schwab day** = regular + extended hours (7:00 AM–8:00 PM ET) of the most recent session, never the previous day. (Earlier tried a rolling window / 24h frame; the user corrected both: "1 full day = Schwab normal + extended hours, not anything else", and "don't show the previous day".)4. **Shade pre-market + after-hours**, leave the regular session clear.5. **12-hour AM/PM** time axis, never 24-hour.6. **Dropped Russell 2000**; overview is 6 instruments, **3-up on desktop** (the user vetoed 2-up).7. **Symbol page:** ranges **YTD 1M 3M 6M 1Y 3Y 5Y MAX, default 1Y**; **EMA 21 + RSI default on**; dropped the 1D/1W intraday ranges.8. **Indicator blurb under the symbol chart:** a direct RSI verdict + plain-language price-vs-MA lines + the golden/death-cross posture.Also added the GitHub `origin` SSH remote. **Deferred** the "denser candles for1M/3M" ask to Phase G (a data-layer change touching the rate-limit-criticalpath — see the Roadmap entry) rather than build it unsupervised.**2026-06-04 — Phase E: split the dashboard into a market overview + a separatewatchlist.** The user wanted the top graph to read "how is the whole marketdoing" at a glance, not show the watchlist; the watchlist stays as its own
modified
frontend/static_src/home/scripts/hero.js
@@ -1,34 +1,23 @@// The dashboard's market-overview graph + market reads (Phase C / E).// The dashboard's market-overview + watchlist charts (Phase F).//// Draws the market overview — the major indexes plus gold, crude and bitcoin// (cash indexes during the regular session, the E-mini futures off-hours) — on// one chart, each as % change from today's open (the TradingView/Google// "compare" shape), and fills the headline reads. Both come from /api/dashboard,// re-fetched ~every minute (and on tab focus) so the chart and reads stay live// without a reload. The personal watchlist is a separate section below and is// not on this graph; its cards live-tick via the base stream client.import { createChart, LineSeries, ColorType } from "lightweight-charts";// Non-semantic line palette for the overview (green/amber/red stay reserved// for good/ok/bad reads elsewhere). Chosen to spread across the wheel so// adjacent lines stay tellable apart; the S&P baseline is drawn in ink.const PALETTE = [ "#2f6fb0", // blue "#e07b29", // orange "#8a4fb3", // purple "#0f8b8d", // teal "#c2407a", // magenta "#8c6239", // brown "#3b4a9c", // indigo "#6b8e23", // olive];const INK = "#211f1a";const DASH = "·";// Both the fixed market overview and the personal watchlist are drawn as the// same per-instrument chart card: one Schwab trading day (7 AM–8 PM ET), an// area line coloured green/red by the day's direction, pre-market / after-hours// shaded, and a headline value + % change vs the previous close (the// universally-quoted number). Everything comes from /api/dashboard, re-fetched// ~every minute and on tab focus. Overview cards are built here; watchlist cards// are server-rendered shells (for the link + remove button) that we draw into.import { createChart, AreaSeries, ColorType } from "lightweight-charts";// The server sends a friendly `name` per series (e.g. "S&P 500", "Gold"); fall// back to the raw ticker if one is ever missing.const displayName = (s) => s.name || s.ticker;// Semantic day-direction colours (the Paper Ledger up/down inks) + soft fills// that match the watchlist spark-card aesthetic.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 REF = "rgba(33, 31, 26, 0.28)"; // dashed previous-close lineconst DASH = "·";const SESSION_LABELS = { regular: "Regular session",
@@ -37,19 +26,28 @@ const SESSION_LABELS = { closed: "Market closed",};const pctFmt = (v) => (v >= 0 ? "+" : "") + v.toFixed(2) + "%";function fmtMoney(n) {// ── formatters ─────────────────────────────────────────────────────────────function fmtValue(n, unit) { if (n == null || Number.isNaN(n)) return DASH; return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const s = n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return unit === "$" ? "$" + s : s;}function fmtPct(n) { if (n == null || Number.isNaN(n)) return DASH; return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "exceptZero", }) + "%"; return ( n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: "exceptZero", }) + "%" );}function fmtSigned(n, unit) { if (n == null || Number.isNaN(n)) return DASH; const s = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const sign = n >= 0 ? "+" : "-"; return unit === "$" ? `${sign}$${s}` : `${sign}${s}`;}// Compact volume: 1.2M / 853K, matching the server-side `compact` filter shape.function fmtCompact(n) { if (n == null || Number.isNaN(n)) return DASH; const abs = Math.abs(n);
@@ -59,13 +57,30 @@ function fmtCompact(n) { return String(n);}const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);// A fixed timestamp as an ET clock time, matching the server `asof` filter.// 12-hour AM/PM ET clock for the axis ticks and crosshair (never 24-hour).function fmtAxisTime(tSec) { return new Date(tSec * 1000).toLocaleTimeString("en-US", { timeZone: "America/New_York", hour: "numeric", minute: "2-digit", hour12: true, });}function fmtCrosshairTime(tSec) { return new Date(tSec * 1000).toLocaleString("en-US", { timeZone: "America/New_York", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true, });}function fmtClock(ms) { if (!ms) return null; return new Date(ms) .toLocaleTimeString("en-US", { timeZone: "America/New_York", hour: "numeric", minute: "2-digit", }) .toLocaleTimeString("en-US", { timeZone: "America/New_York", hour: "numeric", minute: "2-digit" }) .replace(/\s/g, "") .toLowerCase();}
@@ -83,103 +98,383 @@ function setTone(role, tone, prefix) { el.classList.add(prefix + tone);}export function initHero() { const mount = document.querySelector('[data-role="hero-chart"]'); if (!mount) return;// ── extended-hours shading ───────────────────────────────────────────────────// US regular session in ET minutes-of-day (9:30 AM – 4:00 PM). Everything else// is "extended" (pre-market / after-hours / overnight) and gets shaded.const REG_START = 9 * 60 + 30;const REG_END = 16 * 60;function etMinutes(tSec) { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", hour: "2-digit", minute: "2-digit", hour12: false, }).formatToParts(new Date(tSec * 1000)); let h = 0; let m = 0; for (const p of parts) { if (p.type === "hour") h = parseInt(p.value, 10); else if (p.type === "minute") m = parseInt(p.value, 10); } if (h === 24) h = 0; return h * 60 + m;}const isExtended = (tSec) => { const x = etMinutes(tSec); return x < REG_START || x >= REG_END;};// Shade the extended-hours spans behind a chart's line (pointer-transparent// overlay divs), recomputed on every relayout.function renderBands(entry) { const box = entry.bandsEl; if (!box) return; box.innerHTML = ""; const times = entry.times; if (!times || times.length < 2) return; const ts = entry.chart.timeScale(); const w = entry.chartEl.clientWidth; const coords = times.map((p) => ts.timeToCoordinate(p.t)); let sum = 0; let n = 0; for (let k = 1; k < coords.length; k++) { if (coords[k] != null && coords[k - 1] != null) { sum += coords[k] - coords[k - 1]; n++; } } const half = (n ? sum / n : 6) / 2; let i = 0; while (i < times.length) { if (!times[i].ext || coords[i] == null) { i++; continue; } let j = i; while (j + 1 < times.length && times[j + 1].ext && coords[j + 1] != null) j++; const left = Math.max(0, coords[i] - half); const right = Math.min(w, coords[j] + half); if (right > left) { const band = document.createElement("div"); band.className = "ov-band"; band.style.left = `${left}px`; band.style.width = `${right - left}px`; box.appendChild(band); } i = j + 1; }}// ── chart card ───────────────────────────────────────────────────────────────// Attach a lightweight-charts area chart to a card's `.ov-card__chart` mount.function attachChart(chartEl) { const bandsEl = document.createElement("div"); bandsEl.className = "ov-bands"; chartEl.appendChild(bandsEl); const chart = createChart(mount, { const chart = createChart(chartEl, { autoSize: true, handleScroll: false, handleScale: false, layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#6b6456", textColor: "#8a8372", fontFamily: "'JetBrains Mono', monospace", fontSize: 10, attributionLogo: false, }, grid: { vertLines: { color: "rgba(33,31,26,0.06)" }, horzLines: { color: "rgba(33,31,26,0.07)" }, vertLines: { visible: false }, horzLines: { color: "rgba(33,31,26,0.05)" }, }, rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.16, bottom: 0.08 }, }, rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" }, timeScale: { borderColor: "rgba(33,31,26,0.16)", borderColor: "rgba(33,31,26,0.12)", timeVisible: true, secondsVisible: false, tickMarkFormatter: (t) => fmtAxisTime(t), }, crosshair: { mode: 1 }, localization: { priceFormatter: pctFmt }, crosshair: { mode: 1, vertLine: { labelVisible: true, width: 1, color: "rgba(33,31,26,0.25)", style: 3 }, horzLine: { labelVisible: true, color: "rgba(33,31,26,0.25)", style: 3 }, }, localization: { timeFormatter: (t) => fmtCrosshairTime(t) }, }); const series = chart.addSeries(AreaSeries, { lineWidth: 2, priceLineVisible: false, lastValueVisible: false, crosshairMarkerRadius: 3, crosshairMarkerBorderWidth: 0, }); const seriesByTicker = new Map(); // ticker -> { series } const entry = { chart, series, chartEl, bandsEl, refLine: null, times: [], points: [], unit: "pts" }; attachMeasure(entry); // Keep the shading + measure band glued to the data on every relayout. chart.timeScale().subscribeVisibleLogicalRangeChange(() => { renderBands(entry); if (entry.renderMeasure) entry.renderMeasure(); }); return entry;}// Click-drag measure tool: a shaded band + a readout chip showing the % and// value change between the two bars under the drag (the symbol chart's gesture,// ported to the mini charts). Snaps to real bars; suppresses the navigating// click on watchlist cards when a drag actually happened.function attachMeasure(entry) { const el = entry.chartEl; const band = document.createElement("div"); band.className = "ov-measure-band"; band.hidden = true; const readout = document.createElement("div"); readout.className = "ov-measure-readout"; readout.hidden = true; el.append(band, readout); let dragging = false; let anchorX = null; let curX = null; let moved = false; const localX = (e) => e.clientX - el.getBoundingClientRect().left; // Real (valued) bars and their current x-coordinates; whitespace is ignored. function realCoords() { const ts = entry.chart.timeScale(); const out = []; for (const p of entry.points) { const x = ts.timeToCoordinate(p.t); if (x != null) out.push({ p, x }); } return out; } function nearest(coords, x) { let best = null; let bd = Infinity; for (const o of coords) { const d = Math.abs(o.x - x); if (d < bd) { bd = d; best = o; } } return best; } function render() { if (anchorX == null || curX == null) { band.hidden = true; readout.hidden = true; return; } const coords = realCoords(); const a = nearest(coords, anchorX); const b = nearest(coords, curX); if (!a || !b || a.p.t === b.p.t) { band.hidden = true; readout.hidden = true; return; } const left = Math.min(a.x, b.x); const right = Math.max(a.x, b.x); band.style.left = `${left}px`; band.style.width = `${right - left}px`; band.hidden = false; function drawSeries(list) { const start = a.p.t < b.p.t ? a.p : b.p; const end = a.p.t < b.p.t ? b.p : a.p; const abs = end.v - start.v; const pct = start.v !== 0 ? (abs / start.v) * 100 : 0; const up = abs >= 0; readout.dataset.dir = up ? "up" : "down"; readout.innerHTML = `<span class="ov-measure__pct">${up ? "▲" : "▼"} ${up ? "+" : ""}${pct.toFixed(2)}%</span>` + `<span class="ov-measure__sub">${fmtSigned(abs, entry.unit)}</span>`; readout.hidden = false; const mid = (left + right) / 2; const rw = readout.offsetWidth; const max = el.clientWidth - rw - 4; readout.style.left = `${Math.min(max, Math.max(4, mid - rw / 2))}px`; } entry.renderMeasure = render; el.addEventListener("pointerdown", (e) => { if (!entry.points || entry.points.length < 2) return; dragging = true; moved = false; anchorX = localX(e); curX = anchorX; try { el.setPointerCapture(e.pointerId); } catch { /* ignore */ } render(); }); el.addEventListener("pointermove", (e) => { if (!dragging) return; curX = localX(e); if (Math.abs(curX - anchorX) > 3) moved = true; render(); }); function end(e) { if (!dragging) return; dragging = false; try { el.releasePointerCapture(e.pointerId); } catch { /* ignore */ } // A click with no drag clears the selection. if (!moved) { anchorX = null; curX = null; render(); } } el.addEventListener("pointerup", end); el.addEventListener("pointercancel", end); // Suppress the watchlist card's navigation when the pointerup ended a drag. el.addEventListener( "click", (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; } }, true, );}// Draw/update a series into an attached chart entry.function drawSeries(entry, s) { entry.points = s.points; // real bars, for the measure tool entry.unit = s.unit; const color = s.up ? UP : DOWN; entry.series.applyOptions({ lineColor: color, topColor: s.up ? UP_FILL : DOWN_FILL, bottomColor: "transparent", crosshairMarkerBackgroundColor: color, }); entry.chart.applyOptions({ localization: { priceFormatter: (v) => fmtValue(v, s.unit), timeFormatter: (t) => fmtCrosshairTime(t) }, }); // Frame exactly the Schwab day [start_t, end_t]: pad whitespace (time only) // before the first bar and after the last so a partial day plots from the left. const pts = s.points; const step = pts.length > 1 ? pts[1].t - pts[0].t : 900; const data = []; if (pts.length) { for (let t = s.start_t; t < pts[0].t; t += step) data.push({ time: t }); for (const p of pts) data.push({ time: p.t, value: p.v }); for (let t = pts[pts.length - 1].t + step; t <= s.end_t; t += step) data.push({ time: t }); } else { for (let t = s.start_t; t <= s.end_t; t += step) data.push({ time: t }); } entry.series.setData(data); entry.times = data.map((d) => ({ t: d.time, ext: isExtended(d.time) })); if (entry.refLine) entry.series.removePriceLine(entry.refLine); entry.refLine = entry.series.createPriceLine({ price: s.base, color: REF, lineWidth: 1, lineStyle: 2, axisLabelVisible: false, }); entry.chart.timeScale().fitContent(); renderBands(entry);}// Update a card's header value + % pill.function setHead(root, s) { 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.classList.remove("is-up", "is-down", "is-flat"); c.classList.add(s.change_pct == null ? "is-flat" : s.change_pct >= 0 ? "is-up" : "is-down"); }}const escapeHtml = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);export function initHero() { const overviewGrid = document.querySelector('[data-role="overview-grid"]'); const overviewCards = new Map(); // ticker -> { root, entry } const watchCards = new Map(); // ticker -> { root, entry } function makeOverviewCard(s) { const root = document.createElement("div"); root.className = "ov-card"; root.dataset.ticker = s.ticker; root.innerHTML = `<div class="ov-card__head">` + `<div class="ov-card__id"><span class="ov-card__name">${escapeHtml(s.name)}</span></div>` + `<div class="ov-card__nums"><span class="ov-card__value num"></span>` + `<span class="ov-card__chg num"></span></div></div>` + `<div class="ov-card__chart"></div>`; overviewGrid.appendChild(root); const entry = attachChart(root.querySelector(".ov-card__chart")); return { root, entry }; } function drawOverview(list) { const empty = document.querySelector('[data-role="hero-empty"]'); if (!overviewGrid) return; if (!list || !list.length) { if (empty) empty.hidden = false; return; } if (empty) empty.hidden = true; const seen = new Set(); const legend = []; let ci = 0; for (const s of list) { seen.add(s.ticker); const color = s.baseline ? INK : PALETTE[ci++ % PALETTE.length]; let entry = seriesByTicker.get(s.ticker); if (!entry) { const series = chart.addSeries(LineSeries, { color, lineWidth: s.baseline ? 2 : 1.75, // The title labels the line at its last value on the price axis, so // each line is identifiable without decoding the colour. title: displayName(s), priceLineVisible: false, lastValueVisible: true, crosshairMarkerRadius: 3, }); entry = { series }; seriesByTicker.set(s.ticker, entry); } else { entry.series.applyOptions({ color, title: displayName(s) }); let c = overviewCards.get(s.ticker); if (!c) { c = makeOverviewCard(s); overviewCards.set(s.ticker, c); } entry.series.setData(s.points.map((p) => ({ time: p.t, value: p.v }))); const last = s.points.length ? s.points[s.points.length - 1].v : null; legend.push({ name: displayName(s), color, baseline: s.baseline, last }); setHead(c.root, s); drawSeries(c.entry, s); } // Drop series whose ticker is no longer in the payload. for (const [t, entry] of seriesByTicker) { for (const [t, c] of overviewCards) { if (!seen.has(t)) { chart.removeSeries(entry.series); seriesByTicker.delete(t); c.entry.chart.remove(); c.root.remove(); overviewCards.delete(t); } } chart.timeScale().fitContent(); renderLegend(legend); } function renderLegend(items) { const box = document.querySelector('[data-role="hero-legend"]'); if (!box) return; box.innerHTML = ""; for (const it of items) { const el = document.createElement("span"); el.className = "legend-item" + (it.baseline ? " legend-item--baseline" : ""); const sw = document.createElement("span"); sw.className = "legend-item__swatch"; sw.style.background = it.color; const name = document.createElement("span"); name.textContent = it.name; const pct = document.createElement("span"); pct.className = "legend-item__pct"; pct.textContent = it.last == null ? "" : pctFmt(it.last); el.append(sw, name, pct); box.appendChild(el); } // Watchlist cards are server-rendered shells; draw the chart into each and // refresh its value/%. A card with no series (no intraday bars) keeps its // server-rendered figures and simply shows no line. function drawWatchlist(list) { const byTicker = new Map((list || []).map((s) => [s.ticker, s])); document.querySelectorAll(".watch-grid .ov-card").forEach((root) => { const s = byTicker.get(root.dataset.ticker); if (!s) return; let c = watchCards.get(root.dataset.ticker); if (!c) { c = { root, entry: attachChart(root.querySelector(".ov-card__chart")) }; watchCards.set(root.dataset.ticker, c); } setHead(root, s); drawSeries(c.entry, s); }); } function patchReads(r) {
@@ -209,12 +504,6 @@ export function initHero() { ? "Prices update during market hours " + DASH + " ET" : "US equities " + DASH + " all times ET", ); // Mirror the overview's cash-vs-futures state on the graph note: outside the // regular session the indexes are drawn from their E-mini futures. setText( "overview-mode", session === "regular" ? "" : " " + DASH + " index futures (off-hours)", ); } async function refresh() {
@@ -226,16 +515,12 @@ export function initHero() { } catch { return; } drawSeries(data.series); drawOverview(data.series); drawWatchlist(data.watchlist); patchReads(data.reads); patchSession(data.session); } // Draw immediately from stored data, then pull fresh quotes once on open (the // dashboard otherwise only updates via the market-hours intraday poll, so it // can look stale on open — especially after the close) and redraw. The 60s // loop keeps the chart/reads live; the watchlist cards live-tick over the // stream from the quotes the open refresh publishes. refresh(); fetch("/api/dashboard/refresh") .catch(() => {})
modified
frontend/static_src/home/styles/home.scss
@@ -79,19 +79,26 @@ font-size: var(--fs-xs); color: var(--ink-faint);}.hero-graph__base { color: var(--ink-dim); font-weight: 600;}.hero-graph__chart {/* The overview + watchlist grids: one mini chart per instrument. One column on a phone, two on a tablet, three on desktop. Explicit columns (rather than auto-fill) so the overview (inside the padded hero card) and the watchlist (full width) both land on the same 3-up on desktop. */.overview-grid { position: relative; width: 100%; height: 300px; display: grid; grid-template-columns: 1fr; gap: 11px; min-height: 120px;}@media (min-width: $bp-sm) { .hero-graph__chart { height: 380px;@media (min-width: $bp-md) { .overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }}@media (min-width: $bp-lg) { .overview-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }}
@@ -105,39 +112,206 @@ font-size: var(--fs-sm);}/* Re-assert [hidden] — the display:flex above otherwise beats the UA rule, so the placeholder would linger over the drawn chart (cf. the symbol page's the placeholder would linger over the drawn charts (cf. the symbol page's .ind-btn[hidden] fix). */.hero-graph__empty[hidden] { display: none;}.hero-graph__legend {/* ---------- one instrument's chart card (overview + watchlist) ---------- */.ov-card { @include card; position: relative; display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); padding-top: var(--sp-1); flex-direction: column; gap: 10px; padding: 13px 14px 10px; transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;}.legend-item { display: inline-flex; align-items: center; gap: 0.4em; font-size: var(--fs-xs);/* The card's inner stack. Overview cards stack directly; watchlist cards wrap the same stack in a navigating link. */.ov-card__link { display: flex; flex-direction: column; gap: 10px; color: inherit;}.ov-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-2);}.ov-card__id { display: flex; flex-direction: column; gap: 1px; min-width: 0;}.ov-card__name { @include serif; font-size: var(--fs-md); font-weight: 600; line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.ov-card__sub { font-size: var(--fs-2xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.legend-item--baseline {.ov-card__nums { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none;}.ov-card__value { font-size: var(--fs-md); font-weight: 700; color: var(--ink); line-height: 1;}.legend-item__swatch { width: 14px; height: 3px; border-radius: 2px; flex: none;/* The % move as a tinted pill — the card's at-a-glance good/bad signal. */.ov-card__chg { font-size: var(--fs-2xs); font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 999px; background: var(--ink-wash, rgba(33, 31, 26, 0.06)); color: var(--ink-dim);}.ov-card__chg.is-up { color: var(--up); background: var(--up-soft);}.ov-card__chg.is-down { color: var(--down); background: var(--down-soft);}.ov-card__chart { position: relative; width: 100%; height: 156px;}/* ---------- watchlist card extras (link + remove) ---------- */.ov-card--watch:hover { border-color: var(--rule-strong); transform: translateY(-2px); box-shadow: var(--lift-hover);}.legend-item__pct {.ov-card__remove { position: absolute; top: 8px; right: 8px; z-index: 2; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 1.05rem; line-height: 1; color: var(--ink-faint); background: var(--surface); border: 1px solid transparent; border-radius: 50%; cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, border-color 0.12s;}.ov-card--watch:hover .ov-card__remove,.ov-card__remove:focus-visible { opacity: 1;}.ov-card__remove:hover { color: var(--down); border-color: var(--down);}.ov-card__remove:disabled { cursor: default; opacity: 0.4;}/* Extended-hours (pre-market / after-hours / overnight) shading. A subtle, non-semantic ink tint over those spans so the clear regular-session window stands out. Pointer-transparent so the crosshair still reads through. The bottom inset keeps the tint off the time-axis labels. */.ov-bands { position: absolute; top: 0; right: 0; bottom: 22px; left: 0; pointer-events: none; overflow: hidden;}.ov-band { position: absolute; top: 0; bottom: 0; background: rgba(33, 31, 26, 0.05);}/* Click-drag measure tool (mirrors the symbol chart): a shaded band between two bars + a readout chip with the % and value change. Both pointer-transparent. */.ov-measure-band { position: absolute; top: 0; bottom: 22px; z-index: 3; background: rgba(33, 31, 26, 0.08); border-left: 1px solid var(--rule-strong); border-right: 1px solid var(--rule-strong); pointer-events: none;}.ov-measure-band[hidden] { display: none;}.ov-measure-readout { position: absolute; top: 4px; z-index: 4; display: flex; flex-direction: column; gap: 1px; padding: 3px 7px; background: var(--surface); border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); box-shadow: var(--lift-hover); white-space: nowrap; pointer-events: none;}.ov-measure-readout[hidden] { display: none;}.ov-measure__pct { @include mono; font-weight: 600; font-size: var(--fs-xs); font-weight: 700;}.ov-measure-readout[data-dir="up"] .ov-measure__pct { color: var(--up);}.ov-measure-readout[data-dir="down"] .ov-measure__pct { color: var(--down);}.ov-measure__sub { @include mono; font-size: var(--fs-2xs); color: var(--ink-faint);}/* ---------- headline market reads ---------- */
@@ -270,161 +444,8 @@ font-size: var(--fs-sm);}/* The watchlist reuses the overview grid + chart cards (`.overview-grid` + `.ov-card--watch`); it only needs the top margin under its header. */.watch-grid { margin-top: var(--sp-3); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px;}@media (min-width: $bp-sm) { .watch-grid { grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); }}/* The watch card wraps a sparkline card (`.spark-card` styles below) in a relative box so a remove button can float in its corner without nesting an interactive control inside the navigating link. */.watch-card { position: relative;}.watch-card__link { display: flex; flex-direction: column; gap: 9px; color: inherit;}.watch-card__remove { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 1.05rem; line-height: 1; color: var(--ink-faint); background: var(--surface); border: 1px solid transparent; border-radius: 50%; cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, border-color 0.12s;}.watch-card:hover .watch-card__remove,.watch-card__remove:focus-visible { opacity: 1;}.watch-card__remove:hover { color: var(--down); border-color: var(--down);}.watch-card__remove:disabled { cursor: default; opacity: 0.4;}/* ---------- shared sparkline card (also used by the live stream client) ---- */.spark-card { @include card; display: flex; flex-direction: column; gap: 9px; padding: 13px 14px; color: var(--ink); transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;}.spark-card:hover { color: var(--ink); border-color: var(--rule-strong); transform: translateY(-2px); box-shadow: var(--lift-hover);}.spark-card__head { display: flex; align-items: baseline; gap: var(--sp-2);}.spark-card__sym { @include mono; font-weight: 700; font-size: var(--fs-md);}.spark-card__name { font-size: var(--fs-2xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;}.spark { display: block; width: 100%; height: 46px;}.spark__line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke;}.spark__area { stroke: none;}.spark__base { stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 2; opacity: 0.7; vector-effect: non-scaling-stroke;}.is-up-card .spark__line { stroke: var(--up);}.is-up-card .spark__area { fill: var(--up-soft);}.is-down-card .spark__line { stroke: var(--down);}.is-down-card .spark__area { fill: var(--down-soft);}.spark--empty { display: flex; align-items: center; justify-content: center; height: 46px; color: var(--ink-faint); font-size: 0.72rem;}.spark-card__foot { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding-top: 8px; border-top: 1px solid var(--rule);}.spark-card__price { font-size: var(--fs-md); font-weight: 700;}.spark-card__chg { font-size: var(--fs-sm); font-weight: 700;}.spark-card.flash-up { animation: flash-up 0.7s ease-out;}.spark-card.flash-down { animation: flash-down 0.7s ease-out;}
modified
frontend/static_src/symbol/styles/symbol.scss
@@ -1960,3 +1960,194 @@ a.anomaly__link:hover .anomaly__body { transition: none; }}/* ---------- indicator read (under the chart) ---------- A colour-coded read of the chart's indicators: an overall trend verdict, an RSI momentum gauge, and one signal tile per moving average. *//* Tone helper: each toned element exposes its semantic ink + soft wash as vars so the gauge / tiles can paint from one place. */.ind-tone--up { --tone: var(--up); --tone-soft: var(--up-soft); }.ind-tone--down { --tone: var(--down); --tone-soft: var(--down-soft); }.ind-tone--warn { --tone: var(--warn); --tone-soft: var(--warn-soft); }.ind-tone--steady { --tone: var(--ink-dim); --tone-soft: rgba(33, 31, 26, 0.07); }.ind-read { display: flex; flex-direction: column; gap: var(--sp-3);}.ind-read__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3);}.ind-read__title { @include serif; font-size: var(--fs-lg); font-weight: 600;}/* Overall verdict pill. */.ind-verdict { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px 5px 10px; border-radius: 999px; font-weight: 700; font-size: var(--fs-sm); color: var(--tone); background: var(--tone-soft);}.ind-verdict--up { color: var(--up); background: var(--up-soft); }.ind-verdict--down { color: var(--down); background: var(--down-soft); }.ind-verdict--warn { color: var(--warn); background: var(--warn-soft); }.ind-verdict--steady { color: var(--ink-dim); background: rgba(33, 31, 26, 0.07); }.ind-verdict__dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor;}.ind-verdict__note { font-weight: 500; font-size: var(--fs-xs); opacity: 0.85;}/* RSI momentum gauge. */.ind-rsi { border: 1px solid var(--rule); border-radius: var(--radius); background: var(--surface); padding: var(--sp-3); display: flex; flex-direction: column; gap: 4px;}.ind-rsi__top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2);}.ind-rsi__label { @include eyebrow;}.ind-rsi__verdict { font-weight: 700; font-size: var(--fs-sm); color: var(--tone);}.ind-rsi__track { position: relative; height: 8px; margin-top: 26px; border-radius: 999px; background: var(--rule);}.ind-rsi__zone { position: absolute; top: 0; bottom: 0;}.ind-rsi__zone--under { left: 0; width: 30%; background: var(--up-soft); border-radius: 999px 0 0 999px;}.ind-rsi__zone--over { left: 70%; right: 0; background: var(--down-soft); border-radius: 0 999px 999px 0;}.ind-rsi__marker { position: absolute; top: -4px; bottom: -4px; width: 2px; background: var(--tone); border-radius: 1px;}.ind-rsi__bubble { position: absolute; bottom: 14px; left: 0; transform: translateX(-50%); padding: 2px 7px; border-radius: 7px; background: var(--tone); color: var(--surface); font-size: var(--fs-2xs); font-weight: 700;}.ind-rsi__scale { display: flex; justify-content: space-between; margin-top: 7px; font-size: var(--fs-2xs); color: var(--ink-faint);}.ind-rsi__note { margin-top: 4px; font-size: var(--fs-sm); color: var(--ink-dim); line-height: 1.4;}/* Trend signal tiles. */.ind-signals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px;}@media (min-width: $bp-sm) { .ind-signals { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }}.ind-sig { border: 1px solid var(--rule); border-left: 3px solid var(--tone); border-radius: var(--radius-sm); background: var(--tone-soft); padding: 9px 11px; display: flex; flex-direction: column; gap: 3px;}.ind-sig__top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px;}.ind-sig__label { @include eyebrow;}.ind-sig__value { @include mono; font-size: var(--fs-2xs); color: var(--ink-dim);}.ind-sig__status { @include mono; font-weight: 700; font-size: var(--fs-md); color: var(--tone); line-height: 1.1;}.ind-sig__note { font-size: var(--fs-2xs); color: var(--ink-dim);}.ind-read__foot { font-size: var(--fs-xs); color: var(--ink-faint);}
@@ -44,90 +44,6 @@ pub fn pos(value: f64, lo: f64, hi: f64) -> f64 { (p.clamp(0.0, 100.0) * 100.0).round() / 100.0}// ──────────────────────────── dashboard sparkline ──────────────────────────//// Phase 11. Geometry for the tiny intraday line on each home-dashboard card.// Drawn server-side from the latest session's bar closes; the stream client// then nudges the trailing point as live quotes arrive./// Fixed viewBox the sparklines are drawn in: 100 wide, 36 tall. The line is/// confined to y ∈ [`SPARK_TOP`, `SPARK_BOTTOM`] so a 1-2px stroke never clips/// at the edges. The stream client's live-tip code mirrors these numbers.const SPARK_W: f64 = 100.0;const SPARK_TOP: f64 = 3.0;const SPARK_BOTTOM: f64 = 33.0;/// Geometry for one dashboard sparkline.#[derive(Debug, Clone, Serialize)]pub struct Sparkline { /// Polyline points (`"x,y x,y …"`, oldest first) for the line itself. pub line: String, /// The line closed down to the baseline at both ends, for the area fill. pub area: String, /// Value range the y-axis maps. The stream client places a live price on /// this same scale to move the trailing point. pub lo: f64, pub hi: f64, /// y of the previous close, drawn as a faint reference rule; `None` when /// no prior close is known. pub baseline: Option<f64>,}/// Build a [`Sparkline`] from a session's intraday closes (oldest first) and,/// when known, the prior close. `None` for an empty series. The value range is/// widened to include `prev_close` so the reference rule always lands in box.pub fn sparkline(closes: &[f64], prev_close: Option<f64>) -> Option<Sparkline> { if closes.is_empty() { return None; } let mut lo = closes.iter().copied().fold(f64::INFINITY, f64::min); let mut hi = closes.iter().copied().fold(f64::NEG_INFINITY, f64::max); if let Some(p) = prev_close { lo = lo.min(p); hi = hi.max(p); } // Map a value to a y coordinate: a higher value sits closer to the top // (smaller y). A flat series (hi == lo) pins to the vertical midpoint. let y = |v: f64| -> f64 { let t = if hi > lo { (v - lo) / (hi - lo) } else { 0.5 }; let yy = SPARK_BOTTOM - t * (SPARK_BOTTOM - SPARK_TOP); (yy * 100.0).round() / 100.0 }; // Evenly space the points across the full width; a lone point centres. let x = |i: usize| -> f64 { let xx = if closes.len() > 1 { i as f64 / (closes.len() - 1) as f64 * SPARK_W } else { SPARK_W / 2.0 }; (xx * 100.0).round() / 100.0 }; let mut line = String::new(); for (i, &c) in closes.iter().enumerate() { if i > 0 { line.push(' '); } line.push_str(&format!("{},{}", x(i), y(c))); } let area = format!( "{},{} {} {},{}", x(0), SPARK_BOTTOM, line, x(closes.len() - 1), SPARK_BOTTOM, ); Some(Sparkline { line, area, lo, hi, baseline: prev_close.map(y), })}// ─────────────────────────── computed ratios ───────────────────────────────//// Phase 7. Each ratio is computed from the latest full fiscal year's SEC
modified
src/routes/home.rs
@@ -19,7 +19,7 @@ use axum::{};use serde::Serialize;use crate::compute::{self, Sparkline};use crate::compute;use crate::market;use crate::render::render_to_string;use crate::{watchlist, AppState};
@@ -40,47 +40,90 @@ const VIX: &str = "^VIX";/// while the dashboard is open (it carries a `data-ticker`) so it stays fresh.const VOLUME_PROXY: &str = "SPY";/// One slot in the fixed market-overview graph (Phase E). During the **regular**/// session the `cash` ticker is drawn (the live cash index); outside it (pre //// after-hours / closed) the `off` ticker is drawn instead — the E-mini future,/// which trades nearly 24h, so the overview keeps moving overnight and shows/// where the market is heading. Instruments that already trade ~24h (gold, crude,/// BTC) use the same ticker in both states./// 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/// trades ~24h, so the chart shows pre-market + regular + after-hours movement./// - `quote` drives the headline value + %. For an index this is the **cash/// index** (`regularMarketPrice` vs `chartPreviousClose`) — the number every/// market site shows, frozen at the closing change after 4pm.////// Instruments that already trade ~24h (gold, crude, BTC) use one ticker for both.struct OverviewSlot { cash: &'static str, off: &'static str, quote: &'static str, chart: &'static str, name: &'static str, /// The S&P slot is drawn as the chart's ink baseline line. baseline: bool, /// Priced in dollars (gold, crude, BTC) rather than index points — drives the /// `$`-vs-`pts` unit hint the per-instrument chart formats its values with. dollar: bool,}/// The market overview: a fixed, non-editable read of "how is the whole market/// doing", separate from the personal watchlist (which is cards only and no/// longer on this graph). VIX is deliberately absent — it swings ~10x the indexes/// and would squash a normalized %-from-open overlay; it stays a read instead./// doing", separate from the personal watchlist. Each slot is its own chart/// (pts for indexes, $ for gold/crude/BTC). VIX is deliberately absent — it stays/// a headline read.const OVERVIEW: &[OverviewSlot] = &[ OverviewSlot { cash: "^SPX", off: "ES=F", name: "S&P 500", baseline: true }, OverviewSlot { cash: "^DJI", off: "YM=F", name: "Dow", baseline: false }, OverviewSlot { cash: "^NDX", off: "NQ=F", name: "Nasdaq 100", baseline: false }, OverviewSlot { cash: "^RUT", off: "RTY=F", name: "Russell 2000", baseline: false }, OverviewSlot { cash: "GC=F", off: "GC=F", name: "Gold", baseline: false }, OverviewSlot { cash: "CL=F", off: "CL=F", name: "Crude Oil", baseline: false }, OverviewSlot { cash: "BTC-USD", off: "BTC-USD", name: "Bitcoin", baseline: false }, OverviewSlot { quote: "^SPX", chart: "ES=F", name: "S&P 500", dollar: false }, OverviewSlot { quote: "^DJI", chart: "YM=F", name: "Dow", dollar: false }, OverviewSlot { quote: "^NDX", chart: "NQ=F", name: "Nasdaq 100", dollar: false }, OverviewSlot { quote: "GC=F", chart: "GC=F", name: "Gold", dollar: true }, OverviewSlot { quote: "CL=F", chart: "CL=F", name: "Crude Oil", dollar: true }, OverviewSlot { quote: "BTC-USD", chart: "BTC-USD", name: "Bitcoin", dollar: true },];/// The overview tickers + display names for `session`: cash indexes during the/// regular session, the E-mini futures (and the ~24h instruments) otherwise.fn overview_for(session: market::Session) -> Vec<(&'static str, &'static str, bool)> { let regular = session == market::Session::Regular; OVERVIEW .iter() .map(|s| (if regular { s.cash } else { s.off }, s.name, s.baseline)) .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()}/// Every ticker the overview needs polled / quoted — both the quote (cash) and/// chart (futures) tickers, de-duplicated in slot order.fn overview_tickers() -> Vec<&'static str> { let mut out: Vec<&'static str> = Vec::new(); for s in OVERVIEW { for t in [s.quote, s.chart] { if !out.contains(&t) { out.push(t); } } } out}/// A symbol's latest session = the intraday bars within this window of its most/// recent bar (regular+extended spans ~16h; the prior session sits ~24h back).const SESSION_WINDOW_MS: i64 = 23 * 3600 * 1000;/// The overview charts frame exactly one Schwab trading day: extended-hours open/// (7:00 AM ET) through extended-hours close (8:00 PM ET), so each chart shows/// just that day — pre-market, the regular session, and after-hours — and never/// bleeds into the previous day.const SCHWAB_OPEN_MIN: u32 = 7 * 60; // 7:00 AM ETconst SCHWAB_CLOSE_MIN: u32 = 20 * 60; // 8:00 PM ET/// The Schwab trading-day window [open, close] in epoch-ms for the ET calendar/// day that `latest_ms` falls in (so a Friday-evening view frames Friday, a/// weekend view still frames Friday's last session, etc.).fn schwab_day_window(latest_ms: i64) -> Option<(i64, i64)> { use chrono::TimeZone as _; use chrono_tz::America::New_York; let date = New_York.timestamp_millis_opt(latest_ms).single()?.date_naive(); let at = |min: u32| { let naive = date.and_hms_opt(min / 60, min % 60, 0)?; // Pick the earlier instant on a fall-back DST repeat; both are fine here. New_York .from_local_datetime(&naive) .earliest() .map(|dt| dt.timestamp_millis()) }; Some((at(SCHWAB_OPEN_MIN)?, at(SCHWAB_CLOSE_MIN)?))}/// The value unit for a symbol: index points for equity indexes, dollars for/// everything else (stocks, ETFs, crypto, dollar-priced commodity futures).fn unit_for(kind: &str) -> &'static str { if kind == "index" { "pts" } else { "$" }}/// Calendar days of daily closes to pull for the 50/200-day SMA trend read.const SMA_LOOKBACK_DAYS: i64 = 320;
@@ -88,17 +131,20 @@ const SMA_LOOKBACK_DAYS: i64 = 320;/// Volume vs its recent average: this many trading days form the baseline.const VOLUME_AVG_DAYS: i64 = 65;/// One card on the dashboard: a symbol's price, day move, and intraday spark./// One watchlist card shell, server-rendered for the initial paint, the symbol/// link, and the remove button. The Schwab-day chart + the live value/% are then/// drawn into it by `hero.js` from `/api/dashboard` (the same treatment as the/// overview cards), so a watchlist card and an overview card look identical.#[derive(Serialize, Clone)]struct SparkCard { ticker: String, name: String, price: Option<f64>, change_pct: Option<f64>, /// Sparkline geometry, `None` until the symbol has intraday bars. spark: Option<Sparkline>, /// Colour hook: true when the day's change is not negative (or unknown). up: bool, /// "$" for dollar-priced symbols (stocks/ETFs/crypto), "pts" for indexes. unit: &'static str,}/// The dashboard's headline market reads, server-rendered and then refreshed by
@@ -123,14 +169,28 @@ struct MarketReads { asof: Option<i64>,}/// One overlaid line on the day graph: a symbol's intraday move as % from the/// session's open, on a shared time axis./// One instrument's own chart in the overview grid: its latest session's actual/// values (index points or dollars) on its own axis, plus the headline figures/// the card shows above the chart (last value + % change from the open).#[derive(Serialize)]struct Series { ticker: String, name: String, /// True for the S&P baseline (drawn distinctly). baseline: bool, /// "$" 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. base: f64, /// The latest value (the card's headline figure). last: f64, /// % change from `base` (the day move shown beside the value). change_pct: f64, /// True when the day move is not negative — drives the green/red line colour. up: bool, /// UNIX seconds bounding the Schwab trading-day frame (extended-hours open /// and close). The chart always spans exactly this one day, so a partial day /// plots from the left rather than stretching across the width. start_t: i64, end_t: i64, points: Vec<SeriesPoint>,}
@@ -138,7 +198,7 @@ struct Series {struct SeriesPoint { /// UNIX seconds (lightweight-charts wants seconds, not ms). t: i64, /// Percent change from the session's first bar. /// The bar's actual close value (index points or dollars). v: f64,}
@@ -147,7 +207,11 @@ struct SeriesPoint {struct DashboardData { session: String, reads: MarketReads, /// The fixed market-overview charts. series: Vec<Series>, /// The session's watchlist, drawn with the same per-instrument chart /// treatment as the overview (Schwab day, shading, % vs prev close). watchlist: Vec<Series>,}async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response {
@@ -162,13 +226,10 @@ async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response { let reads = market_reads(&state).await; let market_session = market::session_at(chrono::Utc::now()); // The overview tickers for this session, rendered as hidden `data-ticker` // The overview tickers (both cash + futures), rendered as hidden `data-ticker` // nodes so the live stream registers them with the interest registry and the // demand-driven intraday poll keeps their bars fresh while the page is open. let overview_tickers: Vec<&str> = overview_for(market_session) .into_iter() .map(|(t, _, _)| t) .collect(); let overview_tickers: Vec<&str> = overview_tickers(); let extra = minijinja::context! { title => "Markets",
@@ -196,34 +257,59 @@ async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response { }}/// `GET /api/dashboard` — the day graph series + the market reads, polled by the/// page (~every minute) so the chart and reads stay live without a reload. The/// series are normalized %-from-open so the watchlist and the S&P baseline share/// one axis (the TradingView/Google "compare" shape).async fn dashboard_api(State(state): State<AppState>) -> Response { // No session needed: the overview is fixed, not per-browser. (The watchlist // cards live-tick over the base stream; they are not on this graph.)/// `GET /api/dashboard` — the per-instrument overview series + the market reads,/// polled by the page (~every minute) so the charts and reads stay live without a/// reload. Each series carries its own actual values (points or dollars) plus its/// last value and % change from the open; the page draws one chart per series.async fn dashboard_api(State(state): State<AppState>, headers: HeaderMap) -> Response { let market_session = market::session_at(chrono::Utc::now()); // The market-overview set for this session: cash indexes during the regular // session, the E-mini futures otherwise. The baseline (S&P) leads. let overview = overview_for(market_session); let mut series = Vec::with_capacity(overview.len()); for (ticker, name, baseline) in overview { if let Some(s) = pct_series(&state, ticker, name, baseline).await { // The fixed market-overview set. The S&P slot leads. let slots = overview(); let mut series = Vec::with_capacity(slots.len()); for (quote, chart, name, dollar) in slots { if let Some(s) = overview_series(&state, quote, chart, name, dollar).await { series.push(s); } } // The session's watchlist, drawn with the same chart treatment as the // overview. Each is a single-ticker series (the symbol is both quote + chart; // stocks/ETFs carry their own pre/post bars via Yahoo's includePrePost). let session = watchlist::resolve(&state.pool, &headers).await; let wl = watchlist::list(&state.pool, &session.sid).await; let mut watchlist = Vec::with_capacity(wl.len()); for t in &wl { if let Some(s) = watchlist_series(&state, t).await { watchlist.push(s); } } let data = DashboardData { session: market_session.as_str().to_string(), reads: market_reads(&state).await, series, watchlist, }; Json(data).into_response()}/// One watchlist symbol as a chart series, identical in shape to an overview/// slot: the symbol is its own quote + chart ticker, with the unit (points for an/// index, dollars otherwise) read off its kind.async fn watchlist_series(state: &AppState, ticker: &str) -> Option<Series> { let row: Option<(String, String)> = sqlx::query_as("SELECT name, kind FROM symbols WHERE ticker = ?") .bind(ticker) .fetch_optional(&state.pool) .await .ok() .flatten(); let (name, kind) = row?; overview_series(state, ticker, ticker, &name, unit_for(&kind) == "$").await}/// `GET /api/dashboard/refresh` — the dashboard's on-open refresh. Pulls fresh/// quotes for the watchlist + the baseline reads (^SPX/^VIX/SPY) once, so opening/// the page always shows current data rather than whatever was last stored. The
@@ -234,7 +320,7 @@ async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) -> // The watchlist cards, the session's overview symbols, and the VIX / volume // reads — everything the open dashboard shows gets a fresh quote. let mut tickers = watchlist::list(&state.pool, &session.sid).await; for (t, _, _) in overview_for(market::session_at(chrono::Utc::now())) { for t in overview_tickers() { tickers.push(t.to_string()); } for b in [VIX, VOLUME_PROXY] {
@@ -262,42 +348,87 @@ fn session_label(s: market::Session) -> &'static str { }}/// Build one symbol's normalized intraday series: its latest session's 15-minute/// bars as % change from the session's first bar's open. `None` when the symbol/// has no intraday bars (e.g. never polled, or a holiday).async fn pct_series(state: &AppState, ticker: &str, name: &str, baseline: bool) -> Option<Series> {/// 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).async fn overview_series( state: &AppState, quote_ticker: &str, chart_ticker: &str, name: &str, dollar: bool,) -> Option<Series> { // Anchor to the Schwab day that the chart ticker's most recent bar falls in, // so the chart shows just that one day and never the previous one. let latest_ms: i64 = sqlx::query_scalar("SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?") .bind(chart_ticker) .fetch_optional(&state.pool) .await .ok() .flatten() .flatten()?; let (start_ms, end_ms) = schwab_day_window(latest_ms)?; let rows: Vec<(i64, f64, f64)> = sqlx::query_as( "SELECT ts, open, close FROM intraday_bars \ WHERE ticker = ? \ AND ts >= (SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?) - ? \ WHERE ticker = ? AND ts >= ? AND ts <= ? \ ORDER BY ts", ) .bind(ticker) .bind(ticker) .bind(SESSION_WINDOW_MS) .bind(chart_ticker) .bind(start_ms) .bind(end_ms) .fetch_all(&state.pool) .await .unwrap_or_default(); // Base off the first bar's open; fall back to its close if open is zero. let base = rows.first().map(|r| if r.1 > 0.0 { r.1 } else { r.2 })?; // 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. let last = last_price.or_else(|| rows.last().map(|r| r.2))?; // Reference: the previous close, else the session's first-bar open. let open = rows.first().map(|r| if r.1 > 0.0 { r.1 } else { r.2 }); let base = prev_close.filter(|p| *p > 0.0).or(open)?; if base <= 0.0 { return None; } let points: Vec<SeriesPoint> = rows .iter() .map(|(ts, _open, close)| SeriesPoint { t: ts / 1000, v: (close / base - 1.0) * 100.0, }) .map(|(ts, _open, close)| SeriesPoint { t: ts / 1000, v: *close }) .collect(); if points.is_empty() { return None; } let change_pct = (last / base - 1.0) * 100.0; Some(Series { ticker: ticker.to_string(), ticker: quote_ticker.to_string(), name: name.to_string(), baseline, unit: if dollar { "$" } else { "pts" }, base, last, change_pct, up: change_pct >= 0.0, start_t: start_ms / 1000, end_t: end_ms / 1000, points, })}
@@ -412,18 +543,19 @@ async fn last_and_prev(state: &AppState, ticker: &str) -> Option<(Option<f64>, O .flatten()}/// Build a sparkline card per ticker, in order: current price, the day's change,/// and a sparkline of the latest session's bars. A ticker the universe does not/// hold is skipped./// Build a watchlist card shell per ticker, in order: ticker, name, current/// price, the day's change (vs prev close), and the points/dollars unit. The/// chart itself is drawn client-side from `/api/dashboard`. A ticker the universe/// does not hold is skipped.async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> { if tickers.is_empty() { return Vec::new(); } // One query for the price rows; the `IN` placeholder count matches `tickers`. type SparkRow = (String, String, Option<f64>, Option<f64>); type SparkRow = (String, String, String, Option<f64>, Option<f64>); let placeholders = vec!["?"; tickers.len()].join(","); let sql = format!( "SELECT s.ticker, s.name, \ "SELECT s.ticker, s.name, s.kind, \ COALESCE(s.last_price, \ (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \ COALESCE(s.prev_close, \
@@ -440,23 +572,9 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> { let mut cards = Vec::with_capacity(tickers.len()); for &t in tickers { let Some((ticker, name, last, prev)) = by_ticker.remove(t) else { let Some((ticker, name, kind, last, prev)) = by_ticker.remove(t) else { continue; }; // The latest session's intraday closes, oldest first. let closes: Vec<f64> = sqlx::query_scalar( "SELECT close FROM intraday_bars \ WHERE ticker = ? \ AND ts >= (SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?) - ? \ ORDER BY ts", ) .bind(&ticker) .bind(&ticker) .bind(SESSION_WINDOW_MS) .fetch_all(&state.pool) .await .unwrap_or_default(); let change_pct = match (last, prev) { (Some(l), Some(p)) => Some(compute::change(l, p).pct), _ => None,
@@ -466,8 +584,8 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> { name, price: last, change_pct, spark: compute::sparkline(&closes, prev), up: change_pct.map_or(true, |p| p >= 0.0), unit: unit_for(&kind), }); } cards
modified
src/routes/symbols.rs
@@ -63,6 +63,152 @@ struct Stats { vol_ratio: Option<f64>,}/// The chart's indicators distilled into a colour-coded read: an overall/// trend verdict, an RSI momentum gauge, and one signal tile per moving average/// (where the price sits vs it) plus the 50/200 cross. Built from the daily/// closes; `None` until a symbol has enough history.#[derive(Serialize)]struct IndicatorRead { /// Overall trend verdict ("Bullish" / "Mixed" / "Bearish") + its tone and a /// one-line tally ("3 of 4 trend signals bullish"). verdict: String, verdict_tone: String, verdict_note: String, /// RSI(14): the value, its 0–100 position (for the gauge), bucket label, /// tone, and a plain-language verdict. rsi: f64, rsi_pos: f64, rsi_label: String, rsi_tone: String, rsi_note: String, /// One colour-coded tile per moving-average signal. signals: Vec<IndicatorSignal>,}/// One signal tile: the indicator label, its current value, a short status word/// (Above / Below / Golden cross / …), the tone colour, and a plain meaning.#[derive(Serialize)]struct IndicatorSignal { label: String, value: String, status: String, tone: String, note: String,}/// Build the colour-coded indicator read from the daily closes (oldest first),/// the current price, and whether the symbol is dollar-priced. Needs enough/// history for RSI(14)/EMA(21); the 50/200 averages join once they exist.fn build_indicator_read(closes: &[f64], price: f64, dollar: bool) -> Option<IndicatorRead> { if closes.len() < 30 || price <= 0.0 { return None; } let rsi = compute::rsi(closes, 14).last().copied().flatten()?; let fmt = |v: f64| { let n = format!("{v:.2}"); if dollar { format!("${n}") } else { n } }; // RSI verdict: 70+/30- are the textbook overbought/oversold extremes; the // 45–55 middle is balanced, with a "leaning" read on either side. let (rsi_label, rsi_tone, rsi_note) = if rsi >= 70.0 { ("Overbought", "down", format!("RSI {rsi:.0} — overbought: momentum is stretched and may be due for a pullback.")) } else if rsi <= 30.0 { ("Oversold", "up", format!("RSI {rsi:.0} — oversold: selling looks stretched and may be due for a bounce.")) } else if rsi >= 55.0 { ("Leaning bullish", "up", format!("RSI {rsi:.0} — firm momentum, not yet overbought.")) } else if rsi <= 45.0 { ("Leaning bearish", "down", format!("RSI {rsi:.0} — soft momentum, not yet oversold.")) } else { ("Neutral", "steady", format!("RSI {rsi:.0} — momentum is balanced between buyers and sellers.")) }; let ema21 = compute::ema(closes, 21).last().copied().flatten(); let sma50 = compute::sma(closes, 50).last().copied().flatten(); let sma200 = compute::sma(closes, 200).last().copied().flatten(); // One tile per average that exists, each relating the current price to it. let mut signals = Vec::new(); let mut bull = 0u32; let mut total = 0u32; let mut ma_tile = |label: &str, val: f64, span: &str| { let above = price >= val; if above { bull += 1; } total += 1; signals.push(IndicatorSignal { label: label.to_string(), value: fmt(val), status: if above { "Above" } else { "Below" }.to_string(), tone: if above { "up" } else { "down" }.to_string(), note: format!("{span} trend is {}", if above { "up" } else { "down" }), }); }; if let Some(v) = ema21 { ma_tile("EMA 21", v, "Near-term"); } if let Some(v) = sma50 { ma_tile("SMA 50", v, "Medium-term"); } if let Some(v) = sma200 { ma_tile("SMA 200", v, "Long-term"); } // The 50-vs-200 posture (the golden/death-cross regime). if let (Some(f), Some(s)) = (sma50, sma200) { let golden = f >= s; if golden { bull += 1; } total += 1; signals.push(IndicatorSignal { label: "50 / 200-day".to_string(), value: String::new(), status: if golden { "Golden cross" } else { "Death cross" }.to_string(), tone: if golden { "up" } else { "down" }.to_string(), note: if golden { "50-day above the 200-day — bullish".to_string() } else { "50-day below the 200-day — bearish".to_string() }, }); } // Overall verdict from the trend tally. let (verdict, verdict_tone) = if total == 0 { ("No signal", "steady") } else if bull == total { ("Bullish", "up") } else if bull == 0 { ("Bearish", "down") } else { ("Mixed", "warn") }; let verdict_note = format!("{bull} of {total} trend signals bullish"); Some(IndicatorRead { verdict: verdict.to_string(), verdict_tone: verdict_tone.to_string(), verdict_note, rsi, rsi_pos: rsi.clamp(0.0, 100.0), rsi_label: rsi_label.to_string(), rsi_tone: rsi_tone.to_string(), rsi_note, signals, })}/// The live quote shown in the symbol header, when one exists. The header/// carries `data-field` hooks, so the stream client patches these in place as/// fresh quotes arrive; this is just the server-rendered starting point.
@@ -1241,6 +1387,14 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) .map(|q| q.price) .or_else(|| stats.as_ref().map(|s| s.close)); // Plain-language read of the chart's indicators (RSI verdict + price vs each // moving average), shown beneath the chart. Built from the daily closes // (oldest first) against the current price; `None` without enough history. let indicators = price.and_then(|p| { let closes: Vec<f64> = bars.iter().rev().map(|b| b.4).collect(); build_indicator_read(&closes, p, symbol.kind != "index") }); // Stock fundamentals are loaded once and shared by the ratio cards // (`build_fundamentals`) and the anomaly feed's YoY detector // (`build_anomalies` via `models::fundamentals_anomalies`).
@@ -1500,6 +1654,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) title => ticker, symbol => symbol, stats => stats, indicators => indicators, quote => quote, fundamentals => fundamentals, standing => standing,
@@ -1610,6 +1765,7 @@ fn range_cutoff(range: &str) -> Option<String> { "1M" => Some((today - chrono::Duration::days(31)).to_string()), "3M" => Some((today - chrono::Duration::days(93)).to_string()), "6M" => Some((today - chrono::Duration::days(186)).to_string()), "3Y" => Some((today - chrono::Duration::days(1098)).to_string()), "5Y" => Some((today - chrono::Duration::days(1830)).to_string()), // 1Y is the default for any unrecognised value. _ => Some((today - chrono::Duration::days(366)).to_string()),
modified
templates/pages/home.html
@@ -1,6 +1,6 @@{% extends "base.html" %}{% block title %}Markets{% endblock %}{% block description %}A live market overview — the major indexes, gold, crude, and bitcoin normalized for the day (index futures off-hours) — plus your personal watchlist.{% endblock %}{% block description %}A live market overview — the major indexes, gold, crude, and bitcoin, each its own day chart — plus your personal watchlist.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/home/index.js', 'css') }}">{% endblock %}{% block main %}
@@ -14,19 +14,19 @@ <span class="dash-banner__sub" data-role="session-note">{% if session == 'closed' %}Prices update during market hours · ET{% else %}US equities · all times ET{% endif %}</span> </div> {# The market-overview graph: the major indexes + gold, crude and bitcoin, each as % change from today's open, on one axis. Cash indexes during the regular session, the E-mini futures off-hours. Drawn by hero.js from /api/dashboard; this is its mount point and a no-JS fallback line. #} {# The market overview: one chart per instrument (the major indexes + gold, crude and bitcoin), each on its own axis showing the day's actual values (points or dollars) with its live value + % change from the open. Cash indexes during the regular session, the E-mini futures off-hours. hero.js builds the cards into the grid from /api/dashboard; this is its mount point. #} <section class="hero-graph"> <div class="hero-graph__head"> <h1 class="hero-graph__title">Market overview</h1> <span class="hero-graph__note">% change from the open<span class="hero-graph__mode" data-role="overview-mode">{% if session != 'regular' %} · index futures (off-hours){% endif %}</span></span> <span class="hero-graph__note">live value & % change vs prev close</span> </div> <div class="hero-graph__chart" data-role="hero-chart"> <div class="overview-grid" data-role="overview-grid"> <p class="hero-graph__empty" data-role="hero-empty">Loading today’s session…</p> </div> <div class="hero-graph__legend" data-role="hero-legend"></div> </section> {# Overview symbols as hidden data-ticker nodes: the live stream registers them
@@ -76,35 +76,27 @@ <p class="watch-msg" data-role="watch-msg" role="status" hidden></p> {% if cards %} <div class="watch-grid"> {# Same chart-card shell as the overview. data-ticker registers the symbol with the live stream (so its intraday bars stay polled); hero.js draws the Schwab-day chart + refreshes the value/% from /api/dashboard. The remove button sits outside the link so the whole card stays navigable. #} <div class="overview-grid watch-grid"> {% for c in cards %} {# data-ticker drives the live stream; the `spark-card` class lets the base client repaint the sparkline tip on each quote. The remove button sits outside the link so the whole card stays navigable. #} <div class="watch-card spark-card{% if c.up %} is-up-card{% else %} is-down-card{% endif %}" data-ticker="{{ c.ticker }}"> <a class="watch-card__link" href="/s/{{ c.ticker|urlencode }}"> <div class="spark-card__head"> <span class="spark-card__sym">{{ c.ticker }}</span> <span class="spark-card__name">{{ c.name }}</span> </div> {% if c.spark %} <svg class="spark" viewBox="0 0 100 36" preserveAspectRatio="none" data-lo="{{ c.spark.lo }}" data-hi="{{ c.spark.hi }}" aria-hidden="true"> <polygon class="spark__area" points="{{ c.spark.area }}"/> {% if c.spark.baseline is not none %} <line class="spark__base" x1="0" x2="100" y1="{{ c.spark.baseline }}" y2="{{ c.spark.baseline }}"/> {% endif %} <polyline class="spark__line" points="{{ c.spark.line }}"/> </svg> {% else %} <div class="spark spark--empty">no intraday data</div> {% endif %} <div class="spark-card__foot"> <span class="spark-card__price num" data-field="price">{{ c.price|money }}</span> <span class="spark-card__chg num{% if c.change_pct is none %} is-flat{% elif c.change_pct >= 0 %} is-up{% else %} is-down{% endif %}" data-field="change_pct">{{ c.change_pct|pct }}</span> <div class="ov-card ov-card--watch" data-ticker="{{ c.ticker }}" data-unit="{{ c.unit }}"> <a class="ov-card__link" href="/s/{{ c.ticker|urlencode }}"> <div class="ov-card__head"> <div class="ov-card__id"> <span class="ov-card__name">{{ c.ticker }}</span> <span class="ov-card__sub">{{ c.name }}</span> </div> <div class="ov-card__nums"> <span class="ov-card__value num">{{ c.price|money }}</span> <span class="ov-card__chg num {% if c.change_pct is none %}is-flat{% elif c.change_pct >= 0 %}is-up{% else %}is-down{% endif %}">{{ c.change_pct|pct }}</span> </div> </div> <div class="ov-card__chart"></div> </a> <button class="watch-card__remove" type="button" data-remove data-ticker="{{ c.ticker }}" aria-label="Remove {{ c.ticker }} from watchlist" title="Remove">×</button> <button class="ov-card__remove" type="button" data-remove data-ticker="{{ c.ticker }}" aria-label="Remove {{ c.ticker }} from watchlist" title="Remove">×</button> </div> {% endfor %} </div>
modified
templates/pages/symbol.html
@@ -192,12 +192,12 @@ {% if stats %} <section class="panel chart-panel"> <div class="chart-bar"> {# 1D / 1W are the intraday ranges (Phase 6): today's / the week's 15m bars on a minute axis, live-ticked from the quote stream. The rest are daily. A hairline after 1W sets the two groups apart. #} {# Daily-candle ranges. Default 1Y. (The old 1D / 1W intraday ranges were dropped — the demand-only model can't keep enough 15m bars to draw them legibly; short-range detail comes from the denser-candle ranges.) #} <div class="range-bar"> {% for r in ["1D", "1W", "1M", "6M", "YTD", "1Y", "5Y", "MAX"] %} <button type="button" class="range-btn{% if r == '1W' %} range-btn--group-end{% endif %}{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button> {% for r in ["YTD", "1M", "3M", "6M", "1Y", "3Y", "5Y", "MAX"] %} <button type="button" class="range-btn{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button> {% endfor %} </div> {# Filled by chart.js after each load: the % / absolute move over the
@@ -210,9 +210,9 @@ {% for ind in [ {"key": "sma50", "label": "SMA 50", "on": true, "dot": true, "hide": false}, {"key": "sma200", "label": "SMA 200", "on": true, "dot": true, "hide": false}, {"key": "ema21", "label": "EMA 21", "on": false, "dot": true, "hide": false}, {"key": "ema21", "label": "EMA 21", "on": true, "dot": true, "hide": false}, {"key": "volume", "label": "Volume", "on": true, "dot": false, "hide": false}, {"key": "rsi", "label": "RSI", "on": false, "dot": true, "hide": false}, {"key": "rsi", "label": "RSI", "on": true, "dot": true, "hide": false}, {"key": "benchmark", "label": (symbol.benchmark or "Benchmark"), "on": true, "dot": true, "hide": (not symbol.benchmark)} ] %} <button type="button" class="ind-btn{% if ind.on %} is-active{% endif %}"
@@ -225,6 +225,57 @@ <div id="chart" data-ticker="{{ symbol.ticker }}"></div> </section> {# Colour-coded read of the chart's indicators: an overall trend verdict, an RSI momentum gauge, and one tile per moving-average signal. Mechanical, not advice. #} {% if indicators %} <section class="panel ind-read"> <div class="ind-read__head"> <h2 class="ind-read__title">What the indicators say</h2> <span class="ind-verdict ind-verdict--{{ indicators.verdict_tone }}"> <span class="ind-verdict__dot" aria-hidden="true"></span>{{ indicators.verdict }} <span class="ind-verdict__note">{{ indicators.verdict_note }}</span> </span> </div> {# RSI momentum gauge: a 0–100 track with oversold / overbought zones and a marker at the current value, coloured by its zone. #} <div class="ind-rsi ind-tone--{{ indicators.rsi_tone }}"> <div class="ind-rsi__top"> <span class="ind-rsi__label">Momentum · RSI 14</span> <span class="ind-rsi__verdict">{{ indicators.rsi_label }}</span> </div> <div class="ind-rsi__track"> <span class="ind-rsi__zone ind-rsi__zone--over"></span> <span class="ind-rsi__zone ind-rsi__zone--under"></span> <span class="ind-rsi__marker" style="left: {{ indicators.rsi_pos }}%"> <span class="ind-rsi__bubble num">{{ indicators.rsi|round(0)|int }}</span> </span> </div> <div class="ind-rsi__scale"> <span>0</span><span>oversold 30</span><span>overbought 70</span><span>100</span> </div> <p class="ind-rsi__note">{{ indicators.rsi_note }}</p> </div> {# Trend signal tiles, colour-coded bullish / bearish. #} <div class="ind-signals"> {% for sig in indicators.signals %} <div class="ind-sig ind-tone--{{ sig.tone }}"> <div class="ind-sig__top"> <span class="ind-sig__label">{{ sig.label }}</span> {% if sig.value %}<span class="ind-sig__value num">{{ sig.value }}</span>{% endif %} </div> <div class="ind-sig__status">{{ sig.status }}</div> <div class="ind-sig__note">{{ sig.note }}</div> </div> {% endfor %} </div> <p class="ind-read__foot">A mechanical reading of the chart’s indicators — not investment advice.</p> </section> {% endif %} <h2 class="section-title">Key stats<span class="section-title__asof">as of {{ stats.date|shortdate }}</span></h2> <section class="keystats">