repos

Add derived Q4 column and growth cues to financials table (Phase 23 + 24)

14f23374 by Isaac Bythewood · 1 month ago

modified PLAN.md
@@ -32,19 +32,17 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-22_**Current phase: none in progress. Phase 21 (Home & search refinements) iscomplete and verified, not yet deployed.** Phases 0 through 12 (the MVP) plusPhase 14 (company leadership), Phase 18 (ETF profiles) and Phase 20 (strongest& weakest home panels) are complete, verified, and **live in production athttps://finance.bythewood.me**. Phase 21, three refinements to shippedfeatures (the home page split into Indexes and Commodities sections with anindex-futures swap off-hours, a synchronous full backfill on add-symbol, andsearch auto-navigation on a single result), was built and verified on2026-05-22 (see the Done list and the decisions log) and ships on the next`git push server master`. Remaining post-MVP work: the loose-ordered Phase 13,15, 16, 17, 19 backlog plus Phase 22 (show data age everywhere), Phase 23 (Q4in the quarterly financials table) and Phase 24 (financials tablereadability).**Current phase: none in progress. Phase 23 + 24 (financials table — Q4column and readability) is complete, verified, and deployed.** Phases 0through 12 (the MVP) plus Phase 14 (company leadership), Phase 18 (ETFprofiles), Phase 20 (strongest & weakest home panels), Phase 21 (home &search refinements) and Phase 23 + 24 are complete, verified, and **live inproduction at https://finance.bythewood.me**. Phase 23 + 24 were builttogether as one financials-table pass on 2026-05-22 (see the Done list andthe decisions log): a derived Q4 column in the quarterly table, per-cellperiod-over-period growth cues, and an em-dash missing-value glyph in placeof the middle dot. Remaining post-MVP work: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere).**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +
@@ -615,7 +613,7 @@ 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 21 home & search refinements.** Complete, verified, not yet deployed.- **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`    replaced the flat `DASHBOARD` const with `INDEXES` (each cash index paired
@@ -656,16 +654,59 @@ schema, unused for now.    verification-only add and was removed afterwards; `RTY=F` stays as a    curated symbol.)- **Phase 23 + 24 financials table — Q4 column & readability.** Complete,  verified, and deployed to production. Built together as one symbol-page  financials pass; no new data source, no schema change, no network.  - **Phase 23 — derived Q4 column.** SEC XBRL carries no discrete fourth    quarter (no Q4 10-Q; Q4 lives only inside the 10-K's full-year figure, so    `fundamentals` holds zero `fiscal_qtr = 4` rows). `routes/symbols.rs`    gained `derive_q4`: for every fiscal year with the full year and all three    prior quarters present, it derives `Q4 = FY - (Q1 + Q2 + Q3)` for the four    flow / per-share metrics (revenue, net income, diluted EPS, dividend per    share) and emits a synthetic `FundFact` with period label `Q4-<year>`.    `build_fundamentals` folds the derived rows in before building the    quarterly periods and the cell lookup, so a Q4 column slots into the    quarterly table exactly like a stored quarter. Diluted EPS does not    decompose perfectly (the diluted share count drifts quarter to quarter)    but the residual is small; the plan calls for showing it. A genuine Q4    row, should XBRL ever carry one, wins over the derived one.  - **Phase 24a — per-cell growth cues.** Each financials-table cell now    carries a period-over-period cue against the column to its left: a small    up / down triangle, plus a semantic green / red where a rise has a clear    good/bad reading. `FundRow.cells` changed from `Vec<String>` to    `Vec<FundCell>` (a formatted figure plus a `dir` and a `sense`); a new    `Trend` on each `TableMetric` sets the reading — `RiseGood` for revenue,    net income, EPS and dividend; `RiseBad` for total liabilities; `Neutral`    for total assets and shareholder equity (a rise there can be debt-funded    or a fall a buyback, so the arrow shows but stays uncoloured). The first    column, a flat figure, and a cell with a missing value on either side    carry no cue.  - **Phase 24b — missing-value glyph.** The middle dot (`·`), which read as    a stray decimal point, was replaced with an em dash (`—`), the universal    "no data" mark. There were three copies of the same placeholder constant    (`routes/symbols.rs`, `compute.rs`, `templates.rs`), all changed, so the    glyph is consistent across the financials table, the ratio cards, the ETF    fund stats and every empty-value template filter app-wide.  - Verified: cargo + bun build clean. `/s/AAPL` quarterly table shows the    derived Q4-2024 and Q4-2025 columns and the maths checks out (Q4-2025    revenue $102.5B = FY $416.2B − Q1 $124.3B − Q2 $95.4B − Q3 $94.0B). Growth    cues render green / red on the four flow metrics and inverted on    liabilities, with faint uncoloured arrows on the neutral rows; the first    column and a flat dividend carry none. `/s/DELL` (which has reporting    gaps) shows the em-dash glyph and correctly drops the cue across a missing    column; its two uncomputable ratio cards also show `—`. No `·` remains    except as a legitimate separator (page title, footer, the leadership role    line). Desktop (1280px) and phone (390px) render with no overflow and    zero console errors; the wide quarterly table scrolls within its card.**Resuming, next action****Phase 21 (Home & search refinements) is complete and verified** (2026-05-22),not yet deployed. The MVP plus Phase 14, Phase 18 and Phase 20 are live athttps://finance.bythewood.me; Phase 21 ships on the next`git push server master` (the boot seed picks up the new `RTY=F` symbol from`universe/starter.csv` automatically, as it would any starter-list change). Nophase is in progress. Remaining post-MVP work: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere), Phase 23 (Q4 inthe quarterly financials table) and Phase 24 (financials table readability);the user picks which to take next. There is still no GitHub repo for finance:**Phase 23 + 24 (financials table — Q4 column and readability) is complete,verified and deployed** (2026-05-22). The MVP plus Phase 14, Phase 18,Phase 20, Phase 21 and Phase 23 + 24 are live athttps://finance.bythewood.me. No phase is in progress. Remaining post-MVPwork: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere); the user pickswhich to take next. There is still no GitHub repo for finance:the user deferred that; if one is created later, add it as `origin` and the`overshard/finance` slug already in taproot's `projects.conf` lines up.
@@ -1015,7 +1056,9 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  small design pass on the wording and where it rides without cluttering the  Paper Ledger look.- [ ] **Phase 23: Q4 in the quarterly financials table.** (Captured 2026-05-22- [x] **Phase 23: Q4 in the quarterly financials table.** Complete, verified  and deployed 2026-05-22, built together with Phase 24 — see their shared  Done entry in Status and the decisions log. (Captured 2026-05-22  from a vibe-coding side note.) The symbol page's quarterly financials table  shows only Q1-Q3. The cause is confirmed: SEC XBRL carries no discrete Q4 —  there is no Q4 10-Q, the fourth quarter is reported only inside the 10-K's
@@ -1026,7 +1069,9 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  all three quarters are present. No schema change and no new data — a pure  derivation from facts already stored.- [ ] **Phase 24: Financials table readability.** (Captured 2026-05-22 from- [x] **Phase 24: Financials table readability.** Complete, verified and  deployed 2026-05-22, built together with Phase 23 — see their shared Done  entry in Status and the decisions log. (Captured 2026-05-22 from  two vibe-coding side notes raised while Phase 21 was in progress.) Two  presentation refinements to the symbol page's fundamentals area, no new data  source: (1) **Per-cell growth cues.** In the annual and quarterly financials
@@ -1576,6 +1621,33 @@ finance/  non-empty-query match. Verified end to end (see the Phase 21 Done entry).  Not yet deployed; ships on the next push, and the boot seed adds `RTY=F` on  the box.- **2026-05-22 — Phase 21 confirmed deployed.** On resuming, server `master`  was checked (`git ls-remote server master`) and found at commit `5cb5dc2`,  the Phase 21 commit — so Phase 21 is in fact live in production, despite the  entry above recording it as "not yet deployed" (the same plan/reality drift  the Phase 14 entry hit). The Status section was corrected.- **2026-05-22 — Phase 23 + 24 picked and built together.** Asked which  backlog phase to take next, the user chose Phase 23 and Phase 24 as a single  pass: both are small, self-contained refinements to the symbol page's  financials area with no new data, and Phase 23's derived Q4 column and  Phase 24's growth cues touch the same table. Design calls made during the  build: (1) Q4 is derived per metric, not all-or-nothing — the `Q4-<year>`  column appears if any of the four flow metrics derives, and a metric missing  a quarter shows `—` in that column. (2) The growth cue's good/bad reading is  a per-metric `Trend`: revenue / net income / EPS / dividend are `RiseGood`,  total liabilities is `RiseBad`, and total assets and shareholder equity are  `Neutral` — a rise in either can be debt-funded or a buyback, so they get an  uncoloured directional arrow rather than a green/red verdict (the plan left  the metrics beyond revenue / net income / liabilities as an open design  question). (3) The cue compares each cell to the column immediately left of  it (year over year in the annual table, quarter over quarter in the  quarterly one); a gap on either side breaks the comparison. (4) The  missing-value glyph fix was applied to all three copies of the placeholder  constant (`routes/symbols.rs`, `compute.rs`, `templates.rs`), not only the  one the plan named, since they all mean "no value" and a mix of `—` and `·`  would be worse than the original; the em dash is now the single app-wide  empty-value mark. Deployed to production on 2026-05-22 via  `git push server master`.---
@@ -1604,4 +1676,7 @@ finance/  board roster from SEC Form 3/4/5 ownership filings, plus a recent-changes  list from 8-K item 5.02. An unsynced stock shows a pending note; ETFs and  indexes show no Leadership section (Phase 14).- A stock's Financials table (`/s/AAPL`, Quarterly) shows a derived Q4 column  and per-cell up/down growth cues; a figure the company did not report shows  an em dash (`—`), not a middle dot (Phase 23 + 24).- No automated test suite or linter, matching the sibling projects.
modified frontend/static_src/symbol/styles/symbol.scss
@@ -608,6 +608,37 @@  font-size: 0.86rem;}/* period-over-period growth cue (Phase 24): a small arrow on every changed   figure, plus a semantic colour where a rise has a clear good/bad reading */.fin__table td.fin-cell--good {  color: var(--up);}.fin__table td.fin-cell--bad {  color: var(--down);}.fin-cell__dir {  margin-left: 4px;  font-size: 0.62rem;  vertical-align: 0.06em;  /* faint by default; a graded cell re-colours it to match the figure */  color: var(--ink-faint);}.fin__table td.fin-cell--good .fin-cell__dir,.fin__table td.fin-cell--bad .fin-cell__dir {  color: inherit;}.fin-cell__dir--up::before {  content: "\25B4";}.fin-cell__dir--down::before {  content: "\25BE";}/* ---------- recent SEC filings ---------- */.filings {  padding: 2px 16px;
modified src/compute.rs
@@ -3,9 +3,9 @@use serde::Serialize;/// Placeholder shown for a figure that cannot be computed. Matches the/// `templates.rs` empty-value glyph (a middle dot).const DASH: &str = "\u{00b7}";/// Placeholder shown for a figure that cannot be computed — an em dash, the/// unambiguous "no data" mark used for every empty value across the app.const DASH: &str = "\u{2014}";/// An absolute and percentage change between two prices.#[derive(Debug, Clone, Copy)]
modified src/routes/symbols.rs
@@ -92,15 +92,61 @@ fn quote_state_label() -> &'static str {// ── fundamentals + filings (Phase 7) ──────────────────────────────────────/// Placeholder for a fundamentals cell the company did not report.const DASH: &str = "\u{00b7}";/// Placeholder glyph for a value the company did not report — an em dash, an/// unambiguous "no data" mark (a middle dot read as a stray decimal point).const DASH: &str = "\u{2014}";/// Whether a period-over-period rise in a metric is good news, for the/// financials-table growth cue (PLAN.md Phase 24).#[derive(Clone, Copy)]enum Trend {    /// A rise reads as good: revenue, earnings, dividends.    RiseGood,    /// A rise reads as bad: liabilities.    RiseBad,    /// No good/bad reading — total assets and equity, where a rise can be    /// debt-funded or a fall can be a shareholder-friendly buyback. The cue    /// still shows the direction, just without a colour.    Neutral,}impl Trend {    /// How a rise vs the prior period reads: `good`, `bad`, or `` (no colour).    fn rise(self) -> &'static str {        match self {            Trend::RiseGood => "good",            Trend::RiseBad => "bad",            Trend::Neutral => "",        }    }    /// How a fall vs the prior period reads.    fn fall(self) -> &'static str {        match self {            Trend::RiseGood => "bad",            Trend::RiseBad => "good",            Trend::Neutral => "",        }    }}/// One cell of a financials table: a formatted figure and its period-over-/// period growth cue (PLAN.md Phase 24).#[derive(Serialize)]struct FundCell {    /// The formatted figure, or [`DASH`] where nothing was reported.    display: String,    /// Direction vs the column to its left: `up`, `down`, or `` (the first    /// column, a flat figure, or a missing value on either side).    dir: &'static str,    /// How that move reads for this metric: `good`, `bad`, or `` (no colour).    sense: &'static str,}/// One row of a financials table: a metric label and its formatted value in/// each displayed period (`·` where the company reported nothing)./// One row of a financials table: a metric label and one cell per period.#[derive(Serialize)]struct FundRow {    label: String,    cells: Vec<String>,    cells: Vec<FundCell>,}/// A financials table (annual or quarterly) as period columns and metric rows.
@@ -148,22 +194,24 @@ struct FilingRow {/// `false` for a per-share figure (shown as plain dollars, e.g. `$6.08`)./// `in_quarterly` is `false` for the balance-sheet rows: only the fiscal/// year-end balance is collected, so those rows appear in the annual table/// only (see `providers::sec::classify`)./// only (see `providers::sec::classify`). `trend` sets the period-over-period/// growth cue's good/bad reading (PLAN.md Phase 24).struct TableMetric {    metric: &'static str,    label: &'static str,    is_money: bool,    in_quarterly: bool,    trend: Trend,}const FUND_TABLE_METRICS: &[TableMetric] = &[    TableMetric { metric: "revenue", label: "Revenue", is_money: true, in_quarterly: true },    TableMetric { metric: "net_income", label: "Net income", is_money: true, in_quarterly: true },    TableMetric { metric: "eps_diluted", label: "Diluted EPS", is_money: false, in_quarterly: true },    TableMetric { metric: "dividends_per_share", label: "Dividend / share", is_money: false, in_quarterly: true },    TableMetric { metric: "assets", label: "Total assets", is_money: true, in_quarterly: false },    TableMetric { metric: "liabilities", label: "Total liabilities", is_money: true, in_quarterly: false },    TableMetric { metric: "equity", label: "Shareholder equity", is_money: true, in_quarterly: false },    TableMetric { metric: "revenue", label: "Revenue", is_money: true, in_quarterly: true, trend: Trend::RiseGood },    TableMetric { metric: "net_income", label: "Net income", is_money: true, in_quarterly: true, trend: Trend::RiseGood },    TableMetric { metric: "eps_diluted", label: "Diluted EPS", is_money: false, in_quarterly: true, trend: Trend::RiseGood },    TableMetric { metric: "dividends_per_share", label: "Dividend / share", is_money: false, in_quarterly: true, trend: Trend::RiseGood },    TableMetric { metric: "assets", label: "Total assets", is_money: true, in_quarterly: false, trend: Trend::Neutral },    TableMetric { metric: "liabilities", label: "Total liabilities", is_money: true, in_quarterly: false, trend: Trend::RiseBad },    TableMetric { metric: "equity", label: "Shareholder equity", is_money: true, in_quarterly: false, trend: Trend::Neutral },];/// Format a whole-dollar figure compactly: `391035000000.0` -> `$391.0B`.
@@ -226,8 +274,10 @@ fn filing_title(form: &str) -> String {}/// Build one financials table for the given periods (each `(fiscal_year,/// period_label)`), pulling formatted cells from `lookup`. The quarterly table/// omits the balance-sheet rows, which are only collected per fiscal year./// period_label)`, oldest first), pulling formatted cells from `lookup`. The/// quarterly table omits the balance-sheet rows, which are only collected per/// fiscal year. Each cell also carries a period-over-period growth cue/// (PLAN.md Phase 24), computed against the column to its left.fn fund_table(    periods: &[(i64, String)],    lookup: &HashMap<(String, String), f64>,
@@ -237,15 +287,28 @@ fn fund_table(        .iter()        .filter(|m| !quarterly || m.in_quarterly)        .map(|m| {            // Walk periods oldest-first, carrying the prior period's value so            // each cell can be marked up / down against the one before it.            let mut prev: Option<f64> = None;            let cells = periods                .iter()                .map(                    |(_, period)| match lookup.get(&(m.metric.to_string(), period.clone())) {                        Some(v) if m.is_money => fmt_usd_compact(*v),                        Some(v) => fmt_per_share(*v),                .map(|(_, period)| {                    let value = lookup                        .get(&(m.metric.to_string(), period.clone()))                        .copied();                    let display = match value {                        Some(v) if m.is_money => fmt_usd_compact(v),                        Some(v) => fmt_per_share(v),                        None => DASH.to_string(),                    },                )                    };                    let (dir, sense) = match (prev, value) {                        (Some(p), Some(v)) if v > p => ("up", m.trend.rise()),                        (Some(p), Some(v)) if v < p => ("down", m.trend.fall()),                        _ => ("", ""),                    };                    prev = value;                    FundCell { display, dir, sense }                })                .collect();            FundRow {                label: m.label.to_string(),
@@ -259,6 +322,50 @@ fn fund_table(    }}/// The flow / per-share metrics whose Q4 can be derived as the full fiscal/// year minus its first three quarters. The balance-sheet metrics are excluded/// — a year-end balance is a snapshot, not a sum of quarters.const Q4_DERIVABLE: &[&str] = &["revenue", "net_income", "eps_diluted", "dividends_per_share"];/// Derive the missing Q4 facts. SEC XBRL carries no discrete fourth quarter:/// there is no Q4 10-Q, so Q4 lives only inside the 10-K's full-year figure./// For every fiscal year with the full year and all three prior quarters/// present, Q4 is `FY - (Q1 + Q2 + Q3)` (PLAN.md Phase 23). Diluted EPS does/// 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();    for f in facts {        by.entry((f.metric.as_str(), f.fiscal_year))            .or_default()            .insert(f.fiscal_qtr, f.value);    }    let mut derived = Vec::new();    for ((metric, year), vals) in by {        // A genuine Q4 row (rare, but XBRL does carry a few) always wins.        if !Q4_DERIVABLE.contains(&metric) || vals.contains_key(&Some(4)) {            continue;        }        let (Some(&fy), Some(&q1), Some(&q2), Some(&q3)) = (            vals.get(&None),            vals.get(&Some(1)),            vals.get(&Some(2)),            vals.get(&Some(3)),        ) else {            continue;        };        derived.push(models::FundFact {            metric: metric.to_string(),            period: format!("Q4-{year}"),            fiscal_year: year,            fiscal_qtr: Some(4),            value: fy - q1 - q2 - q3,        });    }    derived}/// Assemble the fundamentals view from a company's stored facts plus the/// latest price. `None` when the company has no fundamentals stored yet.fn build_fundamentals(facts: &[models::FundFact], price: Option<f64>) -> Option<FundamentalsView> {
@@ -266,6 +373,13 @@ fn build_fundamentals(facts: &[models::FundFact], price: Option<f64>) -> Option<        return None;    }    // SEC XBRL has no discrete Q4 (PLAN.md Phase 23); derive it and fold the    // derived rows in, so the quarterly periods and the cell lookup below pick    // them up exactly like a stored fact.    let derived = derive_q4(facts);    let facts: Vec<models::FundFact> = facts.iter().cloned().chain(derived).collect();    let facts: &[models::FundFact] = &facts;    // (metric, period) -> value, for table-cell lookup.    let mut lookup: HashMap<(String, String), f64> = HashMap::new();    for f in facts {
modified src/templates.rs
@@ -162,8 +162,9 @@ fn fmt_grouped(n: f64, dp: usize) -> String {    out}/// Empty-value placeholder shown when a metric is missing.const DASH: &str = "\u{00b7}";/// Empty-value placeholder shown when a metric is missing — an em dash, an/// unambiguous "no data" mark (a middle dot read as a stray decimal point).const DASH: &str = "\u{2014}";/// `1234.5` -> `$1,234.50`fn money_filter(value: Value) -> Result<String, Error> {
modified templates/pages/symbol.html
@@ -19,7 +19,7 @@        {% for row in table.rows %}        <tr>          <th scope="row">{{ row.label }}</th>          {% for c in row.cells %}<td class="num">{{ c }}</td>{% endfor %}          {% for c in row.cells %}<td class="num{% if c.sense %} fin-cell--{{ c.sense }}{% endif %}">{{ c.display }}{% if c.dir %}<span class="fin-cell__dir fin-cell__dir--{{ c.dir }}" aria-hidden="true"></span>{% endif %}</td>{% endfor %}        </tr>        {% endfor %}      </tbody>
@@ -300,12 +300,12 @@    <dl class="fund__stats">      <div class="fund__stat">        <dt class="fund__stat-cap">Net assets</dt>        <dd class="fund__stat-val num">{{ fund.net_assets if fund.net_assets else '·' }}</dd>        <dd class="fund__stat-val num">{{ fund.net_assets if fund.net_assets else '—' }}</dd>      </div>      {% if not fund.is_commodity %}      <div class="fund__stat">        <dt class="fund__stat-cap">Holdings</dt>        <dd class="fund__stat-val num">{{ fund.holdings_count if fund.holdings_count is not none else '·' }}</dd>        <dd class="fund__stat-val num">{{ fund.holdings_count if fund.holdings_count is not none else '—' }}</dd>      </div>      {% endif %}      {% if fund.report_date %}