@@ -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;
@@ -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()); }}
@@ -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' %}↑{% elif e.glyph == 'down' %}↓{% elif e.glyph == 'drawdown' %}↡{% elif e.glyph == 'fund-up' %}+{% elif e.glyph == 'fund-down' %}−{% else %}❖{% 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 — 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' %}