repos

Phase E: market-overview graph, split from the watchlist

238da54a by Isaac Bythewood · 1 month ago

Phase E: market-overview graph, split from the watchlist

Make the dashboard's top graph a fixed, session-aware market overview and
keep the personal watchlist entirely separate (cards only, off the graph).

- Overview set (fixed): S&P 500, Dow, Nasdaq 100, Russell 2000, gold, crude,
  bitcoin. Session-aware: cash indexes during the regular session, the E-mini
  futures (ES/YM/NQ/RTY) off-hours so it keeps moving overnight. /api/dashboard
  feeds the normalized %-from-open overlay from overview_for(session); S&P is
  the ink baseline.
- Watchlist no longer on the graph; its cards stay as a separate section.
- VIX is read-only (it would squash a normalized overlay). Reads strip trimmed
  to VIX / Market volume / S&P 50-200 trend (dropped the redundant S&P price
  tile); desktop grid to 3 columns.
- Overview tickers render as hidden data-ticker nodes so the live stream polls
  them; off-hours intraday filter widened to kind IN ('future','crypto') so BTC
  keeps polling overnight. /api/dashboard/refresh pulls overview + VIX + SPY +
  watchlist on open.
- Add BTC-USD as kind 'crypto' (real daily chart, unlike =F futures); crypto
  handled like index/future in search, refresh plan, and backfill.
