repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

43.1 KB · 1051 lines · Rust Raw History
   1//! `GET /` — the markets dashboard (Phase C), and `GET /api/dashboard` behind it.
   2//!
   3//! A TradingView-style read of the day: a normalized %-vs-S&P-500 day graph over
   4//! the session's market reads (S&P, volume, VIX, the 50/200-day trend) and the
   5//! browser's personal, editable watchlist. The watchlist is session-scoped (a
   6//! `fin_sid` cookie; see `crate::watchlist`), seeded with starters on a first
   7//! visit. The dashboard is the one exception to the demand-only model: the
   8//! scheduler's active home sweep (`scheduler::run_home_sweep_if_due`) keeps its
   9//! instruments fresh on a 15-minute cadence even with nobody on the site, so
  10//! the page always opens current. An open page still gets the faster treatment
  11//! (the stream interest registry's ~5-minute poll plus the on-open refresh).
  12
  13use std::collections::HashMap;
  14
  15use axum::{
  16    extract::State,
  17    http::{header, HeaderMap},
  18    response::{Html, IntoResponse, Response},
  19    routing::get,
  20    Json, Router,
  21};
  22use serde::{Deserialize, Serialize};
  23
  24use crate::compute;
  25use crate::guard::{EndpointGuard, Permit};
  26use crate::market;
  27use crate::providers::http;
  28use crate::providers::yahoo::{Mover, YahooProvider};
  29use crate::render::render_to_string;
  30use crate::{db, scheduler, watchlist, AppState};
  31
  32pub fn router() -> Router<AppState> {
  33    Router::new()
  34        .route("/", get(home))
  35        .route("/api/dashboard", get(dashboard_api))
  36        .route("/api/dashboard/refresh", get(dashboard_refresh))
  37        .route("/api/movers", get(movers_api))
  38}
  39
  40/// The S&P 500 cash index — the SMA-trend read and the day graph's baseline.
  41const BASELINE: &str = "^SPX";
  42/// The volatility gauge behind the VIX read.
  43const VIX: &str = "^VIX";
  44/// A liquid S&P 500 ETF used as the "market volume" proxy: cash indexes carry no
  45/// real share volume on Yahoo, so the dashboard reads volume off SPY. Polled
  46/// while the dashboard is open (it carries a `data-ticker`) so it stays fresh.
  47const VOLUME_PROXY: &str = "SPY";
  48
  49/// The high-yield corporate-bond ETF used as the dashboard's credit-stress proxy:
  50/// a falling HYG means widening high-yield spreads (risk-off), the bond market's
  51/// tell that a sell-off is a real credit event, not noise. Already in the seed.
  52const CREDIT: &str = "HYG";
  53
  54/// One slot in the fixed market-overview grid. Two tickers, so each card can show
  55/// the full extended-hours day *and* the universally-quoted number:
  56/// - `chart` draws the line. For an index this is the E-mini **future**, which
  57///   trades ~24h, so the chart shows pre-market + regular + after-hours movement.
  58/// - `quote` drives the headline value + %. For an index this is the **cash
  59///   index** (`regularMarketPrice` vs `chartPreviousClose`) — the number every
  60///   market site shows, frozen at the closing change after 4pm.
  61///
  62/// Instruments that already trade ~24h (gold, crude, BTC) use one ticker for both.
  63struct OverviewSlot {
  64    quote: &'static str,
  65    chart: &'static str,
  66    name: &'static str,
  67    /// Priced in dollars (gold, crude, BTC) rather than index points — drives the
  68    /// `$`-vs-`pts` unit hint the per-instrument chart formats its values with.
  69    dollar: bool,
  70}
  71
  72/// The market overview: a fixed, non-editable read of "how is the whole market
  73/// doing", separate from the personal watchlist. Each slot is its own chart
  74/// (pts for indexes, $ for gold/crude/BTC). VIX is deliberately absent — it stays
  75/// a headline read.
  76const OVERVIEW: &[OverviewSlot] = &[
  77    OverviewSlot { quote: "^SPX", chart: "ES=F", name: "S&P 500", dollar: false },
  78    OverviewSlot { quote: "^DJI", chart: "YM=F", name: "Dow", dollar: false },
  79    OverviewSlot { quote: "^NDX", chart: "NQ=F", name: "Nasdaq 100", dollar: false },
  80    OverviewSlot { quote: "GC=F", chart: "GC=F", name: "Gold", dollar: true },
  81    OverviewSlot { quote: "CL=F", chart: "CL=F", name: "Crude Oil", dollar: true },
  82    OverviewSlot { quote: "BTC-USD", chart: "BTC-USD", name: "Bitcoin", dollar: true },
  83];
  84
  85/// The 11 SPDR Select Sector ETFs, the cheap stand-in for a 500-name S&P heatmap:
  86/// each is a market-cap slice of one GICS sector, so their day moves show *which*
  87/// part of the market is driving the index at a glance, for 11 quotes instead of
  88/// 500. Ordered roughly by S&P weight so the biggest movers read first.
  89const SECTORS: &[(&str, &str)] = &[
  90    ("XLK", "Technology"),
  91    ("XLF", "Financials"),
  92    ("XLC", "Communication"),
  93    ("XLY", "Discretionary"),
  94    ("XLV", "Health Care"),
  95    ("XLI", "Industrials"),
  96    ("XLP", "Staples"),
  97    ("XLE", "Energy"),
  98    ("XLU", "Utilities"),
  99    ("XLRE", "Real Estate"),
 100    ("XLB", "Materials"),
 101];
 102
 103/// The sector ETF tickers, for the home sweep / on-open refresh quote set.
 104fn sector_tickers() -> Vec<&'static str> {
 105    SECTORS.iter().map(|(t, _)| *t).collect()
 106}
 107
 108/// The overview slots as (quote ticker, chart ticker, display name, dollar unit).
 109fn overview() -> Vec<(&'static str, &'static str, &'static str, bool)> {
 110    OVERVIEW.iter().map(|s| (s.quote, s.chart, s.name, s.dollar)).collect()
 111}
 112
 113/// Every ticker the overview needs polled / quoted — both the quote (cash) and
 114/// chart (futures) tickers, de-duplicated in slot order.
 115fn overview_tickers() -> Vec<&'static str> {
 116    let mut out: Vec<&'static str> = Vec::new();
 117    for s in OVERVIEW {
 118        for t in [s.quote, s.chart] {
 119            if !out.contains(&t) {
 120                out.push(t);
 121            }
 122        }
 123    }
 124    out
 125}
 126
 127/// Everything the dashboard reads a quote for: the overview slots (cash +
 128/// futures tickers) plus the VIX and volume-proxy headline reads. The
 129/// scheduler's active home sweep and the on-open refresh both poll exactly
 130/// this set (each adding the watchlist symbols on top).
 131pub(crate) fn dashboard_tickers() -> Vec<&'static str> {
 132    let mut out = overview_tickers();
 133    for t in [VIX, VOLUME_PROXY, CREDIT] {
 134        if !out.contains(&t) {
 135            out.push(t);
 136        }
 137    }
 138    for t in sector_tickers() {
 139        if !out.contains(&t) {
 140            out.push(t);
 141        }
 142    }
 143    out
 144}
 145
 146/// The overview charts frame exactly one Schwab trading day: extended-hours open
 147/// (7:00 AM ET) through extended-hours close (8:00 PM ET), so each chart shows
 148/// just that day — pre-market, the regular session, and after-hours — and never
 149/// bleeds into the previous day.
 150const SCHWAB_OPEN_MIN: u32 = 7 * 60; // 7:00 AM ET
 151const SCHWAB_CLOSE_MIN: u32 = 20 * 60; // 8:00 PM ET
 152
 153/// Once the regular session closes on Friday (4:00 PM ET) the dashboard switches
 154/// from the single-day frame to the whole trading week (Mon 7 AM → Fri 8 PM ET),
 155/// so the weekend read shows how the full week went, not just where Friday landed.
 156/// It reverts to the single-day frame at Monday's extended-hours open (7:00 AM ET).
 157const FRIDAY_CLOSE_MIN: u32 = 16 * 60; // 4:00 PM ET
 158
 159/// Epoch-ms for `min` minutes-of-day on the ET calendar `date`. Picks the earlier
 160/// instant on a fall-back DST repeat; both are fine for these window bounds.
 161fn et_ms(date: chrono::NaiveDate, min: u32) -> Option<i64> {
 162    use chrono::TimeZone as _;
 163    use chrono_tz::America::New_York;
 164    let naive = date.and_hms_opt(min / 60, min % 60, 0)?;
 165    New_York
 166        .from_local_datetime(&naive)
 167        .earliest()
 168        .map(|dt| dt.timestamp_millis())
 169}
 170
 171/// The Schwab trading-day window [open, close] in epoch-ms for the ET calendar
 172/// day that `latest_ms` falls in (so a Friday-evening view frames Friday, a
 173/// weekend view still frames Friday's last session, etc.).
 174fn schwab_day_window(latest_ms: i64) -> Option<(i64, i64)> {
 175    use chrono::TimeZone as _;
 176    use chrono_tz::America::New_York;
 177    let date = New_York.timestamp_millis_opt(latest_ms).single()?.date_naive();
 178    Some((et_ms(date, SCHWAB_OPEN_MIN)?, et_ms(date, SCHWAB_CLOSE_MIN)?))
 179}
 180
 181/// The full-week window when the end-of-week view is active, else `None`.
 182///
 183/// Active from Friday's regular close (4:00 PM ET) through the weekend until
 184/// Monday's extended-hours open (7:00 AM ET). When active it frames the trading
 185/// week that just ended: Monday 7:00 AM → Friday 8:00 PM ET. Returns that window
 186/// plus the Monday `NaiveDate` (the caller reads the prior Friday's close — the
 187/// last daily close strictly before Monday — as the week's % base).
 188fn week_window(now_ms: i64) -> Option<(i64, i64, chrono::NaiveDate)> {
 189    use chrono::{Datelike as _, Duration, TimeZone as _, Timelike as _, Weekday};
 190    use chrono_tz::America::New_York;
 191    let now = New_York.timestamp_millis_opt(now_ms).single()?;
 192    let minutes = now.hour() * 60 + now.minute();
 193    // How many days back the just-closed Friday sits from `now`'s ET date.
 194    let days_back = match now.weekday() {
 195        Weekday::Fri if minutes >= FRIDAY_CLOSE_MIN => 0,
 196        Weekday::Sat => 1,
 197        Weekday::Sun => 2,
 198        Weekday::Mon if minutes < SCHWAB_OPEN_MIN => 3,
 199        _ => return None,
 200    };
 201    let friday = now.date_naive() - Duration::days(days_back);
 202    let monday = friday - Duration::days(4);
 203    Some((
 204        et_ms(monday, SCHWAB_OPEN_MIN)?,
 205        et_ms(friday, SCHWAB_CLOSE_MIN)?,
 206        monday,
 207    ))
 208}
 209
 210/// The value unit for a symbol: index points for equity indexes, dollars for
 211/// everything else (stocks, ETFs, crypto, dollar-priced commodity futures).
 212fn unit_for(kind: &str) -> &'static str {
 213    if kind == "index" {
 214        "pts"
 215    } else {
 216        "$"
 217    }
 218}
 219
 220/// Calendar days of daily closes to pull for the 50/200-day SMA trend read.
 221const SMA_LOOKBACK_DAYS: i64 = 320;
 222
 223/// Volume vs its recent average: this many trading days form the baseline.
 224const VOLUME_AVG_DAYS: i64 = 65;
 225
 226/// One watchlist card shell, server-rendered for the initial paint, the symbol
 227/// link, and the remove button. The Schwab-day chart + the live value/% are then
 228/// drawn into it by `hero.js` from `/api/dashboard` (the same treatment as the
 229/// overview cards), so a watchlist card and an overview card look identical.
 230#[derive(Serialize, Clone)]
 231struct SparkCard {
 232    ticker: String,
 233    name: String,
 234    price: Option<f64>,
 235    change_pct: Option<f64>,
 236    /// Colour hook: true when the day's change is not negative (or unknown).
 237    up: bool,
 238    /// "$" for dollar-priced symbols (stocks/ETFs/crypto), "pts" for indexes.
 239    unit: &'static str,
 240}
 241
 242/// The dashboard's headline market reads, server-rendered and then refreshed by
 243/// the `/api/dashboard` poll. Every field is best-effort: a missing one renders
 244/// as a dash and is simply skipped by the live patcher.
 245#[derive(Serialize, Default)]
 246struct MarketReads {
 247    /// VIX level, its tone bucket (calm/steady/elevated/stressed), and a label.
 248    vix_level: Option<f64>,
 249    vix_tone: Option<String>,
 250    /// Market volume proxy (SPY): today's volume, the ratio to its recent
 251    /// average, a heavy/normal/light label, and when it was last quoted.
 252    volume: Option<i64>,
 253    volume_ratio: Option<f64>,
 254    volume_label: Option<String>,
 255    volume_asof: Option<i64>,
 256    /// The S&P's stance vs its 50- and 200-day averages, plus a tone.
 257    sma_read: Option<String>,
 258    sma_tone: Option<String>,
 259    /// The S&P's drawdown from its record close (`<= 0`), the crash-response lead
 260    /// read, with a tone and a zone label (slight dip / pullback / correction /
 261    /// bear, the deeper zones flagged as the DCA add zone).
 262    drawdown_pct: Option<f64>,
 263    drawdown_tone: Option<String>,
 264    drawdown_label: Option<String>,
 265    /// Credit-stress read off the high-yield ETF (HYG) day move, with tone + label.
 266    credit_pct: Option<f64>,
 267    credit_tone: Option<String>,
 268    credit_label: Option<String>,
 269    /// Freshest quote time (epoch-ms) across the baseline reads, for the
 270    /// "prices as of …" caption.
 271    asof: Option<i64>,
 272}
 273
 274/// One instrument's own chart in the overview grid: its latest session's actual
 275/// values (index points or dollars) on its own axis, plus the headline figures
 276/// the card shows above the chart (last value + % change from the open).
 277#[derive(Serialize)]
 278struct Series {
 279    ticker: String,
 280    name: String,
 281    /// "$" for dollar-priced instruments (gold, crude, BTC), "pts" otherwise.
 282    unit: &'static str,
 283    /// The % base / the chart's dashed reference line. During the regular session
 284    /// this is the previous close; off-hours it is the reference the headline move
 285    /// is measured against (the futures' prior settlement, or the prev close).
 286    base: f64,
 287    /// The latest value (the card's headline figure). Off-hours this is the live
 288    /// extended-hours value (the future for an index, the pre-market bar for a
 289    /// stock), not the frozen regular-session close.
 290    last: f64,
 291    /// % change from `base` — the headline move, session-appropriate (the cash
 292    /// day move during the regular session, the futures move pre-market/overnight,
 293    /// the pre-market move for a stock, the close move after hours).
 294    change_pct: f64,
 295    /// Week-to-date % move: the cash value vs the cash close before this ET week's
 296    /// Monday, so the card can show the day AND the week move at once. `None` when
 297    /// there is no prior-week close to anchor to.
 298    week_pct: Option<f64>,
 299    /// Which session the headline reflects, so an off-hours number is never read
 300    /// as the close: `None` during the regular session (the plain cash number),
 301    /// else "Futures" / "Pre-market" / "After hours" / "Overnight" / "At close".
 302    headline_label: Option<&'static str>,
 303    /// Epoch-ms source time of the headline quote, for the per-card freshness chip
 304    /// ("live" / "2m ago" / "stale"). `None` when no quote has been stored yet.
 305    asof: Option<i64>,
 306    /// True when the headline move is not negative — drives the green/red line colour.
 307    up: bool,
 308    /// UNIX seconds bounding the chart frame (extended-hours open and close).
 309    /// Normally a single Schwab day; in end-of-week mode the whole trading week
 310    /// (Mon 7 AM → Fri 8 PM ET). A partial frame plots from the left rather than
 311    /// stretching across the width.
 312    start_t: i64,
 313    end_t: i64,
 314    /// True when the frame spans the whole week (Fri 4 PM → Mon 7 AM ET), so the
 315    /// chart axis labels days instead of just times.
 316    week: bool,
 317    points: Vec<SeriesPoint>,
 318}
 319
 320#[derive(Serialize)]
 321struct SeriesPoint {
 322    /// UNIX seconds (lightweight-charts wants seconds, not ms).
 323    t: i64,
 324    /// The bar's actual close value (index points or dollars).
 325    v: f64,
 326}
 327
 328/// One sector tile in the "what's driving the market" heatmap: a sector ETF's
 329/// latest-session % move, the cell coloured green/red by it (clamped at ±3% on
 330/// the client). `change_pct` is `None` until the ETF has been quoted.
 331#[derive(Serialize)]
 332struct SectorTile {
 333    ticker: String,
 334    name: &'static str,
 335    change_pct: Option<f64>,
 336}
 337
 338/// What `/api/dashboard` returns and what `home` seeds the page with.
 339#[derive(Serialize)]
 340struct DashboardData {
 341    session: String,
 342    reads: MarketReads,
 343    /// The fixed market-overview charts.
 344    series: Vec<Series>,
 345    /// The sector heatmap (11 SPDR sector ETFs), so the dashboard shows which
 346    /// part of the market is driving the index, not just the index level.
 347    sectors: Vec<SectorTile>,
 348    /// The session's watchlist, drawn with the same per-instrument chart
 349    /// treatment as the overview (Schwab day, shading, % vs prev close).
 350    watchlist: Vec<Series>,
 351}
 352
 353/// Build the sector heatmap: each SPDR sector ETF's most-recent-session % move
 354/// (latest price vs its previous close). A cheap, fixed set of local reads.
 355async fn sector_tiles(state: &AppState) -> Vec<SectorTile> {
 356    let mut tiles = Vec::with_capacity(SECTORS.len());
 357    for (ticker, name) in SECTORS {
 358        let (last, prev, _asof) = quote_row(state, ticker).await;
 359        let change_pct = match (last, prev) {
 360            (Some(l), Some(p)) if p > 0.0 => Some((l / p - 1.0) * 100.0),
 361            _ => None,
 362        };
 363        tiles.push(SectorTile {
 364            ticker: (*ticker).to_string(),
 365            name,
 366            change_pct,
 367        });
 368    }
 369    tiles
 370}
 371
 372async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response {
 373    let session = watchlist::resolve(&state.pool, &headers).await;
 374    let tickers = watchlist::list(&state.pool, &session.sid).await;
 375
 376    let cards = spark_cards_for(
 377        &state,
 378        &tickers.iter().map(String::as_str).collect::<Vec<_>>(),
 379    )
 380    .await;
 381    let reads = market_reads(&state).await;
 382    let market_session = market::session_at(chrono::Utc::now());
 383
 384    // The overview tickers (both cash + futures), rendered as hidden `data-ticker`
 385    // nodes so the live stream registers them with the interest registry and the
 386    // demand-driven intraday poll keeps their bars fresh while the page is open.
 387    let overview_tickers: Vec<&str> = overview_tickers();
 388
 389    let extra = minijinja::context! {
 390        title => "Markets",
 391        cards => cards,
 392        empty => tickers.is_empty(),
 393        reads => reads,
 394        vix => VIX,
 395        volume_proxy => VOLUME_PROXY,
 396        credit_proxy => CREDIT,
 397        overview_tickers => overview_tickers,
 398        sector_tickers => sector_tickers(),
 399        session => market_session.as_str(),
 400        session_label => session_label(market_session),
 401    };
 402
 403    match render_to_string(&state, "pages/home.html", "/", extra) {
 404        Ok(html) => {
 405            let mut resp = Html(html).into_response();
 406            if let Some(c) = session.set_cookie {
 407                if let Ok(v) = header::HeaderValue::from_str(&c) {
 408                    resp.headers_mut().insert(header::SET_COOKIE, v);
 409                }
 410            }
 411            resp
 412        }
 413        Err(resp) => resp,
 414    }
 415}
 416
 417/// `GET /api/dashboard` — the per-instrument overview series + the market reads,
 418/// polled by the page (~every minute) so the charts and reads stay live without a
 419/// reload. Each series carries its own actual values (points or dollars) plus its
 420/// last value and % change from the open; the page draws one chart per series.
 421async fn dashboard_api(State(state): State<AppState>, headers: HeaderMap) -> Response {
 422    let market_session = market::session_at(chrono::Utc::now());
 423
 424    // The fixed market-overview set (the S&P slot leads) and the session's
 425    // watchlist, drawn with the same chart treatment. Each watchlist symbol is
 426    // a single-ticker series (the symbol is both quote + chart; stocks/ETFs
 427    // carry their own pre/post bars via Yahoo's includePrePost). Every series
 428    // is a handful of independent SQLite reads, so they are all built
 429    // concurrently rather than one after another — ~12+ series on a typical
 430    // dashboard, and the response is what gates the page's first chart paint.
 431    let session = watchlist::resolve(&state.pool, &headers).await;
 432    let wl = watchlist::list(&state.pool, &session.sid).await;
 433    let (series, watchlist) = tokio::join!(
 434        futures_util::future::join_all(
 435            overview()
 436                .into_iter()
 437                .map(|(quote, chart, name, dollar)| overview_series(
 438                    &state, quote, chart, name, dollar
 439                )),
 440        ),
 441        futures_util::future::join_all(wl.iter().map(|t| watchlist_series(&state, t))),
 442    );
 443    let series: Vec<Series> = series.into_iter().flatten().collect();
 444    let watchlist: Vec<Series> = watchlist.into_iter().flatten().collect();
 445
 446    let data = DashboardData {
 447        session: market_session.as_str().to_string(),
 448        reads: market_reads(&state).await,
 449        series,
 450        sectors: sector_tiles(&state).await,
 451        watchlist,
 452    };
 453
 454    Json(data).into_response()
 455}
 456
 457/// One watchlist symbol as a chart series, identical in shape to an overview
 458/// slot: the symbol is its own quote + chart ticker, with the unit (points for an
 459/// index, dollars otherwise) read off its kind.
 460async fn watchlist_series(state: &AppState, ticker: &str) -> Option<Series> {
 461    let row: Option<(String, String)> =
 462        sqlx::query_as("SELECT name, kind FROM symbols WHERE ticker = ?")
 463            .bind(ticker)
 464            .fetch_optional(&state.pool)
 465            .await
 466            .ok()
 467            .flatten();
 468    let (name, kind) = row?;
 469    overview_series(state, ticker, ticker, &name, unit_for(&kind) == "$").await
 470}
 471
 472/// `GET /api/dashboard/refresh` — the dashboard's on-open refresh. Pulls fresh
 473/// quotes for the watchlist + the baseline reads (^SPX/^VIX/SPY) once, so opening
 474/// the page always shows current data rather than whatever was last stored. The
 475/// scheduler's staleness gate skips anything quoted in the last few minutes, so a
 476/// reload doesn't re-hit Yahoo. Published quotes live-tick the open cards.
 477async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) -> Response {
 478    let session = watchlist::resolve(&state.pool, &headers).await;
 479    // The watchlist cards, the session's overview symbols, and the VIX / volume
 480    // reads — everything the open dashboard shows gets a fresh quote.
 481    let wl = watchlist::list(&state.pool, &session.sid).await;
 482    let mut tickers = wl.clone();
 483    for t in dashboard_tickers() {
 484        tickers.push(t.to_string());
 485    }
 486    let refreshed =
 487        crate::scheduler::refresh_quotes(&state.pool, &state.config, &state.hub, &tickers).await;
 488
 489    // In the end-of-week view the charts span Mon–Fri, but the routine poll only
 490    // ever stores one day of 15-minute bars at a time, so any day the dashboard
 491    // wasn't open is missing. Backfill the whole week (one guarded range=5d pull
 492    // per still-incomplete symbol) for the symbols actually drawn as charts: the
 493    // overview's chart tickers (the futures lines) and the watchlist. It runs
 494    // detached — the paced, guarded pulls take many seconds and only need to
 495    // happen once per weekend, so we don't hold the refresh request open for
 496    // them; the filled bars land on the next ~60s dashboard poll. Already-covered
 497    // symbols are skipped, so this is a no-op once the week is complete.
 498    if let Some((start_ms, end_ms, _monday)) =
 499        week_window(chrono::Utc::now().timestamp_millis())
 500    {
 501        let mut charted: Vec<String> = OVERVIEW.iter().map(|s| s.chart.to_string()).collect();
 502        charted.extend(wl.iter().cloned());
 503        let bg = state.clone();
 504        tokio::spawn(async move {
 505            crate::scheduler::backfill_intraday_week(
 506                &bg.pool,
 507                &bg.config,
 508                &charted,
 509                start_ms,
 510                end_ms,
 511            )
 512            .await;
 513        });
 514    }
 515
 516    let mut resp = Json(serde_json::json!({ "refreshed": refreshed })).into_response();
 517    if let Some(c) = session.set_cookie {
 518        if let Ok(v) = header::HeaderValue::from_str(&c) {
 519            resp.headers_mut().insert(header::SET_COOKIE, v);
 520        }
 521    }
 522    resp
 523}
 524
 525/// Movers cache: re-fetch the screener at most this often. The pull is a guarded,
 526/// paced 3-call job; in between, the cached blob is served. Demand-driven — only
 527/// fetched when the dashboard is open and the cache has aged out.
 528const MOVERS_TTL_MS: i64 = 8 * 60 * 1000;
 529const MOVERS_META_KEY: &str = "movers_json";
 530/// Rows shown per movers list.
 531const MOVERS_COUNT: usize = 10;
 532/// Rows pulled per Yahoo screener before filtering to the S&P 500. Yahoo's
 533/// predefined screeners rank the *whole* market, most of it micro-caps the user
 534/// has never heard of, so we pull a wide slice and keep only the S&P 500 names
 535/// (then the top `MOVERS_COUNT`). Wide enough that a normal day still yields ten
 536/// large-cap movers per list.
 537const MOVERS_FETCH_COUNT: u32 = 100;
 538
 539/// The three market-movers lists for the dashboard's "what's driving it" tables.
 540#[derive(Serialize, Deserialize)]
 541struct MoversData {
 542    /// Epoch-ms the lists were fetched, for the freshness caption. `None` = empty.
 543    asof: Option<i64>,
 544    gainers: Vec<Mover>,
 545    losers: Vec<Mover>,
 546    actives: Vec<Mover>,
 547}
 548
 549impl MoversData {
 550    fn empty() -> Self {
 551        MoversData { asof: None, gainers: vec![], losers: vec![], actives: vec![] }
 552    }
 553}
 554
 555/// `GET /api/movers` — top gainers / losers / most active. Served from an 8-minute
 556/// `meta` cache; on a miss it does one guarded, paced pull of the three predefined
 557/// Yahoo screeners and stores the result. On a guard stop or fetch failure it
 558/// falls back to the (stale) cache, else an empty set, so the dashboard degrades
 559/// quietly and never hammers Yahoo. The page fetches this after first paint, so a
 560/// cold pull never blocks the dashboard.
 561async fn movers_api(State(state): State<AppState>) -> Response {
 562    let now = db::now_ms();
 563    let cached: Option<MoversData> = db::get_meta(&state.pool, MOVERS_META_KEY)
 564        .await
 565        .ok()
 566        .flatten()
 567        .and_then(|raw| serde_json::from_str(&raw).ok());
 568
 569    if let Some(c) = &cached {
 570        if c.asof.is_some_and(|t| now - t < MOVERS_TTL_MS) {
 571            return Json(c).into_response();
 572        }
 573    }
 574
 575    if let Some(fresh) = fetch_movers_fresh(&state, now).await {
 576        return Json(fresh).into_response();
 577    }
 578
 579    // Refresh did not land (guard stop or all calls failed): serve stale, else empty.
 580    match cached {
 581        Some(c) => Json(c).into_response(),
 582        None => Json(MoversData::empty()).into_response(),
 583    }
 584}
 585
 586/// One guarded, paced pull of the three movers screeners, stored to the cache.
 587/// `None` when the guard denies the first call or every call fails (so the caller
 588/// can fall back to the cache); a partial result (some lists empty) still returns.
 589async fn fetch_movers_fresh(state: &AppState, now: i64) -> Option<MoversData> {
 590    let yahoo = YahooProvider::new(http::build_client(&state.config));
 591    let guard = EndpointGuard::with_budget(state.pool.clone(), "yahoo", scheduler::YAHOO_BUDGET);
 592    let mut out = MoversData::empty();
 593    let mut any = false;
 594    for (scr, slot) in [("day_gainers", 0u8), ("day_losers", 1), ("most_actives", 2)] {
 595        match guard.acquire().await {
 596            Ok(Permit::Granted) => {}
 597            // Denied (breaker/budget/pacing) or an acquire error: stop and let the
 598            // caller serve the cache rather than push against the guard.
 599            _ => break,
 600        }
 601        match yahoo.fetch_movers(scr, MOVERS_FETCH_COUNT).await {
 602            Ok(mut rows) => {
 603                let _ = guard.record_success().await;
 604                any = true;
 605                // Keep only S&P 500 names, then the top MOVERS_COUNT, so the
 606                // lists read as recognizable large caps rather than micro-caps.
 607                rows.retain(|m| crate::sp500::is_member(&m.symbol));
 608                rows.truncate(MOVERS_COUNT);
 609                match slot {
 610                    0 => out.gainers = rows,
 611                    1 => out.losers = rows,
 612                    _ => out.actives = rows,
 613                }
 614            }
 615            Err(e) => {
 616                let _ = guard.record_failure(&e).await;
 617            }
 618        }
 619    }
 620    if !any {
 621        return None;
 622    }
 623    out.asof = Some(now);
 624    if let Ok(json) = serde_json::to_string(&out) {
 625        let _ = db::set_meta(&state.pool, MOVERS_META_KEY, &json).await;
 626    }
 627    Some(out)
 628}
 629
 630/// A human session label for the dashboard's market-hours banner.
 631fn session_label(s: market::Session) -> &'static str {
 632    match s {
 633        market::Session::Regular => "Regular session",
 634        market::Session::Pre => "Pre-market",
 635        market::Session::Post => "After hours",
 636        market::Session::Closed => "Market closed",
 637    }
 638}
 639
 640/// One symbol's latest value, prior close, and quote age: the live last price
 641/// (else the latest stored daily close), the close before it, and the epoch-ms
 642/// the quote was sourced at (for the freshness chip).
 643async fn quote_row(state: &AppState, ticker: &str) -> (Option<f64>, Option<f64>, Option<i64>) {
 644    let today = market::et_date(chrono::Utc::now());
 645    sqlx::query_as(
 646        "SELECT \
 647           COALESCE(s.last_price, \
 648             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \
 649           COALESCE(s.prev_close, \
 650             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \
 651                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \
 652              ORDER BY d DESC LIMIT 1)), \
 653           s.last_quote_at \
 654         FROM symbols s WHERE s.ticker = ?",
 655    )
 656    .bind(&today)
 657    .bind(ticker)
 658    .fetch_optional(&state.pool)
 659    .await
 660    .ok()
 661    .flatten()
 662    .unwrap_or((None, None, None))
 663}
 664
 665/// The cash close strictly before the current ET week's Monday — the week-to-date
 666/// % base, so "this week" reads as the move since last week ended (the prior
 667/// Friday's close).
 668async fn week_base_close(state: &AppState, ticker: &str, now_ms: i64) -> Option<f64> {
 669    use chrono::{Datelike as _, Duration, TimeZone as _};
 670    use chrono_tz::America::New_York;
 671    let now = New_York.timestamp_millis_opt(now_ms).single()?;
 672    let monday = now.date_naive() - Duration::days(now.weekday().num_days_from_monday() as i64);
 673    sqlx::query_scalar(
 674        "SELECT close FROM daily_prices WHERE ticker = ? AND d < ? ORDER BY d DESC LIMIT 1",
 675    )
 676    .bind(ticker)
 677    .bind(monday.to_string())
 678    .fetch_optional(&state.pool)
 679    .await
 680    .ok()
 681    .flatten()
 682    .filter(|c: &f64| *c > 0.0)
 683}
 684
 685/// The session-appropriate headline source for a card: which value, which % base,
 686/// and how to label it, so a number is never read as something it isn't.
 687///
 688/// - **Regular session:** the cash value vs its previous close, unlabelled — the
 689///   universally-quoted day move.
 690/// - **Pre-market:** for an index, the E-mini **future** vs its prior settlement
 691///   ("Futures", what every market site shows at 7am); for a stock, its
 692///   pre-market bar vs the previous close ("Pre-market").
 693/// - **After hours:** the regular-session **close** ("At close") — what everyone
 694///   still quotes as the day's result after 4pm.
 695/// - **Overnight (closed):** for an index, the future ("Overnight"); for a stock,
 696///   the last close ("At close").
 697///
 698/// `cash` and `fut` are each `(last, prev_close, asof_ms)`; `fut` is `Some` only
 699/// for an index slot (a distinct futures chart ticker). `ext_bar` is the last
 700/// drawn bar's close, the live extended-hours value for a stock pre-market.
 701fn headline(
 702    session: market::Session,
 703    cash: (Option<f64>, Option<f64>, Option<i64>),
 704    fut: Option<(Option<f64>, Option<f64>, Option<i64>)>,
 705    ext_bar: Option<f64>,
 706) -> (Option<f64>, Option<f64>, Option<&'static str>, Option<i64>) {
 707    use market::Session::{Closed, Post, Pre, Regular};
 708    match session {
 709        Regular => (cash.0, cash.1, None, cash.2),
 710        Pre => match fut {
 711            Some(f) => (f.0, f.1, Some("Futures"), f.2),
 712            None => (ext_bar.or(cash.0), cash.1, Some("Pre-market"), cash.2),
 713        },
 714        Post => (cash.0, cash.1, Some("At close"), cash.2),
 715        Closed => match fut {
 716            Some(f) => (f.0, f.1, Some("Overnight"), f.2),
 717            None => (cash.0, cash.1, Some("At close"), cash.2),
 718        },
 719    }
 720}
 721
 722/// Build one slot's overview chart over a single Schwab trading day (extended open
 723/// through extended close, framed by `start_t`/`end_t`). The **line** is the
 724/// `chart` ticker's 15-minute bars (the future for an index, so pre-market +
 725/// regular + after-hours all show). The headline **value + %** are session-aware
 726/// (see [`headline`]): the cash index during the regular session, the future
 727/// pre-market and overnight, the pre-market bar for a stock, the close after
 728/// hours — so the number matches what Yahoo/MarketWatch show at the moment you
 729/// look, instead of a frozen cash close at 7am or midnight. `week_pct` carries the
 730/// week-to-date move alongside. `None` when the chart ticker has no intraday bars.
 731async fn overview_series(
 732    state: &AppState,
 733    quote_ticker: &str,
 734    chart_ticker: &str,
 735    name: &str,
 736    dollar: bool,
 737) -> Option<Series> {
 738    let now = chrono::Utc::now();
 739    let now_ms = now.timestamp_millis();
 740    let session = market::session_at(now);
 741    // An index slot pairs a cash quote ticker with a distinct futures chart ticker;
 742    // gold/crude/BTC and watchlist symbols are a single ticker (no futures proxy).
 743    let is_index_slot = quote_ticker != chart_ticker;
 744
 745    // Chart frame: the whole trading week after Friday's close, else the Schwab
 746    // day the chart ticker's most recent bar falls in.
 747    let (start_ms, end_ms, week_monday) = match week_window(now_ms) {
 748        Some((s, e, mon)) => (s, e, Some(mon)),
 749        None => {
 750            let latest_ms: i64 =
 751                sqlx::query_scalar("SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?")
 752                    .bind(chart_ticker)
 753                    .fetch_optional(&state.pool)
 754                    .await
 755                    .ok()
 756                    .flatten()
 757                    .flatten()?;
 758            let (s, e) = schwab_day_window(latest_ms)?;
 759            (s, e, None)
 760        }
 761    };
 762
 763    let rows: Vec<(i64, f64, f64)> = sqlx::query_as(
 764        "SELECT ts, open, close FROM intraday_bars \
 765         WHERE ticker = ? AND ts >= ? AND ts <= ? \
 766         ORDER BY ts",
 767    )
 768    .bind(chart_ticker)
 769    .bind(start_ms)
 770    .bind(end_ms)
 771    .fetch_all(&state.pool)
 772    .await
 773    .unwrap_or_default();
 774
 775    // The session's first-bar open (the % base's last resort) and the last drawn
 776    // bar's close (a stock's live extended-hours value pre-market).
 777    let open = rows.first().map(|r| if r.1 > 0.0 { r.1 } else { r.2 });
 778    let ext_bar = rows.last().map(|r| r.2);
 779
 780    // The cash quote (regular-session number + week base) and, for an index slot,
 781    // the futures quote (the off-hours number). The tuples are Copy.
 782    let cash = quote_row(state, quote_ticker).await;
 783    let fut = if is_index_slot {
 784        Some(quote_row(state, chart_ticker).await)
 785    } else {
 786        None
 787    };
 788
 789    // Headline value / base / label / age. In the end-of-week frame the move is
 790    // the whole-week change (prior Friday's close → Friday's close); otherwise it
 791    // is the session-aware headline.
 792    let (last_opt, base_opt, label, asof) = if let Some(monday) = week_monday {
 793        let prior: Option<f64> = sqlx::query_scalar(
 794            "SELECT close FROM daily_prices WHERE ticker = ? AND d < ? ORDER BY d DESC LIMIT 1",
 795        )
 796        .bind(quote_ticker)
 797        .bind(monday.to_string())
 798        .fetch_optional(&state.pool)
 799        .await
 800        .ok()
 801        .flatten();
 802        (cash.0.or(ext_bar), prior, Some("This week"), cash.2)
 803    } else {
 804        headline(session, cash, fut, ext_bar)
 805    };
 806
 807    let last = last_opt.or(ext_bar)?;
 808    let base = base_opt.filter(|p| *p > 0.0).or(open)?;
 809    if base <= 0.0 {
 810        return None;
 811    }
 812    let change_pct = (last / base - 1.0) * 100.0;
 813
 814    // Week-to-date move (cash value vs the close before this week's Monday), shown
 815    // alongside the day move. In the weekend frame the headline already IS the
 816    // week move, so reuse it.
 817    let week_pct = if week_monday.is_some() {
 818        Some(change_pct)
 819    } else {
 820        match (cash.0, week_base_close(state, quote_ticker, now_ms).await) {
 821            (Some(c), Some(wb)) => Some((c / wb - 1.0) * 100.0),
 822            _ => None,
 823        }
 824    };
 825
 826    let points: Vec<SeriesPoint> = rows
 827        .iter()
 828        .map(|(ts, _open, close)| SeriesPoint { t: ts / 1000, v: *close })
 829        .collect();
 830    Some(Series {
 831        ticker: quote_ticker.to_string(),
 832        name: name.to_string(),
 833        unit: if dollar { "$" } else { "pts" },
 834        base,
 835        last,
 836        change_pct,
 837        week_pct,
 838        headline_label: label,
 839        asof,
 840        up: change_pct >= 0.0,
 841        start_t: start_ms / 1000,
 842        end_t: end_ms / 1000,
 843        week: week_monday.is_some(),
 844        points,
 845    })
 846}
 847
 848/// Build the dashboard's headline reads from stored data (no network): the S&P
 849/// level/move, the VIX, the SPY-proxied market volume, and the S&P's 50/200-day
 850/// stance.
 851async fn market_reads(state: &AppState) -> MarketReads {
 852    let mut r = MarketReads::default();
 853
 854    // VIX level + tone.
 855    if let Some((Some(level), _)) = last_and_prev(state, VIX).await {
 856        r.vix_level = Some(level);
 857        r.vix_tone = Some(compute::vix_tone(level).to_string());
 858    }
 859
 860    // Market volume proxy (SPY): today's volume vs its recent average.
 861    let vol: Option<(Option<i64>, Option<i64>)> =
 862        sqlx::query_as("SELECT volume, fetched_at FROM quotes WHERE ticker = ?")
 863            .bind(VOLUME_PROXY)
 864            .fetch_optional(&state.pool)
 865            .await
 866            .ok()
 867            .flatten();
 868    if let Some((Some(today), asof)) = vol {
 869        if today > 0 {
 870            r.volume = Some(today);
 871            r.volume_asof = asof;
 872            let avg: Option<f64> = sqlx::query_scalar(
 873                "SELECT AVG(volume) FROM (SELECT volume FROM daily_prices \
 874                 WHERE ticker = ? AND volume > 0 ORDER BY d DESC LIMIT ?)",
 875            )
 876            .bind(VOLUME_PROXY)
 877            .bind(VOLUME_AVG_DAYS)
 878            .fetch_optional(&state.pool)
 879            .await
 880            .ok()
 881            .flatten();
 882            if let Some(avg) = avg.filter(|a| *a > 0.0) {
 883                // `today` is cumulative volume *so far*. Compare it to the volume
 884                // typically seen by this point in the session, not the whole-day
 885                // average, so the read is not structurally "Light" all morning.
 886                let frac = market::volume_session_fraction(chrono::Utc::now());
 887                let ratio = today as f64 / (avg * frac);
 888                r.volume_ratio = Some(ratio);
 889                r.volume_label = Some(
 890                    if ratio >= 1.15 {
 891                        "Heavy"
 892                    } else if ratio <= 0.85 {
 893                        "Light"
 894                    } else {
 895                        "Normal"
 896                    }
 897                    .to_string(),
 898                );
 899            }
 900        }
 901    }
 902
 903    // S&P stance vs its 50- and 200-day moving averages.
 904    let closes_desc: Vec<f64> = sqlx::query_scalar(
 905        "SELECT close FROM daily_prices WHERE ticker = ? ORDER BY d DESC LIMIT ?",
 906    )
 907    .bind(BASELINE)
 908    .bind(SMA_LOOKBACK_DAYS)
 909    .fetch_all(&state.pool)
 910    .await
 911    .unwrap_or_default();
 912    if let Some(&last) = closes_desc.first() {
 913        let closes: Vec<f64> = closes_desc.iter().rev().copied().collect();
 914        let sma50 = compute::sma(&closes, 50).last().copied().flatten();
 915        let sma200 = compute::sma(&closes, 200).last().copied().flatten();
 916        if let (Some(s50), Some(s200)) = (sma50, sma200) {
 917            let (read, tone) = match (last >= s50, last >= s200) {
 918                (true, true) => ("Above its 50- and 200-day average", "up"),
 919                (false, false) => ("Below its 50- and 200-day average", "down"),
 920                (true, false) => ("Above its 50-day, below its 200-day", "warn"),
 921                (false, true) => ("Below its 50-day, above its 200-day", "warn"),
 922            };
 923            r.sma_read = Some(read.to_string());
 924            r.sma_tone = Some(tone.to_string());
 925        }
 926    }
 927
 928    // S&P drawdown from its record close — the crash-response lead read. The
 929    // record is the deepest daily history we hold (seeded ~10y, enough for the
 930    // recent peak); a live value above it just reads as 0% (at highs).
 931    if let Some((Some(last), _)) = last_and_prev(state, BASELINE).await {
 932        let ath: Option<f64> =
 933            sqlx::query_scalar("SELECT MAX(close) FROM daily_prices WHERE ticker = ?")
 934                .bind(BASELINE)
 935                .fetch_optional(&state.pool)
 936                .await
 937                .ok()
 938                .flatten();
 939        if let Some(high) = ath.filter(|a| *a > 0.0) {
 940            let dd = (last / high.max(last) - 1.0) * 100.0;
 941            let (tone, label) = compute::drawdown_read(dd);
 942            r.drawdown_pct = Some(dd);
 943            r.drawdown_tone = Some(tone.to_string());
 944            r.drawdown_label = Some(label.to_string());
 945        }
 946    }
 947
 948    // Credit stress via the high-yield ETF's day move.
 949    if let Some((Some(last), Some(prev))) = last_and_prev(state, CREDIT).await {
 950        if prev > 0.0 {
 951            let pct = (last / prev - 1.0) * 100.0;
 952            let (tone, label) = compute::credit_read(pct);
 953            r.credit_pct = Some(pct);
 954            r.credit_tone = Some(tone.to_string());
 955            r.credit_label = Some(label.to_string());
 956        }
 957    }
 958
 959    // Age of the *oldest* baseline read, for the "prices as of" caption. MIN,
 960    // not MAX: the caption asserts every read is at least this fresh, so a just-
 961    // refreshed SPY must not make a stale VIX or drawdown read as current.
 962    r.asof = sqlx::query_scalar(
 963        "SELECT MIN(fetched_at) FROM quotes WHERE ticker IN (?, ?, ?)",
 964    )
 965    .bind(BASELINE)
 966    .bind(VIX)
 967    .bind(VOLUME_PROXY)
 968    .fetch_optional(&state.pool)
 969    .await
 970    .ok()
 971    .flatten()
 972    .flatten();
 973
 974    r
 975}
 976
 977/// The latest price and the prior close for one symbol: the live last price
 978/// (else the latest stored daily close) and the close before it.
 979async fn last_and_prev(state: &AppState, ticker: &str) -> Option<(Option<f64>, Option<f64>)> {
 980    let today = market::et_date(chrono::Utc::now());
 981    sqlx::query_as(
 982        "SELECT \
 983           COALESCE(s.last_price, \
 984             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \
 985           COALESCE(s.prev_close, \
 986             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \
 987                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \
 988              ORDER BY d DESC LIMIT 1)) \
 989         FROM symbols s WHERE s.ticker = ?",
 990    )
 991    .bind(&today)
 992    .bind(ticker)
 993    .fetch_optional(&state.pool)
 994    .await
 995    .ok()
 996    .flatten()
 997}
 998
 999/// Build a watchlist card shell per ticker, in order: ticker, name, current
