@@ -32,17 +32,19 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-22_**Current phase: none in progress. Phase 14 (Company leadership) is completeand verified, not yet deployed.** Phases 0 through 12 (the MVP) plus Phase 18(ETF profiles) and Phase 20 (strongest & weakest home panels) are complete,verified, and **live in production at https://finance.bythewood.me**. Phase 14— a current officers-and-board roster from SEC Form 3/4/5 ownership XML plus aleadership-changes feed from 8-K item 5.02 — was built and verified on**Current phase: none in progress. Phase 21 (Home & search refinements) iscomplete and verified, not yet deployed.** Phases 0 through 12 (the MVP) plusPhase 14 (company leadership), Phase 18 (ETF profiles) and Phase 20 (strongest& weakest home panels) are complete, verified, and **live in production athttps://finance.bythewood.me**. Phase 21, three refinements to shippedfeatures (the home page split into Indexes and Commodities sections with anindex-futures swap off-hours, a synchronous full backfill on add-symbol, andsearch auto-navigation on a single result), was built and verified on2026-05-22 (see the Done list and the decisions log) and ships on the next`git push server master`. Remaining post-MVP work: the loose-ordered Phase 13,15, 16, 17, 19 backlog plus the captured Phase 21 (home & search refinements),Phase 22 (show data age everywhere) and Phase 23 (Q4 in the quarterlyfinancials table).15, 16, 17, 19 backlog plus Phase 22 (show data age everywhere), Phase 23 (Q4in the quarterly financials table) and Phase 24 (financials tablereadability).**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +
@@ -569,7 +571,7 @@ schema, unused for now. ~225ms warm — the per-render standings scan, a fixed page-load snapshot as planned.- **Phase 14 company leadership.** Complete, verified, not yet deployed.- **Phase 14 company leadership.** Complete, verified, deployed to production. - Migration `0006` adds the `leadership` table (one row per insider: director/officer flags, officer title, `last_seen`), `symbols.leadership_synced_at`, and an `items` column on `filings` for the
@@ -613,17 +615,58 @@ schema, unused for now. overflow and zero console errors. The sweep is paced and guarded — it backfills the universe over several daily `sec` cycles.- **Phase 21 home & search refinements.** Complete, verified, not yet deployed. - Three independent refinements to already-shipped features. - **Home page index/commodity split + index-futures swap.** `routes/home.rs` replaced the flat `DASHBOARD` const with `INDEXES` (each cash index paired with its index future) and `COMMODITIES`; `dashboard_cards` now returns two card lists, and a new `spark_cards_for` builds a sparkline section from any ticker slice. Outside the regular cash session each index card resolves to its index future (`^SPX`->`ES=F`, `^DJI`->`YM=F`, `^NDX`->`NQ=F`, `^RUT`->`RTY=F`), which trades nearly around the clock; `^NDQ` and `^VIX` have no clean tradable future and always show the cash index. `templates/pages/home.html` now renders two sections, "Indexes" and "Commodities", in place of the single "Indexes & commodities" row. `RTY=F` (Russell 2000 E-Mini) was added to `universe/starter.csv` as a live-quotes-only future so `^RUT` has a future to swap to. - **Synchronous full backfill on add-symbol.** A new `scheduler::backfill_symbol` pulls a freshly-added symbol's deep daily history from Stooq and its full SEC data (CIK resolution, fundamentals, filings and the leadership roster for a stock; the fund profile for an ETF), routed through the same `EndpointGuard`s as the background jobs and reusing the existing store helpers. `POST /api/symbols` calls it before responding, replacing the old deferred `schedule_next("history")`, so a user-added symbol's page is complete the moment the add returns. Best-effort: a guard denial or upstream error for any one piece is logged and skipped, leaving that piece for the normal scheduler sweep. - **Search auto-navigate on a single result.** `routes/search.rs` now redirects (303) straight to `/s/{ticker}` when a non-empty query matches exactly one symbol; browse mode (empty query) and multi-result searches render the search page as before. - Verified: `/` renders "Indexes" and "Commodities" as two sections; during the regular session the index cards are the cash indexes, and a forced off-hours run showed them swapped to `ES=F`/`YM=F`/`NQ=F`/`RTY=F` with `^NDQ`/`^VIX` staying cash. Adding `RDDT` (Reddit — a real stock absent from the universe) through `POST /api/symbols` took 51s and returned with 545 daily bars, 73 fundamentals, 24 filings and a 13-person leadership roster all already stored; `/s/RDDT` then rendered complete. `/search?q=AAPL` and `?q=Microsoft` 303-redirect to the symbol page; browse and a multi-hit query do not. Desktop (1280px) and phone (390px) render with no overflow and zero console errors. (`RDDT` was a verification-only add and was removed afterwards; `RTY=F` stays as a curated symbol.)**Resuming, next action****Phase 14 (company leadership) is complete and verified** (2026-05-22), notyet deployed. The MVP plus Phase 18 and Phase 20 are live athttps://finance.bythewood.me; Phase 14 ships to production on the next`git push server master` (migration `0006` applies on the box and the `sec`job backfills the leadership rosters over its daily cycles, as Phase 18'sprofiles backfilled). No phase is in progress. Remaining post-MVP work: theloose-ordered Phase 13, 15, 16, 17, 19 backlog plus the captured Phase 21(home & search refinements), Phase 22 (show data age everywhere) and Phase 23(Q4 in the quarterly financials table); the user picks which to take next. There is still no GitHub repo for finance: the userdeferred that; if one is created later, add it as `origin` and the**Phase 21 (Home & search refinements) is complete and verified** (2026-05-22),not yet deployed. The MVP plus Phase 14, Phase 18 and Phase 20 are live athttps://finance.bythewood.me; Phase 21 ships on the next`git push server master` (the boot seed picks up the new `RTY=F` symbol from`universe/starter.csv` automatically, as it would any starter-list change). Nophase is in progress. Remaining post-MVP work: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere), Phase 23 (Q4 inthe quarterly financials table) and Phase 24 (financials table readability);the user picks which to take next. There is still no GitHub repo for finance:the user deferred that; if one is created later, add it as `origin` and the`overshard/finance` slug already in taproot's `projects.conf` lines up.Note: Phase 18 added the `quick-xml` crate (N-PORT XML streaming parser) and
@@ -634,7 +677,7 @@ Note: because `^RUT`/`^VIX` stay historyless, `meta.seed_completed` is neverset, so the boot seed re-runs on every restart (cheap: ~2 Stooq calls thatreturn "No data", which the guard now counts as successful empty responses,everything else being a local upsert) and then defers the first incrementalhistory run by 6h. This is intended; see the decisions log. The 9 futures arehistory run by 6h. This is intended; see the decisions log. The 10 futures areexcluded from the seed entirely, so they add nothing to this.**Build/run reminders for a fresh context**
@@ -862,8 +905,8 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). (a treemap). Market cap is shares outstanding (from SEC, Phase 7) times the latest price. (The movers list and index sparklines once bundled here moved into the Phase 11 home redesign when it was promoted.)- [x] **Phase 14: Company leadership.** Complete and verified 2026-05-22 (not yet deployed) — see the Phase 14 entry in Status and the decisions log.- [x] **Phase 14: Company leadership.** Complete, verified and deployed 2026-05-22; see the Phase 14 entry in Status and the decisions log. (Picked as the next backlog phase and scoped 2026-05-22.) Two things on the symbol page, stocks only: a **current roster** of officers and board (name + title),
@@ -939,9 +982,11 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). later layers leadership (Phase 14) and industry context (Phase 15) on top. Built in the Paper Ledger system.- [ ] **Phase 21: Home & search refinements.** (Captured 2026-05-22 from three vibe-coding "side notes" while Phase 14 was being scoped; budgeted here, not acted on mid-phase.) Three independent tweaks to already-shipped features:- [x] **Phase 21: Home & search refinements.** Complete and verified 2026-05-22 (not yet deployed); see the Phase 21 entry in Status and the decisions log. (Captured 2026-05-22 from three vibe-coding "side notes" while Phase 14 was being scoped; budgeted here, not acted on mid-phase.) Three independent tweaks to already-shipped features: (1) **Home page — split commodities from indexes** into their own section, and during the pre-market and post-market sessions show the index *futures* in place of the cash indexes (e.g. the S&P 500 E-mini `ES=F` instead of
@@ -981,6 +1026,22 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). all three quarters are present. No schema change and no new data — a pure derivation from facts already stored.- [ ] **Phase 24: Financials table readability.** (Captured 2026-05-22 from two vibe-coding side notes raised while Phase 21 was in progress.) Two presentation refinements to the symbol page's fundamentals area, no new data source: (1) **Per-cell growth cues.** In the annual and quarterly financials tables, make it visible at a glance whether the company is growing period over period: a semantic color and a small up/down icon on each figure showing whether it improved or worsened against the prior period (year over year in the annual table, quarter over quarter in the quarterly one). Needs a small design pass on which metrics a "rise is good" reading even applies to (revenue and net income clearly; total liabilities is the opposite) and how the cue rides without cluttering the Paper Ledger table. (2) **Missing-value glyph.** A fundamentals cell the company did not report currently shows a middle dot (`·`, the `DASH` const in `routes/symbols.rs`), which reads as a stray decimal point — the user noticed it on DELL. Replace it with an em dash (`—`) or a similar unambiguous "no data" mark.---## Key files
@@ -1462,7 +1523,8 @@ finance/ item-5.02 changes feed reuses the `filings` table — a new `items` column (migration `0006`) populated from the `submissions` `items` array — so it cost no new request. Names are stored as filed (last-name-first, caps) and title-cased for display. Not yet deployed; ships on the next push. title-cased for display. Deployed to production on 2026-05-22 via `git push server master` (commit `aea52ba`).- **2026-05-22: two more side notes captured.** (1) The user wants data freshness — the age of displayed data — shown consistently across the whole app, the home page included, since it is critically important; budgeted as
@@ -1477,6 +1539,43 @@ finance/ lives only in the 10-K's full-year figure — zero `fiscal_qtr = 4` rows in `fundamentals`). Budgeted as Phase 23 — derive Q4 as FY - (Q1+Q2+Q3) for the flow metrics.- **2026-05-22 — two financials-readability side notes captured as Phase 24.** While Phase 21 was in progress the user floated two refinements to the symbol page's fundamentals area: per-cell growth cues (a semantic color and a small up/down icon marking whether each figure improved or worsened against the prior period, year over year and quarter over quarter), and replacing the `·` middle-dot missing-value placeholder (which reads as a stray decimal) with a clearer em-dash-style mark. Budgeted as Phase 24 per the vibe-coding process rather than acted on mid-phase.- **2026-05-22 — Phase 14 confirmed deployed; Phase 21 picked next.** The Status section briefly recorded Phase 14 as "not yet deployed"; on resuming, the server `master` was checked (`git ls-remote server master`) and found at commit `aea52ba`, the Phase 14 commit, so Phase 14 is in fact live in production. The plan's deployment notes were corrected. Asked which loose-ordered backlog phase to take next, the user chose Phase 21, Home & search refinements.- **2026-05-22 — Phase 21 home & search refinements shipped.** Three independent refinements to already-shipped features. Design Q&A settled three points: (1) `RTY=F`, the Russell 2000 E-Mini, was added to the universe so `^RUT` also swaps to a future off-hours, alongside the clean `^SPX`/`^DJI`/`^NDX` swaps; `^NDQ` (Nasdaq Composite) and `^VIX` have no clean tradable future and keep the cash index. (2) The index-future swap shows whenever the regular cash session is closed (pre-market, after-hours, and the overnight/weekend Closed window), matching how the commodity cards already behave. (3) For add-symbol the user chose a fully synchronous backfill: `POST /api/symbols` pulls the new symbol's deep history and its entire SEC data (fundamentals, filings, leadership roster, or an ETF fund profile) inside the request before responding, accepting a longer request (the `RDDT` verification add took 51s) over deferring anything to a later scheduler cycle. Build calls: the home page's single sparkline row became two sections (`INDEXES` + `COMMODITIES` consts, `dashboard_cards` returning two lists, a shared `spark_cards_for`); `scheduler::backfill_symbol` reuses the existing store helpers and endpoint guards and is best-effort, so a guard denial only defers a piece to the normal sweep rather than failing the add; the search redirect is a 303 to `/s/{ticker}` on a single non-empty-query match. Verified end to end (see the Phase 21 Done entry). Not yet deployed; ships on the next push, and the boot seed adds `RTY=F` on the box.---
modified
src/routes/home.rs
@@ -14,6 +14,7 @@ use axum::{extract::State, response::Response, routing::get, Router};use serde::Serialize;use crate::compute::{self, Sparkline};use crate::market;use crate::models::{self, SymbolCardRow};use crate::render::render;use crate::AppState;
@@ -22,14 +23,26 @@ pub fn router() -> Router<AppState> { Router::new().route("/", get(home))}/// The dashboard's curated sparkline row: the major US indexes followed by the/// headline commodities (WTI crude, gold, natural gas). Hardcoded on purpose —/// the home page is a fixed, opinionated view, not a user-built watchlist.const DASHBOARD: &[&str] = &[ "^SPX", "^DJI", "^NDX", "^NDQ", "^RUT", "^VIX", // indexes "CL=F", "GC=F", "NG=F", // crude oil, gold, natural gas/// The dashboard's index cards: each cash index paired with its index future./// Outside the regular cash session the future is shown in the card's place/// (it trades nearly around the clock, while the cash index sits frozen on its/// last close; see PLAN.md Phase 21). The Nasdaq Composite (`^NDQ`) and the/// volatility index (`^VIX`) have no clean tradable future, so they always/// show the cash index. Hardcoded on purpose: the home page is a fixed,/// opinionated view, not a user-built watchlist.const INDEXES: &[(&str, Option<&str>)] = &[ ("^SPX", Some("ES=F")), ("^DJI", Some("YM=F")), ("^NDX", Some("NQ=F")), ("^RUT", Some("RTY=F")), ("^NDQ", None), ("^VIX", None),];/// The dashboard's commodity cards: WTI crude, gold, natural gas. Shown as the/// futures themselves, since there is no cash instrument to swap to.const COMMODITIES: &[&str] = &["CL=F", "GC=F", "NG=F"];/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;
@@ -123,7 +136,7 @@ async fn home(State(state): State<AppState>) -> Response { .await .unwrap_or(seeded); let spark_cards = dashboard_cards(&state).await; let (index_cards, commodity_cards) = dashboard_cards(&state).await; // One scan of the curated stocks feeds both the movers and the strongest / // weakest panels. let stocks = load_stocks(&state).await;
@@ -133,7 +146,8 @@ async fn home(State(state): State<AppState>) -> Response { let extra = minijinja::context! { title => "Markets", empty => false, spark_cards => spark_cards, index_cards => index_cards, commodity_cards => commodity_cards, gainers => gainers, losers => losers, strongest => strongest,
@@ -143,12 +157,41 @@ async fn home(State(state): State<AppState>) -> Response { render(&state, "pages/home.html", "/", extra)}/// The curated dashboard symbols, in `DASHBOARD` order, each with a current/// price, the day's change, and a sparkline of the latest session's bars.async fn dashboard_cards(state: &AppState) -> Vec<SparkCard> { // One query for the price rows. The `IN` list is built from the DASHBOARD // const — never user input — so the placeholder count is fixed and safe. let placeholders = vec!["?"; DASHBOARD.len()].join(",");/// The dashboard's index and commodity sparkline cards.////// Outside the regular cash session each index card resolves to its index/// future (see `INDEXES`): the future trades nearly around the clock, so the/// card stays live overnight instead of freezing on the 16:00 ET close.async fn dashboard_cards(state: &AppState) -> (Vec<SparkCard>, Vec<SparkCard>) { let regular = matches!( market::session_at(chrono::Utc::now()), market::Session::Regular ); // During the regular cash session show each index itself; outside it, // swap in the index future where one exists. let index_tickers: Vec<&str> = INDEXES .iter() .map(|&(index, future)| match future { Some(fut) if !regular => fut, _ => index, }) .collect(); let indexes = spark_cards_for(state, &index_tickers).await; let commodities = spark_cards_for(state, COMMODITIES).await; (indexes, commodities)}/// Build a sparkline card for each ticker in `tickers`, in that order: a/// current price, the day's change, and a sparkline of the latest session's/// bars. A ticker the universe does not hold is skipped.async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> { if tickers.is_empty() { return Vec::new(); } // One query for the price rows. The `IN` list is built from the hardcoded // dashboard consts — never user input — so the placeholder count is fixed // and safe. let placeholders = vec!["?"; tickers.len()].join(","); let sql = format!( "SELECT s.ticker, s.name, s.kind, \ COALESCE(s.last_price, \
@@ -158,15 +201,15 @@ async fn dashboard_cards(state: &AppState) -> Vec<SparkCard> { FROM symbols s WHERE s.ticker IN ({placeholders})" ); let mut q = sqlx::query_as::<_, SymbolCardRow>(&sql); for t in DASHBOARD { for t in tickers { q = q.bind(*t); } let rows: Vec<SymbolCardRow> = q.fetch_all(&state.pool).await.unwrap_or_default(); let mut by_ticker: HashMap<String, SymbolCardRow> = rows.into_iter().map(|r| (r.0.clone(), r)).collect(); let mut cards = Vec::with_capacity(DASHBOARD.len()); for &t in DASHBOARD { let mut cards = Vec::with_capacity(tickers.len()); for &t in tickers { // Skip a dashboard symbol the universe somehow does not hold. let Some((ticker, name, _kind, last, prev)) = by_ticker.remove(t) else { continue;
modified
src/routes/search.rs
@@ -10,7 +10,7 @@ use std::collections::HashMap;use axum::{ extract::{Query, State}, response::Response, response::{IntoResponse, Redirect, Response}, routing::get, Router,};
@@ -91,6 +91,15 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState .unwrap_or_default(); let mut results: Vec<Card> = rows.into_iter().map(to_card).collect(); // A search that pinpoints exactly one symbol jumps straight to its page, // rather than rendering a single card the user must then click (Phase 21). // Browse mode (an empty query) never redirects. if !query.is_empty() && results.len() == 1 { let target = format!("/s/{}", urlencoding::encode(&results[0].ticker)); return Redirect::to(&target).into_response(); } let result_count = results.len() as i64; // Attach each stock card's strong / fair / weak verdict badge (Phase 20).
modified
src/routes/symbols.rs
@@ -961,10 +961,11 @@ fn add_err(status: StatusCode, msg: impl Into<String>) -> Response {/// The Search page calls this when a query names a ticker the universe does/// not hold yet. The ticker is validated against Yahoo — one request that also/// yields its name, kind, exchange and currency — then the symbol row is/// inserted, the quote that same lookup returned is stored, and the history/// job is brought forward so the deep daily backfill lands within a tick. The/// Yahoo request goes through the shared endpoint guard, like every other/// outbound call (see PLAN.md's anti-spam policy)./// inserted, the quote that same lookup returned is stored, and the symbol's/// full backfill (deep daily history and all SEC data) is pulled synchronously/// before the response, so its page is complete the moment the add returns/// (PLAN.md Phase 21; see `scheduler::backfill_symbol`). Every outbound call/// goes through the shared endpoint guard (see PLAN.md's anti-spam policy).async fn add_symbol(State(state): State<AppState>, Json(body): Json<AddSymbolBody>) -> Response { let Some(ticker) = valid_ticker(&body.ticker) else { return add_err(
@@ -1081,11 +1082,11 @@ async fn add_symbol(State(state): State<AppState>, Json(body): Json<AddSymbolBod tracing::warn!("add_symbol store_intraday {ticker}: {e:#}"); } } // Bring the history job forward so the deep daily backfill runs on the // next scheduler tick rather than waiting out the ~6h interval. if let Err(e) = scheduler::schedule_next(&state.pool, "history", now).await { tracing::warn!("add_symbol schedule history for {ticker}: {e:#}"); } // Pull the full backfill — deep daily history and all SEC data — before // responding, so the new symbol's page is complete the moment the add // returns rather than filling in over later scheduler cycles. Best-effort // and guard-routed; see `scheduler::backfill_symbol`. scheduler::backfill_symbol(&state.pool, &state.config, &ticker, &info.kind).await; tracing::info!("add_symbol: added {ticker} ({}, {})", info.name, info.kind); Json(AddSymbolResponse {
modified
src/scheduler.rs
@@ -1301,6 +1301,233 @@ pub(crate) async fn store_intraday( Ok(())}// ── synchronous backfill for a freshly-added symbol (Phase 21) ─────────────/// Run one guarded outbound call: acquire a permit, await `call`, and feed the/// outcome back to the guard. `None` when the guard denied the request (the/// breaker is open or the hourly budget is spent) or the permit could not be/// acquired; `Some` carries whatever the call itself returned.async fn guarded<T>( guard: &EndpointGuard, call: impl std::future::Future<Output = anyhow::Result<T>>,) -> Option<anyhow::Result<T>> { match guard.acquire().await { Ok(Permit::Granted) => { let result = call.await; match &result { Ok(_) => { let _ = guard.record_success().await; } Err(e) => { let _ = guard.record_failure(e).await; } } Some(result) } Ok(Permit::Denied(why)) => { tracing::info!("[backfill] guard denied: {why}"); None } Err(e) => { tracing::warn!("[backfill] guard error: {e:#}"); None } }}/// Synchronously backfill everything for one just-added symbol: its deep daily/// history from Stooq and, for a stock or ETF, its SEC data. The add-symbol/// route (`routes::symbols`) calls this so a user-added symbol's page is/// complete the moment the add returns, rather than filling in over later/// scheduler cycles (PLAN.md Phase 21).////// Best-effort and guard-routed: every outbound call passes through the same/// `EndpointGuard` the background jobs use, and a guard denial or upstream/// error for any one piece is logged and skipped. The symbol is already added;/// the normal scheduler sweeps pick up whatever this run missed.pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker: &str, kind: &str) { backfill_history(pool, config, ticker, kind).await; // SEC data covers stocks and ETFs; indexes and futures do not file. The // whole SEC step is skipped with no contact email configured, as `run_sec` // skips itself. if config.sec_contact_email.is_empty() { return; } let sec = SecProvider::new(providers::http::build_sec_client(config)); let guard = EndpointGuard::with_budget(pool.clone(), sec.name(), SEC_BUDGET); match kind { "stock" => backfill_stock_sec(pool, &sec, &guard, ticker).await, "etf" => backfill_etf_sec(pool, &sec, &guard, ticker).await, _ => {} }}/// Pull and store one symbol's deep daily history from Stooq. A no-op for a/// future (Stooq carries no `=F` history) or when Stooq is not configured.async fn backfill_history(pool: &SqlitePool, config: &Config, ticker: &str, kind: &str) { if kind == "future" || config.stooq_apikey.is_empty() { return; } let stooq = StooqProvider::new( providers::http::build_client(config), config.stooq_apikey.clone(), ); let guard = EndpointGuard::new(pool.clone(), stooq.name()); match guarded(&guard, stooq.daily(ticker, None)).await { Some(Ok(bars)) if !bars.is_empty() => match seed::store_daily(pool, ticker, &bars).await { Ok(()) => tracing::info!("[backfill] {ticker} <- {} daily bars", bars.len()), Err(e) => tracing::warn!("[backfill] store history {ticker}: {e:#}"), }, // A valid but empty response (a historyless symbol): stamp it checked // so the history job does not immediately re-fetch it. Some(Ok(_)) => { let _ = mark_history_checked(pool, ticker).await; } _ => {} }}/// Backfill a stock's SEC data: resolve its CIK, then pull fundamentals,/// filings, and the officer/board roster.async fn backfill_stock_sec( pool: &SqlitePool, sec: &SecProvider, guard: &EndpointGuard, ticker: &str,) { let Some(cik) = resolve_one_cik(pool, sec, guard, ticker, false).await else { tracing::info!("[backfill] {ticker}: no SEC CIK, leaving it for the sec job"); return; }; if let Some(Ok(facts)) = guarded(guard, sec.facts(&cik)).await { match store_fundamentals(pool, ticker, &facts).await { Ok(()) => { let _ = mark_sec_synced(pool, ticker, "fundamentals_synced_at").await; } Err(e) => tracing::warn!("[backfill] store facts {ticker}: {e:#}"), } } if let Some(Ok(filings)) = guarded(guard, sec.filings(&cik)).await { match store_filings(pool, ticker, &filings).await { Ok(()) => { let _ = mark_sec_synced(pool, ticker, "filings_synced_at").await; } Err(e) => tracing::warn!("[backfill] store filings {ticker}: {e:#}"), } } backfill_leadership(pool, sec, guard, ticker, &cik).await;}/// Backfill a stock's officer/board roster from a window of its most recent/// Form 3/4/5 ownership filings, mirroring the leadership sweep in `run_sec`.async fn backfill_leadership( pool: &SqlitePool, sec: &SecProvider, guard: &EndpointGuard, ticker: &str, cik: &str,) { let Some(Ok(index)) = guarded(guard, sec.ownership_index(cik)).await else { return; }; let to_parse: Vec<_> = index.into_iter().take(LEADERSHIP_MAX_FILINGS).collect(); let mut roster: Vec<(OwnershipPerson, String)> = Vec::new(); let mut complete = true; for f in &to_parse { match guarded(guard, sec.ownership_doc(cik, &f.accession, &f.primary_doc)).await { Some(Ok(people)) => { for p in people { if p.is_director || p.is_officer { roster.push((p, f.filed_at.clone())); } } } // A parse or network error for one filing: skip it and build the // roster from the rest, exactly as `run_sec` does. Some(Err(e)) => tracing::warn!("[backfill] ownership_doc {ticker}: {e:#}"), // A guard denial leaves the roster only partial: leave it unsynced // so the next `sec` cycle finishes it. None => complete = false, } } let _ = store_leadership(pool, ticker, &roster).await; if complete { let _ = mark_sec_synced(pool, ticker, "leadership_synced_at").await; }}/// Backfill an ETF's fund profile: resolve its fund CIK, pull the filing list,/// then either the N-PORT portfolio or a commodity trust's AUM.async fn backfill_etf_sec(pool: &SqlitePool, sec: &SecProvider, guard: &EndpointGuard, ticker: &str) { let Some(cik) = resolve_one_cik(pool, sec, guard, ticker, true).await else { tracing::info!("[backfill] {ticker}: no SEC fund CIK, leaving it for the sec job"); return; }; // `resolve_fund_ciks` stored the series id alongside the CIK. let series_id: Option<String> = sqlx::query_scalar("SELECT series_id FROM symbols WHERE ticker = ?") .bind(ticker) .fetch_one(pool) .await .ok() .flatten(); let id = FundId { cik: cik.clone(), series_id, }; let Some(Ok(ff)) = guarded(guard, sec.fund_filings(&id)).await else { return; }; let _ = store_filings(pool, ticker, &ff.filings).await; match ff.shape { FundShape::Portfolio { nport_href } => { if let Some(Ok(portfolio)) = guarded(guard, sec.fund_portfolio(&nport_href)).await { if store_fund_portfolio(pool, ticker, &portfolio).await.is_ok() { let _ = mark_fund_synced(pool, ticker).await; } } } FundShape::CommodityTrust => { if let Some(Ok(aum)) = guarded(guard, sec.fund_aum(&cik)).await { if store_fund_commodity(pool, ticker, aum).await.is_ok() { let _ = mark_fund_synced(pool, ticker).await; } } } FundShape::Unknown => { let _ = mark_fund_synced(pool, ticker).await; } }}/// Resolve and store a freshly-added symbol's SEC CIK from the bulk ticker map./// `fund` selects the mutual-fund map (ETFs) over the operating-company map/// (stocks). Returns the stored CIK on success.async fn resolve_one_cik( pool: &SqlitePool, sec: &SecProvider, guard: &EndpointGuard, ticker: &str, fund: bool,) -> Option<String> { if fund { if let Some(Ok(map)) = guarded(guard, sec.fund_ticker_map()).await { let _ = resolve_fund_ciks(pool, &map).await; } } else if let Some(Ok(map)) = guarded(guard, sec.cik_map()).await { let _ = resolve_ciks(pool, &map).await; } sqlx::query_scalar::<_, Option<String>>("SELECT cik FROM symbols WHERE ticker = ?") .bind(ticker) .fetch_one(pool) .await .ok() .flatten()}/// Prune aged rows once per `PRUNE_INTERVAL_SECS`. `intraday_bars` keeps a/// rolling ~14-day window; `fetch_log` keeps ~30 days. `daily_prices` is/// permanent and never touched here.
modified
templates/pages/home.html
@@ -22,9 +22,14 @@ <a class="page-head__link" href="/search">Browse all {{ total }} symbols</a> </div> <h2 class="section-title">Indexes & commodities</h2> <h2 class="section-title">Indexes</h2> <div class="spark-grid"> {% for c in spark_cards %}{{ spark_card(c) }}{% endfor %} {% for c in index_cards %}{{ spark_card(c) }}{% endfor %} </div> <h2 class="section-title">Commodities</h2> <div class="spark-grid"> {% for c in commodity_cards %}{{ spark_card(c) }}{% endfor %} </div> <h2 class="section-title">Today’s movers</h2>
modified
universe/starter.csv
@@ -146,6 +146,7 @@ SMCI,Super Micro Computer Inc.,stock,NASDAQES=F,S&P 500 E-Mini Futures,future,CMENQ=F,Nasdaq 100 E-Mini Futures,future,CMEYM=F,Dow E-Mini Futures,future,CMERTY=F,Russell 2000 E-Mini Futures,future,CMECL=F,Crude Oil Futures (WTI),future,NYMEXBZ=F,Brent Crude Oil Futures,future,NYMEXGC=F,Gold Futures,future,COMEX