@@ -32,7 +32,29 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 25 (earnings dates) complete, verified**Current phase: Phase 15 (industry trends) complete and verifiedlocally 2026-05-23.** Yahoo `quoteSummary.assetProfile` is the source(clean GICS-style names); migration `0012` adds`symbols.asset_profile_synced_at` (the long-existing-but-unpopulated`symbols.sector` / `symbols.industry` columns are now written by anew `asset_profile` scheduler section on the `yahoo` `EndpointGuard`).Four surfaces — `/industries` sector index, `/industries/{sector}`and `/industries/{sector}/{industry}` detail pages (composite chart +seasonality + members), a sector · industry tag on the symbol pageheader, and a "Today's industries" panel on the home page betweenTop picks and Stock health. Top nav and bottom nav gained anIndustries entry. Pure aggregation on render (in-memory over theclassified universe), mirroring Phase 20 strongest / weakest. Healthpage lists the new `asset_profile` job between `fund_metadata` and`earnings_calendar`. Verified locally: cargo + bun build clean, threenew compute unit tests pass; on a fresh DB seeded mid-test `/` and`/industries` and `/s/AAPL` and `/health` all 200; the asset_profilejob is correctly bring-forwarded on boot and the `/industries` emptystate renders as designed until the first sweep lands.**Phase 13 (market heat map) dropped from the roadmap on 2026-05-23at the user's request.****Phase 25 (earnings dates) complete, verifiedand deployed to production 2026-05-23 (commit `aac84ae`).** 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:
@@ -145,6 +167,30 @@ backlog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 15 industry trends.** Complete and verified locally 2026-05-23. New `asset_profile` Yahoo scheduler section populates each stock's `symbols.sector` and `symbols.industry` (existing columns since `0001_initial.sql` but never written before) from `quoteSummary.assetProfile`, monthly cadence on the existing `yahoo` guard; migration `0012` adds the `asset_profile_synced_at` staleness column. `routes/industries.rs` carries the sector index, sector detail and industry detail pages plus a composite-history JSON endpoint; the equal-weight composite, trailing returns (1d / 5d / 1m / 3m / 1y), and 12-month seasonality are pure `compute.rs` helpers. The symbol page header carries a sector · industry tag pair linking to the relevant `/industries/{sector}/{industry}`. The home page gained a "Today's industries" panel between Top picks and Stock health: top-3 and bottom-3 sectors by today's equal-weight composite move. Top nav + bottom nav gained an Industries entry. Anti-spam: ~512 stocks × monthly refresh = ~17 requests/day on the 1000/hr `yahoo` guard. Verified locally: `cargo build` clean, `bun run build` clean, three new Phase 15 compute unit tests pass alongside the existing 10; on a fresh dev DB the boot seed populated 547 symbols and `/`, `/industries`, `/s/AAPL`, `/health`, `/api/health` all returned 200. `/industries` renders the empty state cleanly until the first asset_profile sweep lands (Yahoo rate-limits the WSL2 dev IP, so verification stops at "renders" until production picks it up). Not yet deployed.- **Phase 25 earnings dates.** Complete, verified, and deployed to production 2026-05-23 (commit `aac84ae`). On-deploy state: the new `earnings_calendar` job populates `next_earnings_at` on its first
@@ -1323,8 +1369,11 @@ vibe-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`). Phase 25 (earnings dates) iscomplete, verified, and deployed to production 2026-05-23 (commit`aac84ae`). Remainingpost-MVP work is the loose-ordered Phase 13, 15, 19, 27, 29 backlog. Phase 26 (dividend payouts)`aac84ae`). Phase 13 (market heat map) was **dropped from the roadmap**on 2026-05-23 at the user's request. Phase 15 (industry trends) waspicked as the next phase the same day and is **in progress** — see itsPhases-list entry for the settled scope. Remaining post-MVP work afterPhase 15 ships is the loose-ordered Phase 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
@@ -1563,11 +1612,9 @@ Phases 13 through 19 are the post-MVP backlog: ideas captured during planning,to be built after the Phase 12 ship. Order among them is loose, and severaldepend on Phase 5 (live quotes) and Phase 7 (SEC data).- [ ] **Phase 13: Market heat map.** A home-dashboard market-cap heat map: one tile per symbol, sized by market cap, colored green or red by the day's move (a treemap). Market cap is shares outstanding (from SEC, Phase 7) times the latest price. (The movers list and index sparklines once bundled here moved into the Phase 11 home redesign when it was promoted.)- [~] **Phase 13: Market heat map. Dropped 2026-05-23 (see decisions log).** The user removed the heat map from the roadmap. Phase number kept as a placeholder so later phases keep their numbering and the decisions log entry has a target.- [x] **Phase 14: Company leadership.** Complete, verified and deployed 2026-05-22; see the Phase 14 entry in Status and the decisions log. (Picked as the next backlog phase and scoped 2026-05-22.) Two things on the
@@ -1586,11 +1633,89 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). the SEC source already shipped; the ownership-XML sweep is network-heavier than Phase 7 (one request per filing) but paced and budgeted by the existing `sec` `EndpointGuard`.- [ ] **Phase 15: Industry trends.** Treat industries as first-class: aggregate symbols by industry (sector and industry come from SEC in Phase 7), show industry-level performance, seasonality (the months an industry tends to do well or poorly, computed from `daily_prices`), and how the industry is trending currently.- [ ] **Phase 15: Industry trends.** (Picked as the next backlog phase and scoped 2026-05-23 — see decisions log. In progress.) Treat industries as first-class: classify every stock by sector + industry, surface industry-level performance, seasonality, and current trend. **Data source.** Yahoo `quoteSummary.assetProfile` carries the clean, GICS-style `sector` ("Technology") and `industry` ("Consumer Electronics") fields the page deserves; SEC `submissions.sicDescription` is the rougher ALLCAPS SIC taxonomy and was rejected. `symbols.sector` and `symbols.industry` columns already exist (since `0001_initial.sql`) but were never populated by any prior phase; this phase fills them. The Phase 28 `quoteSummary` plumbing on `YahooProvider` is the foundation — a new inherent method targets only the `assetProfile` module so the request is Yahoo's smallest on that endpoint (mirrors Phase 25's `earnings_calendar` shape). **Schema.** Migration `0012` adds `symbols.asset_profile_synced_at` (epoch-ms). No new table — industry-level stats are computed on each render the same way the home strongest / weakest panels already work (Phase 20); the in-memory aggregation is cheap over ~500 stocks. **Scheduler.** A new `asset_profile` section on the existing `yahoo` `EndpointGuard`, stocks only, monthly staleness on `asset_profile_synced_at`. Brought forward to the first tick on boot the same way `sec` / `fund_metadata` / `earnings_calendar` are — so a deploy backfills the universe within hours rather than a daily interval. `backfill_symbol` pulls the assetProfile inside the add-symbol request, mirroring Phase 21's intent. Health page lists the new job between `fund_metadata` and `earnings_calendar`. **Compute.** Two pure helpers: - `industry_aggregate(member_closes)` — equal-weight per-day composite over each member's daily closes, returns trailing 1d / 5d / 21d / 63d / 252d returns alongside the YTD return. (Cap-weighted is rejected for v1: we do not store shares-outstanding.) - `seasonality(closes, dates)` — twelve calendar-month averages of that month's average daily return across all years of history. Returns `[(Jan, +0.4%), (Feb, -0.1%), ...]`. **Routes.** New `routes/industries.rs`: - `GET /industries` — sector index. Lists the ~11 sectors rolled up from member industries, each row showing day / 1m / 1y composite return + a trend badge + member count. Each sector links to its detail page. - `GET /industries/{sector-slug}` — sector page: composite price chart (lightweight-charts, 5-year cap so the payload stays modest), server-rendered seasonality SVG (similar in spirit to the home sparkline), the breakdown of industries inside the sector, and the member list with day move + standing badge. - `GET /industries/{sector-slug}/{industry-slug}` — industry page: same layout, narrower membership. - `GET /api/industries/{sector}/{industry?}/history` — composite price series (daily closes, equal-weight from each member's `daily_prices`), capped to 5 years, plus `^SPX` benchmark scaled to the same anchor. **Symbol page.** A new sector · industry caption joins the EQUITY / exchange / currency tag row in the symbol header, each part linking to the corresponding industries page. Stocks only; ETFs / indexes / futures carry no caption (no `assetProfile` data). **Home page.** A new "Today's industries" panel between Top picks and Strongest & weakest. Two columns mirroring strongest / weakest layout: top 3 and bottom 3 sectors by today's composite move, each row a magnitude tint + sector name + composite % change + member count. Sectors only (the panel stays a quick at-a-glance read; industry-level moves are noisier and live on `/industries`). **Top nav.** Adds "Industries" between Markets and Search in both the topnav and the bottom nav. **Slug helper.** A small lower-kebab-case function with a tiny overrides map for the well-known sector names ("Real Estate" → "real-estate", "Basic Materials" → "basic-materials"); a member resolver does the inverse. **Anti-spam.** ~512 stocks × monthly refresh = ~17 requests/day average on the existing `yahoo` 1000/hr guard. No new endpoint guard, no new budget. Two stretch fields (`fullTimeEmployees`, `numberOfAnalystOpinions`) the assetProfile module also carries are not parsed by v1; the column list stays minimal. Stocks only (ETFs carry the Phase 28 fund category instead; indexes and futures have no sector). Pure-render on the four pages, plus one new scheduler section.- [x] **Phase 16: Per-ticker anomaly feed.** Complete, verified, and deployed to production 2026-05-23 (commit `a839737`). See the Phase 16 Done entry in Status and the decisions log. (Picked as the next backlog phase and
@@ -3218,6 +3343,31 @@ finance/ (commit `8a16b14`); the live `finance.bythewood.me/` renders the Healthiest / Most concerning panel pair and `/s/AAPL` the new Stock health panel.- **2026-05-23 — Phase 13 (market heat map) dropped from the roadmap.** The user removed it: with the home page already carrying movers, strongest / weakest, healthiest / concerning, and top picks panels, a cap-weighted treemap was judged redundant on the at-a-glance reading the home page is meant to provide. The Phase 13 entry stays as a one-line tombstone so the later phases keep their numbering and this decision has a target. Shares outstanding from SEC stay unstored — nothing else in the app needs cap- weighting today.- **2026-05-23 — Phase 15 picked next; scope settled in three Q&A.** Source: Yahoo `quoteSummary.assetProfile` (clean GICS-style names "Technology" / "Consumer Electronics") over SEC `submissions.sicDescription` (rougher ALLCAPS SIC taxonomy needing a hand-coded 400-row mapping). Surfaces: all four — `/industries` sector index, per-industry detail page, symbol-page sector · industry tag, and a "Today's industries" home panel. Granularity: sector + industry drill-down. Symbol-page columns `sector` / `industry` already exist (since `0001_initial.sql`) but were never populated by any prior phase — a small artefact carried since the original schema; this phase finally fills them. Aggregation lives on each render (in-memory over ~500 stocks) rather than in a snapshot table, mirroring how Phase 20 strongest / weakest works today; cheap enough to skip the cache. The home panel shows sectors only (industry-level moves are noisier; they belong on `/industries`). Equal-weight composite over cap-weighted, since the app does not store shares-outstanding (and the Phase 13 drop above means no reason to start). Anti-spam: ~512 stocks × monthly refresh = ~17/day on the existing `yahoo` 1000/hr guard, no new endpoint.---
modified
frontend/static_src/home/styles/home.scss
@@ -594,3 +594,69 @@ display: none; }}/* ---------- Today's industries panel (Phase 15) ---------- *//* Each row is one sector: name on the left, member count, and the day composite %. Same magnitude-tint pattern as `.mover` and `.standing`. */.industry-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 12px 6px; color: var(--ink); border-bottom: 1px solid var(--rule); text-decoration: none; &:last-child { border-bottom: 0; } &::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm); } > * { position: relative; } &:hover { background: var(--well); }}.industry-row--up::before { background: var(--up-soft);}.industry-row--down::before { background: var(--down-soft);}.industry-row__name { font-size: 0.92rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.industry-row__count { font-size: 0.74rem; color: var(--ink-faint);}.industry-row__chg { font-size: 0.95rem; font-weight: 600; text-align: right; min-width: 5.5ch;}
added
frontend/static_src/industries/index.js
@@ -0,0 +1,84 @@// Industries page (Phase 15).//// On `/industries` the page is server-rendered HTML; nothing to do here.// On a sector / industry detail page a `#ind-chart` element carries// `data-sector` (and optionally `data-industry`); we fetch the equal-weight// composite history + ^SPX benchmark and draw both with lightweight-charts.import "./styles/industries.scss";import { createChart, AreaSeries, LineSeries, ColorType } from "lightweight-charts";const COMP_LINE = "#2f7d4f";const COMP_FILL_TOP = "rgba(47, 125, 79, 0.20)";const COMP_FILL_BTM = "rgba(47, 125, 79, 0.00)";const BENCH_LINE = "#7a5237";const chartOptions = { autoSize: true, handleScroll: false, handleScale: false, layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: "#6b6456", fontFamily: "'JetBrains Mono', monospace", attributionLogo: false, }, grid: { vertLines: { color: "rgba(33,31,26,0.07)" }, horzLines: { color: "rgba(33,31,26,0.07)" }, }, rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" }, timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 }, crosshair: { mode: 1 },};const el = document.getElementById("ind-chart");if (el) { const sector = el.dataset.sector; const industry = el.dataset.industry; // `/api/industries/{sector}/{industry?}/history` returns a composite + benchmark // series, each anchored at 100 on the first shared trading date. const url = industry ? `/api/industries/${encodeURIComponent(sector)}/${encodeURIComponent(industry)}/history` : `/api/industries/${encodeURIComponent(sector)}/history`; fetch(url) .then((res) => { if (!res.ok) throw new Error(`industries chart ${res.status}`); return res.json(); }) .then((d) => { if (!d.composite || d.composite.length < 2) { el.innerHTML = '<p class="ind-chart__empty">No composite history available yet.</p>'; return; } const chart = createChart(el, chartOptions); const comp = chart.addSeries(AreaSeries, { lineColor: COMP_LINE, topColor: COMP_FILL_TOP, bottomColor: COMP_FILL_BTM, lineWidth: 2, priceFormat: { type: "custom", formatter: fmtIndex, minMove: 0.01 }, }); comp.setData(d.composite.map((p) => ({ time: p.d, value: p.v }))); if (d.benchmark && d.benchmark.length > 1) { const bench = chart.addSeries(LineSeries, { color: BENCH_LINE, lineWidth: 2, lineStyle: 2, priceFormat: { type: "custom", formatter: fmtIndex, minMove: 0.01 }, priceLineVisible: false, }); bench.setData(d.benchmark.map((p) => ({ time: p.d, value: p.v }))); } chart.timeScale().fitContent(); }) .catch((err) => { el.innerHTML = `<p class="ind-chart__empty">Chart failed: ${err.message}</p>`; console.error(err); });}function fmtIndex(n) { if (n == null || !Number.isFinite(n)) return "—"; return n.toFixed(1);}
added
frontend/static_src/industries/styles/industries.scss
@@ -0,0 +1,272 @@@use "../../base/styles/_variables" as *;@use "../../base/styles/_mixins" as *;/* ============================================================================ Industries (Phase 15) Three surfaces share this stylesheet: - `/industries` sector index (a sortable table + a blurb) - `/industries/{sector}` and `/industries/{sector}/{industry}` detail pages (stat row + composite chart + seasonality + members + breakdown) - The home page also reuses these tokens for its `.industry-row`, but that lives in home.scss to stay co-located with the panel layout. ========================================================================== */.ind-blurb { margin: 0 0 16px; max-width: 78ch; color: var(--ink-dim); font-size: 0.86rem;}.ind-empty { padding: 60px 0; text-align: center; color: var(--ink-faint); font-size: 0.9rem;}.ind-crumbs { margin: -6px 0 14px; color: var(--ink-faint); font-size: 0.78rem; a { color: var(--ink-dim); text-decoration: none; } a:hover { color: var(--ink); }}/* shared sector index / industry-inside-sector table */.ind-table { width: 100%; border-collapse: collapse; margin-bottom: 24px; th, td { padding: 9px 10px; border-bottom: 1px solid var(--rule); text-align: right; font-size: 0.86rem; } thead th { @include eyebrow; color: var(--ink-faint); text-align: right; border-bottom: 1px solid var(--rule-strong); } th.ind-table__sec, td.ind-table__sec { text-align: left; } td.num { @include mono; } tbody th.ind-table__sec { font-weight: 500; } a { color: var(--ink); text-decoration: none; } a:hover { color: var(--accent, var(--ink)); text-decoration: underline; }}/* stat row on the detail pages */.ind-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin: 14px 0 24px;}@media (max-width: 720px) { .ind-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }}.ind-stat { @include card; padding: 10px 12px;}.ind-stat__label { @include eyebrow; color: var(--ink-faint); margin-bottom: 4px;}.ind-stat__val { @include mono; font-size: 1.05rem;}/* composite chart */.ind-chart-panel { padding: 14px; margin-bottom: 22px;}#ind-chart { height: 320px; width: 100%;}.ind-chart__empty { padding: 60px 0; text-align: center; color: var(--ink-faint); font-size: 0.88rem;}/* seasonality bar grid: one column per calendar month */.ind-season { padding: 14px 14px 18px; margin-bottom: 24px;}.season-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 6px; align-items: end; height: 160px;}.season-cell { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-end; height: 100%; position: relative;}.season-cell__bar { width: 100%; min-height: 1px; background: var(--ink-faint); border-radius: 2px 2px 0 0;}.season-cell--up .season-cell__bar { background: var(--up, #2f7d4f);}.season-cell--down .season-cell__bar { background: var(--down, #b3402b);}.season-cell--empty .season-cell__bar { background: transparent; border-bottom: 1px dashed var(--rule);}.season-cell__lbl { @include eyebrow; text-align: center; margin-top: 6px; color: var(--ink-faint);}.season-cell__val { @include mono; font-size: 0.62rem; text-align: center; color: var(--ink-faint); margin-top: 2px;}@media (max-width: 520px) { // Phone: the 12-month grid stays one row but the value lines wrap-out, so // hide them and lean on the bar height + month label only. .season-cell__val { display: none; }}/* members list */.ind-members { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px;}.ind-member { display: grid; grid-template-columns: 64px 1fr auto auto; gap: 10px; align-items: baseline; padding: 9px 12px; text-decoration: none; color: var(--ink); background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); position: relative; overflow: hidden; // Magnitude tint behind the row. &::before { content: ""; position: absolute; inset: 0 auto 0 0; width: var(--bar, 0%); background: var(--ink-faint); opacity: 0.06; }}.ind-member--up::before { background: var(--up, #2f7d4f); opacity: 0.09;}.ind-member--down::before { background: var(--down, #b3402b); opacity: 0.09;}.ind-member__sym { @include mono; font-weight: 600;}.ind-member__name { color: var(--ink-dim); font-size: 0.86rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.ind-member__industry { @include eyebrow; color: var(--ink-faint); font-size: 0.6rem;}.ind-member__price { @include mono; font-size: 0.86rem;}.ind-member__chg { @include mono; font-size: 0.86rem; text-align: right; min-width: 60px;}
modified
frontend/static_src/symbol/styles/symbol.scss
@@ -27,6 +27,20 @@ border-radius: 4px;}// Phase 15: sector / industry tag, linked to its /industries page. Same shape// as a plain `.sym-head__tag`, with the link colour subdued so it does not// shout among the kind / exchange tags beside it..sym-head__tag--link { color: var(--ink-dim); text-decoration: none; transition: color 0.1s, border-color 0.1s; &:hover { color: var(--ink); border-color: var(--ink); }}.sym-head__name { color: var(--ink-dim); margin-top: 4px;
modified
frontend/vite.config.js
@@ -19,6 +19,7 @@ export default defineConfig({ health: resolve(__dirname, "static_src/health/index.js"), search: resolve(__dirname, "static_src/search/index.js"), backtest: resolve(__dirname, "static_src/backtest/index.js"), industries: resolve(__dirname, "static_src/industries/index.js"), }, }, },
added
migrations/0012_asset_profile.sql
@@ -0,0 +1,15 @@-- finance migration 0012: asset profile sync tracker (Phase 15).---- Sector and industry classification for each stock comes from Yahoo's-- `quoteSummary.assetProfile` module. The columns the values land in —-- `symbols.sector` and `symbols.industry` — already exist (since-- `0001_initial.sql`) but were never populated by any prior phase. Phase 15-- finally fills them through a new `asset_profile` scheduler section on the-- existing `yahoo` `EndpointGuard`.---- This migration only adds the sync-staleness timestamp. Stocks only — the-- column stays NULL forever on every non-stock row (ETFs carry the Phase 28-- `fund_metadata.category` for the equivalent classification; indexes and-- futures have no sector).ALTER TABLE symbols ADD COLUMN asset_profile_synced_at INTEGER;
@@ -106,6 +106,7 @@ pub fn router(state: AppState) -> Router { Router::new() .merge(routes::home::router()) .merge(routes::backtest::router()) .merge(routes::industries::router()) .merge(routes::symbols::router()) .merge(routes::search::router()) .merge(routes::stream::router())
@@ -1643,6 +1643,176 @@ pub fn next_earnings_estimate(dates: &[&str]) -> Option<String> { Some(next.format("%Y-%m-%d").to_string())}// ────────────────────────── industry aggregation (Phase 15) ─────────────────//// Pure helpers that aggregate per-stock daily-close series into one industry// composite, and roll a single member's history into a 12-month seasonality// average. No DB, no I/O — `routes/industries.rs` does the SQL and hands the// numbers in./// Equal-weight composite return read for one industry / sector aggregation./// Each return is the average across the industry's members of that member's/// own simple return over the same trailing window. `None` when too few/// members carry enough history for the period.#[derive(Debug, Clone, Default, Serialize)]pub struct IndustryReturns { /// One-day return (latest close vs prior close), percent. pub d1: Option<f64>, /// Trailing five-day return, percent. pub d5: Option<f64>, /// Trailing ~21-day (one month) return, percent. pub d21: Option<f64>, /// Trailing ~63-day (one quarter) return, percent. pub d63: Option<f64>, /// Trailing ~252-day (one year) return, percent. pub d252: Option<f64>, /// Number of members that contributed at least one return figure. pub members: usize,}/// Compute one industry's equal-weight composite trailing returns from each/// member's oldest-first daily close series. A member with too short a/// history simply drops out of that period's average rather than skewing it;/// the average is over the members that *did* qualify for that period.////// Every period is computed independently — a stock with 200 bars contributes/// to d5 / d21 / d63 but not d252. `None` for a period where no member/// qualifies. The `members` count is the union: any member that contributed/// to at least one period.pub fn industry_returns(member_closes: &[&[f64]]) -> IndustryReturns { // Lookback offsets keyed in trading days. d5 is "one trading week" — five // bars back — matching how the existing Phase 8 / 30 maths counts. let mean_of_returns = |lookback: usize| -> Option<f64> { let rets: Vec<f64> = member_closes .iter() .filter_map(|closes| { if closes.len() <= lookback { return None; } let last = *closes.last()?; let prev = closes[closes.len() - 1 - lookback]; if prev <= 0.0 { return None; } Some((last - prev) / prev * 100.0) }) .collect(); if rets.is_empty() { return None; } Some(rets.iter().sum::<f64>() / rets.len() as f64) }; let members = member_closes .iter() .filter(|closes| closes.len() >= 2) .count(); IndustryReturns { d1: mean_of_returns(1), d5: mean_of_returns(5), d21: mean_of_returns(21), d63: mean_of_returns(63), d252: mean_of_returns(252), members, }}/// One twelve-cell seasonality reading: the average daily return for each/// calendar month across all years of history. Percent per day, not per/// month — kept comparable to the trailing returns above and avoids a/// compounding artefact from uneven trading-day counts. `None` for months/// with no observations.#[derive(Debug, Clone, Serialize)]pub struct Seasonality { /// Twelve entries, Jan → Dec. pub months: [Option<f64>; 12],}/// Per-month average daily return computed across all years of `closes` //// `dates`. Both slices must be the same length and aligned, oldest first./// `dates` are `YYYY-MM-DD`; an unparseable date is skipped.pub fn seasonality(closes: &[f64], dates: &[&str]) -> Option<Seasonality> { if closes.len() != dates.len() || closes.len() < 2 { return None; } let mut sums = [0.0_f64; 12]; let mut counts = [0u32; 12]; for i in 1..closes.len() { let (prev, cur) = (closes[i - 1], closes[i]); if prev <= 0.0 { continue; } // Month bucketed from the *current* bar's date — the return earned by // sitting through that bar, attributed to the month it landed in. let Some(d) = chrono::NaiveDate::parse_from_str(dates[i], "%Y-%m-%d").ok() else { continue; }; let m = d.month0() as usize; sums[m] += (cur - prev) / prev * 100.0; counts[m] += 1; } let mut months: [Option<f64>; 12] = Default::default(); for i in 0..12 { if counts[i] > 0 { months[i] = Some(sums[i] / counts[i] as f64); } } Some(Seasonality { months })}#[cfg(test)]mod phase15_tests { use super::*; #[test] fn industry_returns_equal_weights_each_member() { // Two members, identical 252-day flat-to-step pattern. The composite // should be the same as each member's individual return. let mut a: Vec<f64> = (0..260).map(|i| 100.0 + i as f64 * 0.1).collect(); let b = a.clone(); // Bump the last bar to make d1 non-trivial. *a.last_mut().unwrap() = 110.0; let mut b = b.clone(); *b.last_mut().unwrap() = 110.0; let r = industry_returns(&[&a[..], &b[..]]); assert_eq!(r.members, 2); // d1: prior bar was 100 + 258*0.1 = 125.8 -> 110, return is negative. let prior = 100.0 + 258.0 * 0.1; let expected = (110.0 - prior) / prior * 100.0; assert!((r.d1.unwrap() - expected).abs() < 1e-9); } #[test] fn industry_returns_drops_too_short_members_per_period() { // One full-history member, one too-short member. d252 should reflect // only the full-history member; d1 should reflect both (each carries // at least 2 bars). let long: Vec<f64> = (0..260).map(|i| 100.0 + i as f64).collect(); let short = vec![200.0, 201.0]; let r = industry_returns(&[&long[..], &short[..]]); assert!(r.d252.is_some(), "long member should qualify for d252"); assert!(r.d1.is_some(), "both members qualify for d1"); // members = 2 (both ≥ 2 bars), even though only one carried d252. assert_eq!(r.members, 2); } #[test] fn seasonality_averages_by_calendar_month() { // Two consecutive January bars (one return), plus two consecutive // February bars (one return). Both deterministic. let dates = ["2025-01-15", "2025-01-16", "2025-02-15", "2025-02-16"]; let closes = [100.0, 101.0, 200.0, 196.0]; // Jan +1.0%, Feb −2.0% let date_refs: Vec<&str> = dates.iter().copied().collect(); let s = seasonality(&closes, &date_refs).unwrap(); assert!((s.months[0].unwrap() - 1.0).abs() < 1e-9); assert!((s.months[1].unwrap() - (-2.0)).abs() < 1e-9); for m in 2..12 { assert!(s.months[m].is_none()); } }}#[cfg(test)]mod phase25_tests { use super::*;
@@ -50,6 +50,10 @@ pub struct SymbolRow { /// When this stock's Yahoo earnings-calendar snapshot was last /// refreshed (Phase 25). NULL = never swept. Stocks only. pub earnings_synced_at: Option<i64>, /// When this stock's Yahoo `quoteSummary.assetProfile` snapshot was /// last refreshed (Phase 15). Backs the `sector` / `industry` columns. /// NULL = never swept. Stocks only — non-stock rows stay NULL forever. pub asset_profile_synced_at: Option<i64>, pub last_price: Option<f64>, pub prev_close: Option<f64>, pub last_quote_at: Option<i64>,
modified
src/providers/mod.rs
@@ -308,6 +308,19 @@ pub struct FundMetadata { pub strategy_summary: Option<String>,}/// One stock's Yahoo `assetProfile` classification (Phase 15). Both fields/// are optional: small-cap and foreign tickers occasionally carry only one,/// and a request that returned an `assetProfile` module Yahoo populated only/// partially leaves the other side `None` rather than rejecting the row.#[derive(Debug, Clone, Default)]pub struct AssetProfile { /// GICS-style sector ("Technology"). From `assetProfile.sector`. pub sector: Option<String>, /// GICS-style industry ("Consumer Electronics"). From /// `assetProfile.industry`. pub industry: Option<String>,}/// An upstream rejected a request with an explicit rate-limit signal (HTTP 429/// or 503). A provider returns this as the source of its `anyhow::Error` so the/// `EndpointGuard` (see `src/guard.rs`) can recognise it by downcast and trip
modified
src/providers/yahoo.rs
@@ -17,7 +17,8 @@ use reqwest::{header::RETRY_AFTER, StatusCode};use serde::Deserialize;use crate::providers::{ DividendEvent, FundMetadata, IntradayBar, Quote, QuoteData, QuoteProvider, RateLimited, AssetProfile, DividendEvent, FundMetadata, IntradayBar, Quote, QuoteData, QuoteProvider, RateLimited,};/// Near-real-time quotes from Yahoo Finance.
@@ -412,6 +413,66 @@ impl YahooProvider { Ok(next_secs.map(|s| s * 1000)) } /// Fetch a stock's sector and industry classification from Yahoo's /// `quoteSummary.assetProfile` module (Phase 15). One request to the same /// v10 endpoint that serves `fund_metadata` and `earnings_calendar`, /// asking only for the `assetProfile` module — Yahoo's smallest reply /// for this concern. /// /// Returns `Ok(Some(profile))` with whichever of `sector` / `industry` /// Yahoo carries (a small cap with a partial profile leaves the absent /// field `None`); `Ok(None)` when Yahoo cleanly does not know the /// symbol (404 or `quoteSummary.error`); gating responses (429 / 503 / /// 401 / 403) surface as the typed [`RateLimited`] so the endpoint /// guard trips at once. pub async fn asset_profile(&self, ticker: &str) -> Result<Option<AssetProfile>> { let sym = urlencoding::encode(&yahoo_symbol(ticker)).into_owned(); let url = format!( "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{sym}\ ?modules=assetProfile" ); 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); }; let ap = result.asset_profile.unwrap_or_default(); let trim = |s: Option<String>| s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty()); Ok(Some(AssetProfile { sector: trim(ap.sector), industry: trim(ap.industry), })) } /// 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.
@@ -526,6 +587,11 @@ struct PriceModule {#[serde(rename_all = "camelCase", default)]struct AssetProfileModule { long_business_summary: Option<String>, /// GICS-style sector ("Technology"). Stocks only; Yahoo leaves this /// blank or omits the module entirely on ETFs / indexes / funds. sector: Option<String>, /// GICS-style industry ("Consumer Electronics"). See `sector`. industry: Option<String>,}/// Yahoo's `{ "raw": ..., "fmt": "..." }` numeric carrier. Deserialises from
modified
src/routes/health.rs
@@ -255,6 +255,11 @@ fn job_meta(job: &str) -> (&str, &str) { expected earnings date. Refreshed monthly and whenever the \ stored date passes.", ), "asset_profile" => ( "Stock sector & industry", "Yahoo quoteSummary `assetProfile` for each stock — sector and \ industry classification. Refreshed monthly.", ), other => (other, ""), }}
@@ -266,11 +271,12 @@ fn job_rank(job: &str) -> u8 { "history" => 1, "sec" => 2, "fund_metadata" => 3, "earnings_calendar" => 4, "dividends" => 5, "intraday" => 6, "daily_close" => 7, _ => 9, "asset_profile" => 4, "earnings_calendar" => 5, "dividends" => 6, "intraday" => 7, "daily_close" => 8, _ => 10, }}
modified
src/routes/home.rs
@@ -157,8 +157,29 @@ struct StockRow { /// When this stock's SEC leadership roster last synced (epoch-ms); feeds /// the health panels' freshness caption. leadership_synced_at: Option<i64>, /// Yahoo `assetProfile` sector (Phase 15). `None` until the asset_profile /// sweep has reached this stock; the sector panels drop those rows. sector: Option<String>, /// When the asset_profile sweep last touched this stock (Phase 15). asset_profile_synced_at: Option<i64>,}/// One row in the home page's "Today's industries" panel: a sector with its/// composite day move, member count, and the magnitude tint sized against/// the largest absolute composite shown across the top / bottom rows.#[derive(Serialize, Clone)]struct IndustryRow { name: String, slug: String, members: usize, change_pct: f64, bar: f64, up: bool,}/// How many sectors to show in each of the top / bottom industry panels.const INDUSTRY_LIMIT: usize = 3;async fn home(State(state): State<AppState>) -> Response { let seeded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM symbols WHERE is_seeded = 1") .fetch_one(&state.pool)
@@ -184,6 +205,11 @@ async fn home(State(state): State<AppState>) -> Response { let (gainers, losers) = movers(&stocks); let (strongest, weakest) = strength_panels(&stocks); let (healthiest, concerning) = health_panels(&stocks); let (top_industries, bottom_industries) = industry_panels(&stocks); let industries_asof = stocks .iter() .filter_map(|s| s.asset_profile_synced_at) .max(); // Top picks (Phase 30): computed live every render, so the panel works on // day 1 before the snapshot job has run. A separate scan (LOOKBACK_DAYS is
@@ -229,6 +255,9 @@ async fn home(State(state): State<AppState>) -> Response { concerning => concerning, health_asof => health_asof, pick_slates => pick_slates, top_industries => top_industries, bottom_industries => bottom_industries, industries_asof => industries_asof, total => total, }; render(&state, "pages/home.html", "/", extra)
@@ -341,7 +370,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> { // 1. Price per curated stock: the live last price, else the latest daily // close; plus the prior close it is changing against. Also pulls each // stock's fundamentals and leadership sync times (for the panels' // freshness captions, Phase 22 + Phase 17). // freshness captions, Phase 22 + Phase 17) and the Phase 15 sector + // asset-profile sync time (for the Today's industries panel). type PriceRow = ( String, String,
@@ -350,6 +380,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> { Option<i64>, Option<i64>, Option<i64>, Option<String>, Option<i64>, ); let price_rows: Vec<PriceRow> = sqlx::query_as( "SELECT s.ticker, s.name, \
@@ -357,7 +389,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> { (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \ COALESCE(s.prev_close, \ (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \ s.last_quote_at, s.fundamentals_synced_at, s.leadership_synced_at \ s.last_quote_at, s.fundamentals_synced_at, s.leadership_synced_at, \ s.sector, s.asset_profile_synced_at \ FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'", ) .fetch_all(&state.pool)
@@ -443,6 +476,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> { last_quote_at, fundamentals_synced_at, leadership_synced_at, sector, asset_profile_synced_at, )| { let stock_closes = closes.get(&ticker).map(Vec::as_slice).unwrap_or(&[]); let ratios = facts.get(&ticker).and_then(|f| {
@@ -471,12 +506,81 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> { last_quote_at, fundamentals_synced_at, leadership_synced_at, sector: sector.filter(|s| !s.is_empty()), asset_profile_synced_at, } }, ) .collect()}/// Today's biggest sector composites (top / bottom equal-weight day moves)/// for the home page. Curated `is_seeded` stocks only — same universe as/// movers / strongest — so a small user-added stock's noise does not crowd/// out a recognised sector. Sectors only (industry-level moves are noisier/// and live on `/industries`).fn industry_panels(stocks: &[StockRow]) -> (Vec<IndustryRow>, Vec<IndustryRow>) { // Bucket each stock's day move by sector. Drop a stock without both a // sector classification and a computable change. let mut by_sector: HashMap<String, Vec<f64>> = HashMap::new(); for s in stocks { let Some(sector) = s.sector.as_deref() else { continue; }; let (Some(last), Some(prev)) = (s.last, s.prev) else { continue; }; if prev <= 0.0 { continue; } by_sector .entry(sector.to_string()) .or_default() .push((last - prev) / prev * 100.0); } if by_sector.is_empty() { return (Vec::new(), Vec::new()); } let mut rows: Vec<IndustryRow> = by_sector .into_iter() .map(|(name, pcts)| { let avg = pcts.iter().sum::<f64>() / pcts.len() as f64; IndustryRow { slug: crate::routes::industries::slug(&name), name, members: pcts.len(), change_pct: avg, bar: 0.0, up: avg >= 0.0, } }) .collect(); rows.sort_by(|a, b| { b.change_pct .partial_cmp(&a.change_pct) .unwrap_or(Ordering::Equal) }); let mut top: Vec<IndustryRow> = rows.iter().take(INDUSTRY_LIMIT).cloned().collect(); let mut bottom: Vec<IndustryRow> = rows.iter().rev().take(INDUSTRY_LIMIT).cloned().collect(); // Scale every magnitude tint to the largest absolute move shown across // both panels (mirrors movers / strongest / health). let max_abs = top .iter() .chain(bottom.iter()) .map(|r| r.change_pct.abs()) .fold(0.0_f64, f64::max); for r in top.iter_mut().chain(bottom.iter_mut()) { r.bar = if max_abs > 0.0 { (r.change_pct.abs() / max_abs * 100.0).clamp(0.0, 100.0) } else { 0.0 }; } (top, bottom)}/// The day's biggest gainers and losers among the curated large-cap stocks./// Each row also carries the stock's strong / fair / weak badge (Phase 20).fn movers(stocks: &[StockRow]) -> (Vec<Mover>, Vec<Mover>) {
added
src/routes/industries.rs
@@ -0,0 +1,671 @@//! Industry trends (Phase 15).//!//! Treats sectors and industries as first-class. Each stock is classified by//! Yahoo's `quoteSummary.assetProfile`, populated by the `asset_profile`//! scheduler job; this module aggregates the classifications into://!//! - `GET /industries` — sector index page.//! - `GET /industries/{sector-slug}` — sector detail page.//! - `GET /industries/{sector-slug}/{industry-slug}` — industry detail page.//! - `GET /api/industries/{sector-slug}/{industry-slug?}/history` —//! equal-weight composite price series (capped to 5 years) plus the `^SPX`//! benchmark anchored to the same first date.//!//! Aggregation is in-memory, on every render — same pattern as the home//! Strongest & Weakest panels (Phase 20). The data the page leans on//! (current prices and trailing daily closes for the curated stocks) is//! already loaded by other panels in a similar shape, so the cost is small.//! ETFs / indexes / futures are excluded; they carry no asset profile.use std::cmp::Ordering;use std::collections::HashMap;use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router,};use serde::Serialize;use serde_json::json;use crate::compute::{self, IndustryReturns};use crate::render::render;use crate::AppState;/// Trading-day cap on the composite price chart. Five years is long enough/// to span a full market cycle, short enough that the equal-weight scan over/// dozens of members is cheap; matches the deep-history cap in/// `routes::backtest`'s `HIST_LOOKBACK_DAYS`.const COMPOSITE_LOOKBACK_DAYS: i64 = 5 * 365 + 2;/// One row per stock with classification + the figures the page needs./// Loaded once and reused for the sector index, the detail pages, and (with/// a follow-up SQL pass for daily closes) the chart API.#[derive(Clone)]struct StockClass { ticker: String, name: String, sector: String, industry: String, last: Option<f64>, prev: Option<f64>,}/// Load every stock with both a sector and an industry populated, alongside/// its live last-price and prev-close (for the day move).async fn load_classified(state: &AppState) -> Vec<StockClass> { type Row = (String, String, String, String, Option<f64>, Option<f64>); sqlx::query_as::<_, Row>( "SELECT s.ticker, s.name, s.sector, s.industry, \ COALESCE(s.last_price, \ (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \ COALESCE(s.prev_close, \ (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \ FROM symbols s \ WHERE s.kind = 'stock' \ AND s.sector IS NOT NULL AND s.sector <> '' \ AND s.industry IS NOT NULL AND s.industry <> ''", ) .fetch_all(&state.pool) .await .unwrap_or_default() .into_iter() .map(|(ticker, name, sector, industry, last, prev)| StockClass { ticker, name, sector, industry, last, prev, }) .collect()}/// Group `rows` by `key(row)`, returning each group's name and members in a/// deterministic order (groups alphabetical, members alphabetical by ticker).fn group_by<K, F>(rows: &[StockClass], key: F) -> Vec<(String, Vec<StockClass>)>where K: Into<String>, F: Fn(&StockClass) -> K,{ let mut map: HashMap<String, Vec<StockClass>> = HashMap::new(); for r in rows { map.entry(key(r).into()).or_default().push(r.clone()); } let mut groups: Vec<(String, Vec<StockClass>)> = map.into_iter().collect(); for (_, members) in groups.iter_mut() { members.sort_by(|a, b| a.ticker.cmp(&b.ticker)); } groups.sort_by(|a, b| a.0.cmp(&b.0)); groups}/// Equal-weight day-move composite across `members`. `None` when no member/// has both `last` and `prev`.fn day_composite(members: &[StockClass]) -> Option<f64> { let pcts: Vec<f64> = members .iter() .filter_map(|m| { let (last, prev) = (m.last?, m.prev?); if prev <= 0.0 { return None; } Some((last - prev) / prev * 100.0) }) .collect(); if pcts.is_empty() { return None; } Some(pcts.iter().sum::<f64>() / pcts.len() as f64)}/// Lowercase, hyphenated slug for a sector / industry name. ASCII-only —/// our universe is US-listed so this is enough. Whitespace and `&` collapse;/// anything not `[a-z0-9-]` is dropped. Empty input maps to `"other"`.pub fn slug(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut prev_dash = false; for c in s.chars() { let mapped = if c.is_ascii_alphanumeric() { prev_dash = false; c.to_ascii_lowercase() } else if c.is_whitespace() || c == '-' || c == '&' || c == '/' || c == ',' { if prev_dash { continue; } prev_dash = true; '-' } else { // Drop punctuation entirely so "Industrials, Inc." doesn't gain // an extra dash from the comma + the space after it. continue; }; out.push(mapped); } let trimmed = out.trim_matches('-').to_string(); if trimmed.is_empty() { "other".to_string() } else { trimmed }}pub fn router() -> Router<AppState> { Router::new() .route("/industries", get(index_page)) .route("/industries/{sector}", get(sector_page)) .route("/industries/{sector}/{industry}", get(industry_page)) .route( "/api/industries/{sector}/history", get(sector_history_api), ) .route( "/api/industries/{sector}/{industry}/history", get(industry_history_api), )}/// One row in the `/industries` sector index.#[derive(Serialize)]struct SectorIndexRow { name: String, slug: String, members: usize, industries: usize, /// Composite returns (day / month / quarter / year). Each is built from /// equal-weight averages over the sector's members. returns: IndustryReturns,}async fn index_page(State(state): State<AppState>) -> Response { let rows = load_classified(&state).await; if rows.is_empty() { let extra = minijinja::context! { title => "Industries", empty => true, }; return render(&state, "pages/industries_index.html", "/industries", extra); } let tickers: Vec<&str> = rows.iter().map(|r| r.ticker.as_str()).collect(); let closes_map = load_closes_map(&state, &tickers).await; let groups = group_by(&rows, |r| r.sector.clone()); let mut sectors: Vec<SectorIndexRow> = groups .into_iter() .map(|(name, members)| { let industries = members .iter() .map(|m| m.industry.as_str()) .collect::<std::collections::HashSet<_>>() .len(); let member_closes: Vec<&[f64]> = members .iter() .filter_map(|m| closes_map.get(&m.ticker).map(Vec::as_slice)) .collect(); let returns = compute::industry_returns(&member_closes); SectorIndexRow { slug: slug(&name), name, members: members.len(), industries, returns, } }) .collect(); // Default order: best day move first, but keep a stable secondary sort by // name so a no-data day still reads alphabetically. sectors.sort_by(|a, b| { let (ar, br) = (a.returns.d1.unwrap_or(f64::NEG_INFINITY), b.returns.d1.unwrap_or(f64::NEG_INFINITY)); br.partial_cmp(&ar).unwrap_or(Ordering::Equal).then(a.name.cmp(&b.name)) }); let total = sectors.iter().map(|s| s.members).sum::<usize>(); let total_industries: usize = sectors.iter().map(|s| s.industries).sum(); let extra = minijinja::context! { title => "Industries", empty => false, sectors => sectors, total => total, total_industries => total_industries, }; render(&state, "pages/industries_index.html", "/industries", extra)}/// Bulk-load the trailing-N-day daily closes for every ticker in `tickers`,/// grouped by ticker, oldest first. One scan over `daily_prices` joined to/// the symbol list.async fn load_closes_map(state: &AppState, tickers: &[&str]) -> HashMap<String, Vec<f64>> { if tickers.is_empty() { return HashMap::new(); } let cutoff = (chrono::Utc::now().date_naive() - chrono::Duration::days(COMPOSITE_LOOKBACK_DAYS)) .to_string(); let placeholders = vec!["?"; tickers.len()].join(","); let sql = format!( "SELECT p.ticker, p.close FROM daily_prices p \ WHERE p.ticker IN ({placeholders}) AND p.d >= ? ORDER BY p.ticker, p.d" ); let mut q = sqlx::query_as::<_, (String, f64)>(&sql); for t in tickers { q = q.bind(*t); } q = q.bind(&cutoff); let rows = q.fetch_all(&state.pool).await.unwrap_or_default(); let mut out: HashMap<String, Vec<f64>> = HashMap::new(); for (t, c) in rows { out.entry(t).or_default().push(c); } out}/// Bulk-load every ticker's dated daily closes (oldest first, paired with the/// trading-date string). Used by the chart API where we need each bar's date/// to align the composite.async fn load_dated_closes_map( state: &AppState, tickers: &[&str],) -> HashMap<String, Vec<(String, f64)>> { if tickers.is_empty() { return HashMap::new(); } let cutoff = (chrono::Utc::now().date_naive() - chrono::Duration::days(COMPOSITE_LOOKBACK_DAYS)) .to_string(); let placeholders = vec!["?"; tickers.len()].join(","); let sql = format!( "SELECT p.ticker, p.d, p.close FROM daily_prices p \ WHERE p.ticker IN ({placeholders}) AND p.d >= ? ORDER BY p.ticker, p.d" ); let mut q = sqlx::query_as::<_, (String, String, f64)>(&sql); for t in tickers { q = q.bind(*t); } q = q.bind(&cutoff); let rows = q.fetch_all(&state.pool).await.unwrap_or_default(); let mut out: HashMap<String, Vec<(String, f64)>> = HashMap::new(); for (t, d, c) in rows { out.entry(t).or_default().push((d, c)); } out}/// One member row on a sector / industry detail page.#[derive(Serialize, Clone)]struct MemberRow { ticker: String, name: String, industry: String, price: Option<f64>, change_pct: Option<f64>, /// Magnitude tint (0..100), scaled to the largest day move shown. bar: f64,}/// Build a list of member rows, scaling each row's tint to the largest/// absolute day-move shown.fn member_rows(members: &[StockClass]) -> Vec<MemberRow> { let mut rows: Vec<MemberRow> = members .iter() .map(|m| { let change_pct = match (m.last, m.prev) { (Some(l), Some(p)) if p > 0.0 => Some((l - p) / p * 100.0), _ => None, }; MemberRow { ticker: m.ticker.clone(), name: m.name.clone(), industry: m.industry.clone(), price: m.last, change_pct, bar: 0.0, } }) .collect(); let max_abs = rows .iter() .filter_map(|r| r.change_pct.map(f64::abs)) .fold(0.0_f64, f64::max); for r in rows.iter_mut() { if let (Some(p), true) = (r.change_pct, max_abs > 0.0) { r.bar = (p.abs() / max_abs * 100.0).clamp(0.0, 100.0); } } // Best-first by day move; missing values sink to the bottom. rows.sort_by(|a, b| { let (ax, bx) = ( a.change_pct.unwrap_or(f64::NEG_INFINITY), b.change_pct.unwrap_or(f64::NEG_INFINITY), ); bx.partial_cmp(&ax).unwrap_or(Ordering::Equal) }); rows}/// Roll the per-industry breakdown out for a sector page.#[derive(Serialize)]struct IndustryBreakdownRow { name: String, slug: String, members: usize, day_pct: Option<f64>,}async fn sector_page( State(state): State<AppState>, Path(sector_slug): Path<String>,) -> Response { let rows = load_classified(&state).await; let members: Vec<StockClass> = rows .into_iter() .filter(|r| slug(&r.sector) == sector_slug) .collect(); if members.is_empty() { return not_found(&state, &format!("Sector '{sector_slug}' not found")); } let sector_name = members[0].sector.clone(); let tickers: Vec<&str> = members.iter().map(|m| m.ticker.as_str()).collect(); let closes_map = load_closes_map(&state, &tickers).await; let member_closes: Vec<&[f64]> = members .iter() .filter_map(|m| closes_map.get(&m.ticker).map(Vec::as_slice)) .collect(); let returns = compute::industry_returns(&member_closes); // Per-industry breakdown inside this sector. let by_industry = group_by(&members, |r| r.industry.clone()); let mut breakdown: Vec<IndustryBreakdownRow> = by_industry .into_iter() .map(|(name, group)| IndustryBreakdownRow { slug: slug(&name), name, members: group.len(), day_pct: day_composite(&group), }) .collect(); breakdown.sort_by(|a, b| { let (ar, br) = (a.day_pct.unwrap_or(f64::NEG_INFINITY), b.day_pct.unwrap_or(f64::NEG_INFINITY)); br.partial_cmp(&ar).unwrap_or(Ordering::Equal).then(a.name.cmp(&b.name)) }); // Seasonality computed on the composite — average across members of each // member's per-month average daily return. A member with too short a // history contributes whichever months it covers. let dated_map = load_dated_closes_map(&state, &tickers).await; let seasonality_months = sector_seasonality(&members, &dated_map); let seasonality_max = seasonality_months .iter() .filter_map(|v| v.map(f64::abs)) .fold(0.0_f64, f64::max); let member_view = member_rows(&members); let extra = minijinja::context! { title => sector_name.clone(), scope => "sector", sector_name => sector_name, sector_slug => sector_slug, industry_name => "", industry_slug => "", returns => returns, breakdown => breakdown, seasonality => seasonality_months, seasonality_max => seasonality_max, members => member_view, }; render(&state, "pages/industries_detail.html", "/industries", extra)}async fn industry_page( State(state): State<AppState>, Path((sector_slug, industry_slug)): Path<(String, String)>,) -> Response { let rows = load_classified(&state).await; let members: Vec<StockClass> = rows .into_iter() .filter(|r| slug(&r.sector) == sector_slug && slug(&r.industry) == industry_slug) .collect(); if members.is_empty() { return not_found( &state, &format!("Industry '{sector_slug}/{industry_slug}' not found"), ); } let sector_name = members[0].sector.clone(); let industry_name = members[0].industry.clone(); let tickers: Vec<&str> = members.iter().map(|m| m.ticker.as_str()).collect(); let closes_map = load_closes_map(&state, &tickers).await; let member_closes: Vec<&[f64]> = members .iter() .filter_map(|m| closes_map.get(&m.ticker).map(Vec::as_slice)) .collect(); let returns = compute::industry_returns(&member_closes); let dated_map = load_dated_closes_map(&state, &tickers).await; let seasonality_months = sector_seasonality(&members, &dated_map); let seasonality_max = seasonality_months .iter() .filter_map(|v| v.map(f64::abs)) .fold(0.0_f64, f64::max); let member_view = member_rows(&members); let extra = minijinja::context! { title => format!("{industry_name} · {sector_name}"), scope => "industry", sector_name => sector_name, sector_slug => sector_slug, industry_name => industry_name, industry_slug => industry_slug, returns => returns, seasonality => seasonality_months, seasonality_max => seasonality_max, members => member_view, breakdown => Vec::<IndustryBreakdownRow>::new(), }; render(&state, "pages/industries_detail.html", "/industries", extra)}/// Average each member's per-month seasonality into one 12-cell composite. A/// member that can't produce a reading for a given month simply does not vote/// in that month; an empty month maps to `None`.fn sector_seasonality( members: &[StockClass], dated_map: &HashMap<String, Vec<(String, f64)>>,) -> Vec<Option<f64>> { let mut sums = [0.0_f64; 12]; let mut counts = [0u32; 12]; for m in members { let Some(rows) = dated_map.get(&m.ticker) else { continue; }; let dates: Vec<&str> = rows.iter().map(|(d, _)| d.as_str()).collect(); let closes: Vec<f64> = rows.iter().map(|(_, c)| *c).collect(); let Some(s) = compute::seasonality(&closes, &dates) else { continue; }; for (i, v) in s.months.iter().enumerate() { if let Some(v) = v { sums[i] += *v; counts[i] += 1; } } } (0..12) .map(|i| { if counts[i] > 0 { Some(sums[i] / counts[i] as f64) } else { None } }) .collect()}// ──────────────────────────── chart API ────────────────────────────────────async fn sector_history_api( State(state): State<AppState>, Path(sector_slug): Path<String>,) -> Response { composite_history(state, sector_slug, None).await}async fn industry_history_api( State(state): State<AppState>, Path((sector_slug, industry_slug)): Path<(String, String)>,) -> Response { composite_history(state, sector_slug, Some(industry_slug)).await}/// Build the equal-weight composite daily series for a sector (or industry,/// when `industry_slug` is `Some`) plus the `^SPX` benchmark scaled to the/// composite's first date. JSON shape:////// ```json/// { "composite": [{"d": "YYYY-MM-DD", "v": 100.0}, ...],/// "benchmark": [{"d": "YYYY-MM-DD", "v": 100.0}, ...] }/// ```async fn composite_history( state: AppState, sector_slug: String, industry_slug: Option<String>,) -> Response { let rows = load_classified(&state).await; let members: Vec<StockClass> = rows .into_iter() .filter(|r| { slug(&r.sector) == sector_slug && industry_slug .as_ref() .map_or(true, |is| slug(&r.industry) == *is) }) .collect(); if members.is_empty() { return ( StatusCode::NOT_FOUND, Json(json!({"error": "no members"})), ) .into_response(); } let tickers: Vec<&str> = members.iter().map(|m| m.ticker.as_str()).collect(); let dated = load_dated_closes_map(&state, &tickers).await; // Build a per-date equal-weight indexed series: for each trading date the // composite is the average across members of (member_close / member_base), // where each member's base is its first close inside the lookback window. // Members whose history starts later simply do not contribute until their // first bar; the composite continues with the available members. This // mirrors how an equal-weight index handles a new constituent. let mut bases: HashMap<&str, f64> = HashMap::new(); for m in &members { if let Some(rows) = dated.get(&m.ticker) { if let Some((_, first_close)) = rows.first() { if *first_close > 0.0 { bases.insert(m.ticker.as_str(), *first_close); } } } } // Union of every trading date seen in the lookback window. let mut date_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); for rows in dated.values() { for (d, _) in rows { date_set.insert(d.clone()); } } // Per-member, per-date close lookup. let mut member_dates: HashMap<&str, HashMap<&str, f64>> = HashMap::new(); for m in &members { let Some(rows) = dated.get(&m.ticker) else { continue; }; let mut by_date: HashMap<&str, f64> = HashMap::new(); for (d, c) in rows.iter() { by_date.insert(d.as_str(), *c); } member_dates.insert(m.ticker.as_str(), by_date); } #[derive(Serialize)] struct Point { d: String, v: f64, } let mut composite: Vec<Point> = Vec::with_capacity(date_set.len()); for d in &date_set { let mut sum = 0.0; let mut n = 0u32; for m in &members { let Some(by_date) = member_dates.get(m.ticker.as_str()) else { continue; }; let Some(close) = by_date.get(d.as_str()) else { continue; }; let Some(base) = bases.get(m.ticker.as_str()) else { continue; }; sum += close / base * 100.0; n += 1; } if n > 0 { composite.push(Point { d: d.clone(), v: sum / n as f64, }); } } // Benchmark: ^SPX scaled to 100 at the composite's first date. let benchmark = load_benchmark_series(&state, composite.first().map(|p| p.d.as_str())).await; Json(json!({ "composite": composite, "benchmark": benchmark, })) .into_response()}/// Pull `^SPX` daily closes from `anchor_date` forward, scaled so the first/// bar at or after the anchor reads `100`. Empty when the anchor is missing/// or the index has no bars there.async fn load_benchmark_series( state: &AppState, anchor_date: Option<&str>,) -> Vec<serde_json::Value> { let Some(anchor) = anchor_date else { return Vec::new(); }; let rows: Vec<(String, f64)> = sqlx::query_as( "SELECT d, close FROM daily_prices WHERE ticker = '^SPX' AND d >= ? ORDER BY d", ) .bind(anchor) .fetch_all(&state.pool) .await .unwrap_or_default(); let Some(base) = rows.first().map(|(_, c)| *c) else { return Vec::new(); }; if base <= 0.0 { return Vec::new(); } rows.into_iter() .map(|(d, c)| json!({"d": d, "v": c / base * 100.0})) .collect()}fn not_found(state: &AppState, msg: &str) -> Response { let extra = minijinja::context! { title => "Industries", message => msg, }; let mut response = render(state, "pages/not_found.html", "/industries", extra); *response.status_mut() = StatusCode::NOT_FOUND; response}
modified
src/routes/mod.rs
@@ -1,6 +1,7 @@pub mod backtest;pub mod health;pub mod home;pub mod industries;pub mod search;pub mod seo;pub mod stream;
modified
src/routes/symbols.rs
@@ -1439,6 +1439,19 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) None }; // Phase 15: slugs for the sector / industry header tags' links. Computed // here so the template stays declarative. let sector_slug = symbol .sector .as_deref() .map(crate::routes::industries::slug) .unwrap_or_default(); let industry_slug = symbol .industry .as_deref() .map(crate::routes::industries::slug) .unwrap_or_default(); let extra = minijinja::context! { title => ticker, symbol => symbol,
@@ -1455,6 +1468,8 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) anomalies => anomalies, earnings => earnings, filings => filings, sector_slug => sector_slug, industry_slug => industry_slug, }; render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)}
modified
src/scheduler.rs
@@ -116,6 +116,15 @@ const FUND_METADATA_STALE_SECS: i64 = 30 * 24 * 3600;const EARNINGS_INTERVAL_SECS: i64 = 24 * 3600;const EARNINGS_STALE_SECS: i64 = 30 * 24 * 3600;/// Asset-profile refresh (Phase 15). A company's sector and industry/// classification changes only on a structural shift (a reverse-merger or a/// reclassification by the index provider), so a monthly cadence is plenty./// Daily due-check; one request per stock through the shared `yahoo`/// `EndpointGuard`. ~512 stocks × monthly = ~17 requests/day in steady/// state, well below the 1000/hr budget.const ASSET_PROFILE_INTERVAL_SECS: i64 = 24 * 3600;const ASSET_PROFILE_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<()> {
@@ -179,6 +188,14 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle tracing::warn!("[scheduler] bring earnings_calendar job forward: {e}"); } // Asset profile job (Phase 15): same bring-forward pattern. A fresh // deploy populates each curated stock's sector and industry within // hours rather than the daily interval; resumable, the no-stale fast // path is free. if let Err(e) = schedule_next(&pool, "asset_profile", now_ms()).await { tracing::warn!("[scheduler] bring asset_profile 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;
@@ -268,6 +285,19 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle Err(e) => tracing::warn!("[scheduler] earnings_calendar due-check: {e}"), } // Asset profile (Phase 15): sweep stocks whose sector / industry // classification has gone stale (monthly). Same Yahoo guard; one // request per stock per month in steady state. match is_due(&pool, "asset_profile", now_ms()).await { Ok(true) => { if let Err(e) = run_asset_profile(&pool, &config, &hub).await { tracing::warn!("[scheduler] asset_profile: {e:#}"); } } Ok(false) => {} Err(e) => tracing::warn!("[scheduler] asset_profile 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
@@ -1549,6 +1579,156 @@ pub(crate) async fn store_earnings_next( Ok(())}// ────────────── stock asset profile sweep (Phase 15) ──────────────────────/// Sweep stocks whose Yahoo `quoteSummary.assetProfile` snapshot has gone/// stale (monthly), refreshing each stock's sector and industry. One request/// per stock through the shared `yahoo` `EndpointGuard`; mirrors the/// `earnings_calendar` shape — guard-paced, resumable, broadcast-to-/health.////// A stock Yahoo cleanly knows but has no `assetProfile` module for (uneven/// coverage on small caps) is still stamped synced, so the sweep does not/// re-fetch the same empty answer every cycle.async fn run_asset_profile( pool: &SqlitePool, config: &Config, hub: &Hub,) -> anyhow::Result<()> { let started = now_ms(); let next = started + ASSET_PROFILE_INTERVAL_SECS * 1000; let cutoff = started - ASSET_PROFILE_STALE_SECS * 1000; let stale: Vec<String> = sqlx::query_scalar( "SELECT ticker FROM symbols \ WHERE kind = 'stock' \ AND (asset_profile_synced_at IS NULL OR asset_profile_synced_at < ?) \ ORDER BY ticker", ) .bind(cutoff) .fetch_all(pool) .await?; if stale.is_empty() { mark_ok(pool, "asset_profile", Some(next)).await?; return Ok(()); } mark_fetching(pool, "asset_profile").await?; notify_health(hub); tracing::info!( "[scheduler] asset_profile: 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.asset_profile(ticker).await { Ok(Some(profile)) => { guard.record_success().await?; if let Err(e) = store_asset_profile(pool, ticker, &profile).await { tracing::warn!("[scheduler] asset_profile store {ticker}: {e:#}"); errors += 1; continue; } ok += 1; } // Yahoo answered cleanly but had no profile for this stock — // stamp it checked so the next sweep does not re-fetch the // same empty answer. Ok(None) => { guard.record_success().await?; let _ = mark_asset_profile_synced(pool, ticker).await; empty += 1; } Err(e) => { guard.record_failure(&e).await?; errors += 1; tracing::warn!("[scheduler] asset_profile {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] asset_profile: {full}"); log_fetch( pool, "asset_profile", "yahoo", "skipped", Some(&full), Some(ok), dur, started, ).await?; } None => { tracing::info!("[scheduler] asset_profile: {detail}"); log_fetch( pool, "asset_profile", "yahoo", "ok", Some(&detail), Some(ok), dur, started, ).await?; } } mark_ok(pool, "asset_profile", Some(next)).await?; notify_health(hub); Ok(())}/// Write one stock's sector / industry classification and stamp it freshly/// synced. Either field may be `None` when Yahoo's coverage is partial; an/// empty / whitespace value is dropped at parse time, not stored. `pub(crate)`:/// the add-symbol backfill reuses it.pub(crate) async fn store_asset_profile( pool: &SqlitePool, ticker: &str, profile: &crate::providers::AssetProfile,) -> sqlx::Result<()> { let now = now_ms(); sqlx::query( "UPDATE symbols SET sector = ?, industry = ?, \ asset_profile_synced_at = ?, updated_at = ? \ WHERE ticker = ?", ) .bind(&profile.sector) .bind(&profile.industry) .bind(now) .bind(now) .bind(ticker) .execute(pool) .await?; Ok(())}/// Stamp a stock as freshly asset-profile-synced without overwriting its/// stored sector / industry (used on a clean-empty Yahoo response so the/// sweep does not re-fetch).async fn mark_asset_profile_synced(pool: &SqlitePool, ticker: &str) -> sqlx::Result<()> { let now = now_ms(); sqlx::query( "UPDATE symbols SET asset_profile_synced_at = ?, updated_at = ? WHERE ticker = ?", ) .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> {
@@ -1957,6 +2137,13 @@ pub(crate) async fn backfill_symbol(pool: &SqlitePool, config: &Config, ticker: if kind == "stock" { backfill_earnings_calendar(pool, config, ticker).await; } // Phase 15: stocks get their Yahoo assetProfile pulled too — so a // user-added stock immediately shows up under its sector and industry // on /industries and carries the symbol-page header tag, rather than // waiting on the next monthly scheduler sweep. if kind == "stock" { backfill_asset_profile(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`
@@ -1997,6 +2184,33 @@ async fn backfill_fund_metadata(pool: &SqlitePool, config: &Config, ticker: &str }}/// Pull and store a freshly-added stock's sector / industry classification/// (Phase 15). Mirrors `backfill_earnings_calendar`: same `yahoo` guard,/// best-effort, no failure propagated to the add-symbol response.async fn backfill_asset_profile(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.asset_profile(ticker)).await { Some(Ok(Some(profile))) => match store_asset_profile(pool, ticker, &profile).await { Ok(()) => { tracing::info!( "[backfill] {ticker} <- asset_profile ({} / {})", profile.sector.as_deref().unwrap_or("—"), profile.industry.as_deref().unwrap_or("—"), ); } Err(e) => tracing::warn!("[backfill] store asset_profile {ticker}: {e:#}"), }, // Yahoo answered cleanly but had no profile for this stock — stamp // it so the next sweep does not re-fetch the same empty. Some(Ok(None)) => { let _ = mark_asset_profile_synced(pool, ticker).await; } Some(Err(e)) => tracing::warn!("[backfill] asset_profile {ticker}: {e:#}"), None => {} }}/// 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.
modified
templates/base.html
@@ -31,6 +31,7 @@ </form> <nav class="topnav"> <a href="/"{% if request.path == '/' %} class="is-active"{% endif %}>Markets</a> <a href="/industries"{% if request.path == '/industries' %} class="is-active"{% endif %}>Industries</a> <a href="/search"{% if request.path == '/search' %} class="is-active"{% endif %}>Search</a> </nav> {% include "includes/status_indicator.html" %}
@@ -46,6 +47,10 @@ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 19V11M10 19V5M16 19v-6M21 19H3"/></svg> <span>Markets</span> </a> <a href="/industries"{% if request.path == '/industries' %} class="is-active"{% endif %}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 21h18M5 21V9l7-5 7 5v12M9 21v-6h6v6"/></svg> <span>Industries</span> </a> <a href="/search"{% if request.path == '/search' %} class="is-active"{% endif %}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> <span>Search</span>
modified
templates/pages/home.html
@@ -70,6 +70,48 @@ {% for slate in pick_slates %}{{ picks_column(slate) }}{% endfor %} </div> <h2 class="section-title">Today’s industries{% if industries_asof %}<span class="section-title__asof">classifications synced {{ industries_asof|ago }}</span>{% endif %}<span class="section-title__asof"><a href="/industries">All industries →</a></span></h2> <p class="section-note">Equal-weight day move of the curated stocks inside each sector. The classification comes from Yahoo’s <code>assetProfile</code> and lands in the background; a sector waits for at least one of its members to sync.</p> <div class="movers"> <section class="movers__panel"> <h3 class="movers__title">Sectors moving up</h3> {% if top_industries %} <div class="movers__list"> {% for r in top_industries %} <a class="industry-row {{ 'industry-row--up' if r.up else 'industry-row--down' }}" href="/industries/{{ r.slug }}" style="--bar: {{ r.bar }}%"> <span class="industry-row__name">{{ r.name }}</span> <span class="industry-row__count">{{ r.members }} stocks</span> <span class="industry-row__chg num {{ 'is-up' if r.up else 'is-down' }}">{{ r.change_pct|pct }}</span> </a> {% endfor %} </div> {% else %} <p class="movers__empty">No sector classifications synced yet.</p> {% endif %} </section> <section class="movers__panel"> <h3 class="movers__title">Sectors moving down</h3> {% if bottom_industries %} <div class="movers__list"> {% for r in bottom_industries %} <a class="industry-row {{ 'industry-row--up' if r.up else 'industry-row--down' }}" href="/industries/{{ r.slug }}" style="--bar: {{ r.bar }}%"> <span class="industry-row__name">{{ r.name }}</span> <span class="industry-row__count">{{ r.members }} stocks</span> <span class="industry-row__chg num {{ 'is-up' if r.up else 'is-down' }}">{{ r.change_pct|pct }}</span> </a> {% endfor %} </div> {% else %} <p class="movers__empty">No sector classifications synced yet.</p> {% endif %} </section> </div> <h2 class="section-title">Stock health{% if health_asof %}<span class="section-title__asof">last sync {{ health_asof|ago }}</span>{% endif %}</h2> <p class="section-note">Fundamentals + recent price and growth trajectory + leadership stability, rolled into one read per stock. Industry context
added
templates/pages/industries_detail.html
@@ -0,0 +1,136 @@{% extends "base.html" %}{% block title %}{{ title }}{% endblock %}{% block description %}{{ sector_name }}{% if industry_name %} · {{ industry_name }}{% endif %} — composite trailing returns, seasonality, and members.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/industries/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap"> <div class="page-head"> <h1> {% if scope == 'industry' %}{{ industry_name }}{% else %}{{ sector_name }}{% endif %} </h1> <a class="page-head__link" href="/industries">All industries</a> </div> <p class="ind-crumbs"> {% if scope == 'industry' %} <a href="/industries">Industries</a> › <a href="/industries/{{ sector_slug }}">{{ sector_name }}</a> › <span>{{ industry_name }}</span> {% else %} <a href="/industries">Industries</a> › <span>{{ sector_name }}</span> {% endif %} </p> <section class="ind-stats"> {% set r = returns %} <div class="ind-stat"> <div class="ind-stat__label">Today</div> <div class="ind-stat__val num{% if r.d1 is not none and r.d1 >= 0 %} is-up{% elif r.d1 is not none %} is-down{% endif %}">{{ r.d1|pct }}</div> </div> <div class="ind-stat"> <div class="ind-stat__label">5d</div> <div class="ind-stat__val num{% if r.d5 is not none and r.d5 >= 0 %} is-up{% elif r.d5 is not none %} is-down{% endif %}">{{ r.d5|pct }}</div> </div> <div class="ind-stat"> <div class="ind-stat__label">1m</div> <div class="ind-stat__val num{% if r.d21 is not none and r.d21 >= 0 %} is-up{% elif r.d21 is not none %} is-down{% endif %}">{{ r.d21|pct }}</div> </div> <div class="ind-stat"> <div class="ind-stat__label">3m</div> <div class="ind-stat__val num{% if r.d63 is not none and r.d63 >= 0 %} is-up{% elif r.d63 is not none %} is-down{% endif %}">{{ r.d63|pct }}</div> </div> <div class="ind-stat"> <div class="ind-stat__label">1y</div> <div class="ind-stat__val num{% if r.d252 is not none and r.d252 >= 0 %} is-up{% elif r.d252 is not none %} is-down{% endif %}">{{ r.d252|pct }}</div> </div> <div class="ind-stat"> <div class="ind-stat__label">Members</div> <div class="ind-stat__val num">{{ r.members }}</div> </div> </section> <h2 class="section-title">Equal-weight composite</h2> <p class="section-note">A 5-year price-only index, rebased to 100 at the first trading date all members share. <code>^SPX</code> is overlaid on the same scale for comparison.</p> <section class="panel chart-panel ind-chart-panel"> <div id="ind-chart" data-sector="{{ sector_slug }}" {% if industry_slug %}data-industry="{{ industry_slug }}"{% endif %}> </div> </section> <h2 class="section-title">Seasonality</h2> <p class="section-note">Average daily return by calendar month, across all years of stored history for the members. Tall green bars are months the basket historically printed positive average days; tall red bars the opposite. Reads at a glance, not a forecast.</p> <section class="panel ind-season"> {% set months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] %} <div class="season-grid"> {% for v in seasonality %} {% if v is none %} <div class="season-cell season-cell--empty"> <div class="season-cell__bar"></div> <div class="season-cell__lbl">{{ months[loop.index0] }}</div> <div class="season-cell__val num">—</div> </div> {% else %} {% set abs = v|abs %} {% set h = (abs / seasonality_max * 100) if seasonality_max > 0 else 0 %} <div class="season-cell {% if v >= 0 %}season-cell--up{% else %}season-cell--down{% endif %}"> <div class="season-cell__bar" style="height: {{ h }}%"></div> <div class="season-cell__lbl">{{ months[loop.index0] }}</div> <div class="season-cell__val num">{{ v|pct }}</div> </div> {% endif %} {% endfor %} </div> </section> {% if scope == 'sector' and breakdown %} <h2 class="section-title">Industries inside this sector</h2> <table class="ind-table"> <thead> <tr> <th scope="col" class="ind-table__sec">Industry</th> <th scope="col" class="num">Today</th> <th scope="col" class="num">Members</th> </tr> </thead> <tbody> {% for i in breakdown %} <tr> <th scope="row" class="ind-table__sec"> <a href="/industries/{{ sector_slug }}/{{ i.slug }}">{{ i.name }}</a> </th> <td class="num{% if i.day_pct is not none and i.day_pct >= 0 %} is-up{% elif i.day_pct is not none %} is-down{% endif %}">{{ i.day_pct|pct }}</td> <td class="num">{{ i.members }}</td> </tr> {% endfor %} </tbody> </table> {% endif %} <h2 class="section-title">Members</h2> <div class="ind-members"> {% for m in members %} <a class="ind-member {% if m.change_pct is not none and m.change_pct >= 0 %}ind-member--up{% elif m.change_pct is not none %}ind-member--down{% endif %}" href="/s/{{ m.ticker|urlencode }}" style="--bar: {{ m.bar }}%"> <span class="ind-member__sym num">{{ m.ticker }}</span> <span class="ind-member__name">{{ m.name }}</span> {% if scope == 'sector' %}<span class="ind-member__industry">{{ m.industry }}</span>{% endif %} <span class="ind-member__price num">{{ m.price|money }}</span> <span class="ind-member__chg num{% if m.change_pct is not none and m.change_pct >= 0 %} is-up{% elif m.change_pct is not none %} is-down{% endif %}">{{ m.change_pct|pct }}</span> </a> {% endfor %} </div></div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/industries/index.js') }}"></script>{% endblock %}
added
templates/pages/industries_index.html
@@ -0,0 +1,57 @@{% extends "base.html" %}{% block title %}Industries{% endblock %}{% block description %}Stocks aggregated by sector and industry: day, month, and year-trailing composite moves at a glance.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/industries/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap"> <div class="page-head"> <h1>Industries</h1> <a class="page-head__link" href="/">Back to home</a> </div> {% if empty %} <p class="ind-empty">No stocks have been classified yet. The Yahoo <code>assetProfile</code> sweep runs in the background and fills in over time — check the <a href="/health">data health page</a> to follow its progress.</p> {% else %} <p class="ind-blurb">{{ total }} curated stocks across {{ sectors|length }} sectors and {{ total_industries }} industries. Each row’s composite is an equal-weight average of its members’ returns. Click a sector for a deeper read, the seasonality, and the members.</p> <table class="ind-table"> <thead> <tr> <th scope="col" class="ind-table__sec">Sector</th> <th scope="col" class="num">Today</th> <th scope="col" class="num">5d</th> <th scope="col" class="num">1m</th> <th scope="col" class="num">3m</th> <th scope="col" class="num">1y</th> <th scope="col" class="num">Members</th> <th scope="col" class="num">Industries</th> </tr> </thead> <tbody> {% for s in sectors %} <tr> <th scope="row" class="ind-table__sec"> <a href="/industries/{{ s.slug }}">{{ s.name }}</a> </th> {% set r = s.returns %} <td class="num{% if r.d1 is not none and r.d1 >= 0 %} is-up{% elif r.d1 is not none %} is-down{% endif %}">{{ r.d1|pct }}</td> <td class="num{% if r.d5 is not none and r.d5 >= 0 %} is-up{% elif r.d5 is not none %} is-down{% endif %}">{{ r.d5|pct }}</td> <td class="num{% if r.d21 is not none and r.d21 >= 0 %} is-up{% elif r.d21 is not none %} is-down{% endif %}">{{ r.d21|pct }}</td> <td class="num{% if r.d63 is not none and r.d63 >= 0 %} is-up{% elif r.d63 is not none %} is-down{% endif %}">{{ r.d63|pct }}</td> <td class="num{% if r.d252 is not none and r.d252 >= 0 %} is-up{% elif r.d252 is not none %} is-down{% endif %}">{{ r.d252|pct }}</td> <td class="num">{{ s.members }}</td> <td class="num">{{ s.industries }}</td> </tr> {% endfor %} </tbody> </table> {% endif %}</div>{% endblock %}
modified
templates/pages/symbol.html
@@ -38,6 +38,8 @@ <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1> <span class="sym-head__tag">{{ symbol.kind }}</span> {% if symbol.exchange %}<span class="sym-head__tag">{{ symbol.exchange }}</span>{% endif %} {% if symbol.sector %}<a class="sym-head__tag sym-head__tag--link" href="/industries/{{ sector_slug }}">{{ symbol.sector }}</a>{% endif %} {% if symbol.industry %}<a class="sym-head__tag sym-head__tag--link" href="/industries/{{ sector_slug }}/{{ industry_slug }}">{{ symbol.industry }}</a>{% endif %} </div> <div class="sym-head__name">{{ symbol.name }}</div> {# Prefer the live quote; fall back to the most recent daily close. The