repos

Phase 4: ETFs as first-class citizens — quality read + daily NAV

19d0b143 by Isaac Bythewood · 1 month ago

Phase 4: ETFs as first-class citizens — quality read + daily NAV

Add a blended ETF quality read mirroring the stock health donut: a
cost-weighted composite of cost (40%), tracking (25%), diversification
(20%), and size (15%), each graded good/ok/bad, rolled to a 0-100 badge
percent with four sub-readings. Renormalises over whichever factors
grade, so a commodity trust with no holdings drops diversification
cleanly; shows only with >=2 graded factors.

Render it as a quality donut anchoring the ETF symbol-page header
(reusing the health-badge styling), with a hover breakdown and a
non-advice note — the fund-side sibling of the stock health badge.

Tracking is premium/discount to NAV, but Yahoo NAV only refreshes every
30 days (fund_metadata sweep), which made a live-price-vs-stale-NAV
premium read as a bogus "wide gap" on funds that track to the basis
point. Fix with a dedicated daily fund_nav job (YahooProvider::fund_nav,
two quoteSummary modules, through the yahoo guard) + a nav_synced_at
column (migration 0014), and a freshness gate on the symbol page that
drops the tracking factor to "-" when NAV is stale rather than assert a
false premium. /health lists the new "ETF NAV" job.

Also correct the footer's stale Stooq credit (removed Phase 1) to
"Market data via Yahoo Finance - Fundamentals via SEC EDGAR".