modified PLAN.md
@@ -32,9 +32,58 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-06-04 (Phases A-D all built + verified on dev; roadmapcomplete; everything uncommitted, holding per the user's call to batch thecommit/deploy).__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.)_Phase E outcome (market-overview graph, split from the watchlist):- **Top graph is now a fixed, session-aware market overview**, not the watchlist.  `src/routes/home.rs` carries an `OVERVIEW` slot table + `overview_for(session)`;  `/api/dashboard` feeds the normalized %-from-open overlay from it (S&P the ink  baseline). Verified at the close (session=closed): the graph drew the seven  futures-mode lines — `ES=F` ("S&P 500"), `YM=F` (Dow), `NQ=F` (Nasdaq 100),  `RTY=F` (Russell 2000), `GC=F` (Gold), `CL=F` (Crude Oil), `BTC-USD` (Bitcoin) —  and the note read "% change from the open · index futures (off-hours)". During  the regular session the four index slots swap to the cash indexes (`^SPX` etc.).- **Watchlist fully separate.** Its cards render below as before and are no longer  on the graph; copy dropped the "S&P stays on the graph as baseline" line.- **VIX read-only** (off the overlay, where it would squash the scale); the reads  strip was trimmed to VIX / Market volume / S&P 50-200 trend (dropped the  now-redundant S&P price tile), desktop grid → 3 cols.- **Polling.** Overview tickers render as hidden `data-ticker` nodes so the stream  registers them; `run_intraday`'s off-hours filter widened from `kind = 'future'`  to `kind IN ('future','crypto')` so `BTC-USD` keeps polling overnight.  `/api/dashboard/refresh` (on-open) now pulls the session's overview + `^VIX` +  `SPY` + the watchlist (verified `refreshed: 14`).- **`BTC-USD` added** to `universe/starter.csv` as `kind = 'crypto'` (gets a real  daily chart, unlike `=F` futures); `crypto` handled like index/future in search  (filter pill + ordering), the refresh plan (price-only steps), and backfill (no  SEC). `ES=F` already existed.- **Verified on dev:** `cargo build` + `vite build` clean, zero warnings; boot  synced 563 symbols (BTC-USD added); `/api/dashboard` returns the futures-mode  series with friendly names + S&P baseline and the trimmed reads; Playwright at  1280 and 390 rendered the overview graph, the off-hours note, the 3-tile reads  strip, and the separate watchlist with **zero console errors**.- **Known rough edge (polish backlog):** the futures' overnight session boundary  shows a small x-axis gap/step in the overlay (the ~23h window spans a settlement  break, and a just-seeded symbol like BTC starts with few bars); lines align  better once polled together during live hours. Same class of gap noted in Phase  C; a candidate for the eventual polish pass, not a correctness issue.**Phase E direction (decided 2026-06-04).** After living with the Phase Cdashboard the user wants the top graph to stop being the watchlist and become afixed **market overview** ("at a glance, how is the whole market doing"), withthe **watchlist kept entirely separate** (its cards stay where they are, but areno longer drawn on the top graph). The overview is a fixed, non-editable set ofthe things the user usually tracks, and it is **session-aware**: cash indexesduring the regular session, the E-mini futures during pre / after-hours / closed(so the overview keeps moving overnight and shows where the market is heading).Answered four design questions (see the Decisions log): VIX stays a **read only**(it swings ~10x the indexes and would squash a normalized overlay); Nasdaq tracksthe **Nasdaq 100 (^NDX → NQ=F)** so the cash/futures swap is the same index; thereads strip is **kept but trimmed** (drop the now-redundant S&P price tile, keepVIX / Market volume / S&P 50-200 trend); the overview set is **fixed** (thewatchlist stays the only editable list). See Phase E in the Roadmap for the build.**Major refocus in progress (the "demand-only" rewrite).** The previous roadmap(the "distill + ETF-first" rewrite, Phases 1-7, all deployed at `645b351`) shipped
@@ -435,6 +484,52 @@ Rebuild home around the watchlist and the day graph.  each session; watchlist refreshes on the ~5-minute cadence with live data-age;  nothing polls once the dashboard tab is closed.### Phase E — Market-overview graph (split from the watchlist)  ✅ DONE — committed + deployedMake the top graph a fixed, session-aware **market overview** and keep the**watchlist** entirely separate (cards only, off the graph).- **Overview set (fixed, non-editable), session-aware.** Seven slots, each shown  as its cash instrument during the **regular** session and its E-mini future  during **pre / after-hours / closed**; instruments that already trade ~24h use  one ticker in both states:  | Slot | Regular | Off-hours |  |---|---|---|  | S&P 500 | `^SPX` | `ES=F` |  | Dow | `^DJI` | `YM=F` |  | Nasdaq 100 | `^NDX` | `NQ=F` |  | Russell 2000 | `^RUT` | `RTY=F` |  | Gold | `GC=F` | `GC=F` |  | Crude Oil | `CL=F` | `CL=F` |  | Bitcoin | `BTC-USD` | `BTC-USD` |  The S&P slot is the chart's ink baseline line; the rest take the palette. VIX is  **not** on the graph (read only).- **Graph = the existing normalized %-from-open overlay**, just fed the overview  set (via `overview_for(session)`) instead of the watchlist. Header copy becomes  "Market overview", with an "index futures (off-hours)" note when the session is  not regular (kept live by `hero.js`).- **Watchlist** is unchanged structurally (session cookie, add/remove, spark  cards) but no longer appears on the top graph; its copy drops the "S&P stays on  the graph as the baseline" line.- **Reads strip trimmed:** drop the S&P price tile (it's on the graph); keep VIX,  Market volume, and the S&P 50/200-day trend. Desktop grid → 3 columns.- **Polling.** The page renders the overview tickers as hidden `data-ticker`  nodes so the live stream registers them with the interest registry; the  demand-driven `run_intraday` then keeps their intraday bars fresh while the  dashboard is open. Off-hours `run_intraday` already restricts to symbols that  trade ~24h — widened from `kind = 'future'` to `kind IN ('future','crypto')` so  `BTC-USD` keeps polling overnight. `/api/dashboard/refresh` (on-open) pulls the  session's overview set + `^VIX` + `SPY` + the watchlist.- **New universe rows:** `BTC-USD` (`kind = 'crypto'`; gets a real daily chart,  unlike `=F` futures). `ES=F` already existed. `crypto` is handled like  `index`/`future` everywhere it matters (search filter + ordering, refresh plan  price-only steps, no SEC).- **Verify:** during the regular session the graph shows the four cash indexes +  gold/crude/BTC; off-hours it swaps the four to ES/YM/NQ/RTY and the note reads  "index futures (off-hours)"; VIX shows only in the reads strip; the watchlist  cards render below and are absent from the graph; off-hours the overview keeps  ticking (futures + BTC) while VIX/SPY/watchlist hold; zero console errors at  mobile + desktop; `cargo build` + `vite build` clean.### Phase D — Cohesion + polish pass  ✅ DONE on dev (commit + deploy pending)Removed the dead Summary machinery, trimmed `/health` to the demand-only job set +copy, added a "Prices as of" age to the dashboard reads, and fixed a verdict
@@ -454,6 +549,25 @@ block above for the full outcome. **This completes the demand-only roadmap.**## Decisions log**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 ownsection (cards) and comes off the graph. The overview is a fixed set the usertracks — S&P, Dow, Nasdaq, Russell 2000, gold, BTC, crude — plus VIX, and is**session-aware**: live cash indexes during regular hours, the E-mini futures(S&P mini, etc.) off-hours so it keeps moving overnight. Four questions answered:1. **VIX = read only, not on the graph.** On a normalized %-from-open overlay VIX   swings ~10x the indexes and squashes the scale; it stays a headline read.2. **Nasdaq = Nasdaq 100 (`^NDX` → `NQ=F`).** Pairs cleanly with the E-mini so the   cash/futures swap is the same index (not the Composite, which has no exact   future).3. **Reads strip kept but trimmed.** Drop the now-redundant S&P price tile (it's on   the graph); keep VIX, Market volume, and the S&P 50/200-day trend.4. **Overview set is fixed/non-editable.** The watchlist stays the only editable   list; the overview is the user's curated market read.Budgeted into Phase E above. `BTC-USD` added to the universe as `kind = 'crypto'`(handled like index/future); `ES=F` already existed.**2026-06-03 — The "demand-only refocus" kickoff.** The user steered a focus shiftaway from the broad always-on dashboard: the app could not source enough live datato show what they wanted, and the timed sweeps were spending API budget on symbols
modified frontend/static_src/home/scripts/hero.js
@@ -1,14 +1,16 @@// The dashboard's hero day graph + market reads (Phase C).// The dashboard's market-overview graph + market reads (Phase C / E).//// Draws every watchlist symbol plus the S&P 500 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// watchlist cards below already live-tick via the base stream client.// 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 watchlist (green/amber/red stay reserved// 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 = [
@@ -24,8 +26,9 @@ const PALETTE = [const INK = "#211f1a";const DASH = "·";// Display name for a ticker on the axis label and legend.const displayName = (ticker) => (ticker === "^SPX" ? "S&P 500" : ticker);// 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;const SESSION_LABELS = {  regular: "Regular session",
@@ -132,7 +135,7 @@ export function initHero() {          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.ticker),          title: displayName(s),          priceLineVisible: false,          lastValueVisible: true,          crosshairMarkerRadius: 3,
@@ -140,11 +143,11 @@ export function initHero() {        entry = { series };        seriesByTicker.set(s.ticker, entry);      } else {        entry.series.applyOptions({ color, title: displayName(s.ticker) });        entry.series.applyOptions({ color, title: displayName(s) });      }      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({ ticker: s.ticker, color, baseline: s.baseline, last });      legend.push({ name: displayName(s), color, baseline: s.baseline, last });    }    // Drop series whose ticker is no longer in the payload.
@@ -170,7 +173,7 @@ export function initHero() {      sw.className = "legend-item__swatch";      sw.style.background = it.color;      const name = document.createElement("span");      name.textContent = it.ticker === "^SPX" ? "S&P 500" : it.ticker;      name.textContent = it.name;      const pct = document.createElement("span");      pct.className = "legend-item__pct";      pct.textContent = it.last == null ? "" : pctFmt(it.last);
@@ -206,6 +209,12 @@ 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() {
modified frontend/static_src/home/styles/home.scss
@@ -158,7 +158,9 @@}@media (min-width: $bp-sm) {  .reads {    grid-template-columns: repeat(4, minmax(0, 1fr));    // Three reads since Phase E (VIX, Market volume, S&P trend): the S&P price    // tile moved onto the overview graph.    grid-template-columns: repeat(3, minmax(0, 1fr));  }}
modified src/routes/home.rs
@@ -31,7 +31,7 @@ pub fn router() -> Router<AppState> {        .route("/api/dashboard/refresh", get(dashboard_refresh))}/// The S&P 500 cash index — the day graph's baseline and the headline read./// The S&P 500 cash index — the SMA-trend read and the day graph's baseline.const BASELINE: &str = "^SPX";/// The volatility gauge behind the VIX read.const VIX: &str = "^VIX";
@@ -40,6 +40,44 @@ 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.struct OverviewSlot {    cash: &'static str,    off: &'static str,    name: &'static str,    /// The S&P slot is drawn as the chart's ink baseline line.    baseline: 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.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 },];/// 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()}/// 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;
@@ -68,9 +106,6 @@ struct SparkCard {/// as a dash and is simply skipped by the live patcher.#[derive(Serialize, Default)]struct MarketReads {    /// S&P 500 level + day move.    spx_price: Option<f64>,    spx_pct: Option<f64>,    /// VIX level, its tone bucket (calm/steady/elevated/stressed), and a label.    vix_level: Option<f64>,    vix_tone: Option<String>,
@@ -127,14 +162,22 @@ 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`    // 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 extra = minijinja::context! {        title => "Markets",        cards => cards,        empty => tickers.is_empty(),        reads => reads,        baseline => BASELINE,        vix => VIX,        volume_proxy => VOLUME_PROXY,        overview_tickers => overview_tickers,        session => market_session.as_str(),        session_label => session_label(market_session),    };
@@ -157,34 +200,28 @@ async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response {/// 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>, headers: HeaderMap) -> Response {    let session = watchlist::resolve(&state.pool, &headers).await;    let tickers = watchlist::list(&state.pool, &session.sid).await;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.)    let market_session = market::session_at(chrono::Utc::now());    // The baseline leads, then each watchlist symbol.    let mut series = Vec::with_capacity(tickers.len() + 1);    if let Some(s) = pct_series(&state, BASELINE, "S&P 500", true).await {        series.push(s);    }    for t in &tickers {        if let Some(s) = pct_series(&state, t, t, false).await {    // 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 {            series.push(s);        }    }    let data = DashboardData {        session: market::session_at(chrono::Utc::now()).as_str().to_string(),        session: market_session.as_str().to_string(),        reads: market_reads(&state).await,        series,    };    let mut resp = Json(data).into_response();    if let Some(c) = session.set_cookie {        if let Ok(v) = header::HeaderValue::from_str(&c) {            resp.headers_mut().insert(header::SET_COOKIE, v);        }    }    resp    Json(data).into_response()}/// `GET /api/dashboard/refresh` — the dashboard's on-open refresh. Pulls fresh
@@ -194,8 +231,13 @@ async fn dashboard_api(State(state): State<AppState>, headers: HeaderMap) -> Res/// reload doesn't re-hit Yahoo. Published quotes live-tick the open cards.async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) -> Response {    let session = watchlist::resolve(&state.pool, &headers).await;    // 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 b in [BASELINE, VIX, VOLUME_PROXY] {    for (t, _, _) in overview_for(market::session_at(chrono::Utc::now())) {        tickers.push(t.to_string());    }    for b in [VIX, VOLUME_PROXY] {        tickers.push(b.to_string());    }    let refreshed =
@@ -266,14 +308,6 @@ async fn pct_series(state: &AppState, ticker: &str, name: &str, baseline: bool)async fn market_reads(state: &AppState) -> MarketReads {    let mut r = MarketReads::default();    // S&P 500 level + day move.    if let Some((last, prev)) = last_and_prev(state, BASELINE).await {        r.spx_price = last;        if let (Some(l), Some(p)) = (last, prev) {            r.spx_pct = Some(compute::change(l, p).pct);        }    }    // VIX level + tone.    if let Some((Some(level), _)) = last_and_prev(state, VIX).await {        r.vix_level = Some(level);
modified src/routes/search.rs
@@ -53,7 +53,7 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState    let query = raw.trim().to_uppercase();    // Normalise the kind filter to one of the three known kinds, else "all".    let kind = match sq.kind.as_deref().map(str::trim).unwrap_or("") {        k @ ("index" | "future" | "etf" | "stock") => k,        k @ ("index" | "future" | "crypto" | "etf" | "stock") => k,        _ => "",    };
@@ -78,7 +78,7 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState           AND (? = '' OR s.kind = ?) \         ORDER BY (s.ticker = ?) DESC, (s.ticker LIKE ? ESCAPE '\\') DESC, \                  CASE s.kind WHEN 'index' THEN 0 WHEN 'future' THEN 1 \                              WHEN 'etf' THEN 2 ELSE 3 END, s.ticker \                              WHEN 'crypto' THEN 2 WHEN 'etf' THEN 3 ELSE 4 END, s.ticker \         LIMIT 240",    )    .bind(&query)
modified src/scheduler.rs
@@ -211,10 +211,11 @@ async fn run_boot_seed(pool: &SqlitePool, config: &Config) -> anyhow::Result<()>////// Which viewed symbols are polled depends on the session. Inside any trading/// session (pre, regular, post) every viewed symbol is fair game. Outside it,/// only viewed futures are polled: index futures and commodities trade nearly/// around the clock, while indexes, stocks and ETFs sit frozen until the next/// session, so polling them off-hours would only re-fetch a flat quote. This/// is what keeps the dashboard's commodity cards live overnight./// only viewed symbols that trade ~around the clock are polled — index futures,/// commodities, and crypto (BTC) — while indexes, stocks and ETFs sit frozen/// until the next session, so polling them off-hours would only re-fetch a flat/// quote. This is what keeps the dashboard's overview futures/commodity/BTC/// lines live overnight.////// A clean run is recorded only in `data_status` (plus each `quotes.fetched_at`/// row); a `fetch_log` row is written only for a notable run, an error or a
@@ -233,13 +234,19 @@ async fn run_intraday(    let mut targets: Vec<String> = if session.is_open() {        viewed    } else {        let futures: HashSet<String> =            sqlx::query_scalar("SELECT ticker FROM symbols WHERE kind = 'future'")        // Off-hours, only poll symbols that trade ~24h: index futures (and        // commodities) plus crypto (BTC). Cash indexes / stocks / ETFs are        // frozen, so polling them outside the session just burns budget.        let around_clock: HashSet<String> =            sqlx::query_scalar("SELECT ticker FROM symbols WHERE kind IN ('future', 'crypto')")                .fetch_all(pool)                .await?                .into_iter()                .collect();        viewed.into_iter().filter(|t| futures.contains(t)).collect()        viewed            .into_iter()            .filter(|t| around_clock.contains(t))            .collect()    };    if targets.is_empty() {        return Ok(());
modified templates/pages/home.html
@@ -1,6 +1,6 @@{% extends "base.html" %}{% block title %}Markets{% endblock %}{% block description %}A live, normalized view of your watchlist against the S&P 500, with market hours, volume, and volatility.{% 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 extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/home/index.js', 'css') }}">{% endblock %}{% block main %}
@@ -14,13 +14,14 @@    <span class="dash-banner__sub" data-role="session-note">{% if session == 'closed' %}Prices update during market hours &middot; ET{% else %}US equities &middot; all times ET{% endif %}</span>  </div>  {# The hero day graph: every watchlist symbol plus the S&P 500, each as %     change from today's open, on one axis. Drawn by hero.js from /api/dashboard;  {# 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. #}  <section class="hero-graph">    <div class="hero-graph__head">      <h1 class="hero-graph__title">Today, vs the S&amp;P 500</h1>      <span class="hero-graph__note">% change from the open &middot; <span class="hero-graph__base">S&amp;P 500 baseline</span></span>      <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' %} &middot; index futures (off-hours){% endif %}</span></span>    </div>    <div class="hero-graph__chart" data-role="hero-chart">      <p class="hero-graph__empty" data-role="hero-empty">Loading today&rsquo;s session&hellip;</p>
@@ -28,19 +29,21 @@    <div class="hero-graph__legend" data-role="hero-legend"></div>  </section>  {# Headline market reads. S&P carries data-ticker/data-field so the base stream     client live-patches its price; VIX and SPY carry data-ticker only (to keep     them polled) and are patched by hero.js, which also fills volume + trend. #}  {# Overview symbols as hidden data-ticker nodes: the live stream registers them     with the interest registry so the demand-driven intraday poll keeps their     bars fresh while the dashboard is open. The graph itself is drawn from     /api/dashboard; these carry no fields, so quote events no-op on them. #}  <div class="overview-interest" hidden aria-hidden="true">    {% for t in overview_tickers %}<span data-ticker="{{ t }}"></span>{% endfor %}  </div>  {# Headline market reads (context the graph doesn't carry). VIX and SPY carry     data-ticker only (to keep them polled) and are patched by hero.js, which also     fills volume + the S&P trend. The S&P price itself lives on the graph now. #}  <div class="reads-head">    <span class="reads-head__asof" data-role="reads-asof">{% if reads.asof %}Prices as of {{ reads.asof|asof }}{% else %}Awaiting first quotes&hellip;{% endif %}</span>  </div>  <section class="reads">    <div class="read" data-ticker="{{ baseline }}">      <div class="read__label">S&amp;P 500</div>      <div class="read__value num" data-field="price">{{ reads.spx_price|money }}</div>      <div class="read__sub num{% if reads.spx_pct is none %} is-flat{% elif reads.spx_pct >= 0 %} is-up{% else %} is-down{% endif %}" data-field="change_pct">{{ reads.spx_pct|pct }}</div>    </div>    <div class="read" data-ticker="{{ vix }}">      <div class="read__label">Volatility &middot; VIX</div>      <div class="read__value num" data-role="vix-level">{{ reads.vix_level|round(2) if reads.vix_level is not none else "—" }}</div>
@@ -107,8 +110,7 @@    </div>    {% else %}    <p class="watch-empty">Your watchlist is empty. Add a stock or ETF above, or      <a href="/search">browse the universe</a> to find one. The S&amp;P 500 stays      on the graph as the baseline.</p>      <a href="/search">browse the universe</a> to find one.</p>    {% endif %}  </section></div>
modified templates/pages/search.html
@@ -27,6 +27,7 @@      {"value": "",       "label": "All"},      {"value": "index",  "label": "Indexes"},      {"value": "future", "label": "Futures"},      {"value": "crypto", "label": "Crypto"},      {"value": "etf",    "label": "ETFs"},      {"value": "stock",  "label": "Stocks"}    ] %}
modified universe/starter.csv
@@ -561,3 +561,4 @@ GC=F,Gold Futures,future,COMEX,SI=F,Silver Futures,future,COMEX,HG=F,Copper Futures,future,COMEX,NG=F,Natural Gas Futures,future,NYMEX,BTC-USD,Bitcoin,crypto,,