@@ -32,8 +32,27 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 17 (stock health read) complete, verified,and deployed to production 2026-05-23 (commit `8a16b14`).** A single non-advice "health" read**Current phase: Phase 25 (earnings dates) complete, verifiedlocally — not yet deployed.** A new Earnings section on the stocksymbol page between Key stats and Stock health, plus small ink-dotpips on the candlestick chart at each past earnings date. Two surfaces:(1) the page section carries Most recent + Next expected as a pairedheadline (with "23 days ago" / "in 68 days · estimated from cadence"sub-lines) and a list of the last four earnings dates. The next-expecteddate is Yahoo's `quoteSummary.calendarEvents` when available and acadence-estimate fallback from the past 8-K item-2.02 dates when not.(2) The candle chart now carries small ink-faint circle pips above eachmatching bar (Paper Ledger ink palette — wayfinding, not a valueverdict; the candles still own green/red). Migration `0011` adds twocolumns to `symbols` (`next_earnings_at`, `earnings_synced_at`); a new`earnings_calendar` scheduler job on the existing `yahoo``EndpointGuard` sweeps stocks monthly (one request per stock to`v10/finance/quoteSummary?modules=calendarEvents`). Past dates ridefor free off the 8-K item-2.02 filings the Phase 14 `items` columnalready stores. Pure derivation otherwise — no new endpoint guard, noSEC sync needed beyond what Phase 14 ships. **Phase 17 (stock healthread) complete, verified, and deployed to production 2026-05-23(commit `8a16b14`).** A single non-advice "health" readper stock layered over the Phase 20 strength + trajectory composite: itfolds in a leadership-stability signal read off the recent 8-K item-5.02change count (Phase 14). Industry context (Phase 15) was intentionally
@@ -126,6 +145,83 @@ backlog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 25 earnings dates.** Complete, verified locally — not yet deployed. Past earnings dates come for free from the 8-K item-2.02 filings Phase 14 already stores in `filings.items`; the next-expected date is fetched fresh from Yahoo's `quoteSummary.calendarEvents` module with a cadence-estimate fallback when Yahoo carries nothing. Stocks only — ETFs / indexes / futures hide the section cleanly. - **Migration `0011`** adds two columns to `symbols`: `next_earnings_at` (epoch-ms, NULL when Yahoo has no upcoming date or the sync has not run yet) and `earnings_synced_at` (epoch-ms of the last successful sweep). No new table; the past dates ride on the existing `filings.items LIKE '%2.02%'` SELECT. - **Yahoo provider** got `earnings_calendar(ticker)` — one request to `v10/finance/quoteSummary?modules=calendarEvents`, parses `earnings.earningsDate[0]` (the array carries 1 or 2 entries — confirmed date or confirmed/estimated pair). Filters to future dates only (Yahoo sometimes returns the just-passed print before rolling forward). Same defensive RateLimited surfacing as the Phase 28 `fund_metadata` path (429 / 503 / 401 / 403 all trip the `yahoo` `EndpointGuard`). - **Compute** added `next_earnings_estimate(newest_first_dates)` — a pure helper that reads the median gap between the last up-to-4 earnings dates and projects forward. Clamped into 60–200 days so a degenerate same-day-correction gap (a 2.02 press-release + a same-day follow-up) doesn't yield a date in the next week. Returns `None` on fewer than 2 priors. Three unit tests (quarterly cadence, too-few priors, same-day corrections). - **Scheduler** has a new `earnings_calendar` section on the existing `yahoo` `EndpointGuard`, monthly staleness OR re-run once the stored `next_earnings_at` passes (a missed roll-forward never sits stale). Brought forward to the first tick on boot the same way `sec` / `dividends` / `fund_metadata` are. Logs to `fetch_log` + `data_status`; surfaces on `/health` automatically. `scheduler::backfill_symbol` pulls a new stock's earnings calendar inside the add-symbol request, mirroring Phase 21's intent. - **Symbol route** has a new `build_earnings(pool, ticker, next_earnings_at, earnings_synced_at)` that loads past dates from 8-K item-2.02 filings (newest first), computes the "days ago" / "days from now" deltas off `chrono::Utc::now()`, picks the next-date source (Yahoo primary, estimate fallback), and caps the past-dates list at 4 (one trailing year of a quarterly cadence) per the design pass. Returns `None` when there's nothing to show — no past dates *and* no next date — so the template hides the section cleanly. - **History API** (`/api/symbols/{ticker}/history`) gained an optional `earnings: [{time}, ...]` field for stocks, carrying all past 8-K item-2.02 dates. The chart filters to dates that match a visible candle (lightweight-charts ignores markers whose time does not match), sorts ascending (the v5 API requires it), and draws each as a small `aboveBar` `circle` marker via `createSeriesMarkers`. Color `rgba(33,31,26,0.55)` — the warm paper ink, never green / amber / red. The same exception the Phase 8 indicator lines and the Phase 18 asset-mix bar take. - **Symbol template** has a new "Earnings" section between Key stats and Stock health: a 2-up paired headline (Most recent + Next expected) on desktop that stacks on phone, plus a list of the last four dates with days-ago captions, and a quiet provenance line labelling the source. - **Health page** lists the new `earnings_calendar` job between `fund_metadata` and `dividends` (job_meta + job_rank). - **SCSS** added a small `.earn` block to `symbol.scss` mirroring `.div-panel` / `.div-pace` shape (`@include card` panel, eyebrow captions, mono figures, hairline border per cell, 2-column at 520px) and an `.earn-row` history row mirroring `.div-row`. - Verified: cargo + bun build clean; 3 new Phase 25 unit tests pass alongside the 7 prior. `/api/symbols/AAPL/history?range=1Y` returns an 11-entry `earnings` array (Apr 30, Jan 29, Oct 30, Jul 31, ...). `/s/AAPL` renders the Earnings section with "Apr 30 · 23 days ago" + "Jul 30 · in 68 days · estimated from cadence" (Yahoo has not been hit yet on the dev box; the cadence estimate handled the next-date fallback exactly as designed) and four past-date rows. The candlestick chart at 1Y range shows four ink-faint dots above the Aug 1, Oct 30, Jan 29, and Apr 30 candles. `/s/SPY` (ETF), `/s/^SPX` (index), and `/s/GC=F` (future) hide the section cleanly. Desktop (1280px) and phone (390px) both render with no horizontal overflow and zero console errors.- **Phase 17 stock health read.** Complete, verified, and deployed to production 2026-05-23 (commit `8a16b14`). A non-advice synthesis of the data this app already carries (fundamentals + price/growth trajectory + leadership
@@ -1218,8 +1314,9 @@ feeds: iShares/BlackRock, Vanguard, ...) was captured 2026-05-22 from avibe-coding side note mid-Phase-28; see the decisions log.Phase 30 (top picks + backtest) is complete, verified, and deployed toproduction 2026-05-23 (commit `8ea9048`). Remaining post-MVP work isthe loose-ordered Phase 13, 15, 16, 17, 19, 25, 27, 29 backlog. Phase 26 (dividend payouts)production 2026-05-23 (commit `8ea9048`). Phase 25 (earnings dates) iscomplete and verified locally 2026-05-23 — not yet deployed. Remainingpost-MVP work is the loose-ordered Phase 13, 15, 19, 27, 29 backlog. Phase 26 (dividend payouts)is complete and deployed (commit `7608b06`); the MVP plus Phase 14,Phase 18, Phase 20, Phase 21, Phase 23 + 24, Phase 22 and Phase 26 are alllive at https://finance.bythewood.me. There is still no GitHub repo for
@@ -1636,7 +1733,20 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). which reads as a stray decimal point — the user noticed it on DELL. Replace it with an em dash (`—`) or a similar unambiguous "no data" mark.- [ ] **Phase 25: Earnings dates.** (Captured 2026-05-22 as a vibe-coding- [x] **Phase 25: Earnings dates.** Complete and verified locally 2026-05-23 — see the Phase 25 entry in Status (Done list) and the decisions log. Two surfaces: a new symbol-page section between Key stats and Stock health (Most recent + Next expected as a paired headline, plus the last four past dates), and small ink-faint dot pips on the candlestick chart at each past earnings date. Next-date source is Yahoo's `quoteSummary.calendarEvents` primary with a cadence estimate from past 8-K item-2.02 dates as the fallback. Migration `0011` adds `symbols.next_earnings_at` + `symbols.earnings_synced_at`; a new `earnings_calendar` scheduler section sweeps stocks monthly on the existing `yahoo` `EndpointGuard`. Past dates come for free from the 8-K item-2.02 filings Phase 14 already stores. Original phase scope below. (Captured 2026-05-22 as a vibe-coding side note.) Surface a stock's earnings rhythm on the symbol page and the chart. Pieces: (1) A small section showing the most recent earnings date with "N days
@@ -3021,6 +3131,59 @@ finance/ sits above Fundamentals (the existing Phase 20 standing badge stays in place, since it is specifically the per-ratio rollup and the new panel is the broader synthesis).- **2026-05-23 — Phase 25 picked next; design Q&A settled four points.** After Phase 17 deployed, the user picked Phase 25 (earnings dates) over the remaining loose backlog (13 heat map, 15 industry trends, 27 backup providers, 29 issuer-direct ETF feeds). Four scoping questions resolved before any code: 1. **Next-date source.** Yahoo's `quoteSummary.calendarEvents` primary with a cadence-estimate fallback when Yahoo carries nothing for the stock (uneven coverage on small caps). Hybrid beats either alone — Yahoo is authoritative when present, the cadence estimate covers what it does not. 2. **Page placement.** Right under Key stats — top-of-page, before Stock health → Fundamentals → ... → Filings. Earnings rhythm is "what's happening with this company now" and reads alongside the live quote rather than buried near the filings. 3. **Chart pip style.** Small ink dot above each earnings bar via lightweight-charts' v5 `createSeriesMarkers` API, `aboveBar` `circle`, color `rgba(33,31,26,0.55)` — the Paper Ledger ink palette, deliberately *not* green / amber / red so it reads as wayfinding (same exception the Phase 8 indicator lines and the Phase 18 asset-mix bar take). Picked over a vertical hairline guide (too prominent) and an "E" letter glyph (slightly noisier). 4. **Past-dates depth.** Last 4 (one trailing year of a quarterly cadence), matching the Phase 16 anomaly feed's 1-year window. All past dates still ride the chart pip layer (filtered to the visible candle range) so a 5Y view shows all of them.- **2026-05-23 — Phase 25 earnings dates shipped (local).** Yahoo `quoteSummary` is now the source for a fourth concern beyond quotes / dividends / fund metadata — its `calendarEvents` module. Design calls made during the build: (1) **`earnings_calendar` is an inherent `YahooProvider` method, not behind a trait** — same single-source rationale as `dividends` / `fund_metadata` / `lookup`, and Phase 27 (provider redundancy) is the right place to lift it if a second source ever joins. (2) **Past dates come from `filings.items LIKE '%2.02%'`** — Phase 14 already stores 8-K item codes, so no new data source for the past timeline; one SELECT in the symbol route feeds both the page list and the chart pip layer. (3) **Cadence estimate clamps to 60–200 days** so a degenerate same-day gap (a press release + a same-day follow-up 2.02 8-K) does not project a date in the next week. (4) **Job re-runs once `next_earnings_at` passes**, not only on monthly staleness — a stock whose date Yahoo has not yet rolled forward is picked up the next tick after its print lands, so the page is rarely showing a stale "in -2 days" reading. (5) **The chart pip color reuses `--ink` at 55% opacity** rather than introducing a new token: it sits naturally with the warm paper world and matches the Phase 25 design Q&A's "deliberate non-semantic" call. (6) **The `HistoryResponse` carries all past dates** (not just the last 4); the client filters to dates that match a visible candle, so a deep MAX range correctly shows every past pip in scope, while 1M / 6M views only show the ones inside their window. The cadence-estimate path was exercised on the local dev box (Yahoo blanket-429s the WSL2 IP), and the page rendered exactly as expected; Yahoo's calendar will land on the production alpine box on the next sweep after deploy. Not yet deployed.- **2026-05-23 — Phase 17 stock health read shipped (local).** Pure derivation on top of data the app already carries, no schema change and no new network calls. The composite is fundamentals 0.55 +
modified
frontend/static_src/symbol/scripts/chart.js
@@ -4,6 +4,7 @@ import { LineSeries, HistogramSeries, ColorType, createSeriesMarkers,} from "lightweight-charts";// Paper Ledger theme: ink figures and hairline rules on the warm paper
@@ -52,6 +53,12 @@ const RSI_INK = "#3f6f9c";const BENCH_INK = "#7a5237";const VOLUME_UP = "rgba(47,125,79,0.38)";const VOLUME_DOWN = "rgba(178,59,50,0.38)";// Phase 25: earnings-date markers. A small ink dot above each candle that// matches a past 8-K item-2.02 date. Same warm-paper ink-faint as the rest// of the Paper Ledger palette so it reads as wayfinding, not a value verdict// (the candles still own green/red and the indicator inks own the other// non-semantic palette).const EARNINGS_INK = "rgba(33,31,26,0.55)";/** `12.4` -> `+$12.40`, `-3` -> `-$3.00`. */function fmtMoney(n) {
@@ -129,6 +136,13 @@ export function initChart() { visible: false, }); // Earnings-date markers (Phase 25). Stocks only; the payload carries an // `earnings` array of `YYYY-MM-DD` past dates that match candle times. // Each draws a small ink dot above the matching bar. v5's // createSeriesMarkers attaches to the candle series and is replaced // wholesale on each setMarkers call. const earningsMarkers = createSeriesMarkers(series, []); let bars = []; // loaded candles, ascending by time let latest = null; // last loaded payload, kept so RSI can attach on demand
@@ -346,6 +360,23 @@ export function initChart() { if (benchBtn) benchBtn.hidden = bench.length === 0; const benchOn = bench.length > 0 && (!benchBtn || benchBtn.classList.contains("is-active")); benchmarkSeries.applyOptions({ visible: benchOn }); // Phase 25: earnings-date pips. Filter to dates inside the visible // candle window — lightweight-charts ignores markers whose time // does not match a candle, but trimming first keeps the payload small // and the markers sorted ascending (the API requires it). const earnings = d.earnings || []; const candleTimes = new Set(d.candles.map((c) => c.time)); const markers = earnings .filter((e) => candleTimes.has(e.time)) .map((e) => ({ time: e.time, position: "aboveBar", color: EARNINGS_INK, shape: "circle", })) .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0)); earningsMarkers.setMarkers(markers); } // ── indicator toggles ──────────────────────────────────────────────────
modified
frontend/static_src/symbol/styles/symbol.scss
@@ -1472,3 +1472,98 @@ a.anomaly__link:hover .anomaly__body { min-height: 34px; }}/* ---------- earnings (Phase 25) ---------- */.earn { padding: 14px 16px;}/* The two headline cells — most recent + next expected — stack on phone and sit side by side from ~520px, mirroring .div-pace. */.earn__pair { display: grid; grid-template-columns: 1fr; gap: 12px 18px; margin: 0 0 12px;}@media (min-width: 520px) { .earn__pair { grid-template-columns: 1fr 1fr; }}.earn__cell { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid var(--rule); border-radius: 4px;}.earn__cap { @include eyebrow; font-size: 0.6rem;}.earn__val { font-size: 1.05rem; font-weight: 600; display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 8px;}.earn__sub { @include eyebrow; font-size: 0.62rem; font-weight: 400; color: var(--ink-faint);}.earn__past-title { @include eyebrow; color: var(--ink); margin-bottom: 2px; padding-top: 14px; border-top: 1px solid var(--rule-strong);}.earn__list { margin: 0; padding: 0; list-style: none;}.earn-row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 9px 0; border-top: 1px solid var(--rule);}.earn-row:first-child { border-top: none;}.earn-row__date { font-size: 0.85rem; color: var(--ink-dim);}.earn-row__ago { font-size: 0.78rem; color: var(--ink-faint);}.earn__src { margin: 12px 0 0; padding-top: 11px; border-top: 1px solid var(--rule); font-size: 0.74rem; color: var(--ink-faint);}
added
migrations/0011_earnings_calendar.sql
@@ -0,0 +1,23 @@-- finance migration 0011: earnings calendar (Phase 25).---- The next-expected earnings date for each stock, fetched from Yahoo's-- `quoteSummary.calendarEvents` module. Past earnings dates are not stored-- here: they ride for free on the 8-K item-2.02 filings the existing-- `filings` table already carries (Phase 14 added the `items` column), so a-- single SELECT against `filings.items LIKE '%2.02%'` lists them.---- Stocks only — ETFs, indexes and futures have no earnings, so these stay-- NULL forever on every non-stock row. A stock Yahoo has no calendar data-- for (Yahoo's coverage is uneven on small caps) keeps `next_earnings_at`-- NULL even after a successful sync, and the symbol page falls back to a-- cadence estimate derived from the past 8-K item-2.02 dates.-- Forward-looking next earnings date (UTC epoch-ms; date precision but-- stored as a timestamp so it sorts and ages alongside the other-- `*_at` columns). NULL when Yahoo has no upcoming date or the stock-- has not been swept yet.ALTER TABLE symbols ADD COLUMN next_earnings_at INTEGER;-- When the Yahoo `earnings_calendar` scheduler section last refreshed-- this stock's `next_earnings_at`. NULL = never swept. Stocks only.ALTER TABLE symbols ADD COLUMN earnings_synced_at INTEGER;
@@ -1597,6 +1597,84 @@ pub fn drawdown_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> { out}// ── Phase 25: earnings dates ──────────────────────────────────────────────/// Estimate the next earnings date from a stock's recent earnings cadence./// Used as the fallback when Yahoo's `calendarEvents` has no upcoming date/// for the stock (its coverage is uneven on small caps), built from the/// last up-to-four 8-K item-2.02 dates the existing `filings` table already/// carries (Phase 14).////// `dates` is newest-first (matching how the symbol route's `ORDER BY/// filed_at DESC` SELECT returns them). Returns `None` when fewer than two/// priors exist or the spacing reads degenerate (a same-day correction)./// Less reliable than Yahoo when a company moves its reporting calendar,/// but better than no date when Yahoo is empty.pub fn next_earnings_estimate(dates: &[&str]) -> Option<String> { if dates.len() < 2 { return None; } // Parse the newest-first slice into `NaiveDate`s; drop any unparsable // entries (defensive — these come from SEC, but the column is TEXT). let parsed: Vec<chrono::NaiveDate> = dates .iter() .filter_map(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) .take(4) .collect(); if parsed.len() < 2 { return None; } // Gaps between consecutive earnings prints, oldest-to-newest order. let mut gaps: Vec<i64> = parsed .windows(2) .map(|w| (w[0] - w[1]).num_days()) .collect(); if gaps.iter().all(|g| *g <= 1) { return None; } gaps.sort(); let median = gaps[gaps.len() / 2]; // Clamp the median into a sane quarterly band so a stale dataset with // a few-day gap (multiple 8-Ks tagged 2.02 in one cycle) does not // project a date in the next week. Most US large-caps file ~91 days // apart; semi-annual filers ~182. let median = median.clamp(60, 200); let next = parsed[0] + chrono::Duration::days(median); Some(next.format("%Y-%m-%d").to_string())}#[cfg(test)]mod phase25_tests { use super::*; #[test] fn estimates_a_quarterly_cadence() { // Four prints roughly 91 days apart, newest-first. let dates = &["2026-05-01", "2026-02-01", "2025-10-30", "2025-08-01"]; let next = next_earnings_estimate(dates).unwrap(); // Median gap ≈ 90 days, so next ≈ 2026-07-30 (give or take a day // depending on the exact gaps). let parsed = chrono::NaiveDate::parse_from_str(&next, "%Y-%m-%d").unwrap(); let baseline = chrono::NaiveDate::parse_from_str("2026-05-01", "%Y-%m-%d").unwrap(); let gap = (parsed - baseline).num_days(); assert!((85..=95).contains(&gap), "next gap was {gap}d"); } #[test] fn returns_none_on_too_few_priors() { assert!(next_earnings_estimate(&[]).is_none()); assert!(next_earnings_estimate(&["2026-05-01"]).is_none()); } #[test] fn handles_same_day_corrections() { // Two filings on adjacent days (a press-release and a follow-up): the // 1-day gap is degenerate, so the estimate is rejected. let dates = &["2026-05-02", "2026-05-01"]; assert!(next_earnings_estimate(dates).is_none()); }}#[cfg(test)]mod phase28_tests { use super::*;
@@ -42,6 +42,14 @@ pub struct SymbolRow { /// from `universe/starter.csv` (Phase 28). The symbol page's chart /// shows the relative-performance overlay only when this is set. pub benchmark: Option<String>, /// Next-expected earnings date from Yahoo's `quoteSummary.calendarEvents` /// (Phase 25), epoch-ms. NULL when Yahoo has no upcoming date for this /// stock or the symbol has not been swept yet; the symbol page then /// falls back to a cadence estimate from past 8-K item-2.02 filings. pub next_earnings_at: Option<i64>, /// When this stock's Yahoo earnings-calendar snapshot was last /// refreshed (Phase 25). NULL = never swept. Stocks only. pub earnings_synced_at: Option<i64>, pub last_price: Option<f64>, pub prev_close: Option<f64>, pub last_quote_at: Option<i64>,
modified
src/providers/yahoo.rs
@@ -342,6 +342,76 @@ impl YahooProvider { Ok(Some(parse_fund_metadata(result))) } /// Fetch the next-expected earnings date for `ticker` from Yahoo's /// `quoteSummary.calendarEvents` module (Phase 25). One request to the /// same v10 endpoint that already serves `fund_metadata`, asking only for /// the calendar module — Yahoo's smallest reply on this endpoint. /// /// Returns `Ok(Some(epoch_ms))` when Yahoo has an upcoming earnings date, /// `Ok(None)` when it knows the symbol but carries no date (Yahoo's /// coverage is uneven on small caps, and a closely-watched name with a /// just-passed print also briefly reads empty), or `Ok(None)` for an /// unknown symbol (404 or `quoteSummary.error`). Gating responses (429 / /// 503 / 401 / 403) surface as the typed [`RateLimited`], same defensive /// set as `fund_metadata`. pub async fn earnings_calendar(&self, ticker: &str) -> Result<Option<i64>> { let sym = urlencoding::encode(&yahoo_symbol(ticker)).into_owned(); let url = format!( "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{sym}\ ?modules=calendarEvents" ); let resp = self.client.get(&url).send().await?; let status = resp.status(); if matches!( status, StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE | StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN ) { let retry_after_secs = resp .headers() .get(RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|s| s.trim().parse::<i64>().ok()); return Err(anyhow::Error::new(RateLimited { status: status.as_u16(), retry_after_secs, })); } if status == StatusCode::NOT_FOUND { return Ok(None); } let resp = resp.error_for_status()?; let env: QuoteSummaryEnvelope = resp.json().await?; if env.quote_summary.error.is_some() { return Ok(None); } let Some(result) = env .quote_summary .result .and_then(|mut r| if r.is_empty() { None } else { Some(r.remove(0)) }) else { return Ok(None); }; // `earningsDate` is an array; Yahoo populates 1 or 2 entries — the // confirmed date, or a confirmed/estimated pair. The earliest one // is the upcoming print. Future events only: a date in the past // means Yahoo has not yet rolled it forward, so we ignore it. let now_secs = chrono::Utc::now().timestamp(); let next_secs = result .calendar_events .and_then(|c| c.earnings) .and_then(|e| { e.earnings_date .into_iter() .filter_map(|d| Some(d.0 as i64)) .filter(|s| *s >= now_secs) .min() }); Ok(next_secs.map(|s| s * 1000)) } /// Identify a symbol: validate it exists on Yahoo and return its name, /// kind, exchange and currency, alongside the quote the same request /// carried. Used by the Phase 9 add-symbol flow.
@@ -393,6 +463,23 @@ struct QuoteSummaryResult { summary_detail: Option<SummaryDetailModule>, price: Option<PriceModule>, asset_profile: Option<AssetProfileModule>, /// `calendarEvents` (Phase 25) — the upcoming earnings date and ex-div /// date a v10 request can carry alongside the fund modules. calendar_events: Option<CalendarEventsModule>,}#[derive(Default, Deserialize)]#[serde(rename_all = "camelCase", default)]struct CalendarEventsModule { earnings: Option<CalendarEarnings>,}#[derive(Default, Deserialize)]#[serde(rename_all = "camelCase", default)]struct CalendarEarnings { /// Yahoo emits 1 or 2 `RawF64` entries (Unix seconds), the confirmed /// upcoming date or a confirmed/estimated pair. earnings_date: Vec<RawF64>,}#[derive(Default, Deserialize)]
modified
src/routes/health.rs
@@ -249,6 +249,12 @@ fn job_meta(job: &str) -> (&str, &str) { "Yahoo quoteSummary snapshot for each ETF — expense ratio, yield, \ NAV, inception, category, fund family, strategy. Refreshed monthly.", ), "earnings_calendar" => ( "Earnings calendar", "Yahoo quoteSummary `calendarEvents` for each stock — the next \ expected earnings date. Refreshed monthly and whenever the \ stored date passes.", ), other => (other, ""), }}
@@ -260,9 +266,10 @@ fn job_rank(job: &str) -> u8 { "history" => 1, "sec" => 2, "fund_metadata" => 3, "dividends" => 4, "intraday" => 5, "daily_close" => 6, "earnings_calendar" => 4, "dividends" => 5, "intraday" => 6, "daily_close" => 7, _ => 9, }}
modified
src/routes/symbols.rs
@@ -930,6 +930,130 @@ async fn build_leadership(pool: &sqlx::SqlitePool, ticker: &str, synced: bool) - }}// ── earnings dates (Phase 25) ─────────────────────────────────────────────/// One past earnings date shaped for the page.#[derive(Serialize)]struct PastEarningsRow { /// `YYYY-MM-DD`; the template's `shortdate` filter formats it. date: String, /// Days from today; positive for past dates. days_ago: i64,}/// Everything the symbol-page Earnings section needs. Stocks only — every/// caller gates the build on `kind == "stock"`.#[derive(Serialize)]struct EarningsView { /// Most recent past earnings date (`YYYY-MM-DD`), with a days-ago figure. most_recent: Option<PastEarningsRow>, /// Next-expected earnings date (`YYYY-MM-DD`) and days-from-today. next_date: Option<String>, next_days: Option<i64>, /// Where the next date came from: `yahoo` (authoritative), `estimate` /// (cadence projection), or `unknown` (Yahoo has no date and we cannot /// estimate one — too few priors). next_source: &'static str, /// The last few past earnings dates, newest first. Capped to 4 (one /// trailing year of a quarterly cadence) per the design pass. past: Vec<PastEarningsRow>, /// All past earnings dates surfaced to the chart as ink pips above /// each matching candle. Kept here so the route's history API can /// echo them into the chart payload. chart_dates: Vec<String>, /// When this stock's earnings-calendar sync last ran, for the section /// caption. NULL when Yahoo has never been hit for this stock; the page /// then shows the past dates and the cadence estimate without the "as of" /// line so it does not lie about a sync that did not happen. earnings_synced_at: Option<i64>,}/// How many past earnings dates to list on the page. Four covers one trailing/// year of a quarterly cadence; the chart pips show all of them up to the/// chart's visible range.const EARNINGS_PAST_LIMIT: usize = 4;/// Load past earnings dates from `filings.items LIKE '%2.02%'` (Phase 14/// stored 8-K item codes). Newest first; capped to a generous window so a/// company that moved its reporting day still produces a clean median.async fn load_past_earnings(pool: &sqlx::SqlitePool, ticker: &str) -> Vec<String> { sqlx::query_scalar( "SELECT filed_at FROM filings \ WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%2.02%' \ ORDER BY filed_at DESC, accession DESC LIMIT 16", ) .bind(ticker) .fetch_all(pool) .await .unwrap_or_default()}/// Build the Earnings section for a stock. Returns `None` when SEC has not/// synced yet (no past dates to anchor the section) and Yahoo also carries/// no next date — the section is hidden cleanly in that case.async fn build_earnings( pool: &sqlx::SqlitePool, ticker: &str, next_earnings_at: Option<i64>, earnings_synced_at: Option<i64>,) -> Option<EarningsView> { let past_dates = load_past_earnings(pool, ticker).await; if past_dates.is_empty() && next_earnings_at.is_none() { return None; } let today = chrono::Utc::now().date_naive(); let days_between = |d: &str| -> Option<i64> { chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") .ok() .map(|nd| (nd - today).num_days()) }; let most_recent = past_dates.first().and_then(|d| { days_between(d).map(|gap| PastEarningsRow { date: d.clone(), days_ago: -gap, // gap is negative for past dates; flip to days-ago. }) }); // Resolve the next date: Yahoo primary, cadence-estimate fallback. let (next_date, next_source) = match next_earnings_at { Some(ts) => { let date = chrono::DateTime::from_timestamp_millis(ts) .map(|dt| dt.naive_utc().date().format("%Y-%m-%d").to_string()); (date, "yahoo") } None => { let date_refs: Vec<&str> = past_dates.iter().map(String::as_str).collect(); match compute::next_earnings_estimate(&date_refs) { Some(d) => (Some(d), "estimate"), None => (None, "unknown"), } } }; let next_days = next_date.as_deref().and_then(days_between); let past: Vec<PastEarningsRow> = past_dates .iter() .take(EARNINGS_PAST_LIMIT) .filter_map(|d| { days_between(d).map(|gap| PastEarningsRow { date: d.clone(), days_ago: -gap, }) }) .collect(); Some(EarningsView { most_recent, next_date, next_days, next_source, past, chart_dates: past_dates, earnings_synced_at, })}// ── per-ticker anomaly feed (Phase 16) ────────────────────────────────────/// One row in the anomaly feed, as shaped for the template. Wraps
@@ -1298,6 +1422,23 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) // the template hides the section. let anomalies = build_anomalies(&state.pool, &ticker, &symbol.kind, &bars, &facts).await; // Earnings dates (Phase 25). Stocks only; the past dates ride for free // off the existing 8-K item-2.02 filings (Phase 14 stored the `items` // column), the next date is either Yahoo's `calendarEvents` or a cadence // estimate from those past dates. The chart pips also read off the past // dates carried in `earnings.chart_dates`. let earnings = if is_stock { build_earnings( &state.pool, &ticker, symbol.next_earnings_at, symbol.earnings_synced_at, ) .await } else { None }; let extra = minijinja::context! { title => ticker, symbol => symbol,
@@ -1312,6 +1453,7 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) leadership => leadership, dividends => dividends, anomalies => anomalies, earnings => earnings, filings => filings, }; render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)
@@ -1342,6 +1484,14 @@ struct LinePoint { value: f64,}/// One earnings-date marker for the chart. `YYYY-MM-DD` (matches the/// candle `time` field), so the client can index it against the candle/// series directly.#[derive(Serialize)]struct EarningsMarker { time: String,}/// The symbol chart payload (Phase 8 + Phase 28): the candles for the/// selected range plus the indicator overlays, each already trimmed to the/// visible window. Phase 28 adds an optional benchmark series — the
@@ -1365,6 +1515,11 @@ struct HistoryResponse { /// Absent when no benchmark is configured. #[serde(skip_serializing_if = "Option::is_none")] benchmark_ticker: Option<String>, /// Past earnings-date markers for the chart (Phase 25). Each is a /// `YYYY-MM-DD` matching one of the visible candles; the client draws /// a small ink dot above each matching bar. Stocks only; empty otherwise. #[serde(skip_serializing_if = "Vec::is_empty")] earnings: Vec<EarningsMarker>,}/// Earliest `YYYY-MM-DD` to *show* for a range button. `None` means no limit.
@@ -1477,6 +1632,25 @@ async fn history_api( _ => Vec::new(), }; // Earnings-date pips (Phase 25). Stocks only; each pip is dated to an // 8-K item-2.02 filing date (Phase 14 already stored those in // `filings.items`). The chart maps each `time` to its matching candle. let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM symbols WHERE ticker = ?") .bind(&ticker) .fetch_optional(&state.pool) .await .ok() .flatten(); let earnings = if kind.as_deref() == Some("stock") { let dates = load_past_earnings(&state.pool, &ticker).await; dates .into_iter() .map(|time| EarningsMarker { time }) .collect() } else { Vec::new() }; let resp = HistoryResponse { sma50: line(compute::sma(&closes, 50)), sma200: line(compute::sma(&closes, 200)),
@@ -1485,6 +1659,7 @@ async fn history_api( candles: candles.into_iter().skip(start).collect(), benchmark, benchmark_ticker, earnings, }; Json(resp).into_response()
modified
src/scheduler.rs
@@ -106,6 +106,16 @@ const DIVIDENDS_STALE_SECS: i64 = 7 * 24 * 3600;const FUND_METADATA_INTERVAL_SECS: i64 = 24 * 3600;const FUND_METADATA_STALE_SECS: i64 = 30 * 24 * 3600;/// Earnings calendar refresh (Phase 25). Yahoo's `calendarEvents.earnings`/// rolls forward one quarter at a time as each print lands, and a stock's/// next date is irrelevant before it shifts — so a monthly cadence is/// enough to land each new date within a few weeks of when Yahoo learns it./// Daily due-check; one request per stock through the shared `yahoo`/// `EndpointGuard`. The whole sweep also re-runs once the stored/// `next_earnings_at` passes, so a missed roll-forward never sits stale.const EARNINGS_INTERVAL_SECS: i64 = 24 * 3600;const EARNINGS_STALE_SECS: i64 = 30 * 24 * 3600;/// Spawn the scheduler. The returned handle is normally dropped: dropping it/// detaches the task, which then runs for the lifetime of the process.pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle<()> {
@@ -161,6 +171,14 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle tracing::warn!("[scheduler] bring fund_metadata job forward: {e}"); } // Earnings calendar job (Phase 25): same bring-forward pattern, so a // deploy adding the columns backfills the stock universe within a // tick rather than the daily interval. Resumable; no-stale fast path // is free. if let Err(e) = schedule_next(&pool, "earnings_calendar", now_ms()).await { tracing::warn!("[scheduler] bring earnings_calendar job forward: {e}"); } // Prune's last-run time is loop-local: a restart simply re-prunes once, // which is harmless (local-only DELETEs, no network). let mut last_prune: Option<i64> = None;
@@ -237,6 +255,19 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle Err(e) => tracing::warn!("[scheduler] fund_metadata due-check: {e}"), } // Earnings calendar (Phase 25): sweep stocks whose next-expected // earnings date has gone stale or already passed. Same Yahoo // guard; one request per stock per month in steady state. match is_due(&pool, "earnings_calendar", now_ms()).await { Ok(true) => { if let Err(e) = run_earnings_calendar(&pool, &config, &hub).await { tracing::warn!("[scheduler] earnings_calendar: {e:#}"); } } Ok(false) => {} Err(e) => tracing::warn!("[scheduler] earnings_calendar due-check: {e}"), } // Intraday quotes: demand-driven (only symbols a browser is // viewing). Inside a trading session every viewed symbol is // polled; outside it, only viewed futures, which trade nearly
@@ -1375,6 +1406,149 @@ async fn mark_fund_metadata_synced(pool: &SqlitePool, ticker: &str) -> sqlx::Res Ok(())}// ────────────── stock earnings calendar sweep (Phase 25) ──────────────────/// Sweep stocks whose next-expected earnings date has gone stale (monthly)/// or already passed. One request per stock through the shared `yahoo`/// `EndpointGuard`; mirrors `run_fund_metadata`'s shape — guard-paced,/// resumable, broadcast-to-/health.async fn run_earnings_calendar( pool: &SqlitePool, config: &Config, hub: &Hub,) -> anyhow::Result<()> { let started = now_ms(); let next = started + EARNINGS_INTERVAL_SECS * 1000; let cutoff = started - EARNINGS_STALE_SECS * 1000; // Refresh a stock when either its sync-timestamp has aged out OR its // stored next date has already passed (the print landed; Yahoo should // carry the following quarter's date by now). let stale: Vec<String> = sqlx::query_scalar( "SELECT ticker FROM symbols \ WHERE kind = 'stock' \ AND ( \ earnings_synced_at IS NULL OR earnings_synced_at < ? \ OR (next_earnings_at IS NOT NULL AND next_earnings_at < ?) \ ) \ ORDER BY ticker", ) .bind(cutoff) .bind(started) .fetch_all(pool) .await?; if stale.is_empty() { mark_ok(pool, "earnings_calendar", Some(next)).await?; return Ok(()); } mark_fetching(pool, "earnings_calendar").await?; notify_health(hub); tracing::info!( "[scheduler] earnings_calendar: refreshing {} stocks", stale.len() ); let yahoo = YahooProvider::new(providers::http::build_client(config)); let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET); let t0 = Instant::now(); let mut ok = 0i64; let mut empty = 0i64; let mut errors = 0i64; let mut stopped: Option<String> = None; for ticker in &stale { match guard.acquire().await? { Permit::Granted => {} Permit::Denied(why) => { stopped = Some(why); break; } } match yahoo.earnings_calendar(ticker).await { Ok(Some(ts_ms)) => { guard.record_success().await?; if let Err(e) = store_earnings_next(pool, ticker, Some(ts_ms)).await { tracing::warn!("[scheduler] earnings_calendar store {ticker}: {e:#}"); errors += 1; continue; } ok += 1; } // Yahoo answered cleanly but has no upcoming date for this // stock (uneven coverage). Clear the stored next date so the // symbol page falls back to a cadence estimate, and stamp the // sync so the next sweep does not re-fetch the same empty. Ok(None) => { guard.record_success().await?; if let Err(e) = store_earnings_next(pool, ticker, None).await { tracing::warn!("[scheduler] earnings_calendar store {ticker}: {e:#}"); errors += 1; continue; } empty += 1; } Err(e) => { guard.record_failure(&e).await?; errors += 1; tracing::warn!("[scheduler] earnings_calendar {ticker}: {e:#}"); } } } let dur = t0.elapsed().as_millis() as i64; let detail = format!( "{ok}/{} stocks ({empty} empty, {errors} errors)", stale.len() ); match stopped { Some(why) => { let full = format!("stopped early ({why}); {detail}"); tracing::warn!("[scheduler] earnings_calendar: {full}"); log_fetch( pool, "earnings_calendar", "yahoo", "skipped", Some(&full), Some(ok), dur, started, ) .await?; } None => { tracing::info!("[scheduler] earnings_calendar: {detail}"); log_fetch( pool, "earnings_calendar", "yahoo", "ok", Some(&detail), Some(ok), dur, started, ) .await?; } } mark_ok(pool, "earnings_calendar", Some(next)).await?; notify_health(hub); Ok(())}/// Write one stock's next-earnings date and stamp it as freshly synced./// `next` is `None` when Yahoo has no upcoming date (the stored value is/// cleared so the page falls back to a cadence estimate). `pub(crate)`:/// the add-symbol backfill reuses it.pub(crate) async fn store_earnings_next( pool: &SqlitePool, ticker: &str, next: Option<i64>,) -> sqlx::Result<()> { let now = now_ms(); sqlx::query( "UPDATE symbols SET next_earnings_at = ?, earnings_synced_at = ?, \ updated_at = ? \ WHERE ticker = ?", ) .bind(next) .bind(now) .bind(now) .bind(ticker) .execute(pool) .await?; Ok(())}/// Fill in `symbols.cik` for any stock found in the bulk SEC ticker map./// Returns how many were newly resolved.async fn resolve_ciks(pool: &SqlitePool, map: &HashMap<String, String>) -> sqlx::Result<i64> {
@@ -1776,6 +1950,13 @@ pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker: if kind == "etf" { backfill_fund_metadata(pool, config, ticker).await; } // Phase 25: stocks get their Yahoo earnings calendar pulled too — so a // user-added stock's symbol page carries the next-expected earnings // date the moment the add returns, rather than waiting on the next // scheduler cycle. if kind == "stock" { backfill_earnings_calendar(pool, config, ticker).await; } // SEC data covers stocks and ETFs; indexes and futures do not file. The // whole SEC step is skipped with no contact email configured, as `run_sec`
@@ -1816,6 +1997,27 @@ async fn backfill_fund_metadata(pool: &SqlitePool, config: &Config, ticker: &str }}/// Pull and store a freshly-added stock's next-expected earnings date/// (Phase 25). Mirrors `backfill_dividends`: same `yahoo` guard,/// best-effort, no failure propagated to the add-symbol response.async fn backfill_earnings_calendar(pool: &SqlitePool, config: &Config, ticker: &str) { let yahoo = YahooProvider::new(providers::http::build_client(config)); let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET); match guarded(&guard, yahoo.earnings_calendar(ticker)).await { Some(Ok(next)) => match store_earnings_next(pool, ticker, next).await { Ok(()) => { tracing::info!( "[backfill] {ticker} <- earnings_calendar ({})", next.map(|_| "next set").unwrap_or("no upcoming date"), ); } Err(e) => tracing::warn!("[backfill] store earnings {ticker}: {e:#}"), }, Some(Err(e)) => tracing::warn!("[backfill] earnings_calendar {ticker}: {e:#}"), None => {} }}/// Pull and store a freshly-added symbol's dividend / distribution history/// (Phase 26 + Phase 28: stocks and ETFs both use this path). Routed through/// the same `yahoo` guard the dividends sweep uses. Best-effort: a guard
modified
templates/pages/symbol.html
@@ -204,6 +204,58 @@ {# --- fundamentals + financials: stocks only --- #} {% if symbol.kind == 'stock' %} {# --- earnings dates (Phase 25): next-expected date with provenance, days since the last print, and a short list of recent past dates. Past dates come from 8-K item-2.02 filings (already stored by Phase 14); the next date is Yahoo's `calendarEvents` when present, otherwise a cadence estimate from those past dates. --- #} {% if earnings %} <h2 class="section-title">Earnings{% if earnings.earnings_synced_at %}<span class="section-title__asof">calendar synced from Yahoo {{ earnings.earnings_synced_at|ago }}</span>{% endif %}</h2> <section class="panel earn"> <dl class="earn__pair"> <div class="earn__cell"> <dt class="earn__cap">Most recent</dt> {% if earnings.most_recent %} <dd class="earn__val num">{{ earnings.most_recent.date|shortdate }}<span class="earn__sub">{{ earnings.most_recent.days_ago }} day{% if earnings.most_recent.days_ago != 1 %}s{% endif %} ago</span></dd> {% else %} <dd class="earn__val">—<span class="earn__sub">no past earnings filed</span></dd> {% endif %} </div> <div class="earn__cell"> <dt class="earn__cap">Next expected</dt> {% if earnings.next_date %} <dd class="earn__val num">{{ earnings.next_date|shortdate }}<span class="earn__sub"> {%- if earnings.next_days is not none -%} {%- if earnings.next_days >= 0 -%} in {{ earnings.next_days }} day{% if earnings.next_days != 1 %}s{% endif %} {%- else -%} {{ -earnings.next_days }} day{% if -earnings.next_days != 1 %}s{% endif %} ago {%- endif -%} {%- endif -%} {% if earnings.next_source == 'estimate' %} · estimated from cadence{% endif %} </span></dd> {% else %} <dd class="earn__val">—<span class="earn__sub">no upcoming date on file</span></dd> {% endif %} </div> </dl> {% if earnings.past %} <div class="earn__past"> <h3 class="earn__past-title">Recent earnings dates</h3> <ul class="earn__list"> {% for e in earnings.past %} <li class="earn-row"> <span class="earn-row__date num">{{ e.date|shortdate }}</span> <span class="earn-row__ago">{{ e.days_ago }} day{% if e.days_ago != 1 %}s{% endif %} ago</span> </li> {% endfor %} </ul> </div> {% endif %} <p class="earn__src">Past earnings dates come from {{ symbol.ticker }}’s 8-K item 2.02 filings; the next date is Yahoo’s when available, otherwise estimated from the recent reporting cadence.</p> </section> {% endif %} {# --- stock health read (Phase 17): a single non-advice synthesis of the three signals below (fundamentals + price/growth trajectory + leadership stability), so the headline reads in one glance before