Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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}