repos

Phase 30 rework: year horizon -> quarter, out-of-sample backtest

aae3a3c9 by Isaac Bythewood · 1 month ago

Phase 30 rework: year horizon -> quarter, out-of-sample backtest

The year horizon's score was a pass-through of today's standing and
`HistBundle` carried a precomputed standing built from today's
fundamentals + today's full closes, so every historical rebalance was
fed the same input and produced the same top 5 year over year (e.g. MU
in 2022 despite a flat-downward trajectory).

Replaces `pick_year` with `pick_quarter` (trailing 60-day momentum on
the same shape as `pick_month`) and computes the standing per
rebalance from closes sliced to the as-of date and the latest annual
whose period_end is at least 90 days before it (`FILING_LAG_DAYS`).
`FundFact` now carries `period_end`; four SELECT sites updated to
fetch it. Migration 0010 clears any stale `year`-keyed picks rows.

See the 2026-05-23 rework entry in the PLAN decisions log.
modified PLAN.md
@@ -33,8 +33,9 @@ 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, anddeployed to production 2026-05-23 (commit `8ea9048`).** Home page now carries a"Top picks" panel — four columns (Day / Week / Month / Year), 5 rankeddeployed to production 2026-05-23 (commit `8ea9048`); a follow-up reworkshipped same day (see the decisions log).** 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 showsstrategy vs `^SPX` equity, total return, CAGR, per-pick and
@@ -44,10 +45,14 @@ horizons (the user's design call); both win-rate definitions shownside-by-side; one chart with horizon tabs (vs four side-by-side).Migration `0009` adds the `picks` table; the scheduler's new `picks`section snapshots forward each day right after `daily_close`. Thebacktest uses today's standing applied historically (acknowledgedlook-ahead bias, surfaced in the page disclaimer) — for fun andtesting, not an out-of-sample evaluation; the user is explicit thisis for fun, not financial advice. **Phase 28 (ETFs as first-classbacktest is genuinely out-of-sample: at each rebalance the pickergrades a stock only against fundamentals that would actually havebeen filed by then (latest annual whose period_end is at least 90days before the rebalance — `models::FILING_LAG_DAYS`) and onlyagainst closes up to that date — so a stock strong today but weakin 2022 will grade weak in a 2022 rebalance. Migration `0010` clearedstale `year`-horizon snapshot rows during the rework. The user isexplicit this is for fun, not financial advice. **Phase 28 (ETFs as first-classcitizens) is complete, verified, and deployed to production (commit`2ae81d5`).** The new fund_metadata + sector/geography + ETF-distributionsdata populates async via the scheduler's first sec / fund_metadata /
@@ -874,9 +879,12 @@ schema, unused for now.    Feb 2005 first stored bar) +692.8% / +10.24%/yr.- **Phase 30 top picks + backtest.** Complete, verified, and deployed  to production 2026-05-23 (commit `8ea9048`). A home-page "Top picks" panel of 5  forecast-horizon picks per horizon (Day / Week / Month / Year), and a  new `/backtest` page that replays the picker over historical prices.  to production 2026-05-23 (commit `8ea9048`); reworked same day to  replace the `year` horizon with `quarter` and to compute per-  rebalance historical standings (see the decisions log). A home-page  "Top picks" panel of 5 forecast-horizon picks per horizon  (Day / Week / Month / Quarter), and a new `/backtest` page that  replays the picker over historical prices.  Stocks-only across all four horizons per the user's design call; one  chart with horizon tabs on the backtest page; both per-pick and  per-period win rates surfaced. For fun and testing — explicitly not
@@ -891,8 +899,10 @@ schema, unused for now.      `[30, 70]` and the close being above SMA50; same `Weak` filter.    - **Month:** trailing 20-day % return, gated on the close being      above SMA200; same `Weak` filter.    - **Year:** the Phase 20 combined fundamentals + trajectory score,      pass-through — the right answer for the year horizon already.    - **Quarter:** trailing ~63-day (one earnings cycle) % return,      gated on the close being above SMA200; same `Weak` filter.      (Originally **Year**, a pass-through of the Phase 20 standing —      replaced same day; see the decisions log.)  - **Picks module (`src/picks.rs`)** glues the rankers to the DB:    `HORIZONS` const, `compute_picks(bundles)` runs each ranker against    every stock and returns one `PickSlate` (the 5 top per horizon),
@@ -921,7 +931,7 @@ schema, unused for now.    `frontend/static_src/backtest/`): one page, four horizon tabs that    swap content in place via `GET /api/backtest?horizon=…`. The JSON    feed runs `picks::run_backtest`, which walks back from today by    horizon stride (1/5/20/252 trading days), at each rebalance picks    horizon stride (1/5/20/63 trading days), at each rebalance picks    the top 5 with the same rankers, equal-weights them for one stride,    rebalances at the next stride, and tracks both the strategy's and    `^SPX`'s equity from a $10k anchor. Renders an equity curve area
@@ -930,11 +940,14 @@ schema, unused for now.    per-pick win rate, per-period win rate), and a per-rebalance    history table with each period's picks color-tinted by their own    return.  - **Acknowledged look-ahead.** The backtest uses today's standing    (Phase 7 fundamentals are not stored per period), so a stock that    is `Strong` today filters into every historical pick. Surfaced    explicitly in the page disclaimer; v1 trade-off, fits the user's    "for fun and testing" framing.  - **Out-of-sample standings.** At each rebalance date the backtest    grades a stock against the latest annual whose `period_end + 90    days ≤ rebalance` (a conservative SEC filing-lag cushion —    `models::FILING_LAG_DAYS`) and against closes sliced to that date.    `HistBundle` carries raw `FundFact`s; `rank_at` calls    `models::latest_annual_inputs_as_of` then `compute::standing` per    rebalance, so a stock weak in 2022 grades weak in a 2022    rebalance and the year-over-year picks genuinely diverge.  - **Disclaimer style** in `base.scss`: a quiet ink-faint italic line,    smaller than body text, never carries semantic color. Used on both    the home Top picks panel and the backtest page header.
@@ -944,17 +957,19 @@ schema, unused for now.    runs ~1-2s per horizon, warm ~100ms.  - Verified: cargo + bun build clean; `/` renders the four-column    Top picks section on desktop (4-wide) and phone (2x2), every row    carrying a verdict badge over a percent return (or the year score);    `/backtest` loads the Month horizon by default with 49 rebalances    over 4 years (`2022-05-25 → 2026-04-23`), the equity curve drawing    the strategy and `^SPX` from $10k. Headline figures across all    four horizons in local replay: day 250 rebalances / +76.8% vs ^SPX    +27.2% / 54.1% per-pick win / 57.2% beat-bench; week 99 / +59.7%    vs +41.3% / 52.7% / 46.5%; month 49 / +595.8% vs +78.7% / 58.2%    / 61.2%; year 3 / +142.1% vs +47.6% / 66.7% / 100.0%. Phone view    stacks the stat cards 2x2 and the rebalance table scrolls inside    its card. Tabs swap horizons in place without a navigation. Zero    console errors.    carrying a verdict badge over a percent return; `/backtest` loads    the Month horizon by default with the equity curve drawing the    strategy and `^SPX` from $10k. After the same-day rework the    Quarter horizon's local replay (2021-08 → 2026-02, 19 rebalances)    surfaced genuinely varying picks per period: 2021 H2 was tech    (NET, DDOG, NVDA, AMD, MRVL); 2022 H1 rotated to defensives    (CVX, LMT, PM, MO); MU appeared in exactly one 2022 period (where    its trailing-60d momentum and as-of fundamentals warranted entry)    and the backtest honestly recorded the -19.8% exit — a stark    contrast to the pre-rework behaviour where MU was top-5 in every    year by construction. Phone view stacks the stat cards 2x2 and    the rebalance table scrolls inside its card. Tabs swap horizons    in place without a navigation. Zero console errors.- **Phase 26 dividend payouts.** Complete, verified, and deployed to  production (commit `7608b06`). Per-payout dividend history sourced from Yahoo's chart endpoint
@@ -2697,6 +2712,51 @@ finance/  refresh (~144 calls)" are now historical — a full refresh today  spans multiple hours by design; the seed and incremental jobs are  built to stop and resume against the guard.- **2026-05-23 — Phase 30 rework: year horizon → quarter, and a true  out-of-sample backtest.** The user flagged that the Day/Week/Month  tabs varied year-over-year but the Year tab showed the same top-5  in every period — MU in 2022 despite a flat-downward trajectory.  Diagnosed: `pick_year` was a pass-through of today's `Standing`,  and `HistBundle::standing` was computed once from today's  fundamentals and today's full close history, then reused at every  historical rebalance. So the year horizon was constructed to be  identical year-over-year; the short rankers also carried a softer  today-bias via their `Bad`-grade filter (a stock weak now never got  picked historically). Two fixes shipped together:  - **Year → Quarter.** `pick_year` dropped; new `pick_quarter`    mirrors `pick_month`'s shape on a 63-bar window (one earnings    cycle), gated on close > SMA200, same `Weak` filter. A year    forecast that is honestly "today's standing rank" was not pulling    its weight; quarter sits cleanly between month and "no horizon"    and produces a real momentum read. `HORIZONS`, `stride_for`,    `max_bars_for`, and the match arms in `compute_picks` / `rank_at`    all updated. Migration `0010_quarter_horizon.sql` deletes any    stale `year`-keyed rows in the `picks` snapshot table; the next    `daily_close` writes a fresh `quarter` snapshot.  - **Per-rebalance standings.** `models::FundFact` now carries    `period_end`; new `models::latest_annual_inputs_as_of(facts,    price, as_of)` picks the latest annual whose `period_end +    FILING_LAG_DAYS (90) ≤ as_of` — a conservative SEC 10-K filing-    lag cushion (10-Ks are due 60–90 days after fiscal year end    depending on filer category), so the backtest never grades a    stock with figures that did not yet exist on its as-of date.    `HistBundle` carries raw `FundFact`s (not a precomputed    `Standing`); `rank_at` computes the standing per rebalance from    the closes-so-far slice and the as-of-filed inputs. Every    `FundFact` SELECT (4 sites: routes/home.rs, routes/search.rs,    routes/symbols.rs, picks.rs ×2) was extended to fetch the new    `period_end` column.  - **Disclaimers.** The "acknowledged look-ahead bias" copy on the    `/backtest` page and in the `routes/backtest.rs` + `picks.rs`    module docs is replaced with the honest "genuinely out-of-sample"    description. Home-page Top-picks copy reframed from "next year"    to "next quarter".  - Verified: cargo + bun build clean; `/backtest` Quarter tab runs    19 rebalances over 2021-08 → 2026-02 with genuinely varying picks    (2021 H2 tech, 2022 H1 defensives, MU appearing in exactly one    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.---
added migrations/0010_quarter_horizon.sql
@@ -0,0 +1,12 @@-- 0010_quarter_horizon.sql-- The four picker horizons are day / week / month / quarter (PLAN.md Phase 30,-- reworked 2026-05-23). The original `year` horizon was dropped: its score was-- a pass-through of today's standing, so every historical backtest year-- produced an identical top-5. Quarter (~one earnings cycle, 60-day trailing-- momentum gated on SMA200) replaces it.---- The `picks` snapshot table stores horizons as text; clear any snapshot rows-- with the retired key so /backtest never surfaces stale `year` slates.-- Today's daily_close scheduler tick will write a fresh `quarter` snapshot.DELETE FROM picks WHERE horizon = 'year';
modified src/compute.rs
@@ -1278,12 +1278,42 @@ pub fn pick_month(i: &PickInput<'_>) -> Option<f64> {    Some(ret_20d)}/// Year-horizon score: the Phase 20 combined fundamentals-and-trajectory/// score directly. The year horizon's right answer is already what `standing`/// computes — quality businesses with healthy trajectory — so we pass it/// through rather than invent a second long-horizon read.pub fn pick_year(i: &PickInput<'_>) -> Option<f64> {    Some(i.standing?.score * 100.0)/// Trailing window the quarter ranker measures its return over: roughly one/// earnings cycle (~63 trading days).const QUARTER_BARS: usize = 63;/// Quarter-horizon score: the trailing ~63-day price return, gated on the/// close being above its 200-day SMA and the stock not being weak. `None`/// when any of those does not hold or history is too short.////// Medium-to-long momentum read: stocks in a confirmed long-term uptrend that/// have continued to grind higher across roughly one earnings cycle, with/// fundamental quality as a floor. Same shape as `pick_month` on a longer/// window — deliberately *not* a pass-through of the standing score, so the/// backtest produces genuinely different picks at different historical dates.pub fn pick_quarter(i: &PickInput<'_>) -> Option<f64> {    let s = i.standing?;    if matches!(s.grade, Grade::Bad) {        return None;    }    if i.closes.len() <= QUARTER_BARS.max(PICK_SMA_LONG) {        return None;    }    let len = i.closes.len();    let last = i.closes[len - 1];    let start = i.closes[len - 1 - QUARTER_BARS];    if start <= 0.0 || last <= 0.0 {        return None;    }    let ret_q = (last - start) / start * 100.0;    if ret_q <= 0.0 {        return None;    }    let sma200 = sma(i.closes, PICK_SMA_LONG).pop().flatten()?;    if last < sma200 {        return None;    }    Some(ret_q)}#[cfg(test)]
modified src/models.rs
@@ -98,6 +98,10 @@ pub struct FundFact {    pub fiscal_year: i64,    pub fiscal_qtr: Option<i64>,    pub value: f64,    /// `YYYY-MM-DD` end-of-period date. Carried so the backtest can pick the    /// latest annual whose figures would actually have been *filed* by an    /// as-of date (period_end + a filing-lag cushion ≤ as_of).    pub period_end: String,}/// Assemble [`compute::RatioInputs`] for a company's most recent full fiscal
@@ -106,11 +110,41 @@ pub struct FundFact {/// company has no annual facts. Shared by the symbol page and the home/// strongest / weakest ranking so both grade a stock identically.pub fn latest_annual_inputs(facts: &[FundFact], price: Option<f64>) -> Option<compute::RatioInputs> {    latest_annual_inputs_filtered(facts, price, |_| true)}/// Conservative filing-lag cushion applied by [`latest_annual_inputs_as_of`]:/// an SEC 10-K is due 60–90 days after fiscal year end depending on filer/// category, so a backtest standing at date `D` should only see annuals/// whose period_end is at least 90 days before `D`. Prevents the backtest/// from grading a stock with figures that did not yet exist on its as-of date.pub const FILING_LAG_DAYS: i64 = 90;/// As-of variant of [`latest_annual_inputs`] for the backtest: pick the/// latest annual fiscal year whose `period_end + FILING_LAG_DAYS ≤ as_of`./// `None` when no annual would have been filed yet by then.pub fn latest_annual_inputs_as_of(    facts: &[FundFact],    price: Option<f64>,    as_of: chrono::NaiveDate,) -> Option<compute::RatioInputs> {    latest_annual_inputs_filtered(facts, price, |f| {        chrono::NaiveDate::parse_from_str(&f.period_end, "%Y-%m-%d")            .map(|d| d + chrono::Duration::days(FILING_LAG_DAYS) <= as_of)            .unwrap_or(false)    })}fn latest_annual_inputs_filtered(    facts: &[FundFact],    price: Option<f64>,    keep: impl Fn(&FundFact) -> bool,) -> Option<compute::RatioInputs> {    // (metric, fiscal_year) -> value, annual rows only.    let mut annual: HashMap<(&str, i64), f64> = HashMap::new();    let mut latest_fy: Option<i64> = None;    for f in facts {        if f.fiscal_qtr.is_none() {        if f.fiscal_qtr.is_none() && keep(f) {            annual.insert((f.metric.as_str(), f.fiscal_year), f.value);            latest_fy = Some(latest_fy.map_or(f.fiscal_year, |y| y.max(f.fiscal_year)));        }
modified src/picks.rs
@@ -1,15 +1,14 @@//! Top picks across four forecast horizons (Phase 30).//!//! Each horizon has a separate ranker in `compute` (`pick_day` / `pick_week`//! / `pick_month` / `pick_year`). This module is the glue: it loads the//! / `pick_month` / `pick_quarter`). This module is the glue: it loads the//! per-stock inputs once, runs every ranker, and returns the top-N per//! horizon. Two callers use it — the home page (live, every render) and the//! scheduler's `picks` snapshot job (once a day, persisted into `picks` so//! `/backtest` has immutable history to read).//!//! Stocks-only across all four horizons, per the user's design call: the//! short rankers filter on Phase 20 fundamental strength, which only stocks//! carry, and the year horizon delegates to the Phase 20 standing directly.//! Stocks-only across all four horizons, per the user's design call: every//! ranker filters on Phase 20 fundamental strength, which only stocks carry.use std::collections::HashMap;
@@ -45,9 +44,9 @@ pub const HORIZONS: &[Horizon] = &[        desc: "trailing 20-day momentum, gated on the close above SMA200 and fundamentals not weak",    },    Horizon {        key: "year",        label: "Next year",        desc: "Phase 20 combined fundamentals + 12-month trajectory score (no recent-move signal)",        key: "quarter",        label: "Next quarter",        desc: "trailing 60-day momentum (roughly one earnings cycle), gated on the close above SMA200 and fundamentals not weak",    },];
@@ -127,7 +126,7 @@ pub fn compute_picks(bundles: &[StockBundle]) -> Vec<PickSlate> {                        "day" => compute::pick_day(&input),                        "week" => compute::pick_week(&input),                        "month" => compute::pick_month(&input),                        "year" => compute::pick_year(&input),                        "quarter" => compute::pick_quarter(&input),                        _ => None,                    }?;                    Some((b, score))
@@ -182,21 +181,22 @@ pub async fn load_bundles(pool: &SqlitePool) -> sqlx::Result<Vec<StockBundle>> {    }    // 2. Every fundamentals fact for the curated stocks, grouped by ticker.    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64, String)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value, f.period_end \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }
@@ -294,10 +294,11 @@ pub async fn snapshot_today(pool: &SqlitePool, date: &str) -> anyhow::Result<usi// The picks held for one stride, equal-weight, are sold at the next// rebalance close.//// Acknowledged look-ahead: fundamentals are today's (we do not store a// per-period history of Phase 7 facts). The page's disclaimer surfaces// this — the backtest is a stress test "for fun and testing", not a clean// out-of-sample evaluation.// Out-of-sample: at each rebalance date the picker grades a stock only// against fundamentals that would actually have been filed by then// (`models::latest_annual_inputs_as_of`, with a 90-day filing-lag cushion)// and only against closes up to that date. So a stock that grades strong// today but was weak in 2022 will grade weak in a 2022 rebalance./// One historical bar a backtest reads off — a trading date and the close.#[derive(Debug, Clone)]
@@ -306,13 +307,17 @@ pub struct HistBar {    pub close: f64,}/// Per-stock bundle for the backtest: the standing carried as today's value/// (look-ahead bias acknowledged), and the full close history the rankers/// walk over./// Per-stock bundle for the backtest: the full close history the rankers/// walk over plus the raw stored fundamentals. The standing is *not*/// precomputed; `rank_at` builds it at each rebalance date from the slice/// of closes-so-far and the latest annual that would actually have been/// filed by then (see [`models::latest_annual_inputs_as_of`]). That makes/// the backtest genuinely out-of-sample: a stock that is strong today but/// was weak in 2022 won't be graded "strong" in a 2022 rebalance.pub struct HistBundle {    pub ticker: String,    pub bars: Vec<HistBar>,    pub standing: Option<Standing>,    pub facts: Vec<models::FundFact>,}/// The benchmark every horizon's strategy is measured against. Hardcoded to
@@ -321,9 +326,9 @@ pub struct HistBundle {pub const BENCHMARK_TICKER: &str = "^SPX";/// Calendar days of history the backtest's loader pulls. Comfortably covers/// the longest horizon (year, ~5 rebalances × 252 bars ≈ 5 years) plus the/// warm-up the rankers need (200-day SMA + a year for the standing). Capping/// here keeps the load query off the deep history some indexes carry/// the longest horizon (quarter, ~20 rebalances × 63 bars ≈ 5 years) plus/// the warm-up the rankers need (200-day SMA + a year for the standing)./// Capping here keeps the load query off the deep history some indexes carry/// (`^SPX` goes back to 1789), trading no real backtest depth for a fast/// request.const HIST_LOOKBACK_DAYS: i64 = 365 * 7;
@@ -400,20 +405,21 @@ fn stride_for(horizon_key: &str) -> usize {        "day" => 1,        "week" => 5,        "month" => 20,        "year" => 252,        "quarter" => 63,        _ => 1,    }}/// Maximum backtest window per horizon, in trading bars. Cap each so the/// number of rebalances stays meaningful without dragging the request: day/// has too many otherwise, year too few. Roughly 1y / 2y / 4y / 5y./// has too many otherwise, quarter too few on a short window. Roughly/// 1y / 2y / 4y / 5y across the four horizons.fn max_bars_for(horizon_key: &str) -> usize {    match horizon_key {        "day" => 252,        "week" => 504,        "month" => 1008,        "year" => 1260,        "quarter" => 1260,        _ => 252,    }}
@@ -426,31 +432,30 @@ fn max_bars_for(horizon_key: &str) -> usize {pub async fn load_hist_bundles(    pool: &SqlitePool,) -> sqlx::Result<(Vec<HistBundle>, Vec<HistBar>)> {    // 1. The curated stocks' price, plus today's standing as for the live    //    picks. (Name not needed: the backtest table shows tickers only.)    let price_rows: Vec<(String, Option<f64>)> = sqlx::query_as(        "SELECT s.ticker, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)) \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    // 1. The curated stocks' tickers. (Price not needed: the backtest only    //    reads each stock's closes, indexed by date, never `last_price`.    //    Name not needed either: the backtest table shows tickers only.)    let price_rows: Vec<(String,)> = sqlx::query_as(        "SELECT s.ticker FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64, String)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value, f.period_end \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await?;    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }
@@ -478,18 +483,10 @@ pub async fn load_hist_bundles(    let bundles: Vec<HistBundle> = price_rows        .into_iter()        .map(|(ticker, last_price)| {        .map(|(ticker,)| {            let bars = bars_by.remove(&ticker).unwrap_or_default();            let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();            let standing = facts.get(&ticker).and_then(|f| {                let inputs = models::latest_annual_inputs(f, last_price)?;                compute::standing(&compute::compute_ratios(&inputs), &closes)            });            HistBundle {                ticker,                bars,                standing,            }            let facts = facts.remove(&ticker).unwrap_or_default();            HistBundle { ticker, bars, facts }        })        .collect();
@@ -511,18 +508,23 @@ pub async fn load_hist_bundles(    Ok((bundles, bench_bars))}/// Rank `bundles` as of bar index `idx` (within the benchmark's date list)./// Each bundle is sliced to its own bars at-or-before the benchmark's/// `as_of` date, with `last_price` and `prev_close` derived from that slice./// A stock that does not yet have two bars by that date is silently dropped./// Rank `bundles` as of the trading date `as_of`. Each bundle is sliced to/// its own bars at-or-before that date; its standing is computed *as of*/// that date too, using the latest annual that would actually have been/// filed by then (see [`models::latest_annual_inputs_as_of`]) — so a stock/// that is strong today but was weak in 2022 will grade weak in a 2022/// rebalance. A stock that does not yet have two bars, or whose/// fundamentals had not been filed yet, is silently dropped.fn rank_at(    bundles: &[HistBundle],    as_of: &str,    horizon_key: &str,) -> Vec<(String, f64, f64)> {    let as_of_date = chrono::NaiveDate::parse_from_str(as_of, "%Y-%m-%d").ok();    let mut scored: Vec<(String, f64, f64)> = bundles        .iter()        .filter_map(|b| {            let as_of_date = as_of_date?;            // Find the index of the bundle's last bar at-or-before `as_of`.            // `partition_point` returns the first index *after* the predicate            // holds, so subtracting one yields the bar at-or-before. The
@@ -533,21 +535,28 @@ fn rank_at(            }            let idx = upper - 1;            let last_price = b.bars[idx].close;            let prev_close = b.bars[idx - 1].close;            let closes: Vec<f64> = b.bars[..=idx].iter().map(|x| x.close).collect();            // Standing as-of: grade the latest annual whose period_end is            // at least FILING_LAG_DAYS before as_of, against the closes            // sliced to as_of (so the trajectory score reads the same            // window the rankers see).            let standing = models::latest_annual_inputs_as_of(                &b.facts,                Some(last_price),                as_of_date,            )            .and_then(|inputs| {                compute::standing(&compute::compute_ratios(&inputs), &closes)            });            let input = PickInput {                closes: &closes,                standing: b.standing,                standing,            };            // `last_price` and `prev_close` are read above only for the            // entry-price recording below; the rankers themselves consume            // `closes` exclusively.            let _ = (last_price, prev_close);            let score = match horizon_key {                "day" => compute::pick_day(&input),                "week" => compute::pick_week(&input),                "month" => compute::pick_month(&input),                "year" => compute::pick_year(&input),                "quarter" => compute::pick_quarter(&input),                _ => None,            }?;            Some((b.ticker.clone(), score, last_price))
modified src/routes/backtest.rs
@@ -6,11 +6,12 @@//! `picks::run_backtest`, served as JSON at//! `GET /api/backtest?horizon=<key>&capital=<usd>`.//!//! The fundamental signals are today's values (we do not store a per-period//! history of Phase 7 ratios), which is acknowledged look-ahead bias —//! surfaced explicitly in the page's disclaimer. The user's own framing of//! the feature is "for fun and testing", so this is the right trade-off for//! v1; a future phase can layer in point-in-time fundamentals.//! At each historical rebalance date the picker grades a stock using only the//! fundamentals that would actually have been *filed* by then (latest annual//! whose period_end is at least 90 days before the rebalance — see//! `models::FILING_LAG_DAYS`) and only the closes up to that date, so the//! backtest is genuinely out-of-sample: a stock that grades strong today//! but was weak in 2022 will grade weak in a 2022 rebalance.use axum::{    extract::{Query, State},
@@ -42,7 +43,7 @@ async fn backtest_page(State(state): State<AppState>) -> Response {#[derive(Debug, Deserialize)]struct BacktestQuery {    /// One of `day | week | month | year`. Defaults to `month`, the    /// One of `day | week | month | quarter`. Defaults to `month`, the    /// medium-cadence read that has both enough rebalances to be informative    /// and few enough to be quick to glance at.    horizon: Option<String>,
modified src/routes/home.rs
@@ -321,8 +321,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> {    // 2. Every stored fundamentals fact for the curated stocks, grouped by    //    ticker — the basis for each stock's graded ratios.    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value \    let fact_rows: Vec<(String, String, String, i64, Option<i64>, f64, String)> = sqlx::query_as(        "SELECT f.ticker, f.metric, f.period, f.fiscal_year, f.fiscal_qtr, f.value, f.period_end \         FROM fundamentals f JOIN symbols s ON s.ticker = f.ticker \         WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )
@@ -330,13 +330,14 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> {    .await    .unwrap_or_default();    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }
modified src/routes/search.rs
@@ -167,23 +167,24 @@ async fn attach_standings(state: &AppState, cards: &mut [Card]) {    // input, so the placeholder count is bounded and safe.    let placeholders = vec!["?"; stock_tickers.len()].join(",");    let sql = format!(        "SELECT ticker, metric, period, fiscal_year, fiscal_qtr, value \        "SELECT ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end \         FROM fundamentals WHERE ticker IN ({placeholders})"    );    let mut q = sqlx::query_as::<_, (String, String, String, i64, Option<i64>, f64)>(&sql);    let mut q = sqlx::query_as::<_, (String, String, String, i64, Option<i64>, f64, String)>(&sql);    for t in &stock_tickers {        q = q.bind(*t);    }    let fact_rows = q.fetch_all(&state.pool).await.unwrap_or_default();    let mut facts: HashMap<String, Vec<models::FundFact>> = HashMap::new();    for (ticker, metric, period, fiscal_year, fiscal_qtr, value) in fact_rows {    for (ticker, metric, period, fiscal_year, fiscal_qtr, value, period_end) in fact_rows {        facts.entry(ticker).or_default().push(models::FundFact {            metric,            period,            fiscal_year,            fiscal_qtr,            value,            period_end,        });    }
modified src/routes/symbols.rs
@@ -335,12 +335,12 @@ const Q4_DERIVABLE: &[&str] = &["revenue", "net_income", "eps_diluted", "dividen/// not decompose perfectly — the diluted share count drifts quarter to quarter/// — but the residual is small and the plan calls for showing it.fn derive_q4(facts: &[models::FundFact]) -> Vec<models::FundFact> {    // (metric, fiscal_year) -> fiscal_qtr (None = full year) -> value.    let mut by: HashMap<(&str, i64), HashMap<Option<i64>, f64>> = HashMap::new();    // (metric, fiscal_year) -> fiscal_qtr (None = full year) -> (value, period_end).    let mut by: HashMap<(&str, i64), HashMap<Option<i64>, (f64, String)>> = HashMap::new();    for f in facts {        by.entry((f.metric.as_str(), f.fiscal_year))            .or_default()            .insert(f.fiscal_qtr, f.value);            .insert(f.fiscal_qtr, (f.value, f.period_end.clone()));    }    let mut derived = Vec::new();    for ((metric, year), vals) in by {
@@ -348,7 +348,7 @@ fn derive_q4(facts: &[models::FundFact]) -> Vec<models::FundFact> {        if !Q4_DERIVABLE.contains(&metric) || vals.contains_key(&Some(4)) {            continue;        }        let (Some(&fy), Some(&q1), Some(&q2), Some(&q3)) = (        let (Some(fy), Some(q1), Some(q2), Some(q3)) = (            vals.get(&None),            vals.get(&Some(1)),            vals.get(&Some(2)),
@@ -361,7 +361,9 @@ fn derive_q4(facts: &[models::FundFact]) -> Vec<models::FundFact> {            period: format!("Q4-{year}"),            fiscal_year: year,            fiscal_qtr: Some(4),            value: fy - q1 - q2 - q3,            value: fy.0 - q1.0 - q2.0 - q3.0,            // The synthetic Q4 ends on the FY's period end (Q4 closes the fiscal year).            period_end: fy.1.clone(),        });    }    derived
@@ -1018,7 +1020,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)    let fundamentals = if is_stock {        let facts: Vec<models::FundFact> = sqlx::query_as(            "SELECT metric, period, fiscal_year, fiscal_qtr, value \            "SELECT metric, period, fiscal_year, fiscal_qtr, value, period_end \             FROM fundamentals WHERE ticker = ?",        )        .bind(&ticker)
modified templates/pages/backtest.html
@@ -14,11 +14,12 @@     shown is rebalanced equal-weight at the end of every horizon period and     compared to <code>^SPX</code> over the same window.</p>  <p class="disclaimer">For fun and testing. Not financial advice. The picker     uses today&rsquo;s fundamentals applied to historical prices (per-period     fundamentals are not stored) &mdash; treat as a stress test of the     ranker, not an out-of-sample evaluation. Frictionless: no transaction     costs, slippage, or taxes modelled.</p>  <p class="disclaimer">For fun and testing. Not financial advice. At each     rebalance the picker grades a stock only against fundamentals that would     actually have been filed by then (latest annual report at least 90 days     before the rebalance) and only against closes up to that date, so the     backtest is genuinely out-of-sample. Frictionless: no transaction costs,     slippage, or taxes modelled.</p>  {# Horizon tabs: clicking re-runs the backtest in place via the JSON     endpoint, so the URL stays /backtest while the rendered horizon
modified templates/pages/home.html
@@ -58,7 +58,7 @@  <h2 class="section-title">Top picks<span class="section-title__asof"><a href="/backtest">Backtest these &rarr;</a></span></h2>  <p class="section-note">Forecasts for tomorrow, next week, next month and     next year &mdash; ranked off recent momentum and the fundamentals /     next quarter &mdash; ranked off recent momentum and the fundamentals /     trajectory the app already carries, using only data available now.     The figure beside each row is the signal that earned the pick (e.g.     yesterday&rsquo;s move, trailing 20-day return), not a prediction of