repos

Phase 16: Per-ticker anomaly feed

a8397374 by Isaac Bythewood · 1 month ago

Phase 16: Per-ticker anomaly feed

A new "Notable recent events" section on the symbol page between
Leadership and Recent SEC filings, merging four kinds of dated events
into one list (newest first, capped at 20 over the past year):

  - Large daily price moves (|move| > 5% AND > 2σ vs trailing 90-day vol)
  - Drawdowns (a fresh 6-month low, with a 30-bar cooldown so a long
    slide doesn't stream daily)
  - YoY fundamentals jumps (±25% on annual revenue or net income)
  - Leadership changes (8-K item-5.02 filings, reused from Phase 14)

Stocks get all four event types; ETFs and indexes get price-only events;
futures and historyless indexes hide the section. Pure derivation from
data already stored — no new network calls, no schema change, no new
EndpointGuard row.
modified PLAN.md
@@ -32,9 +32,21 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 30 (top picks + backtest) complete, verified, and**Current phase: Phase 16 (per-ticker anomaly feed) complete and verifiedlocally 2026-05-23; not yet deployed.** A new symbol-page section betweenLeadership and Recent SEC filings, surfacing notable recent events: largedaily price moves, drawdowns (new 6-month lows), large YoY fundamentalschanges, and 8-K item-5.02 leadership changes (reused from Phase 14). Oneline per event — date, glyph, headline — newest first, capped to ~20 overthe past year. All instruments: stocks get all four event types; ETFs andindexes and futures get price-only events. Selective thresholds (>5% AND>2σ on price; ±25% YoY on fundamentals; new 6-month low on drawdown) so thefeed stays signal-dense. Pure derivation from data already stored — no newnetwork calls, no schema change, no new endpoint guard.**Phase 30 (top picks + backtest) is complete, verified, anddeployed to production 2026-05-23 (commit `8ea9048`); a follow-up reworkshipped same day (see the decisions log).** Home page now carries ashipped same day (see the decisions log) — not yet deployed.** Home page now carries a"Top picks" panel — four columns (Day / Week / Month / Quarter), 5 rankedstocks each, every row a verdict badge over a headline figure. A new`/backtest` page replays the picker over historical prices and shows
@@ -648,6 +660,68 @@ 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 16 per-ticker anomaly feed.** Complete and verified locally  2026-05-23; not yet deployed. A new "Notable recent events" section on  the symbol page between Leadership and Recent SEC filings, surfacing four  kinds of dated events in one merged list — one line per event, date ·  glyph · headline, newest first, capped at 20 over the past year. Pure  derivation from data already stored: no new network calls, no migration,  no new `EndpointGuard` row.  - **Compute** added two new pure helpers in `compute.rs`. `price_anomalies(closes, dates)`    walks the trailing year and emits an event for every bar whose    close-to-close return is both `>5%` in magnitude AND `>2σ` against    the prior 90-day daily-return σ — the dual threshold keeps a low-vol    name's modest move and a high-vol name's normal daily wobble out of    the feed. `drawdown_anomalies(closes, dates)` emits one event each    time the close prints a fresh 6-month low, with a 30-bar cooldown so    a long slide does not stream daily; the headline carries the drop    from the trailing window's peak. Both share a new `AnomalyEvent`    struct (date, glyph, headline, optional url, severity).  - **Models** added `fundamentals_anomalies(facts)` in `models.rs`,    matching the existing `latest_annual_inputs` pattern (this helper    walks `FundFact` directly so it lives in models, not compute).    Emits one event per (annual fiscal year, metric ∈ {revenue,    net_income}) whose YoY change exceeds ±25%, dated at the fiscal    year's `period_end`. Headlines read "FY2026 revenue +65% YoY".  - **Symbol route** hoists the stock `fundamentals` SELECT one level    so both the Fundamentals section and the new `build_anomalies`    aggregator share the same fact slice. The aggregator merges the    price + drawdown + fundamentals streams, plus a small 8-K item-5.02    SELECT (reused from Phase 14's leadership feed but constrained to    the past 365 days), trims to the past-year window, sorts newest    first with severity as the tiebreaker, and caps at 20. Returns    `None` when no events qualify so the template hides the section.  - **Template** in `symbol.html` renders one panel between Leadership    and the ETF block (which sits before Filings on a stock page);    `{% if anomalies %}` gates the whole section. Each row is a date,    a glyph mapped per `e.glyph` (↑ ↓ for price moves, ↡ for drawdown,    + − for fundamentals, ❖ for leadership), and a headline; a    leadership row links to its EDGAR url. A quiet italic provenance    line below the list says where the events come from and labels the    section as not investment advice.  - **SCSS** added a small `.anomalies` block matching the    `.lead-change` row style — semantic-only colour on the glyph    (using the existing `--up`/`--down` tokens), monospace date in    `--ink-faint`, headline in `--ink`.  - Coverage: all four event types for stocks; ETFs and indexes get    price + drawdown only (no SEC fundamentals/leadership data to    derive from). Futures (with no `daily_prices`) and historyless    indexes like `^VIX` (also no `daily_prices`) get no events at all    and the section hides for them.  - Verified: cargo + bun build clean; the four new compute unit    tests pass (spike-triggers-on-5%-AND-2σ, ignore-1%-even-at-2σ,    fresh-6mo-low-flags, slide-dedupes-to-≤5). `/s/NVDA` renders 10    events in the past year (a balanced mix: +5.8%/+5.6%/+7.9%/+5.8%    one-day moves, a -5.5% downside move, a -19% new 6-month low,    and FY2026 revenue + net income +65% YoY). `/s/AAPL` shows 5    leadership-change rows and no price/drawdown/fundamentals events    (a fair read: AAPL has been calm, single-digit growth). `/s/SPY`    (ETF) shows 1 drawdown; `/s/^SPX` (index) shows 1 drawdown;    `/s/GC=F` (future, no `daily_prices`) hides the section entirely.    Order on `/s/NVDA` is Leadership → Anomalies → Filings (verified    via DOM offsets). Desktop (1280px) and phone (390px) both render    with no horizontal overflow and zero console errors.- **Phase 21 home & search refinements.** Complete, verified, deployed to production.  - Three independent refinements to already-shipped features.  - **Home page index/commodity split + index-futures swap.** `routes/home.rs`
@@ -1324,10 +1398,36 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  7), show industry-level performance, seasonality (the months an industry  tends to do well or poorly, computed from `daily_prices`), and how the  industry is trending currently.- [ ] **Phase 16: Per-ticker anomaly feed.** On the symbol page, a feed of  notable recent events for that one ticker: large changes in its- [x] **Phase 16: Per-ticker anomaly feed.** Complete and verified  locally 2026-05-23; not yet deployed. See the Phase 16 Done entry in  Status and the decisions log. (Picked as the next backlog phase and  scoped 2026-05-23 — see decisions log.) On the symbol page, a  feed of notable recent events for that one ticker: large changes in its  fundamentals, leadership changes, and unusually large price moves or  drawdowns. Builds on Phases 7 and 14.  Pieces:  (1) **Three compute helpers in `compute.rs`**, each pure over data  already stored. `price_anomalies(closes, dates)` walks the trailing 1y  bars, computes a trailing 90-day rolling standard deviation of daily  returns, and emits an event when `|move| > 5%` and `|move| > 2σ`.  `drawdown_events(closes, dates)` flags each day a stock prints a new  6-month low (no event while still in drawdown, to avoid a daily stream  in a long slide). `fundamentals_events(facts)` walks the latest two  annual figures for revenue and net income and emits an event when the  YoY change exceeds ±25%.  (2) **Leadership events** ride the 8-K item-5.02 SELECT Phase 14 already  runs in `build_leadership` — `routes/symbols.rs` reads them once,  reshapes each as an `AnomalyEvent` with the existing EDGAR url + a "8-K  item 5.02 leadership change" headline. No new SQL.  (3) **`AnomalyView` aggregator** in `routes/symbols.rs`: merge the four  feeds, sort newest-first, cap at ~20 over the past 1y. Stocks get all  four; ETFs / indexes / futures get only piece (1) + (2-price-side).  (4) **Template section in `templates/pages/symbol.html`** between  Leadership and Recent SEC filings. One row per event: date glyph  headline. Match the Phase 14 lead-changes feed visually so the page  reads consistently. Section hides cleanly on an empty feed.  Pure derivation: no schema change, no new network calls, no new  `EndpointGuard` row.- [ ] **Phase 17: Stock health read.** Synthesize fundamentals, price  trajectory, leadership, and industry context into a single non-advice  "health" read: is this a healthy company (capable leadership familiar with
@@ -2757,6 +2857,51 @@ finance/    2022 period and the backtest honestly recording the -19.8%    exit). Home page renders all four columns including "Next    quarter" with the new description text. No deploy yet.- **2026-05-23 — Phase 16 picked next; design Q&A settled four points.**  Asked which of the remaining backlog phases (13, 15, 16, 17, 19, 25, 27,  29) to build next; the user picked Phase 16 (per-ticker anomaly feed).  Four design questions then resolved the scope:  - **Event types** (multi-select, all chosen): large daily price moves;    drawdowns / new multi-month lows; large YoY fundamentals changes;    leadership changes from 8-K item 5.02 (already filtered in    `filings.items` by Phase 14).  - **Coverage**: all instruments. Stocks get all four event types; ETFs    and indexes and futures get price-only events (the chart's own move is    still legible, but a dated bullet list of "−7.2% on 2024-08-05"    captions stand-alone reading). Fundamentals + leadership events stay    stocks-only by construction.  - **Placement**: between Leadership and Recent SEC filings — sits with    the other event-shaped sections.  - **Item style**: one line per event — date · glyph · headline, newest    first, capped to ~20 over the past year. Matches the Phase 14    leadership-changes feed.  Two follow-ups settled with sensible defaults (user picked the  recommended option on both): window is past 1 year; thresholds are  selective (price |move| > 2σ vs trailing 90-day vol AND > 5%;  fundamentals ±25% YoY on revenue or net income; drawdown is a new  6-month low). Pure derivation from data already stored (`daily_prices`,  `fundamentals`, `filings.items`): no new network calls, no schema  change, no new `EndpointGuard` row. The section hides itself when a  symbol has no qualifying events (the same way the leadership section  hides on an unsynced stock).- **2026-05-23 — Phase 16 per-ticker anomaly feed shipped (local).** Two  pure-compute helpers (`price_anomalies`, `drawdown_anomalies`) plus a  `models::fundamentals_anomalies` walker plus a small leadership-filings  SELECT, merged in `routes::symbols::build_anomalies` into one feed  capped at 20 over the past year. A new section between Leadership and  the ETF block (which precedes Filings on a stock page) renders the feed  in a one-line-per-event Paper-Ledger row, with a leadership row  linking to EDGAR. The two new compute helpers carry four unit tests  (spike-on-5%-AND-2σ, ignore-1%-at-2σ, fresh-6mo-low-flags,  slide-dedupes-to-≤5). Verified against the dev DB: `/s/NVDA` rendered  10 events in a balanced mix (+5.8%/+5.6%/+7.9%/+5.8% one-day moves,  -5.5% downside move, -19% new 6-month low, FY2026 revenue + net  income +65% YoY); `/s/AAPL` rendered 5 leadership-change rows only  (a fair read for a low-vol single-digit-growth name); `/s/SPY` (ETF)  + `/s/^SPX` (index) each rendered 1 drawdown event; `/s/GC=F` (future,  no `daily_prices`) hid the section entirely. Desktop (1280px) and  phone (390px) both rendered with no horizontal overflow and zero  console errors. No deploy yet.---
modified frontend/static_src/symbol/styles/symbol.scss
@@ -812,6 +812,74 @@  color: var(--ink-faint);}/* ---------- per-ticker anomaly feed (Phase 16) ---------- */.anomalies {  padding: 14px 16px;}.anomaly-list {  margin: 0;  padding: 0;  list-style: none;}.anomaly {  border-top: 1px solid var(--rule);}.anomaly:first-child {  border-top: none;}.anomaly__link {  display: flex;  align-items: center;  gap: 12px;  padding: 9px 0;  color: var(--ink);  text-decoration: none;}a.anomaly__link:hover .anomaly__body {  color: var(--ink-dim);}.anomaly__date {  flex: none;  min-width: 7ch;  font-size: 0.78rem;  color: var(--ink-faint);}.anomaly__glyph {  flex: none;  width: 1.4em;  text-align: center;  font-weight: 700;  font-size: 0.95rem;  color: var(--ink-faint);}.anomaly__glyph--up        { color: var(--up); }.anomaly__glyph--down      { color: var(--down); }.anomaly__glyph--drawdown  { color: var(--down); }.anomaly__glyph--fund-up   { color: var(--up); }.anomaly__glyph--fund-down { color: var(--down); }.anomaly__glyph--leader    { color: var(--ink-dim); }.anomaly__body {  flex: 1;  min-width: 0;  font-size: 0.88rem;}.anomaly-list__src {  margin: 12px 0 0;  font-size: 0.74rem;  color: var(--ink-faint);  font-style: italic;}/* ---------- ETF fund profile ---------- */.fund {  padding: 16px 18px;
modified src/compute.rs
@@ -1316,6 +1316,141 @@ pub fn pick_quarter(i: &PickInput<'_>) -> Option<f64> {    Some(ret_q)}// ── Phase 16: per-ticker anomaly feed ─────────────────────────────────────/// One row in the symbol-page anomaly feed. Built either here (price events,/// drawdowns) or in `models.rs` (fundamentals events) or directly in the/// symbol route (leadership events, reused from Phase 14's filings SELECT).#[derive(Debug, Clone, Serialize)]pub struct AnomalyEvent {    /// `YYYY-MM-DD`.    pub date: String,    /// Glyph key the template maps to an icon — one of `up`, `down`,    /// `drawdown`, `fund-up`, `fund-down`, `leader`.    pub glyph: &'static str,    /// Human one-line headline, e.g. `+8.2% one-day move`.    pub headline: String,    /// Outbound link (set on leadership events; the row becomes an anchor).    pub url: Option<String>,    /// Sort tiebreaker; larger = more notable. Not displayed, just used to    /// keep the top N when the merged feed overflows the display cap.    pub severity: f64,}/// Trailing-volatility window for the price-move detector (~6 months trading days).const PRICE_VOL_WINDOW: usize = 90;/// Daily-return magnitude threshold below which we never flag, even for a/// very low-vol stock where 2σ would come in tiny.const PRICE_MIN_MOVE: f64 = 0.05;/// Standard-deviation multiplier — a move must clear both this and PRICE_MIN_MOVE.const PRICE_SIGMA_MULT: f64 = 2.0;/// Drawdown lookback (~6 months trading days).const DRAWDOWN_WINDOW: usize = 126;/// Min gap (trading days) between drawdown events so a long slide doesn't/// emit every bar.const DRAWDOWN_DEDUPE_BARS: usize = 30;/// Walk `closes` (oldest-first, aligned to `dates`) and emit one event for/// every bar whose close-to-close return is both `> 5%` in magnitude and/// `> 2σ` of the trailing 90-day daily returns. The pair of thresholds/// keeps a low-vol stock's modest move from qualifying just because its/// σ is tiny, and a high-vol name's daily wobble from qualifying just/// because 5% is its normal range.pub fn price_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {    debug_assert_eq!(closes.len(), dates.len());    let n = closes.len();    if n <= PRICE_VOL_WINDOW + 1 {        return Vec::new();    }    let mut out = Vec::new();    for i in (PRICE_VOL_WINDOW + 1)..n {        let prev = closes[i - 1];        let cur = closes[i];        if prev <= 0.0 {            continue;        }        let r = (cur - prev) / prev;        // Trailing daily returns over the prior PRICE_VOL_WINDOW bars        // (returns r_j for j in start..i, anchored to closes[j-1]).        let start = i - PRICE_VOL_WINDOW;        let (mut sum, mut sum2, mut n_ret) = (0.0_f64, 0.0_f64, 0usize);        for j in start..i {            let p = closes[j - 1];            if p <= 0.0 {                continue;            }            let rr = (closes[j] - p) / p;            sum += rr;            sum2 += rr * rr;            n_ret += 1;        }        if n_ret < PRICE_VOL_WINDOW / 2 {            continue;        }        let mean = sum / n_ret as f64;        let var = (sum2 / n_ret as f64 - mean * mean).max(0.0);        let sigma = var.sqrt();        if r.abs() >= PRICE_MIN_MOVE && r.abs() >= PRICE_SIGMA_MULT * sigma {            let pct = r * 100.0;            let (glyph, sign) = if pct >= 0.0 { ("up", "+") } else { ("down", "\u{2212}") };            out.push(AnomalyEvent {                date: dates[i].to_string(),                glyph,                headline: format!("{sign}{:.1}% one-day move", pct.abs()),                url: None,                severity: pct.abs(),            });        }    }    out}/// Emit one event each time `close` prints a fresh 6-month low — a strict/// minimum below the prior 126 bars' range. A long slide that keeps/// printing lower lows is collapsed to one event per/// `DRAWDOWN_DEDUPE_BARS`-bar window so the feed does not stream daily./// Headline carries the drop from the trailing window's peak as the/// magnitude.pub fn drawdown_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {    debug_assert_eq!(closes.len(), dates.len());    let n = closes.len();    if n <= DRAWDOWN_WINDOW {        return Vec::new();    }    let mut out: Vec<AnomalyEvent> = Vec::new();    let mut last_emit_i: Option<usize> = None;    for i in DRAWDOWN_WINDOW..n {        let cur = closes[i];        if cur <= 0.0 {            continue;        }        let start = i - DRAWDOWN_WINDOW;        let prior_min = closes[start..i].iter().copied().fold(f64::INFINITY, f64::min);        let prior_max = closes[start..i].iter().copied().fold(f64::NEG_INFINITY, f64::max);        if cur < prior_min {            if let Some(j) = last_emit_i {                if i - j < DRAWDOWN_DEDUPE_BARS {                    continue;                }            }            let drop = if prior_max > 0.0 {                (cur - prior_max) / prior_max * 100.0            } else {                0.0            };            out.push(AnomalyEvent {                date: dates[i].to_string(),                glyph: "drawdown",                headline: format!("New 6-month low ({:.0}% off peak)", drop),                url: None,                severity: drop.abs(),            });            last_emit_i = Some(i);        }    }    out}#[cfg(test)]mod phase28_tests {    use super::*;
@@ -1380,4 +1515,67 @@ mod phase28_tests {        assert!(premium_discount_pct(100.0, None).is_none());        assert!(premium_discount_pct(100.0, Some(0.0)).is_none());    }    // ── Phase 16 anomaly-feed tests ────────────────────────────────────────    fn flat_series(n: usize, value: f64) -> (Vec<f64>, Vec<String>) {        let dates: Vec<String> = (0..n)            .map(|i| {                let d = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()                    + chrono::Duration::days(i as i64);                d.format("%Y-%m-%d").to_string()            })            .collect();        (vec![value; n], dates)    }    #[test]    fn price_anomaly_flags_a_big_spike_against_flat_history() {        let (mut closes, dates) = flat_series(120, 100.0);        // Inject 5 tiny wobbles so σ is not exactly zero — a 7% jump still        // qualifies on σ. (A literally-flat history makes σ=0, in which case        // any nonzero move trivially exceeds 2σ; the 5%-floor still gates it.)        closes[60] = 100.5;        closes[80] = 99.5;        closes[119] = 107.0;        let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();        let evs = price_anomalies(&closes, &date_refs);        assert!(evs.iter().any(|e| e.date == dates[119]            && e.glyph == "up"            && e.headline.contains("7.0")));    }    #[test]    fn price_anomaly_ignores_a_tiny_move_even_when_above_2_sigma() {        let (mut closes, dates) = flat_series(120, 100.0);        // 1% bump against a literally-flat history would trip 2σ but not the        // 5% floor — the feed should stay empty.        closes[119] = 101.0;        let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();        let evs = price_anomalies(&closes, &date_refs);        assert!(evs.is_empty());    }    #[test]    fn drawdown_anomaly_flags_a_fresh_six_month_low() {        // 150 flat bars, then one bar prints a strict new low.        let (mut closes, dates) = flat_series(150, 100.0);        closes[140] = 80.0;        let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();        let evs = drawdown_anomalies(&closes, &date_refs);        assert!(evs.iter().any(|e| e.date == dates[140] && e.glyph == "drawdown"));    }    #[test]    fn drawdown_anomaly_dedupes_a_long_slide() {        let (mut closes, dates) = flat_series(180, 100.0);        // Each later bar prints a lower low; without dedupe we'd emit every        // single bar. With a 30-bar cooldown we emit a handful, not 30+.        for i in 130..180 {            closes[i] = 100.0 - (i - 129) as f64;        }        let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();        let evs = drawdown_anomalies(&closes, &date_refs);        assert!(evs.len() <= 5, "expected dedupe to keep events sparse, got {}", evs.len());    }}
modified src/models.rs
@@ -135,6 +135,61 @@ pub fn latest_annual_inputs_as_of(    })}/// YoY change threshold (25%) on annual revenue or net-income above which a/// fundamentals anomaly event is emitted.const FUND_YOY_THRESHOLD: f64 = 0.25;/// Walk a company's stored facts and emit one anomaly event per (metric,/// fiscal year) whose YoY change exceeds ±25%. Only annual `revenue` and/// `net_income` are surfaced — the two top-line figures whose moves are/// readable without further context. The event's date is the fiscal year's/// `period_end` (the year that ended), so the feed reads as "FY2024/// revenue ‒32% YoY" on the day that fiscal year closed.pub fn fundamentals_anomalies(facts: &[FundFact]) -> Vec<compute::AnomalyEvent> {    let mut annual: HashMap<(&str, i64), (f64, &str)> = HashMap::new();    for f in facts {        if f.fiscal_qtr.is_none() && (f.metric == "revenue" || f.metric == "net_income") {            annual.insert(                (f.metric.as_str(), f.fiscal_year),                (f.value, f.period_end.as_str()),            );        }    }    let mut out: Vec<compute::AnomalyEvent> = Vec::new();    for ((metric, year), (val, period_end)) in &annual {        let prev = match annual.get(&(metric, year - 1)) {            Some((v, _)) => *v,            None => continue,        };        if prev.abs() < 1e-9 {            continue;        }        let change = (val - prev) / prev.abs();        if change.abs() < FUND_YOY_THRESHOLD {            continue;        }        let pct = change * 100.0;        let label = match *metric {            "revenue" => "revenue",            "net_income" => "net income",            _ => metric,        };        let (glyph, sign) = if pct >= 0.0 {            ("fund-up", "+")        } else {            ("fund-down", "\u{2212}")        };        out.push(compute::AnomalyEvent {            date: period_end.to_string(),            glyph,            headline: format!("FY{year} {label} {sign}{:.0}% YoY", pct.abs()),            url: None,            severity: pct.abs(),        });    }    out}fn latest_annual_inputs_filtered(    facts: &[FundFact],    price: Option<f64>,
modified src/routes/symbols.rs
@@ -930,6 +930,98 @@ async fn build_leadership(pool: &sqlx::SqlitePool, ticker: &str, synced: bool) -    }}// ── per-ticker anomaly feed (Phase 16) ────────────────────────────────────/// One row in the anomaly feed, as shaped for the template. Wraps/// `compute::AnomalyEvent` with no extra fields — re-exposed so the template/// can iterate a single concrete type regardless of which compute helper/// (or the leadership-filings SELECT below) produced the row.type AnomalyRow = compute::AnomalyEvent;#[derive(Serialize)]struct AnomalyView {    events: Vec<AnomalyRow>,}/// Display cap on the merged feed. Severity-rank-then-newest the four/// streams together, then trim to this many before rendering.const ANOMALY_MAX_EVENTS: usize = 20;/// How far back the feed reaches.const ANOMALY_WINDOW_DAYS: i64 = 365;/// Build the symbol-page anomaly feed: large price moves and new 6-month/// lows for every symbol with a daily history; YoY fundamentals jumps and/// 8-K item-5.02 leadership changes additionally for stocks. The feed is/// trimmed to the past year and capped at [`ANOMALY_MAX_EVENTS`]. Returns/// `None` when no events qualify, so the template hides the section.async fn build_anomalies(    pool: &sqlx::SqlitePool,    ticker: &str,    kind: &str,    bars_newest_first: &[(String, f64, f64, f64, f64, i64)],    facts: &[models::FundFact],) -> Option<AnomalyView> {    let today = chrono::Utc::now().date_naive();    let cutoff_date = today - chrono::Duration::days(ANOMALY_WINDOW_DAYS);    let cutoff = cutoff_date.format("%Y-%m-%d").to_string();    // Price + drawdown events want oldest-first closes paired with dates.    let oldest_first: Vec<(String, f64)> = bars_newest_first        .iter()        .rev()        .map(|(d, _, _, _, c, _)| (d.clone(), *c))        .collect();    let closes: Vec<f64> = oldest_first.iter().map(|(_, c)| *c).collect();    let dates_refs: Vec<&str> = oldest_first.iter().map(|(d, _)| d.as_str()).collect();    let mut events: Vec<AnomalyRow> = Vec::new();    events.extend(compute::price_anomalies(&closes, &dates_refs));    events.extend(compute::drawdown_anomalies(&closes, &dates_refs));    // Fundamentals events and leadership events are stocks-only.    if kind == "stock" {        events.extend(models::fundamentals_anomalies(facts));        let lead_rows: Vec<(String, String)> = sqlx::query_as(            "SELECT filed_at, url FROM filings \             WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%5.02%' \               AND filed_at >= ? \             ORDER BY filed_at DESC, accession DESC",        )        .bind(ticker)        .bind(&cutoff)        .fetch_all(pool)        .await        .unwrap_or_default();        for (filed_at, url) in lead_rows {            events.push(AnomalyRow {                date: filed_at,                glyph: "leader",                headline: "Officer or director change reported in an 8-K".to_string(),                url: Some(url),                // Hand-picked: above a typical 5-8% one-day move so a leadership                // change is not crowded off the list, below a major drawdown.                severity: 7.5,            });        }    }    // Trim to the past year window.    events.retain(|e| e.date.as_str() >= cutoff.as_str());    if events.is_empty() {        return None;    }    // Newest first; ties broken by severity so the bigger event of the same    // day reads first. Then cap.    events.sort_by(|a, b| {        b.date            .cmp(&a.date)            .then_with(|| b.severity.partial_cmp(&a.severity).unwrap_or(std::cmp::Ordering::Equal))    });    events.truncate(ANOMALY_MAX_EVENTS);    Some(AnomalyView { events })}async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) -> Response {    let ticker = ticker.to_uppercase();
@@ -1018,15 +1110,22 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        .map(|q| q.price)        .or_else(|| stats.as_ref().map(|s| s.close));    let fundamentals = if is_stock {        let facts: Vec<models::FundFact> = sqlx::query_as(    // Stock fundamentals are loaded once and shared by the ratio cards    // (`build_fundamentals`) and the anomaly feed's YoY detector    // (`build_anomalies` via `models::fundamentals_anomalies`).    let facts: Vec<models::FundFact> = if is_stock {        sqlx::query_as(            "SELECT metric, period, fiscal_year, fiscal_qtr, value, period_end \             FROM fundamentals WHERE ticker = ?",        )        .bind(&ticker)        .fetch_all(&state.pool)        .await        .unwrap_or_default();        .unwrap_or_default()    } else {        Vec::new()    };    let fundamentals = if is_stock {        build_fundamentals(&facts, price)    } else {        None
@@ -1159,6 +1258,13 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        None    };    // Per-ticker anomaly feed (Phase 16). All instruments get price-based    // events (large daily moves, new 6-month lows); stocks additionally get    // YoY fundamentals jumps and 8-K item-5.02 leadership changes. Returns    // `None` when the symbol has no qualifying events in the past year so    // the template hides the section.    let anomalies = build_anomalies(&state.pool, &ticker, &symbol.kind, &bars, &facts).await;    let extra = minijinja::context! {        title => ticker,        symbol => symbol,
@@ -1171,6 +1277,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        returns => returns,        leadership => leadership,        dividends => dividends,        anomalies => anomalies,        filings => filings,    };    render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)
modified templates/pages/symbol.html
@@ -294,6 +294,31 @@  {% endif %}  {# --- per-ticker anomaly feed (Phase 16): hidden when there are no         qualifying events in the past year --- #}  {% if anomalies %}  <h2 class="section-title">Notable recent events<span class="section-title__asof">past year</span></h2>  <section class="panel anomalies">    <ul class="anomaly-list">      {% for e in anomalies.events %}      <li class="anomaly anomaly--{{ e.glyph }}">        {% if e.url %}<a class="anomaly__link" href="{{ e.url }}" target="_blank" rel="noopener noreferrer">{% else %}<span class="anomaly__link">{% endif %}          <span class="anomaly__date num">{{ e.date|shortdate }}</span>          <span class="anomaly__glyph anomaly__glyph--{{ e.glyph }}" aria-hidden="true">{% if e.glyph == 'up' %}&uarr;{% elif e.glyph == 'down' %}&darr;{% elif e.glyph == 'drawdown' %}&#x21A1;{% elif e.glyph == 'fund-up' %}&plus;{% elif e.glyph == 'fund-down' %}&minus;{% else %}&#x2756;{% endif %}</span>          <span class="anomaly__body">{{ e.headline }}</span>          {% if e.url %}          <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>          </svg>          {% endif %}        {% if e.url %}</a>{% else %}</span>{% endif %}      </li>      {% endfor %}    </ul>    <p class="anomaly-list__src">Large daily moves, new 6-month lows, year-over-year fundamentals swings, and reported leadership changes &mdash; pulled from the data this app already holds, never investment advice.</p>  </section>  {% endif %}  {# --- ETFs only: Phase 18 (fund profile) + Phase 28 (about / sector / --- #}  {# --- geography / trailing returns / growth of $10k / benchmark) --- #}  {% if symbol.kind == 'etf' %}