PLAN.md: Phase 4 recorded done (dashboard ETF band deferred to Phase 5),
the NAV-staleness lesson captured, plus two parked riffs (cross-repo
git-trailer scrub — already clean; full footer expansion — Phase 7).
modified PLAN.md
@@ -34,7 +34,7 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-05-30 (Phase 3 complete + deployed)__Last updated: 2026-05-30 (Phase 4 done on dev — commit + deploy pending)_**Major refactor in progress (the "distill + ETF-first" rewrite).** This planwas fully rewritten 2026-05-30 from a sprawling 3,700-line resume doc into this
@@ -54,8 +54,11 @@ focused roadmap. The decisions driving it are in the Decisions log under- **Everything gets distilled** into a fast-scannable, dual-first (mobile +  desktop) design while keeping the futuristic-clean "Paper Ledger" look.**Current work:** Phases 1–3 are **done and deployed.** Next: **Phase 4 (ETFsas true first-class citizens)**.**Current work:** Phases 1–3 are **done and deployed.** Phase 4 (ETFs asfirst-class citizens) is **done on dev, pending commit + deploy** — qualityscore + distinct ETF symbol-page identity shipped; the dashboard ETF band wasdeferred to Phase 5 (user's call). Next after deploy: **Phase 5 (dashboardredesign)**.Phase 3 outcome (drop short-horizon prediction → quality leaderboard):- **The whole picker is gone.** Removed the four horizon rankers
@@ -259,13 +262,34 @@ Kill the rate-limit problem at the root.- Note: the leaderboard's home-page placement is intentionally provisional —  Phase 5 rebuilds the dashboard (hero + bands) around it.### Phase 4 — ETFs as true first-class citizens- A blended ETF **quality score** (cost / diversification / size / tracking)  mirroring the stock health donut, with its own sub-readings.- Distinct ETF symbol-page identity: own header treatment + badge + section set  (holdings, expense/yield, NAV/premium, sector/geo, trailing returns vs  benchmark) instead of stock fundamentals.- Distinct ETF band on the dashboard.### Phase 4 — ETFs as true first-class citizens  ✅ DONE on dev (commit + deploy pending)- ✅ Blended ETF **quality score** (`compute::etf_quality`): cost-weighted blend  of cost (40%) / tracking (25%) / diversification (20%) / size (15%), composite  −1..1 → percent, four sub-reading chips — structurally mirrors the stock  `health_read` donut. Renormalises over gradeable factors (commodity trusts  with no holdings drop diversification cleanly); shows only with ≥2 factors.- ✅ Distinct ETF symbol-page identity: the quality donut anchors the header  top-right (reusing the `health-badge` styling), hover reveals the four  sub-readings + non-advice note. The ETF already shows fund sections (holdings,  expense/yield, NAV/premium, sector/geo, trailing returns vs benchmark) and no  stock fundamentals — so the badge + `ETF` tag complete the distinct identity.- ✅ **Tracking now backed by a real daily NAV.** Discovered the 30-day metadata  cadence made premium/discount unreliable; added a dedicated daily `fund_nav`  scheduler job + `nav_synced_at` column (migration `0014`) so NAV is current,  and a freshness gate that drops the tracking factor to `—` when NAV is stale  rather than assert a bogus premium. (See Decisions log for the full story.)- ⏸ **Distinct ETF band on the dashboard — deferred to Phase 5** (user's call):  the dashboard is fully redesigned in P5, so the ETF band is built there once  rather than built twice.- **Verified on dev:** `cargo build` clean; migration `0014` applies on boot;  `/health` lists the new "ETF NAV" job; every ETF page renders the quality  donut (VOO 89% Strong, GLD 64% with diversification correctly `—`, BND 100%  on its two graded factors); the freshness gate correctly drops tracking to  `—` while NAV is stale (screenshot reviewed). **One runtime path still  unverified live:** the daily `fund_nav` Yahoo fetch — the dev `yahoo` guard's  breaker was open (normal post-deploy back-off) so the job stopped early 0/43;  the fetch reuses the proven `quoteSummary` NAV parse, and prod exercises it on  first deploy once its guard is healthy.### Phase 5 — Dashboard redesign: "how is the market doing TODAY"- Hero: one-line plain-language market verdict + index strip + **breadth**
@@ -281,11 +305,23 @@ Kill the rate-limit problem at the root.### Phase 7 — Health/systems page distillation + final polish pass- Distill `/health` and overall cross-page cohesion; one closing UI polish pass.- Add a discreet data-attribution line ("Market data via Yahoo Finance ·  Fundamentals via SEC EDGAR") in the footer / on `/health`. Yahoo's chart  endpoint is unofficial (no published ToS or attribution requirement to  satisfy), but a tasteful credit is honest, costs nothing, and suits the  professional face the user wants. (Captured 2026-05-30 from a user note.)- **Expand the footer to match the sibling-project pattern.** Finance currently  has a single-line footer; the user wants it grown "a lot" to match how the  other apps do footers. The house pattern (analytics, status, blog, repos) is a  **two-tier footer**: a multi-column upper `<footer>` (columns like About /  Pages / Links — nav links, cross-project links, Portfolio/GitHub/LinkedIn, and  a "Source" link to `github.com/overshard/finance`) over a slim `.footer-bar`  with `© {{ now.year }} Isaac Bythewood · Some rights reserved` + a GitHub icon  link. `repos`'s `footer__grid` with `// LABEL` column headers is the closest  fit for the Paper Ledger aesthetic — model finance's on that. Fold the data  attribution below into one of the columns.- **Data-attribution (partially done early):** the stale **Stooq** credit was a  factual bug (Stooq removed Phase 1), so the one-line footer was corrected  on 2026-05-30 to "Market data via Yahoo Finance · Fundamentals via SEC EDGAR ·  not investment advice" ahead of schedule. The full footer build above still  belongs to this polish pass. Yahoo's chart endpoint is unofficial (no  published ToS/attribution requirement), but a tasteful credit is honest and  suits the professional face the user wants.### Backlog / parked- Watchlists (tables exist, unused — user wants an opinionated no-customization
@@ -298,11 +334,79 @@ Kill the rate-limit problem at the root.- Deep pre-2000 history (lost with Stooq; revisit only if charts feel thin).  Note: index/futures daily history via Yahoo caps at ~10y (the `range=10y`  fallback); ^SPX/^DJI/^NDX still carry deep daily history from before.- **Scrub Claude/Anthropic trailers from git history (cross-repo, force-push).**  User wants every `Co-Authored-By: Claude`, `🤖 Generated with [Claude Code]`,  and any Anthropic ad line removed from *all* commit messages in *all* repos.  **Survey 2026-05-30: history is already clean** — a strict scan across all 9  `~/code` repos (analytics, blog, darkfurrow, finance, isaacbythewood, repos,  status, taproot, timelite) found **zero** such trailers; the only "claude"  hits are legitimate references to the `CLAUDE.md` *filename* in commit  messages, which must NOT be scrubbed. So this is a no-op today and only  matters if a trailer ever slips in. Procedure if needed (do it as its own  focused session — it is irreversible):  1. Per repo, rewrite history dropping the offending trailer lines, e.g.     `git filter-repo --message-callback` (preferred) or a `filter-branch`     fallback, stripping only `Co-Authored-By: Claude*`, `🤖 Generated with*`,     and `Generated with [Claude Code]*` lines — never the `CLAUDE.md` mentions.  2. `git push --force-with-lease` to **every** remote (GitHub `origin` *and*     the deploy remote `server`).  3. **Server fixup:** the deploy is a bare repo + post-receive hook that builds     into the project dir. After a history rewrite the server's checkout will be     on an orphaned commit, so SSH to the alpine host (`taproot` manages it) and     reset the bare repo's `master` + re-run the deploy (`docker compose up     --build --detach`) so `/srv` tracks the rewritten history; verify the     container is healthy and reattached to `bythewood-edge`. Coordinate via the     `taproot` repo (its CLAUDE.md is off-limits to auto-edits per user rule).---## Decisions log**2026-05-30 — Phase 4 build + the NAV-staleness discovery.** Built the ETFquality read (`compute::etf_quality`, mirroring `health_read`) and thesymbol-page quality donut. **Mid-build discovery:** the tracking factor(premium/discount to NAV, the option the user picked) was reading "Wide gap" onfunds that track perfectly (VOO, IVV) because Yahoo's NAV is only refreshedevery 30 days (`FUND_METADATA_STALE_SECS`) — so a live price compared to aweeks-old NAV showed a bogus premium. This broke the premise behind the user'stracking choice. Surfaced it and asked; user chose **refresh NAV daily** sotracking becomes a true daily read. Implemented as:- a dedicated daily `fund_nav` scheduler job (separate from the 30-day static  metadata sweep so the slow fields keep their cadence) that fetches NAV only  (`YahooProvider::fund_nav`, two `quoteSummary` modules) through the `yahoo`  guard — ~43 req/day, negligible vs the 1000/hr budget;- a `nav_synced_at` column (migration `0014`) the daily job stamps;- a **freshness gate** on the symbol page: the tracking factor is graded only  when NAV was synced ≤3 days ago, else it drops to `—` and the cost/div/size  blend renormalises — so a stale NAV never drives a false tracking verdict.Also deferred the **dashboard ETF band to Phase 5** (it rebuilds the dashboardanyway) and corrected the footer's stale Stooq credit early (a factual bug).The `fund_nav` live fetch wasn't exercised on dev (the guard's breaker was openpost-deploy), but it reuses the proven NAV parse and runs on first prod deploy.**2026-05-30 — Phase 4 (ETFs as first-class citizens) design forks resolved.**Answered 3 clarifying questions before building:1. **ETF quality score = cost-weighted, all four factors.** Cost (expense ratio)   ~40%, Tracking ~25%, Diversification ~20%, Size (AUM) ~15%. Composite in   [-1,1] → percent, with four sub-reading chips — structurally mirrors the   stock `health_read` donut. Weights renormalize over whichever factors are   gradeable (commodity trusts like GLD/SLV/IBIT have no holdings, so   diversification drops out and the rest reweight). Show the badge only when   ≥2 factors are gradeable.2. **Tracking factor = premium/discount to NAV**, reusing the existing   `compute::premium_discount_pct` + `premium_grade` (price vs Yahoo NAV). No   new compute, no benchmark-alignment work. (True tracking error vs benchmark   was considered and parked.)3. **Dashboard ETF band deferred to Phase 5.** Phase 4 ships the quality score +   the distinct ETF symbol-page identity (own quality donut + sub-readings in   the header, alongside the fund sections that already exist). The dashboard   ETF band gets built into the Phase 5 dashboard redesign rather than built   twice.Grading bands (initial, tune later): cost cheap ≤0.10% / pricey >0.50%;tracking via `premium_grade` (±0.25% tight, ±1% wide); diversification on top-10holdings concentration (≤~25% broad, >~50% concentrated); size on log10(AUM)centered ~ $2B ok, ≥ $20B large.**2026-05-30 — Phase 3 (drop short-horizon prediction → quality leaderboard).**Executed the kickoff decision to drop prediction. Resolved the one open designfork (what the unified leaderboard ranks by) without blocking: used the existing
@@ -405,6 +509,14 @@ from this doc to keep it scannable.  a new upstream-backed job; it recovers via the half-open probe.- **Chart indicator lines use a non-semantic palette on purpose** — green/amber/  red are reserved for good/ok/bad; candles own green/red.- **Yahoo NAV is only as fresh as you keep it.** The 30-day `fund_metadata`  sweep (`FUND_METADATA_STALE_SECS`) carries a NAV, but comparing a live price  to a weeks-old NAV yields a meaningless premium/discount — it reads as a huge  fake "premium" on funds that actually track to the basis point. NAV is struck  once per trading day; anything that reads a price-vs-NAV premium (the ETF  quality read's tracking factor) must run off the daily `fund_nav` refresh and  gate on `nav_synced_at` freshness. Don't trust the metadata sweep's NAV for  any real-time comparison.- **`cargo` isn't on PATH in this dev container** — use `~/.cargo/bin/cargo`,  and run the dev binary with `FINANCE_ROOT=/home/dev/code/finance` (or from the  project dir so paths resolve from cwd).
added migrations/0014_etf_nav_refresh.sql
@@ -0,0 +1,11 @@-- Phase 4: a separate freshness stamp for the ETF's NAV.---- The 30-day fund_metadata sweep (Phase 28) refreshes the slow static fields-- (expense ratio, category, inception, ...) and carries a NAV too, but a-- monthly NAV is far too stale to compute a meaningful price-vs-NAV premium —-- the signal the ETF quality read's "tracking" factor needs. A dedicated daily-- fund_nav job (Phase 4) now refreshes nav_price on its own cadence; this-- column records when, so the symbol page can gate the tracking factor on a-- fresh NAV (and drop it cleanly when the NAV is stale) without disturbing the-- fund_metadata_synced_at stamp the 30-day sweep owns.ALTER TABLE fund_metadata ADD COLUMN nav_synced_at INTEGER;
modified src/compute.rs
@@ -1260,6 +1260,207 @@ pub fn premium_grade(premium_pct: f64) -> Grade {    }}// ───────────────────────── ETF quality read (Phase 4) ──────────────────────//// An ETF's quality read mirrors the stock health donut: four graded factors —// cost, tracking, diversification, size — rolled into one good/ok/bad verdict// with a 0-100 badge percent and the four sub-readings behind it. It reads the// quality of the *wrapper* (is it cheap, does it hug fair value, is it broad,// is it durable), explicitly not a buy/sell call. Pure: derives only from the// fund's expense ratio, price/NAV premium, top-holdings concentration, and net// assets — all already loaded for the ETF symbol page./// Cost-weighted blend (a user steer). Cost is the one guaranteed, perpetual/// drag so it carries the most weight; tracking next, then diversification,/// then size. The composite renormalises over whichever factors graded, so a/// commodity trust with no holdings is read on the other three, not penalised.const ETF_W_COST: f64 = 0.40;const ETF_W_TRACKING: f64 = 0.25;const ETF_W_DIVERSIFICATION: f64 = 0.20;const ETF_W_SIZE: f64 = 0.15;/// At least this many of the four factors must grade before the badge shows, so/// a fund we know almost nothing about gets no read rather than a hollow one.const ETF_MIN_GRADED: usize = 2;/// An ETF's quality read: an overall strong / fair / weak verdict, the/// composite score, the 0-100 badge percent, and the four sub-components behind/// it so the symbol page can show the breakdown. Structurally a sibling of/// [`HealthRead`] (stocks), with ETF-appropriate factors.#[derive(Debug, Clone, Copy, Serialize)]pub struct EtfQuality {    /// CSS hook for the overall badge: `good` | `ok` | `bad`.    pub overall: Grade,    /// Badge text derived from `overall`: `Strong` | `Fair` | `Weak`.    pub verdict: &'static str,    /// Composite score in [-1, 1].    pub score: f64,    /// `score` mapped linearly to a 0-100 percent for the header badge.    pub percent: u8,    pub cost: Grade,    pub cost_label: &'static str,    pub tracking: Grade,    pub tracking_label: &'static str,    pub diversification: Grade,    pub diversification_label: &'static str,    pub size: Grade,    pub size_label: &'static str,    /// How many of the four factors carried a grade (the rest dropped out of    /// the blend). Not displayed; useful for debugging a thin read.    pub graded: usize,}/// Cost sub-score from the expense ratio (a decimal, e.g. `0.0003` = 0.03%)./// Cheaper is better: ≤0.05% saturates at +1, ≥0.75% at −1, linear between./// Bands suit the curated iShares/Vanguard roster (core funds ~0.03-0.10%; the/// priciest commodity/thematic ones ~0.40-0.75%). `None` when not reported.fn etf_cost_score(expense_ratio: Option<f64>) -> Option<f64> {    let pct = expense_ratio? * 100.0;    if pct < 0.0 {        return None;    }    const CHEAP: f64 = 0.05;    const PRICEY: f64 = 0.75;    Some((1.0 - (pct - CHEAP) / (PRICEY - CHEAP) * 2.0).clamp(-1.0, 1.0))}/// Tracking sub-score from the price's premium/discount to NAV (a signed/// percent). Reuses the page's [`premium_grade`] bands mapped to a numeric:/// tight ≤±0.25% ≈ +1, wide ≥±1% ≈ −1, linear in the absolute gap. `None` when/// NAV is unknown (no premium to read).fn etf_tracking_score(premium_pct: Option<f64>) -> Option<f64> {    let abs = premium_pct?.abs();    const TIGHT: f64 = 0.25;    const WIDE: f64 = 1.00;    Some((1.0 - (abs - TIGHT) / (WIDE - TIGHT) * 2.0).clamp(-1.0, 1.0))}/// Diversification sub-score from the top-10 holdings' combined weight (a/// percent, e.g. `27.5` = 27.5%). Lower concentration is broader/safer: ≤20%/// saturates at +1, ≥60% at −1. `None` for funds with no reported holdings/// (commodity trusts), dropping the factor from the blend.fn etf_diversification_score(top10_pct: Option<f64>) -> Option<f64> {    let c = top10_pct?;    if c <= 0.0 {        return None;    }    const BROAD: f64 = 20.0;    const CONCENTRATED: f64 = 60.0;    Some((1.0 - (c - BROAD) / (CONCENTRATED - BROAD) * 2.0).clamp(-1.0, 1.0))}/// Size sub-score from net assets (AUM, USD), on a log scale: small funds carry/// closure / liquidity risk, large ones are durable. Each 10× in AUM is ±1/// centred on ~$2B (so ~$200M ≈ −1, ~$2B ≈ 0, ≥~$20B ≈ +1). `None` when AUM is/// unknown or non-positive.fn etf_size_score(net_assets: Option<f64>) -> Option<f64> {    let aum = net_assets?;    if aum <= 0.0 {        return None;    }    const MID_LOG: f64 = 9.3; // log10($2B) ≈ 9.30    Some((aum.log10() - MID_LOG).clamp(-1.0, 1.0))}fn etf_cost_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Cheap",        Grade::Ok => "Moderate",        Grade::Bad => "Pricey",        Grade::Unknown => "—",    }}fn etf_tracking_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Tight",        Grade::Ok => "Slight drift",        Grade::Bad => "Wide gap",        Grade::Unknown => "—",    }}fn etf_diversification_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Broad",        Grade::Ok => "Moderate",        Grade::Bad => "Concentrated",        Grade::Unknown => "—",    }}fn etf_size_label(g: Grade) -> &'static str {    match g {        Grade::Good => "Large",        Grade::Ok => "Mid-size",        Grade::Bad => "Small",        Grade::Unknown => "—",    }}/// Roll an ETF's four factors into a single [`EtfQuality`]. `expense_ratio` is a/// decimal; `premium_pct` is the signed price-vs-NAV percent (from/// [`premium_discount_pct`]); `top10_pct` is the summed weight of the ten/// largest holdings (percent), `None` for a fund with no holdings; `net_assets`/// is AUM in USD. Returns `None` until at least [`ETF_MIN_GRADED`] factors/// grade, so a barely-known fund gets no badge rather than a hollow one. The/// composite renormalises over the factors that landed.pub fn etf_quality(    expense_ratio: Option<f64>,    premium_pct: Option<f64>,    top10_pct: Option<f64>,    net_assets: Option<f64>,) -> Option<EtfQuality> {    let cost_raw = etf_cost_score(expense_ratio);    let tracking_raw = etf_tracking_score(premium_pct);    let div_raw = etf_diversification_score(top10_pct);    let size_raw = etf_size_score(net_assets);    let factors = [        (cost_raw, ETF_W_COST),        (tracking_raw, ETF_W_TRACKING),        (div_raw, ETF_W_DIVERSIFICATION),        (size_raw, ETF_W_SIZE),    ];    let graded = factors.iter().filter(|(r, _)| r.is_some()).count();    if graded < ETF_MIN_GRADED {        return None;    }    let (mut weighted, mut total) = (0.0, 0.0);    for (r, w) in factors {        if let Some(v) = r {            weighted += w * v;            total += w;        }    }    let score = weighted / total;    let overall = score_grade(score);    let percent = (((score + 1.0) / 2.0 * 100.0).round() as i32).clamp(0, 100) as u8;    let cost = cost_raw.map_or(Grade::Unknown, score_grade);    let tracking = tracking_raw.map_or(Grade::Unknown, score_grade);    let diversification = div_raw.map_or(Grade::Unknown, score_grade);    let size = size_raw.map_or(Grade::Unknown, score_grade);    Some(EtfQuality {        overall,        // `overall` is never Unknown (score_grade only yields Good/Ok/Bad), so        // Grade::verdict's Strong/Fair/Weak reads cleanly as a quality grade.        verdict: overall.verdict(),        score,        percent,        cost,        cost_label: etf_cost_label(cost),        tracking,        tracking_label: etf_tracking_label(tracking),        diversification,        diversification_label: etf_diversification_label(diversification),        size,        size_label: etf_size_label(size),        graded,    })}// ── Phase 16: per-ticker anomaly feed ─────────────────────────────────────/// One row in the symbol-page anomaly feed. Built either here (price events,
modified src/providers/yahoo.rs
@@ -441,6 +441,23 @@ impl YahooProvider {        Ok(Some(parse_fund_metadata(result)))    }    /// Fetch just the ETF's latest NAV (net asset value per share) via the v10    /// `quoteSummary` `summaryDetail` / `price` modules — the two that carry    /// `navPrice`. The Phase 4 daily NAV refresh calls this so the price-vs-NAV    /// premium behind the ETF quality read's tracking factor stays current,    /// without re-pulling the static fields the 30-day `fund_metadata` sweep    /// owns. `Ok(None)` is a clean empty (unknown symbol or no NAV reported);    /// gating responses (429 / 503 / 401 / 403) surface as the typed    /// [`RateLimited`], the same defensive set as `fund_metadata`.    pub async fn fund_nav(&self, ticker: &str) -> Result<Option<f64>> {        let Some(result) = self.quote_summary(ticker, "summaryDetail,price").await? else {            return Ok(None);        };        let sd = result.summary_detail.unwrap_or_default();        let price = result.price.unwrap_or_default();        Ok(sd.nav_price.or(price.nav_price).map(|v| v.0))    }    /// Shared v10 `quoteSummary` fetch: ensures the crumb is cached, builds    /// the URL with `&crumb=...`, parses gating responses (429 / 503 / 401 /    /// 403) as the typed [`RateLimited`], and treats Yahoo's "unknown
modified src/routes/health.rs
@@ -248,6 +248,12 @@ fn job_meta(job: &str) -> (&str, &str) {            "Yahoo quoteSummary snapshot for each ETF — expense ratio, yield, \             NAV, inception, category, fund family, strategy. Refreshed monthly.",        ),        "fund_nav" => (            "ETF NAV",            "Yahoo quoteSummary NAV for each ETF, refreshed daily — keeps the \             price-vs-NAV premium behind the ETF quality read's tracking factor \             current (the monthly metadata sweep's NAV is too stale for that).",        ),        "earnings_calendar" => (            "Earnings calendar",            "Yahoo quoteSummary `calendarEvents` for each stock — the next \
@@ -270,11 +276,12 @@ fn job_rank(job: &str) -> u8 {        "history" => 1,        "sec" => 2,        "fund_metadata" => 3,        "asset_profile" => 4,        "earnings_calendar" => 5,        "dividends" => 6,        "intraday" => 7,        "daily_close" => 8,        "fund_nav" => 4,        "asset_profile" => 5,        "earnings_calendar" => 6,        "dividends" => 7,        "intraday" => 8,        "daily_close" => 9,        _ => 10,    }}
modified src/routes/symbols.rs
@@ -568,6 +568,10 @@ struct FundMetadataRow {    category: Option<String>,    fund_family: Option<String>,    strategy_summary: Option<String>,    /// When the daily `fund_nav` job last refreshed `nav_price` (Phase 4). The    /// quality read's tracking factor is only graded against a fresh NAV; a    /// stale one drops the factor rather than asserting a bogus premium.    nav_synced_at: Option<i64>,}/// The "About this fund" section of the ETF symbol page. Every field is
@@ -1320,6 +1324,16 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        Vec::new()    };    // Raw ETF figures captured as the fund / metadata blocks build them, then    // rolled into the Phase 4 quality read below. Kept as scalars so the read    // can be computed once both SEC (profile/holdings) and Yahoo (metadata)    // sources are loaded, without re-querying.    let mut etf_net_assets: Option<f64> = None;    let mut etf_top10_pct: Option<f64> = None;    let mut etf_expense_ratio: Option<f64> = None;    let mut etf_nav: Option<f64> = None;    let mut etf_nav_synced_at: Option<i64> = None;    // The ETF fund profile, when the SEC sweep has reached this symbol.    let fund = if is_etf {        let profile = sqlx::query_as::<_, FundProfileRow>(
@@ -1342,6 +1356,14 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)                .fetch_all(&state.pool)                .await                .unwrap_or_default();                etf_net_assets = profile.net_assets;                // Top-10 concentration for the diversification factor: the                // summed weight of the ten largest holdings (rows are rank-                // ordered, `pct` already in percent units). `None` when the                // fund reported no holdings (a commodity trust), so that factor                // drops out of the blend rather than reading as zero.                let top10: f64 = holdings.iter().take(10).filter_map(|h| h.pct).sum();                etf_top10_pct = (top10 > 0.0).then_some(top10);                Some(build_fund(profile, holdings))            }            None => None,
@@ -1355,17 +1377,49 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    // job has swept this symbol. An unswept ETF shows the section's    // "pending" note in the template.    let fund_meta = if is_etf {        sqlx::query_as::<_, FundMetadataRow>(        let row = sqlx::query_as::<_, FundMetadataRow>(            "SELECT expense_ratio, yield_pct, trailing_yield_pct, nav_price, \                    inception_date, category, fund_family, strategy_summary \                    inception_date, category, fund_family, strategy_summary, \                    nav_synced_at \             FROM fund_metadata WHERE ticker = ?",        )        .bind(&ticker)        .fetch_optional(&state.pool)        .await        .ok()        .flatten()        .map(|r| build_fund_meta(r, price))        .flatten();        match row {            Some(row) => {                etf_expense_ratio = row.expense_ratio;                etf_nav = row.nav_price;                etf_nav_synced_at = row.nav_synced_at;                Some(build_fund_meta(row, price))            }            None => None,        }    } else {        None    };    // Phase 4 — ETF quality read: cost-weighted blend of cost, tracking (price    // vs NAV premium), diversification (top-10 concentration), and size (AUM).    // Mirrors the stock health donut. `None` until ≥2 factors grade, so a fund    // the sweeps have barely reached gets no badge rather than a hollow one.    let etf_quality = if is_etf {        // Only read a price-vs-NAV premium (the tracking factor) against a        // *fresh* NAV: NAV is struck daily, so a stale one makes the premium        // meaningless. We let the factor drop out rather than assert a bogus        // tracking verdict. The daily `fund_nav` job keeps NAV current; when it        // is behind (fresh deploy, guard tripped), tracking simply reads "—".        const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;        let nav_fresh =            etf_nav_synced_at.is_some_and(|t| crate::db::now_ms() - t <= NAV_FRESH_MS);        let premium_pct = if nav_fresh {            price.and_then(|p| compute::premium_discount_pct(p, etf_nav))        } else {            None        };        compute::etf_quality(etf_expense_ratio, premium_pct, etf_top10_pct, etf_net_assets)    } else {        None    };
@@ -1463,6 +1517,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        health => health,        fund => fund,        fund_meta => fund_meta,        etf_quality => etf_quality,        returns => returns,        leadership => leadership,        dividends => dividends,
modified src/scheduler.rs
@@ -121,6 +121,18 @@ const DIVIDENDS_STALE_SECS: i64 = 7 * 24 * 3600;const FUND_METADATA_INTERVAL_SECS: i64 = 24 * 3600;const FUND_METADATA_STALE_SECS: i64 = 30 * 24 * 3600;/// ETF NAV refresh (Phase 4). NAV is struck once per trading day, and the/// price-vs-NAV premium behind the ETF quality read's "tracking" factor is only/// meaningful against a *fresh* NAV — so unlike the 30-day `fund_metadata`/// sweep (which owns the slow static fields, NAV included but far too stale to/// read a premium against), this job refreshes *only* nav_price, daily. One/// lightweight `quoteSummary` request per ETF (~43/day) through the shared/// `yahoo` `EndpointGuard` — negligible against the hourly budget. The 20h/// staleness window (< the 24h interval) means every ETF refreshes once per/// daily run.const FUND_NAV_INTERVAL_SECS: i64 = 24 * 3600;const FUND_NAV_STALE_SECS: i64 = 20 * 3600;/// Earnings calendar refresh (Phase 25). Yahoo's `calendarEvents.earnings`/// rolls forward one quarter at a time as each print lands, and a stock's/// next date is irrelevant before it shifts — so a monthly cadence is
@@ -192,6 +204,14 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle            tracing::warn!("[scheduler] bring fund_metadata job forward: {e}");        }        // Fund-NAV job (Phase 4): same bring-forward pattern, so a deploy adding        // the daily NAV refresh populates fresh NAVs — and thus the ETF quality        // read's tracking factor — within a tick rather than waiting out the        // daily interval. Resumable; the no-stale fast path is free.        if let Err(e) = schedule_next(&pool, "fund_nav", now_ms()).await {            tracing::warn!("[scheduler] bring fund_nav job forward: {e}");        }        // Earnings calendar job (Phase 25): same bring-forward pattern, so a        // deploy adding the columns backfills the stock universe within a        // tick rather than the daily interval. Resumable; no-stale fast path
@@ -282,6 +302,19 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                Err(e) => tracing::warn!("[scheduler] fund_metadata due-check: {e}"),            }            // ETF NAV (Phase 4): refresh every ETF's NAV daily so the            // price-vs-NAV premium behind the quality read's tracking factor            // stays current. Same Yahoo guard; one lightweight request per ETF.            match is_due(&pool, "fund_nav", now_ms()).await {                Ok(true) => {                    if let Err(e) = run_fund_nav(&pool, &config, &hub).await {                        tracing::warn!("[scheduler] fund_nav: {e:#}");                    }                }                Ok(false) => {}                Err(e) => tracing::warn!("[scheduler] fund_nav due-check: {e}"),            }            // Earnings calendar (Phase 25): sweep stocks whose next-expected            // earnings date has gone stale or already passed. Same Yahoo            // guard; one request per stock per month in steady state.
@@ -1486,6 +1519,130 @@ pub(crate) async fn store_fund_metadata(    Ok(())}// ─────────────────────── ETF NAV daily refresh (Phase 4) ──────────────────/// Refresh every ETF's NAV daily so the price-vs-NAV premium behind the ETF/// quality read's "tracking" factor (Phase 4) stays current. NAV is struck once/// per trading day; the 30-day `fund_metadata` sweep carries a NAV too but far/// too stale to read a premium against, so this job owns the daily nav_price +/// nav_synced_at on its own cadence, leaving the slow static fields alone. One/// lightweight `quoteSummary` request per ETF through the shared `yahoo` guard./// Mirrors `run_fund_metadata`'s shape — guard-paced, resumable, /health-aware.async fn run_fund_nav(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    let started = now_ms();    let next = started + FUND_NAV_INTERVAL_SECS * 1000;    let cutoff = started - FUND_NAV_STALE_SECS * 1000;    let stale: Vec<String> = sqlx::query_scalar(        "SELECT s.ticker FROM symbols s \         LEFT JOIN fund_metadata m ON m.ticker = s.ticker \         WHERE s.kind = 'etf' \           AND (m.nav_synced_at IS NULL OR m.nav_synced_at < ?) \         ORDER BY s.ticker",    )    .bind(cutoff)    .fetch_all(pool)    .await?;    if stale.is_empty() {        // Fast path: every ETF's NAV is fresh. No fetching banner, no log row.        mark_ok(pool, "fund_nav", Some(next)).await?;        return Ok(());    }    mark_fetching(pool, "fund_nav").await?;    notify_health(hub);    tracing::info!("[scheduler] fund_nav: refreshing {} ETFs", stale.len());    let yahoo = YahooProvider::new(providers::http::build_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), "yahoo", YAHOO_BUDGET);    let t0 = Instant::now();    let mut ok = 0i64;    let mut empty = 0i64;    let mut errors = 0i64;    let mut stopped: Option<String> = None;    for ticker in &stale {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match yahoo.fund_nav(ticker).await {            Ok(nav) => {                guard.record_success().await?;                // `nav` may be None (Yahoo knows the symbol but carries no NAV                // today): still stamp nav_synced_at so we do not re-fetch the                // same empty next tick — the freshness gate then simply finds no                // premium and the tracking factor drops out.                if let Err(e) = store_fund_nav(pool, ticker, nav).await {                    tracing::warn!("[scheduler] fund_nav store {ticker}: {e:#}");                    errors += 1;                    continue;                }                if nav.is_some() {                    ok += 1;                } else {                    empty += 1;                }            }            Err(e) => {                guard.record_failure(&e).await?;                errors += 1;                tracing::warn!("[scheduler] fund_nav {ticker}: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    let detail = format!("{ok}/{} ETFs ({empty} no NAV, {errors} errors)", stale.len());    match stopped {        Some(why) => {            let full = format!("stopped early ({why}); {detail}");            tracing::warn!("[scheduler] fund_nav: {full}");            log_fetch(                pool, "fund_nav", "yahoo", "skipped",                Some(&full), Some(ok), dur, started,            ).await?;        }        None => {            tracing::info!("[scheduler] fund_nav: {detail}");            log_fetch(                pool, "fund_nav", "yahoo", "ok",                Some(&detail), Some(ok), dur, started,            ).await?;        }    }    mark_ok(pool, "fund_nav", Some(next)).await?;    notify_health(hub);    Ok(())}/// Upsert one ETF's freshly-fetched NAV + its sync stamp, touching only the NAV/// columns so the static fields the 30-day `fund_metadata` sweep owns are left/// intact. Inserts a NAV-only row if the metadata sweep has not reached this/// ETF yet (the other columns fill in on its next run). A `None` nav clears any/// prior NAV — honest: we have no fresh value to read a premium against.async fn store_fund_nav(pool: &SqlitePool, ticker: &str, nav: Option<f64>) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO fund_metadata (ticker, nav_price, nav_synced_at, updated_at) \         VALUES (?, ?, ?, ?) \         ON CONFLICT(ticker) DO UPDATE SET \           nav_price = excluded.nav_price, \           nav_synced_at = excluded.nav_synced_at, \           updated_at = excluded.updated_at",    )    .bind(ticker)    .bind(nav)    .bind(now)    .bind(now)    .execute(pool)    .await?;    Ok(())}/// Stamp an ETF as freshly fund-metadata-synced. ETFs only — the column is/// `NULL` forever on every non-ETF row.async fn mark_fund_metadata_synced(pool: &SqlitePool, ticker: &str) -> sqlx::Result<()> {
modified templates/base.html
@@ -58,7 +58,7 @@  </nav>  <footer class="footer">    <p>{{ site.title }} · data from Stooq, Yahoo Finance, and SEC EDGAR · prices are delayed · <a href="/health">data health</a> · &copy; {{ now.year }}</p>    <p>{{ site.title }} · Market data via Yahoo Finance · Fundamentals via SEC EDGAR · prices are delayed · not investment advice · <a href="/health">data health</a> · &copy; {{ now.year }} Isaac Bythewood</p>  </footer>  <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script>
modified templates/pages/symbol.html
@@ -33,7 +33,7 @@{% block main %}<div class="wrap">  <header class="sym-head{% if health %} sym-head--has-health{% endif %}">  <header class="sym-head{% if health or etf_quality %} sym-head--has-health{% endif %}">    <div class="sym-head__id">      <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1>      <span class="sym-head__tag">{{ symbol.kind }}</span>
@@ -107,6 +107,51 @@      </div>    </div>    {% endif %}    {# ETF quality badge (Phase 4): the fund-side sibling of the stock health       donut. Same glanceable % ring, but the four sub-readings are the wrapper's       quality — cost, tracking (price vs NAV), diversification, size. Reuses the       `health-badge` / `health-pop` styling; a symbol is either a stock or an       ETF, so only one of the two badges ever renders. #}    {% if etf_quality %}    <div class="sym-head__health sym-head__health--etf">      <button type="button" class="health-badge health-badge--{{ etf_quality.overall }}"              aria-label="ETF quality: {{ etf_quality.verdict }}, {{ etf_quality.percent }}%"              aria-describedby="etf-quality-pop">        <svg class="health-badge__ring" viewBox="0 0 36 36" aria-hidden="true">          <circle class="health-badge__track" cx="18" cy="18" r="15.9155" fill="none"/>          <circle class="health-badge__fill"  cx="18" cy="18" r="15.9155" fill="none"                  stroke-dasharray="{{ etf_quality.percent }}, 100"/>        </svg>        <span class="health-badge__num num">          <span class="health-badge__pct">{{ etf_quality.percent }}</span><span class="health-badge__unit">%</span>        </span>        <span class="health-badge__caption">{{ etf_quality.verdict }}</span>      </button>      <div class="health-pop" id="etf-quality-pop" role="tooltip">        <p class="health-pop__head">ETF quality <strong class="health-pop__verdict health-pop__verdict--{{ etf_quality.overall }}">{{ etf_quality.verdict }}</strong> &middot; <span class="num">{{ etf_quality.percent }}%</span></p>        <ul class="health-pop__rows">          <li class="health-pop__row health-pop__row--{{ etf_quality.cost }}">            <span class="health-pop__label">Cost</span>            <span class="health-pop__value">{{ etf_quality.cost_label }}</span>          </li>          <li class="health-pop__row health-pop__row--{{ etf_quality.tracking }}">            <span class="health-pop__label">Tracking</span>            <span class="health-pop__value">{{ etf_quality.tracking_label }}</span>          </li>          <li class="health-pop__row health-pop__row--{{ etf_quality.diversification }}">            <span class="health-pop__label">Diversification</span>            <span class="health-pop__value">{{ etf_quality.diversification_label }}</span>          </li>          <li class="health-pop__row health-pop__row--{{ etf_quality.size }}">            <span class="health-pop__label">Size</span>            <span class="health-pop__value">{{ etf_quality.size_label }}</span>          </li>        </ul>        <p class="health-pop__src">A read on the fund wrapper: cost, how tightly it tracks NAV, diversification, and size. For reading at a glance, not investment advice.</p>      </div>    </div>    {% endif %}  </header>  {# Chart and key stats need daily history. A future has none by design