@@ -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;
@@ -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> · © {{ 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> · © {{ 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> · <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