1000/// price, the day's change (vs prev close), and the points/dollars unit. The
1001/// chart itself is drawn client-side from `/api/dashboard`. A ticker the universe
1002/// does not hold is skipped.
1003async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> {
1004    if tickers.is_empty() {
1005        return Vec::new();
1006    }
1007    // One query for the price rows; the `IN` placeholder count matches `tickers`.
1008    type SparkRow = (String, String, String, Option<f64>, Option<f64>);
1009    let today = market::et_date(chrono::Utc::now());
1010    let placeholders = vec!["?"; tickers.len()].join(",");
1011    let sql = format!(
1012        "SELECT s.ticker, s.name, s.kind, \
1013           COALESCE(s.last_price, \
1014             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \
1015           COALESCE(s.prev_close, \
1016             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker \
1017                AND p.d < (CASE WHEN s.last_price IS NOT NULL THEN ? ELSE s.history_last_date END) \
1018              ORDER BY d DESC LIMIT 1)) \
1019         FROM symbols s WHERE s.ticker IN ({placeholders})"
1020    );
1021    // The `?` in the prev-close subquery precedes the IN-list placeholders, so
1022    // bind today's ET date first, then the tickers.
1023    let mut q = sqlx::query_as::<_, SparkRow>(&sql).bind(&today);
1024    for t in tickers {
1025        q = q.bind(*t);
1026    }
1027    let rows: Vec<SparkRow> = q.fetch_all(&state.pool).await.unwrap_or_default();
1028    let mut by_ticker: HashMap<String, SparkRow> =
1029        rows.into_iter().map(|r| (r.0.clone(), r)).collect();
1030
1031    let mut cards = Vec::with_capacity(tickers.len());
1032    for &t in tickers {
1033        let Some((ticker, name, kind, last, prev)) = by_ticker.remove(t) else {
1034            continue;
1035        };
1036        let change_pct = match (last, prev) {
1037            (Some(l), Some(p)) => Some(compute::change(l, p).pct),
1038            _ => None,
1039        };
1040        cards.push(SparkCard {
1041            ticker,
1042            name,
1043            price: last,
1044            change_pct,
1045            up: change_pct.map_or(true, |p| p >= 0.0),
1046            unit: unit_for(&kind),
1047        });
1048    }
1049    cards
1050}