Compare commits
15
Commits
247a7889b9
..
main
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Investing-signal platform for US equities. It runs one strategy, and it is a boring one:
|
Investing-signal platform for US equities. It runs one strategy, and it is a boring one:
|
||||||
|
|
||||||
> **A long-only cross-sectional momentum book.** Buy the top quintile by beta-adjusted 12-1 month momentum, tilt toward higher volatility, hold at most 10 names, cut at 1.5× ATR, then trail at 3× ATR for up to 30 trading days. After an initial-stop exit, re-enter only after the gate has failed and subsequently qualified again.
|
> **A long-only cross-sectional momentum book.** Buy the top quintile by beta-adjusted 12-1 month momentum, tilt toward higher volatility, hold at most 15 names, cut at 1.5× ATR, then trail at 3× ATR for up to 30 trading days. After an initial-stop exit, re-enter only after the gate has failed and subsequently qualified again.
|
||||||
|
|
||||||
**Philosophy:** don't predict price — rank it. The edge is *relative* strength across the universe, and the discipline is in the exit: cut losers fast, let winners run until the trail catches them.
|
**Philosophy:** don't predict price — rank it. The edge is *relative* strength across the universe, and the discipline is in the exit: cut losers fast, let winners run until the trail catches them.
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ flowchart TD
|
|||||||
Q -->|no| SKIP
|
Q -->|no| SKIP
|
||||||
Q -->|yes| RANK["Rank by production score<br/>80% momentum %ile<br/>+ 20% volatility %ile"]
|
Q -->|yes| RANK["Rank by production score<br/>80% momentum %ile<br/>+ 20% volatility %ile"]
|
||||||
|
|
||||||
RANK --> BOOK{"Room in the book?<br/>max 10 positions"}
|
RANK --> BOOK{"Room in the book?<br/>max 15 positions"}
|
||||||
BOOK -->|no| WAIT["Wait for a slot"]
|
BOOK -->|no| WAIT["Wait for a slot"]
|
||||||
BOOK -->|yes| OPEN["OPEN — size at 1% account risk"]
|
BOOK -->|yes| OPEN["OPEN — size at 1% account risk"]
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ indicators.
|
|||||||
|
|
||||||
**Morning** (~02:00 ET) — data and display only, **no** qualifying R:R scan:
|
**Morning** (~02:00 ET) — data and display only, **no** qualifying R:R scan:
|
||||||
|
|
||||||
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
1. **OHLCV** — latest daily bars (Alpaca) plus the SPY benchmark; new tickers backfill ~5 years. A symbol whose bars have been stale for 3 days is probed against SEC for a Form 25/25-NSE/15 and **retired** on a hit (history kept — see *Delisting*).
|
||||||
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
||||||
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v4 risk thermometer; feed no trades.
|
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v4 risk thermometer; feed no trades.
|
||||||
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
||||||
@@ -140,7 +140,8 @@ indicators.
|
|||||||
|
|
||||||
1. **OHLCV fetch** — refresh the in-progress day-t bar (same path as intraday).
|
1. **OHLCV fetch** — refresh the in-progress day-t bar (same path as intraday).
|
||||||
2. **R:R Scan** — Structural S/R, scores, Gate Target Ladder setups, residual 12‑1 + 80/20 rank. Advances post-stop gate-reset transitions; failed scans never count.
|
2. **R:R Scan** — Structural S/R, scores, Gate Target Ladder setups, residual 12‑1 + 80/20 rank. Advances post-stop gate-reset transitions; failed scans never count.
|
||||||
3. **Telegram alerts** — chained immediately so manual MOC fills can still hit ~15:50/15:55.
|
3. **Shadow book** — opt-in automated book; opens top-ranked qualified setups up to capacity at the same near-close prices. Only accepts a scan from this same pipeline run.
|
||||||
|
4. **Telegram alerts** — chained immediately so manual MOC fills can still hit ~15:50/15:55.
|
||||||
|
|
||||||
**After close** (~16:45 ET Mon–Fri):
|
**After close** (~16:45 ET Mon–Fri):
|
||||||
|
|
||||||
@@ -157,6 +158,38 @@ Hourly mid-session (Mon–Fri ~10:00–15:00 ET): only **OHLCV → Outcome Eval*
|
|||||||
|
|
||||||
Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET, also refreshes the fundamentals cache scoring reads) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
|
Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET, also refreshes the fundamentals cache scoring reads) · Backtest (weekly) · Ticker-universe sync (daily). Alerts auto-fire only via the near-close pipeline (still manually triggerable). Deep history backfill and event study are manual-only (Admin → Jobs).
|
||||||
|
|
||||||
|
The SEC import defers a run rather than writing partial data when a filing's XBRL
|
||||||
|
hasn't landed. Two bounds keep that from compounding: `MISSING_XBRL_RETRY_DAYS`
|
||||||
|
caps how long *one* filing blocks promotion, and `PROMOTION_CEILING_DAYS` (7)
|
||||||
|
caps how long the import as a whole can stay deferred — past the ceiling every
|
||||||
|
unresolved filing is aged out in place so `promote()` queues it as a gap row,
|
||||||
|
`source_max_date` advances, and the import self-heals. A `deferred_stale` alert
|
||||||
|
inside that window is normal and clears on its own; check `source_max_date` in
|
||||||
|
`data_import_runs` before diagnosing a wedge.
|
||||||
|
|
||||||
|
### Delisting, not deletion
|
||||||
|
|
||||||
|
Retiring a symbol used to mean `delete_ticker` or a pruning universe bootstrap,
|
||||||
|
both of which cascade through OHLCV, setups and scores. That destroys exactly the
|
||||||
|
history four research documents apologise for: today's tracked universe projected
|
||||||
|
backward is survivorship-biased, and hard-deleting every delisted name is what
|
||||||
|
causes it. Keeping the rows preserves the option to fix that later (it does not
|
||||||
|
fix it — the replay still has to model a delisting as an exit event).
|
||||||
|
|
||||||
|
`tickers` therefore carries `delisted_on` / `delisted_reason` (migration 032);
|
||||||
|
`NULL` means actively traded. The filter is **opt-in** via
|
||||||
|
`ticker_service.active_only`, applied to the live path only — scanner, momentum
|
||||||
|
ranking, scoring, breadth, fundamentals candidates, SEC universe, earnings import,
|
||||||
|
ingestion. The registry and admin views deliberately keep delisted rows visible,
|
||||||
|
and `run_backtest` keeps them on purpose. Detection runs off OHLCV staleness
|
||||||
|
(not the SEC fundamentals import, which stalls for days on unrelated Company-Facts
|
||||||
|
gaps) and retires only on a Form 25/25-NSE/15 hit, so a halt or a rename keeps the
|
||||||
|
existing warning instead. `delisted_on` is the *effective* date — Rule 12d2-2
|
||||||
|
makes a Form 25 removal take effect ten days after filing, so a symbol filed today
|
||||||
|
keeps trading (and keeps qualifying) until that date. It is safe to automate
|
||||||
|
because it is reversible: `clear_delisted` un-retires a false positive, where a
|
||||||
|
delete had already taken the history.
|
||||||
|
|
||||||
### From score to "top pick"
|
### From score to "top pick"
|
||||||
|
|
||||||
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize). **Display and ranking only — it does not select trades.**
|
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize). **Display and ranking only — it does not select trades.**
|
||||||
@@ -166,6 +199,33 @@ Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET
|
|||||||
|
|
||||||
**What the R:R and reach-probability in step 3 actually are.** They are *gate inputs*, computed from a Gate Target Ladder proposal the trade will never exit at — they exist to filter setups, not to forecast the trade you're about to take. A setup with "R:R 2.4:1, 34% reach probability" is not a claim that you'll make 2.4R with 34% probability; it's a claim that this setup cleared the screen. What actually happens to a trade is in the exit box of the diagram above, and on the "what usually happens" panel in the UI. Conflating the two is the single easiest way to misread this app.
|
**What the R:R and reach-probability in step 3 actually are.** They are *gate inputs*, computed from a Gate Target Ladder proposal the trade will never exit at — they exist to filter setups, not to forecast the trade you're about to take. A setup with "R:R 2.4:1, 34% reach probability" is not a claim that you'll make 2.4R with 34% probability; it's a claim that this setup cleared the screen. What actually happens to a trade is in the exit box of the diagram above, and on the "what usually happens" panel in the UI. Conflating the two is the single easiest way to misread this app.
|
||||||
|
|
||||||
|
### Two books: shadow (automated) and discretionary (manual)
|
||||||
|
|
||||||
|
The platform keeps **two** paper books, and the difference between them is the
|
||||||
|
whole point.
|
||||||
|
|
||||||
|
| Book | Who selects | What it measures |
|
||||||
|
|---|---|---|
|
||||||
|
| **Shadow book** (`app/services/shadow_book_service.py`) | The machine — top-ranked qualified setups up to capacity, every near-close scan | The **strategy**, faithfully |
|
||||||
|
| **Discretionary book** | You, by clicking "paper trade" on a setup | The strategy **plus** your discretion and availability |
|
||||||
|
|
||||||
|
The manual book only ever contains trades the user chose to take, inside a ~20
|
||||||
|
minute window, on days they were around. The backtest that validated this
|
||||||
|
strategy does none of that, which makes the manual record unusable on its own as
|
||||||
|
out-of-sample evidence. The shadow book closes that gap: it mirrors
|
||||||
|
`_simulate_portfolio`'s selection rule exactly, orders on the *stored*
|
||||||
|
`strategy_rank` the scanner already wrote (so the two cannot drift apart) and
|
||||||
|
shares the manual book's exit policy — the only difference between the books is
|
||||||
|
*which* qualified setups get taken.
|
||||||
|
|
||||||
|
It runs as a step of the near-close pipeline, straight after the scan so entries
|
||||||
|
mark at the same near-close prices, and it only accepts a scan from the same
|
||||||
|
pipeline run. It is **opt-in** (`shadow_book_enabled`, with capacity, risk % and
|
||||||
|
starting equity under **Admin → Settings → Performance & Shadow Book**) because it
|
||||||
|
writes live trades. The **Dashboard**'s performance chart plots shadow vs
|
||||||
|
discretionary vs SPY; *Signals → Paper Trades* still shows the discretionary book
|
||||||
|
only.
|
||||||
|
|
||||||
## Strategy Status — What's Validated and What Isn't
|
## Strategy Status — What's Validated and What Isn't
|
||||||
|
|
||||||
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (latest run July 2026, ~5 years of OHLCV), not from opinion.
|
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (latest run July 2026, ~5 years of OHLCV), not from opinion.
|
||||||
@@ -176,7 +236,8 @@ Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production gate — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
|
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production gate — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
|
||||||
| **3× ATR trailing exit** (+ 1.5× ATR initial stop, 30-day max hold) | **Production exit — best Sharpe of every exit tested** | Beat hold / SMA50 / 20-day-low / technical-40 and both take-profit variants (July 2026) |
|
| **3× ATR trailing exit** (+ 1.5× ATR initial stop, 30-day max hold) | **Production exit — best Sharpe of every exit tested** | Beat hold / SMA50 / 20-day-low / technical-40 and both take-profit variants (July 2026) |
|
||||||
| **Post-stop gate reset** | **Production re-entry policy** | The initial stop always closes; the ticker must later fail the daily gate and subsequently qualify again. At the production capacity of 10: Sharpe 1.67 → 1.77, CAGR 45.2% → 48.3%, DD 24.3% → 21.6% versus immediate re-entry. [Full study](docs/research/post-stop-reentry.md) |
|
| **Post-stop gate reset** | **Production re-entry policy** | The initial stop always closes; the ticker must later fail the daily gate and subsequently qualify again. At the then-production capacity of 10: Sharpe 1.67 → 1.77, CAGR 45.2% → 48.3%, DD 24.3% → 21.6% versus immediate re-entry. Capacity has since been raised to 15 — see the open question under the re-entry section. [Full study](docs/research/post-stop-reentry.md) |
|
||||||
|
| **Book capacity 15** (raised from 10, 2026-08-05) | **Production sizing** | The focused daily capacity bracket found the count cap was binding and cost real compounding: +1.075pp CAGR paired, 51 paths better / 2 worse, drawdown unchanged. Cash plus the 20% notional cap saturates the book near 12, so the cap no longer binds. [Findings](docs/research/portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
|
||||||
| **Structural S/R** | **Human-facing context only — not a gate and not an exit** | Clean, capped zones are persisted for charts and alerts. The scanner deliberately does not read them. |
|
| **Structural S/R** | **Human-facing context only — not a gate and not an exit** | Clean, capped zones are persisted for charts and alerts. The scanner deliberately does not read them. |
|
||||||
| **Gate Target Ladder** | **Gate input only — not market structure and not an exit** | Volume-free range grid + pivots preserves the useful legacy screening behavior exactly: 1,086/1,086 qualified setups retained and identical Sharpe 2.03 / CAGR 50.0% / DD 21.4% / 321 trades. The exit never reads its target. [Full write-up](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder) |
|
| **Gate Target Ladder** | **Gate input only — not market structure and not an exit** | Volume-free range grid + pivots preserves the useful legacy screening behavior exactly: 1,086/1,086 qualified setups retained and identical Sharpe 2.03 / CAGR 50.0% / DD 21.4% / 321 trades. The exit never reads its target. [Full write-up](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder) |
|
||||||
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
|
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
|
||||||
@@ -187,7 +248,7 @@ Dolt earnings import (daily 02:30 ET) · SEC fundamentals import (daily 04:00 ET
|
|||||||
| Gate target as a take-profit (tested July 2026) | **Rejected** | Sharpe 2.04 → 1.47, CAGR halved. Win rate *rose* — it truncates the right tail where the edge lives |
|
| Gate target as a take-profit (tested July 2026) | **Rejected** | Sharpe 2.04 → 1.47, CAGR halved. Win rate *rose* — it truncates the right tail where the edge lives |
|
||||||
| "Clear-air" gate relaxation (tested July 2026) | **Rejected — failed out-of-sample** | Strictly better in-sample (Sharpe 2.07 / CAGR 62.3% / DD 20.1%), then lost on a real train/test split (Sharpe 2.78 → 2.45). A cautionary tale: nested lookbacks are not OOS |
|
| "Clear-air" gate relaxation (tested July 2026) | **Rejected — failed out-of-sample** | Strictly better in-sample (Sharpe 2.07 / CAGR 62.3% / DD 20.1%), then lost on a real train/test split (Sharpe 2.78 → 2.45). A cautionary tale: nested lookbacks are not OOS |
|
||||||
|
|
||||||
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
|
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward record of the shadow book** — the automated twin that takes every top-ranked qualified setup, with no discretion or availability mixed in. The Dashboard chart tracks it against the discretionary book and SPY; *Signals → Backtest* is what it is being compared against.
|
||||||
|
|
||||||
### Daily post-stop re-entry decision (2026-07-17)
|
### Daily post-stop re-entry decision (2026-07-17)
|
||||||
|
|
||||||
@@ -200,7 +261,9 @@ The production policy is **normal gate reset**, evaluated with daily setup oppor
|
|||||||
| Strict gate reset (live timing analogue) | 342.7% | 44.8% | -23.4% | 1.68 | 471 |
|
| Strict gate reset (live timing analogue) | 342.7% | 44.8% | -23.4% | 1.68 | 471 |
|
||||||
| Fixed five-session cooldown | 250.8% | 36.6% | -22.2% | 1.47 | 473 |
|
| Fixed five-session cooldown | 250.8% | 36.6% | -22.2% | 1.47 | 473 |
|
||||||
|
|
||||||
In the disjoint 2025+ book, gate reset also beat immediate re-entry (Sharpe 1.66 vs 1.55; CAGR 41.8% vs 39.3%) and the fixed five-session rule (Sharpe 1.43; CAGR 32.7%). Its lead over both survived costs of 0.2% and 0.3% per side. The result is capacity-specific: cooldown 5 won at capacity 5, while immediate had slightly higher return and Sharpe at capacity 15. Production uses capacity 10, so that is the portfolio for which this decision is valid.
|
In the disjoint 2025+ book, gate reset also beat immediate re-entry (Sharpe 1.66 vs 1.55; CAGR 41.8% vs 39.3%) and the fixed five-session rule (Sharpe 1.43; CAGR 32.7%). Its lead over both survived costs of 0.2% and 0.3% per side. The result is capacity-specific: cooldown 5 won at capacity 5, while immediate had slightly higher return and Sharpe at capacity 15.
|
||||||
|
|
||||||
|
> **Open question (since 2026-08-05).** This study was run — and gate reset promoted — at capacity 10. Production capacity was subsequently raised to 15, which is the one capacity in the matrix where *immediate* re-entry edged ahead. The re-entry policy is therefore currently running outside the portfolio it was validated on. Nothing else changed, and the two arms differed only modestly, but the matrix should be rerun at capacity 15 before treating gate reset as settled. Until then, keep gate reset (the incumbent) rather than switching on an untested read.
|
||||||
|
|
||||||
Those promotion numbers belong to the selected normal-reset study arm. Under the **pre-cutover** morning-scan scheduler (scan always before any outcome eval), live first-observation timing matched the stricter `strict_gate_reset` analogue (full-period Sharpe 1.68 / CAGR 44.8% / DD 23.4%). After the **near-close cutover** (2026-07), stops closed by earlier same-day intraday evals can receive a same-day fail observation at ~15:30 ET — moving live behavior **toward** the promoted `gate_reset` arm. Requalification still requires a later America/New_York trading date than the failure (`trade_policy` distinct-day guard). Full definitions and all nine policy arms: [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); execution evidence: [docs/research/execution-recovery.md](docs/research/execution-recovery.md).
|
Those promotion numbers belong to the selected normal-reset study arm. Under the **pre-cutover** morning-scan scheduler (scan always before any outcome eval), live first-observation timing matched the stricter `strict_gate_reset` analogue (full-period Sharpe 1.68 / CAGR 44.8% / DD 23.4%). After the **near-close cutover** (2026-07), stops closed by earlier same-day intraday evals can receive a same-day fail observation at ~15:30 ET — moving live behavior **toward** the promoted `gate_reset` arm. Requalification still requires a later America/New_York trading date than the failure (`trade_policy` distinct-day guard). Full definitions and all nine policy arms: [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); execution evidence: [docs/research/execution-recovery.md](docs/research/execution-recovery.md).
|
||||||
|
|
||||||
@@ -208,7 +271,7 @@ Those promotion numbers belong to the selected normal-reset study arm. Under the
|
|||||||
|
|
||||||
### Historical weekly production baseline (pre gate-reset)
|
### Historical weekly production baseline (pre gate-reset)
|
||||||
|
|
||||||
Use this as the historical ranking/exit regression guardrail, not as a return promise or the current re-entry-policy result. This run predates the post-stop gate reset and uses weekly entry replay, so its portfolio headline is not directly comparable with the daily matrix above. Backtest run: local production SQLite snapshot, 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-02, 0.1% per-side costs, price-only SPY benchmark. Numbers below are the 2026-07-11 run (`reports/backtest-20260711-prod-baseline.json`) — measured *after* the primary-target probability floor shipped, which pruned lottery-target setups (1,428 → 1,089 qualified) and lifted Sharpe on all three promotion contenders.
|
Use this as the historical ranking/exit regression guardrail, not as a return promise or the current re-entry-policy result. This run predates the post-stop gate reset **and the 2026-08-05 capacity raise to 15**, and uses weekly entry replay, so its portfolio headline is not directly comparable with the daily matrix above. Backtest run: local production SQLite snapshot, 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-02, 0.1% per-side costs, price-only SPY benchmark. Numbers below are the 2026-07-11 run (`reports/backtest-20260711-prod-baseline.json`) — measured *after* the primary-target probability floor shipped, which pruned lottery-target setups (1,428 → 1,089 qualified) and lifted Sharpe on all three promotion contenders.
|
||||||
|
|
||||||
| Item | Historical weekly baseline |
|
| Item | Historical weekly baseline |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -248,16 +311,16 @@ Parity guard (July 2026): the portfolio monitor's **Production** row replays the
|
|||||||
|
|
||||||
### Tuned and confirmed — do not retest without new data (July 2026)
|
### Tuned and confirmed — do not retest without new data (July 2026)
|
||||||
|
|
||||||
A systematic single-variable sweep (offline prod snapshot, production gate/rank/exit, 2022-06 → 2026-07 plus disjoint 2022–23 / 2024–26 folds) confirmed **every** production setting. Retesting these against the same ~4-year snapshot is wasted compute and invites overfitting; revisit only with meaningfully new data (longer history or broader universe).
|
A systematic single-variable sweep (offline prod snapshot, production gate/rank/exit, 2022-06 → 2026-07 plus disjoint 2022–23 / 2024–26 folds) confirmed every production setting **except book size**, which a later focused bracket reversed (see the row below). Retesting these against the same ~4-year snapshot is wasted compute and invites overfitting; revisit only with meaningfully new data (longer history or broader universe) — or, as with capacity, a demonstrably better measurement lens.
|
||||||
|
|
||||||
| Knob tested | Verdict | Evidence |
|
| Knob tested | Verdict | Evidence |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ATR trail multiple {1.5–4.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau |
|
| ATR trail multiple {1.5–4.0} | **Keep 3.0** | Return+Sharpe peak; ≤2.0 whipsaws out the momentum right tail; ≥2.5 is a plateau |
|
||||||
| SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money |
|
| SPY 200d-MA regime overlay (block entries / go flat) | **Reject** | Halves return (315%→138%) with zero drawdown benefit — the ATR trail already manages downside, and the filter blocks the recovery-phase entries that make the money |
|
||||||
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
||||||
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep 80 × 10** | Monotonically worse in both directions from 80; the 10-slot cap never binds (<10 concurrent) |
|
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep cutoff 80; book size raised to 15 (2026-08-05)** | The cutoff is monotonically worse in both directions from 80. The book-size half of this row was **reversed**: the weekly replay's "the 10-slot cap never binds" read came from EV per trade, which is the wrong lens for anything that changes trade *count*. The focused daily bracket found cap 10 *was* binding and cost +1.075pp CAGR; at 15 the cap never bound in any cell (max observed 12 concurrent, zero full-book skips) |
|
||||||
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
||||||
| Post-stop re-entry: immediate, fixed 2–5 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity |
|
| Post-stop re-entry: immediate, fixed 2–5 sessions, gate resets, confirmation filters | **Keep normal gate reset** — but measured at capacity 10, and capacity is now 15 | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5. The "rerun before changing portfolio capacity" caveat is now outstanding — see the open question above |
|
||||||
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
||||||
|
|
||||||
Two findings future sessions must not re-litigate:
|
Two findings future sessions must not re-litigate:
|
||||||
@@ -270,23 +333,24 @@ Two findings future sessions must not re-litigate:
|
|||||||
A signal earns its way into selection **only** through the factor harness:
|
A signal earns its way into selection **only** through the factor harness:
|
||||||
|
|
||||||
1. Add it as a point-in-time function of past bars in `_signal_values()` (`backtest_service.py`).
|
1. Add it as a point-in-time function of past bars in `_signal_values()` (`backtest_service.py`).
|
||||||
2. Run the backtest (Admin → Jobs, or the weekly run) and read the **Signal edge** table (Signals → Track Record).
|
2. Run the backtest (Admin → Jobs, or the weekly run) and read the report's `signal_eval` section. This one is **local-report only** — the deployed Backtest tab does not render it (see *Reading a local backtest report* below).
|
||||||
3. Wire it into the gate or ranking **only if** |mean IC| ≳ 0.03 with a consistent sign and `reliable: true` (≥ 12 non-overlapping windows).
|
3. Wire it into the gate or ranking **only if** |mean IC| ≳ 0.03 with a consistent sign and `reliable: true` (≥ 12 non-overlapping windows).
|
||||||
|
|
||||||
Corollaries: never let an unvalidated score gate setups; the outcome evaluator must keep scoring **all** setups (unqualified ones are the control group); LLM output stays display-only in the quant path.
|
Corollaries: never let an unvalidated score gate setups; the outcome evaluator must keep scoring **all** setups (unqualified ones are the control group); LLM output stays display-only in the quant path.
|
||||||
|
|
||||||
### Highest-value next experiments (in order)
|
### Highest-value next experiments (in order)
|
||||||
|
|
||||||
> Check **[docs/research/](docs/research/README.md)** first — 12 strategy ideas have already been tested and rejected, including the obvious ones (take-profit exits, regime overlays, inverse-vol sizing, shorts).
|
> Check **[docs/research/](docs/research/README.md)** first — 13 strategy ideas have already been tested and rejected, including the obvious ones (take-profit exits, regime overlays, inverse-vol sizing, shorts, sector-residual momentum).
|
||||||
|
|
||||||
1. **Forward monitor the promoted strategy** — the production UI now behaves like a portfolio monitor for the current strategy, with selectable lookbacks and SPY comparison. Forward paper-trade months are the only evidence the snapshot cannot provide; the July 2026 tuning pass closed every in-sample lead. (Trailing-stop sensitivity and the max-15 capacity check are done — see the tuning table above.)
|
1. **Forward monitor the promoted strategy** — *Signals → Backtest* behaves like a portfolio monitor for the current strategy, with selectable lookbacks and SPY comparison, and the Dashboard chart carries the forward record. Forward months of the **shadow book** are the only evidence the snapshot cannot provide; the July 2026 tuning pass closed every in-sample lead. (Trailing-stop sensitivity and the capacity bracket are done — capacity was raised to 15.)
|
||||||
2. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
|
2. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
|
||||||
3. **Breadth is no longer free leverage** — Phase B found residual-mom t-stat *fell* on liquid-1500 vs the 505-name fingerprint (0.055/1.98 → 0.029/1.33). Any breadth book must clear a pre-registered baseline arm before fip tilts mean anything. (Deeper history was considered and declined.)
|
3. **Breadth is no longer free leverage** — Phase B found residual-mom t-stat *fell* on liquid-1500 vs the 505-name fingerprint (0.055/1.98 → 0.029/1.33). Any breadth book must clear a pre-registered baseline arm before fip tilts mean anything. (Deeper history was considered and declined.)
|
||||||
|
|
||||||
## Key Use Cases
|
## Key Use Cases
|
||||||
|
|
||||||
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists residual-gated qualified setups ranked by the production 80/20 residual/high-vol score, with the #1 flagged "Top pick". Each row opens the ticker page for its chart, Structural S/R, Gate Target Ladder targets and entry/stop.
|
- **Find today's best long setup.** On the **Dashboard**, the *Top Setups* table lists residual-gated qualified setups ranked by the production 80/20 residual/high-vol score, with the #1 flagged "Top pick". Each row opens the ticker page for its chart, Structural S/R, Gate Target Ladder targets and entry/stop.
|
||||||
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 3x ATR trail with a 30-trading-day max hold), and its sentiment stays fresh while open. *Signals → Track Record* shows the realized edge.
|
- **Track a trade you took.** Mark a setup as a **paper trade**: it's marked-to-market against the latest close, auto-closed by the active exit policy (default: 3x ATR trail with a 30-trading-day max hold), and its sentiment stays fresh while open. *Signals → Paper Trades* shows the realized edge of your discretionary book; the Dashboard chart puts it next to the automated shadow book and SPY.
|
||||||
|
- **Ask whether the strategy is worth trading at all.** *Signals → Backtest* replays the promoted strategy over history — portfolio monitor vs SPY over selectable lookbacks, headline risk-adjusted metrics (Sharpe, Sortino, Gain-to-Pain, dollar profit factor) and the report's own recommendation — with the live-outcome evaluation panel underneath it.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -306,7 +370,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- Ticker registry with full cascade delete
|
- Ticker registry with reversible delisting (history preserved) plus an explicit cascade delete
|
||||||
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint — free public sources (Wikipedia / NASDAQ Trader), then the cached snapshot, then a built-in seed list. The seeds are representative, not complete, so a *fresh* install bootstrapped while the public source is unreachable gets a partial universe; a warm instance falls through to its cache.
|
- Universe bootstrap for `sp500`, `nasdaq100`, `nasdaq_all` via admin endpoint — free public sources (Wikipedia / NASDAQ Trader), then the cached snapshot, then a built-in seed list. The seeds are representative, not complete, so a *fresh* install bootstrapped while the public source is unreachable gets a partial universe; a warm instance falls through to its cache.
|
||||||
- OHLCV price storage with upsert and validation
|
- OHLCV price storage with upsert and validation
|
||||||
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
|
- Technical indicators: ADX, EMA, RSI, ATR, Volume Profile, Pivot Points, EMA Cross
|
||||||
@@ -319,6 +383,8 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge)
|
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge)
|
||||||
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
|
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
|
||||||
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
|
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
|
||||||
|
- Shadow book — opt-in automated twin of the backtest's selection rule (top-ranked qualified setups up to capacity, every near-close scan), sharing the manual book's exit policy; the honest forward out-of-sample record
|
||||||
|
- System events — structured job/import/data warnings with acknowledgement, surfaced in Admin and deduplicated for alerting
|
||||||
- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit level + impulse) with a manual chronological correction study
|
- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit level + impulse) with a manual chronological correction study
|
||||||
- Telegram alerts (e.g. regime-quadrant changes)
|
- Telegram alerts (e.g. regime-quadrant changes)
|
||||||
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
|
||||||
@@ -337,7 +403,10 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
- Ticker detail page: chart, scores, sentiment breakdown, fundamentals, technical indicators, S/R table
|
- Ticker detail page: chart, scores, sentiment breakdown, fundamentals, technical indicators, S/R table
|
||||||
- Rankings table with configurable dimension weights
|
- Rankings table with configurable dimension weights
|
||||||
- Trade scanner showing detected R:R setups
|
- Trade scanner showing detected R:R setups
|
||||||
- Admin page: user management, job status with live indicators, enable/disable toggles, data cleanup, system settings
|
- Backtest tab: portfolio monitor vs SPY over selectable lookbacks, headline risk-adjusted tiles (Sharpe, Sortino, Gain-to-Pain, dollar profit factor), the report's recommendation card, and a live-outcome evaluation panel
|
||||||
|
- Dashboard performance chart: cumulative shadow book vs discretionary book vs SPY since the configured start date
|
||||||
|
- Paper Trades tab: open/closed discretionary trades with realized R and P&L tiles
|
||||||
|
- Admin page: user management, job status with live indicators, enable/disable toggles, pipeline readiness, system-event log, ticker management, data cleanup, system settings
|
||||||
- Protected routes with JWT auth, admin-only sections
|
- Protected routes with JWT auth, admin-only sections
|
||||||
- Responsive layout with mobile navigation
|
- Responsive layout with mobile navigation
|
||||||
- Toast notifications for async operations
|
- Toast notifications for async operations
|
||||||
@@ -348,14 +417,14 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/login` | Login | Public |
|
| `/login` | Login | Public |
|
||||||
| `/register` | Register | Public (when enabled) |
|
| `/register` | Register | Public (when enabled) |
|
||||||
| `/` | Dashboard — top setups, open trades, regime (default) | Authenticated |
|
| `/` | Dashboard — top setups, open trades, regime, shadow-vs-manual-vs-SPY performance chart (default) | Authenticated |
|
||||||
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
| `/market` | Market — watchlist + rankings tabs | Authenticated |
|
||||||
| `/signals` | Signals — scanner + track record tabs | Authenticated |
|
| `/signals` | Signals — Setups / Paper Trades / Backtest tabs | Authenticated |
|
||||||
| `/regime` | AI/Tech Risk Monitor | Authenticated |
|
| `/regime` | AI/Tech Risk Monitor | Authenticated |
|
||||||
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
| `/ticker/:symbol` | Ticker Detail | Authenticated |
|
||||||
| `/admin` | Admin Panel | Admin only |
|
| `/admin` | Admin Panel | Admin only |
|
||||||
|
|
||||||
Legacy routes redirect: `/watchlist` → `/market`, `/rankings` → `/market?tab=rankings`, `/scanner` → `/signals`, `/performance` → `/signals?tab=track`.
|
Legacy routes redirect: `/watchlist` → `/market`, `/rankings` → `/market?tab=rankings`, `/scanner` → `/signals`, `/performance` → `/signals?tab=track` (the Paper Trades tab — `track` stays its slug so the old link keeps working).
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -365,7 +434,7 @@ All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Health | `GET /health` |
|
| Health | `GET /health` |
|
||||||
| Auth | `POST /auth/register`, `POST /auth/login` |
|
| Auth | `POST /auth/register`, `POST /auth/login` |
|
||||||
| Tickers | `POST /tickers`, `GET /tickers`, `DELETE /tickers/{symbol}` |
|
| Tickers | `POST /tickers`, `GET /tickers`, `DELETE /tickers/{symbol}`, `POST /tickers/{symbol}/delisting`, `DELETE /tickers/{symbol}/delisting` |
|
||||||
| OHLCV | `POST /ohlcv`, `GET /ohlcv/{symbol}` |
|
| OHLCV | `POST /ohlcv`, `GET /ohlcv/{symbol}` |
|
||||||
| Ingestion | `POST /ingestion/fetch/{symbol}` |
|
| Ingestion | `POST /ingestion/fetch/{symbol}` |
|
||||||
| Indicators | `GET /indicators/{symbol}/{type}`, `GET /indicators/{symbol}/ema-cross` |
|
| Indicators | `GET /indicators/{symbol}/{type}`, `GET /indicators/{symbol}/ema-cross` |
|
||||||
@@ -375,11 +444,11 @@ All under `/api/v1/`. Interactive docs at `/docs` (Swagger) and `/redoc`.
|
|||||||
| Fundamentals | `GET /fundamentals/{symbol}` |
|
| Fundamentals | `GET /fundamentals/{symbol}` |
|
||||||
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
|
| Scores | `GET /scores/{symbol}`, `GET /rankings`, `PUT /scores/weights` |
|
||||||
| Trades | `GET /trades`, `GET /trades/{symbol}`, `GET /trades/{symbol}/history`, `GET /trades/activation`, `GET /trades/performance` |
|
| Trades | `GET /trades`, `GET /trades/{symbol}`, `GET /trades/{symbol}/history`, `GET /trades/activation`, `GET /trades/performance` |
|
||||||
| Paper Trades | `GET /paper-trades`, `POST /paper-trades`, `POST /paper-trades/{id}/close` |
|
| Paper Trades | `GET /paper-trades`, `POST /paper-trades`, `POST /paper-trades/{id}/close`, `GET /paper-trades/equity-curve`, `GET /paper-trades/performance` (shadow vs manual vs SPY), `GET/PUT /paper-trades/exit-policy` |
|
||||||
| Market / Regime | `GET /market/regime`, `GET /regime/monitor`, `GET/PUT /regime/config`, `GET /regime/history`, `GET /regime/event-study`, `GET/PUT /regime/fundamentals`, `GET /backtest/report` |
|
| Market / Regime | `GET /market/regime`, `GET /regime/monitor`, `GET/PUT /regime/config`, `GET /regime/history`, `GET /regime/event-study`, `GET/PUT /regime/fundamentals`, `POST /regime/fundamentals/refresh`, `GET /backtest/report` |
|
||||||
| Jobs | `GET /jobs/running` |
|
| Jobs | `GET /jobs/running` |
|
||||||
| Watchlist | `GET /watchlist`, `POST /watchlist/{symbol}`, `DELETE /watchlist/{symbol}` |
|
| Watchlist | `GET /watchlist`, `POST /watchlist/{symbol}`, `DELETE /watchlist/{symbol}` |
|
||||||
| Admin | `GET /admin/users`, `POST /admin/users`, `PUT /admin/users/{id}/access`, `PUT /admin/users/{id}/password`, `PUT /admin/settings/registration`, `GET /admin/settings`, `PUT /admin/settings/{key}`, `GET/PUT /admin/settings/recommendations`, `GET/PUT /admin/settings/ticker-universe`, `POST /admin/tickers/bootstrap`, `POST /admin/data/cleanup`, `GET /admin/jobs`, `POST /admin/jobs/{name}/trigger`, `PUT /admin/jobs/{name}/toggle`, `GET /admin/pipeline/readiness` |
|
| Admin | `GET /admin/users`, `POST /admin/users`, `PUT /admin/users/{id}/access`, `PUT /admin/users/{id}/password`, `PUT /admin/settings/registration`, `GET /admin/settings`, `PUT /admin/settings/{key}`, `GET/PUT /admin/settings/{recommendations,activation,schedule,performance,shadow-book,sentiment,alerts,ticker-universe}`, `POST /admin/settings/{sentiment,alerts}/test`, `POST /admin/tickers/bootstrap`, `POST /admin/tickers/backfill-names`, `POST /admin/data/cleanup`, `POST /admin/track-record/reset`, `GET /admin/jobs`, `POST /admin/jobs/{name}/trigger`, `PUT /admin/jobs/{name}/toggle`, `GET /admin/pipeline/readiness`, `GET /admin/system-events`, `GET /admin/system-events/summary`, `POST /admin/system-events/acknowledge` |
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -439,8 +508,8 @@ npm run preview # Preview the production build locally
|
|||||||
# Backend tests (in-memory SQLite — no PostgreSQL needed)
|
# Backend tests (in-memory SQLite — no PostgreSQL needed)
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
|
|
||||||
# Frontend: there is no test suite — `npm test` calls vitest, which is not
|
# Frontend: there is no test suite and no `test` script at all. The frontend
|
||||||
# installed. The frontend check is the full TypeScript build:
|
# check is the full TypeScript build:
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
@@ -524,10 +593,10 @@ the [full research record](docs/research/sr-levels-and-exits.md#gtl-tuning-matri
|
|||||||
|
|
||||||
### Reading a local backtest report
|
### Reading a local backtest report
|
||||||
|
|
||||||
The deployed **Signals → Track Record** page is deliberately trimmed to validation
|
The deployed **Signals → Backtest** tab is deliberately trimmed to validation
|
||||||
(portfolio monitor vs SPY, realized paper trades) and how-to-trade. The
|
(portfolio monitor vs SPY, headline metrics, the report's recommendation, and the
|
||||||
strategy-tuning tables that used to live there now live **only** in the local
|
live-outcome evaluation panel). The strategy-tuning tables that used to live there
|
||||||
report — inspect these `reports/backtest-<timestamp>.json` sections and produce the
|
now live **only** in the local report — inspect these `reports/backtest-<timestamp>.json` sections and produce the
|
||||||
matching decision. Every change still goes through the factor harness first (see
|
matching decision. Every change still goes through the factor harness first (see
|
||||||
**The iron rule for strategy changes** above).
|
**The iron rule for strategy changes** above).
|
||||||
|
|
||||||
@@ -564,8 +633,11 @@ Research-only flags, all off by default (the default report is byte-identical to
|
|||||||
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
|
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
|
||||||
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
|
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
|
||||||
|
|
||||||
`recommendation` is the one section surfaced on the deployed page ("What this
|
`portfolio_monitor` and `recommendation` are the sections surfaced on the deployed
|
||||||
backtest recommends"); everything else in this table is intentionally local-only.
|
Backtest tab (the monitor chart/tiles and "What this backtest recommends"; the
|
||||||
|
recommendation is rebuilt on read, so it always matches the lookback on screen and
|
||||||
|
flags one it was not computed on). Everything else in this table is intentionally
|
||||||
|
local-only.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -583,6 +655,14 @@ Configure in `.env` (copy from `.env.example`):
|
|||||||
| `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key |
|
| `OPENAI_API_KEY` | For sentiment (OpenAI path) | — | OpenAI API key |
|
||||||
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
|
| `OPENAI_MODEL` | No | `gpt-4o-mini` | OpenAI model name |
|
||||||
| `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector |
|
| `OPENAI_SENTIMENT_BATCH_SIZE` | No | `5` | Micro-batch size for sentiment collector |
|
||||||
|
| `DEEPSEEK_API_KEY` / `XAI_API_KEY` | For sentiment (those paths) | — | Alternative pluggable sentiment providers |
|
||||||
|
| `SEC_USER_AGENT` | **For fundamentals** | placeholder | SEC EDGAR requires a real `name (contact: email)` UA — the shipped default is a placeholder and SEC will throttle/refuse it |
|
||||||
|
| `SEC_REQUEST_SPACING_SECONDS` | No | `0.2` | Politeness delay between SEC requests |
|
||||||
|
| `SEC_MAX_RETRIES` / `SEC_REQUEST_TIMEOUT_SECONDS` | No | `4` / `30` | SEC client retry and timeout budget |
|
||||||
|
| `DOLT_BINARY` | For earnings import | `dolt` | Path to the `dolt` executable |
|
||||||
|
| `DOLT_DATA_DIR` / `DOLT_EARNINGS_SUBDIR` | No | `dolt-data` / `earnings` | Local Dolt clone location |
|
||||||
|
| `DOLT_MIN_FREE_DISK_GB` | No | `5.0` | Refuse to clone/pull below this free space |
|
||||||
|
| `DOLT_COMMAND_TIMEOUT_SECONDS` | No | `600` | Per-command Dolt timeout |
|
||||||
| `FRED_API_KEY` | Optional (risk monitor) | — | FRED key for the AI/Tech risk monitor (VIX, credit spreads) |
|
| `FRED_API_KEY` | Optional (risk monitor) | — | FRED key for the AI/Tech risk monitor (VIX, credit spreads) |
|
||||||
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
|
| `TELEGRAM_BOT_TOKEN` | Optional (alerts) | — | Telegram bot token for alerts (can also be set in Admin) |
|
||||||
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
|
| `TELEGRAM_CHAT_ID` | Optional (alerts) | — | Telegram chat id for alerts |
|
||||||
@@ -591,6 +671,9 @@ Configure in `.env` (copy from `.env.example`):
|
|||||||
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
|
| `RR_SCAN_FREQUENCY` | No | `daily` | R:R scanner schedule |
|
||||||
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
|
| `DEFAULT_WATCHLIST_AUTO_SIZE` | No | `10` | Auto-watchlist size |
|
||||||
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
| `DEFAULT_RR_THRESHOLD` | No | `1.5` | Minimum R:R ratio for setups |
|
||||||
|
| `OHLCV_HISTORY_DAYS` | No | `1825` | Backfill depth for new tickers (~5 years) |
|
||||||
|
| `OUTCOME_EVALUATION_MAX_BARS` | No | `30` | Bars the outcome evaluator resolves a setup over |
|
||||||
|
| `BACKTEST_WORKERS` | No | `4` | Worker processes for the scheduled backtest |
|
||||||
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
|
| `DB_POOL_SIZE` | No | `5` | Database connection pool size |
|
||||||
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||||
|
|
||||||
@@ -683,7 +766,9 @@ app/
|
|||||||
├── exceptions.py # Exception hierarchy
|
├── exceptions.py # Exception hierarchy
|
||||||
├── middleware.py # Global error handler → JSON envelope
|
├── middleware.py # Global error handler → JSON envelope
|
||||||
├── cache.py # LRU cache with per-ticker invalidation
|
├── cache.py # LRU cache with per-ticker invalidation
|
||||||
|
├── ssl_bootstrap.py # TLS trust-store bootstrap for outbound calls
|
||||||
├── scheduler.py # APScheduler job definitions
|
├── scheduler.py # APScheduler job definitions
|
||||||
|
├── job_catalog.py # Single source of truth for job names + pipeline step lists
|
||||||
├── models/ # SQLAlchemy ORM models
|
├── models/ # SQLAlchemy ORM models
|
||||||
├── schemas/ # Pydantic request/response schemas
|
├── schemas/ # Pydantic request/response schemas
|
||||||
├── services/ # Business logic layer
|
├── services/ # Business logic layer
|
||||||
@@ -703,9 +788,11 @@ frontend/
|
|||||||
│ ├── admin/ # User table, job controls, settings, data cleanup
|
│ ├── admin/ # User table, job controls, settings, data cleanup
|
||||||
│ ├── auth/ # Protected route wrapper
|
│ ├── auth/ # Protected route wrapper
|
||||||
│ ├── charts/ # Canvas candlestick chart
|
│ ├── charts/ # Canvas candlestick chart
|
||||||
|
│ ├── dashboard/ # Top setups, open trades, shadow-vs-manual performance chart
|
||||||
│ ├── layout/ # App shell, sidebar, mobile nav
|
│ ├── layout/ # App shell, sidebar, mobile nav
|
||||||
│ ├── rankings/ # Rankings table, weights form
|
│ ├── rankings/ # Rankings table, weights form
|
||||||
│ ├── scanner/ # Trade table
|
│ ├── scanner/ # Trade table
|
||||||
|
│ ├── signals/ # Setups / Paper Trades / Backtest panels
|
||||||
│ ├── ticker/ # Sentiment panel, fundamentals, indicators, S/R overlay
|
│ ├── ticker/ # Sentiment panel, fundamentals, indicators, S/R overlay
|
||||||
│ ├── ui/ # Badge, toast, skeleton, score card, confirm dialog
|
│ ├── ui/ # Badge, toast, skeleton, score card, confirm dialog
|
||||||
│ └── watchlist/ # Watchlist table, add ticker form
|
│ └── watchlist/ # Watchlist table, add ticker form
|
||||||
@@ -716,16 +803,26 @@ frontend/
|
|||||||
└── styles/ # Global CSS with glassmorphism classes
|
└── styles/ # Global CSS with glassmorphism classes
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
|
├── dolt-integration-plan.md # Design record for the Dolt/SEC fundamentals workstream
|
||||||
|
├── dolt-sec-a3-design.md
|
||||||
|
├── fundamentals-deployment.md
|
||||||
└── research/ # Experiment log: what was tested, the result, the decision
|
└── research/ # Experiment log: what was tested, the result, the decision
|
||||||
├── README.md # Overview — start here before proposing a strategy change
|
├── README.md # Overview — start here before proposing a strategy change
|
||||||
└── sr-levels-and-exits.md
|
├── sr-levels-and-exits.md
|
||||||
|
├── post-stop-reentry.md
|
||||||
|
├── portfolio-capacity-bracket*.md
|
||||||
|
├── execution-recovery.md
|
||||||
|
├── fip-breadth-ic.md
|
||||||
|
├── regime-monitor-v3.md / -v4.md
|
||||||
|
└── … # 16 documents total
|
||||||
|
|
||||||
reports/ # Committed backtest reports (JSON) + compare_reports.py
|
reports/ # Committed backtest reports (JSON) + compare_reports.py
|
||||||
|
|
||||||
deploy/
|
deploy/
|
||||||
├── nginx.conf # Reverse proxy + static file serving
|
├── nginx.conf # Reverse proxy + static file serving
|
||||||
├── setup_db.sh # Idempotent DB setup script
|
├── setup_db.sh # Idempotent DB setup script
|
||||||
└── stock-data-backend.service # systemd unit
|
├── provision_fundamentals.sh # Server-side Dolt/SEC fundamentals provisioning
|
||||||
|
└── signalplatform.service # systemd unit
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
├── conftest.py # Fixtures, strategies, test DB
|
├── conftest.py # Fixtures, strategies, test DB
|
||||||
@@ -743,9 +840,11 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
|||||||
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, `detect_gate_target_ladder`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
|
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, `detect_gate_target_ladder`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
|
||||||
- **Keep the two price-level models separate.** `detect_sr_levels` produces persisted Structural S/R for charts and alerts. `detect_gate_target_ladder` produces transient screening proposals and must never be persisted or presented as market structure. The scanner must not read `SRLevel` rows for target generation.
|
- **Keep the two price-level models separate.** `detect_sr_levels` produces persisted Structural S/R for charts and alerts. `detect_gate_target_ladder` produces transient screening proposals and must never be persisted or presented as market structure. The scanner must not read `SRLevel` rows for target generation.
|
||||||
- **The Gate Target Ladder target is a gate input, never an exit.** `_atr_trailing_close()` does not take it as a parameter, and it must stay that way — take-profit exits were tested and halve CAGR. Any UI or alert that implies the trade exits at the target is a bug ([research](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder)).
|
- **The Gate Target Ladder target is a gate input, never an exit.** `_atr_trailing_close()` does not take it as a parameter, and it must stay that way — take-profit exits were tested and halve CAGR. Any UI or alert that implies the trade exits at the target is a bug ([research](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder)).
|
||||||
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
|
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the realized-outcome record meaningful.
|
||||||
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
|
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
|
||||||
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
|
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
|
||||||
|
- **The shadow book must stay parity-clean.** It orders on the *stored* `strategy_rank` the scanner wrote and mirrors `_simulate_portfolio`'s selection rule; it accepts only a scan from its own pipeline run. Recomputing its ranking, or letting it consume a stale/manual scan, turns the forward OOS record back into an approximation.
|
||||||
|
- **Delisted tickers are retired, never deleted.** Live paths opt into `ticker_service.active_only`; the registry, admin views and `run_backtest` deliberately still see them. Deleting a symbol takes the history that a survivorship-bias fix would need.
|
||||||
- **Discretionary overlay data is forward-only.** `signal_context_snapshots` captures composite/dimension/sentiment/fundamental context for new setups. Do not approximate historical sentiment/fundamental snapshots from today's data.
|
- **Discretionary overlay data is forward-only.** `signal_context_snapshots` captures composite/dimension/sentiment/fundamental context for new setups. Do not approximate historical sentiment/fundamental snapshots from today's data.
|
||||||
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
|
- Style: surgical changes, minimal new files; extend existing services rather than adding parallel ones.
|
||||||
|
|
||||||
@@ -762,11 +861,14 @@ Context for whoever — human or AI — continues this work. The owner pushes st
|
|||||||
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
|
| Backtest + factor rank-IC harness ("Signal edge") | `app/services/backtest_service.py` |
|
||||||
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
|
| Outcome resolution (target/stop/expired/ambiguous) | `app/services/outcome_service.py` |
|
||||||
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
|
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
|
||||||
|
| Shadow book (automated twin of the backtest's selection) | `app/services/shadow_book_service.py` |
|
||||||
|
| Re-entry locks / distinct-day guard / book identities | `app/services/trade_policy.py` |
|
||||||
|
| Ticker registry, delisting + `active_only` filter | `app/services/ticker_service.py` |
|
||||||
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
|
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
|
||||||
| Structural S/R detection, Gate Target Ladder & zone clustering | `app/services/sr_service.py` |
|
| Structural S/R detection, Gate Target Ladder & zone clustering | `app/services/sr_service.py` |
|
||||||
| **Research log — what's been tested and rejected** | **`docs/research/`** |
|
| **Research log — what's been tested and rejected** | **`docs/research/`** |
|
||||||
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
|
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
|
||||||
| Pipelines & job registration | `app/scheduler.py` |
|
| Pipelines & job registration | `app/scheduler.py` (step lists and job names in `app/job_catalog.py`) |
|
||||||
|
|
||||||
### Verifying changes
|
### Verifying changes
|
||||||
|
|
||||||
@@ -775,7 +877,7 @@ pytest tests/ -q # backend; in-memory SQLite, no Postgres needed
|
|||||||
cd frontend && npm run build # full tsc check — this IS the frontend "test"
|
cd frontend && npm run build # full tsc check — this IS the frontend "test"
|
||||||
```
|
```
|
||||||
|
|
||||||
- `npm test` in `frontend/` is dead (vitest isn't installed; there are no frontend test files). Use `npm run build`.
|
- There is no `npm test` in `frontend/` — no test script, no test files. `npm run build` (`tsc -b && vite build`) is the frontend check.
|
||||||
- Backend tests that exercise services which `commit()` need a plain session fixture, not the rolling-back `db_session` — copy the pattern in `tests/unit/test_rr_scanner_integration.py`.
|
- Backend tests that exercise services which `commit()` need a plain session fixture, not the rolling-back `db_session` — copy the pattern in `tests/unit/test_rr_scanner_integration.py`.
|
||||||
- `ruff` reports ~11 pre-existing errors in old test files; those are not regressions.
|
- `ruff` reports ~11 pre-existing errors in old test files; those are not regressions.
|
||||||
|
|
||||||
@@ -792,6 +894,6 @@ Practical consequences:
|
|||||||
|
|
||||||
### Roadmap (agreed June 2026)
|
### Roadmap (agreed June 2026)
|
||||||
|
|
||||||
1. **Forward paper-test the momentum book** — the out-of-sample proof the backtest can't give. Watch Signals → Track Record (live vs backtest).
|
1. **Forward paper-test the momentum book** — the out-of-sample proof the backtest can't give. Watch the Dashboard chart (shadow book vs discretionary vs SPY) against Signals → Backtest.
|
||||||
2. **Full IBKR integration** — read real positions, overlay entries/stops on charts, alert on holdings' score deterioration. (Paper trading, the lighter alternative, is done.)
|
2. **Full IBKR integration** — read real positions, overlay entries/stops on charts, alert on holdings' score deterioration. (Paper trading, the lighter alternative, is done.)
|
||||||
3. Strategy experiments in the order listed under **Strategy Status** above — each one goes through the factor harness first.
|
3. Strategy experiments in the order listed under **Strategy Status** above — each one goes through the factor harness first.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Point-in-time history for the sourced fundamental observation
|
||||||
|
|
||||||
|
Revision ID: 033
|
||||||
|
Revises: 032
|
||||||
|
Create Date: 2026-08-12 00:00:00.000000
|
||||||
|
|
||||||
|
The hyperscaler capex / "good news, stock down" read lived in a single
|
||||||
|
``SystemSetting`` slot, so each refresh overwrote the last and no history
|
||||||
|
existed. The read is now a categorical channel reported alongside State and
|
||||||
|
Warning (never a term in either), and a channel with no history cannot be
|
||||||
|
replayed: a snapshot rebuild would record every historical session as if nothing
|
||||||
|
had ever been observed, and the event study could not measure the channel at all.
|
||||||
|
|
||||||
|
Keyed on ``effective_date`` (the session the observation becomes usable on,
|
||||||
|
normally the next weekday) rather than ``fetched_at``, because that is the gate
|
||||||
|
that stops a rebuild stamping today's reading onto historical rows.
|
||||||
|
|
||||||
|
The table starts empty. ``update_regime_monitor`` records the currently stored
|
||||||
|
observation on its next run, so a deployment does not lose the live reading —
|
||||||
|
but genuine history does not exist and cannot be invented here. Backfilling it
|
||||||
|
from the SEC capex line and earnings-date reactions is separate work; until then
|
||||||
|
every historical session reads ``unknown``, which is the honest value rather than
|
||||||
|
a guessed one.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "033"
|
||||||
|
down_revision: Union[str, None] = "032"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"regime_fundamental_observations",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("effective_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("f1_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("f3_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("capex_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("good_news_stock_down", sa.String(length=10), nullable=False),
|
||||||
|
sa.Column("reasoning", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# One unique index, not a unique constraint plus a plain index: the model
|
||||||
|
# declares `unique=True, index=True`, which SQLAlchemy renders as exactly
|
||||||
|
# this. The constraint-plus-index pairing worked but left a redundant second
|
||||||
|
# index on the column and a permanent metadata diff for autogenerate to keep
|
||||||
|
# trying to reconcile. Matches RegimeSnapshot.date, the sibling table.
|
||||||
|
op.create_index(
|
||||||
|
"ix_regime_fundamental_observations_effective_date",
|
||||||
|
"regime_fundamental_observations",
|
||||||
|
["effective_date"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_regime_fundamental_observations_effective_date",
|
||||||
|
table_name="regime_fundamental_observations",
|
||||||
|
)
|
||||||
|
op.drop_table("regime_fundamental_observations")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Track when a filing gap stops pausing setups
|
||||||
|
|
||||||
|
Revision ID: 034
|
||||||
|
Revises: 033
|
||||||
|
Create Date: 2026-08-21 00:00:00.000000
|
||||||
|
|
||||||
|
An escalated gap stops pausing setups while the issuer's own fundamentals are
|
||||||
|
still recent (``GAP_GATE_RECENT_FILING_DAYS``). That reprieve is not permanent:
|
||||||
|
the stored filings age out, or a newer gap appears, and the pause returns —
|
||||||
|
silently, because ``filing_gap_aged`` only escalates gaps whose ``escalated_at``
|
||||||
|
is NULL and so never fires twice for the same gap.
|
||||||
|
|
||||||
|
``exempted_at`` is the state marker that makes the transition observable. It is
|
||||||
|
set (quietly) while the issuer is exempt and cleared when the exemption lapses,
|
||||||
|
which is when ``filing_gap_repaused`` fires — once per lapse, re-arming if the
|
||||||
|
issuer's data recovers and ages out again.
|
||||||
|
|
||||||
|
Nullable, and carrying no meaning of its own beyond that state: an existing gap
|
||||||
|
starts NULL and is stamped on the next import that finds it exempt.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "034"
|
||||||
|
down_revision: Union[str, None] = "033"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"sec_filing_gaps",
|
||||||
|
sa.Column("exempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sec_filing_gaps", "exempted_at")
|
||||||
@@ -14,6 +14,7 @@ from app.models.settings import SystemSetting, IngestionProgress
|
|||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
|
from app.models.regime_fundamental_observation import RegimeFundamentalObservation
|
||||||
from app.models.benchmark_price import BenchmarkPrice
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
from app.models.system_event import SystemEvent
|
from app.models.system_event import SystemEvent
|
||||||
@@ -39,6 +40,7 @@ __all__ = [
|
|||||||
"AlertLog",
|
"AlertLog",
|
||||||
"PaperTrade",
|
"PaperTrade",
|
||||||
"RegimeSnapshot",
|
"RegimeSnapshot",
|
||||||
|
"RegimeFundamentalObservation",
|
||||||
"BenchmarkPrice",
|
"BenchmarkPrice",
|
||||||
"SignalContextSnapshot",
|
"SignalContextSnapshot",
|
||||||
"SystemEvent",
|
"SystemEvent",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import date as date_type
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, Float, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeFundamentalObservation(Base):
|
||||||
|
"""Point-in-time record of the sourced hyperscaler capex / earnings read.
|
||||||
|
|
||||||
|
One row per ``effective_date`` (unique, upserted). Before this table the
|
||||||
|
observation lived in a single ``SystemSetting`` slot, so every refresh
|
||||||
|
overwrote the previous one and no history existed at all — which made the
|
||||||
|
read impossible to replay, impossible to backtest, and meant a snapshot
|
||||||
|
rebuild could only ever score historical sessions as if nothing had been
|
||||||
|
observed.
|
||||||
|
|
||||||
|
The read is a categorical channel reported beside State and Warning, never a
|
||||||
|
term in either, so this series is not a scoring input. It is the record that
|
||||||
|
makes the channel replayable at all -- and the only route to eventually
|
||||||
|
testing whether it improves prediction conditional on Warning, which is the
|
||||||
|
one thing that could justify combining the channels later.
|
||||||
|
|
||||||
|
``effective_date`` rather than ``fetched_at`` is the key: it is the session
|
||||||
|
the observation becomes usable on (normally the next weekday), and the gate
|
||||||
|
that stops a rebuild stamping today's reading onto historical rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "regime_fundamental_observations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
effective_date: Mapped[date_type] = mapped_column(
|
||||||
|
Date, nullable=False, unique=True, index=True
|
||||||
|
)
|
||||||
|
f1_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
f3_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
capex_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
good_news_stock_down: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
reasoning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
@@ -30,3 +30,8 @@ class SecFilingGap(Base):
|
|||||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
last_attempted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
last_attempted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
escalated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# Set while this gap's issuer is exempt from the setup pause (escalated, and
|
||||||
|
# its own fundamentals still recent — see fundamentals_quality_service).
|
||||||
|
# Cleared when the exemption lapses, which is the moment the pause silently
|
||||||
|
# comes back and the only moment worth alerting on.
|
||||||
|
exempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
+8
-2
@@ -1351,8 +1351,11 @@ async def run_event_study_job() -> None:
|
|||||||
report = await run_event_study_and_store(db)
|
report = await run_event_study_and_store(db)
|
||||||
|
|
||||||
_runtime_progress(job_name, processed=1, total=1)
|
_runtime_progress(job_name, processed=1, total=1)
|
||||||
|
shipped = report.get("shipped") or {}
|
||||||
if report.get("available"):
|
if report.get("available"):
|
||||||
metrics = report.get("metrics") or {}
|
# The shipped quadrant rule is the headline; the fitted-threshold
|
||||||
|
# variant lives under report["fitted"] and is not what fires.
|
||||||
|
metrics = shipped.get("metrics") or {}
|
||||||
msg = (
|
msg = (
|
||||||
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
|
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
|
||||||
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
|
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
|
||||||
@@ -1360,7 +1363,10 @@ async def run_event_study_job() -> None:
|
|||||||
else:
|
else:
|
||||||
msg = report.get("reason", "no data")
|
msg = report.get("reason", "no data")
|
||||||
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
|
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
|
||||||
_log_event(logging.INFO, "job_complete", job=job_name, events=len(report.get("events", [])))
|
_log_event(
|
||||||
|
logging.INFO, "job_complete", job=job_name,
|
||||||
|
events=len(shipped.get("events") or []),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par
|
|||||||
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
|
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary
|
||||||
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
|
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
|
||||||
QUAD_TYPE = "regime_quadrant"
|
QUAD_TYPE = "regime_quadrant"
|
||||||
|
# The fundamental channel gets its own alerts rather than shifting a score:
|
||||||
|
# "the context changed" and "both channels are elevated" are different facts from
|
||||||
|
# "the market axes moved", and fusing them into one number would destroy exactly
|
||||||
|
# the information an operator uses to decide how much the alert is worth.
|
||||||
|
FUND_TYPE = "regime_fundamental"
|
||||||
|
CONFLUENCE_TYPE = "regime_confluence"
|
||||||
|
# States that count as fundamental risk for the confluence test.
|
||||||
|
FUND_ADVERSE = "adverse"
|
||||||
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
|
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
|
||||||
QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
|
QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
|
||||||
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
|
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
|
||||||
@@ -859,16 +867,111 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
metrics = f"State {x:.0f} · Warning {y:.0f}"
|
||||||
|
# The fundamental channel is reported, never added in: this alert is about
|
||||||
|
# the two market axes, and the context is stated beside them so a reader can
|
||||||
|
# judge confluence themselves rather than being handed a fused number.
|
||||||
|
context = data.get("fundamental_context") or {}
|
||||||
|
context_line = (
|
||||||
|
f"fundamentals: {context.get('state', 'unknown')} "
|
||||||
|
f"({context.get('evidence_quality', 'unavailable')})\n"
|
||||||
|
)
|
||||||
text = (
|
text = (
|
||||||
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
|
f"🧭 <b>AI/Tech risk quadrant change</b>\n"
|
||||||
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
f"{QUAD_LABELS.get(prev, prev)} → {QUAD_LABELS.get(new_q, new_q)}\n"
|
||||||
f"{metrics}\n"
|
f"{metrics}\n"
|
||||||
|
f"{context_line}"
|
||||||
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
|
||||||
f"<i>Risk thermometer - not a trade signal.</i>"
|
f"<i>Risk thermometer - not a trade signal.</i>"
|
||||||
)
|
)
|
||||||
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
|
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _last_logged_key(db: AsyncSession, alert_type: str) -> str | None:
|
||||||
|
"""Most recent logged key for a type, our baseline for change detection."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AlertLog.dedup_key)
|
||||||
|
.where(AlertLog.alert_type == alert_type)
|
||||||
|
.order_by(AlertLog.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_regime_fundamental(db: AsyncSession) -> list[tuple[str, str, str]]:
|
||||||
|
"""Fundamental-context changes and market/fundamental confluence.
|
||||||
|
|
||||||
|
Two triggers, deliberately separate from the quadrant alert and from each
|
||||||
|
other, because they answer different questions: *what the evidence says* and
|
||||||
|
*whether both channels agree*. Neither is derived by moving a score.
|
||||||
|
|
||||||
|
``unknown`` never alerts. An absence of evidence is not a change in the
|
||||||
|
evidence, and alerting on it would train the reader to ignore the channel.
|
||||||
|
Both seed silently on first run, exactly as the quadrant alert does.
|
||||||
|
"""
|
||||||
|
from app.services.regime_monitor_service import get_regime_monitor
|
||||||
|
|
||||||
|
data = await get_regime_monitor(db)
|
||||||
|
if not data.get("available"):
|
||||||
|
return []
|
||||||
|
warning = data.get("warning") or {}
|
||||||
|
context = data.get("fundamental_context") or {}
|
||||||
|
state = str(context.get("state") or "unknown")
|
||||||
|
# `usable`, not `available`: the state is deliberately preserved past its
|
||||||
|
# staleness horizon so the card can keep showing the last thing observed, and
|
||||||
|
# an observation whose extraction failed is fresh but knows nothing. Neither
|
||||||
|
# may confirm anything — without this gate a months-old adverse read silently
|
||||||
|
# corroborates every new Warning crossing forever, which is the strongest
|
||||||
|
# claim this channel makes and the one it has least right to make.
|
||||||
|
usable = bool(context.get("usable"))
|
||||||
|
score = warning.get("score")
|
||||||
|
|
||||||
|
quality = data.get("data_quality") or {}
|
||||||
|
if not quality.get("is_fresh") or float(warning.get("coverage") or 0) < 75:
|
||||||
|
return []
|
||||||
|
|
||||||
|
quadrant_cfg = data.get("quadrant_config") or {}
|
||||||
|
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
|
||||||
|
warning_elevated = score is not None and float(score) >= y_div
|
||||||
|
|
||||||
|
out: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
previous_state = await _last_logged_key(db, FUND_TYPE)
|
||||||
|
if previous_state is None:
|
||||||
|
_log_alert(db, FUND_TYPE, state) # seed
|
||||||
|
elif previous_state != state and state != "unknown" and usable:
|
||||||
|
effective = context.get("effective_date")
|
||||||
|
out.append((
|
||||||
|
FUND_TYPE,
|
||||||
|
state,
|
||||||
|
f"📋 <b>Fundamental context changed</b>\n"
|
||||||
|
f"{previous_state} → {state}\n"
|
||||||
|
f"evidence: {context.get('evidence_quality', 'unavailable')}"
|
||||||
|
+ (f" · effective {effective}" if effective else "")
|
||||||
|
+ "\n<i>Context channel — not a score, not a trade signal.</i>",
|
||||||
|
))
|
||||||
|
|
||||||
|
confluence = "yes" if (warning_elevated and state == FUND_ADVERSE and usable) else "no"
|
||||||
|
previous_confluence = await _last_logged_key(db, CONFLUENCE_TYPE)
|
||||||
|
if previous_confluence is None:
|
||||||
|
_log_alert(db, CONFLUENCE_TYPE, confluence) # seed
|
||||||
|
elif previous_confluence != confluence and confluence == "yes":
|
||||||
|
out.append((
|
||||||
|
CONFLUENCE_TYPE,
|
||||||
|
confluence,
|
||||||
|
f"⚠️ <b>Confluence: market and fundamental risk both elevated</b>\n"
|
||||||
|
f"Warning {float(score):.0f} (≥ {y_div:.0f}) with fundamentals {state}\n"
|
||||||
|
f"evidence: {context.get('evidence_quality', 'unavailable')}\n"
|
||||||
|
f"<i>Highest attention. Still a thermometer — not a trade signal.</i>",
|
||||||
|
))
|
||||||
|
elif previous_confluence != confluence:
|
||||||
|
# Falling out of confluence is a state change worth recording as the new
|
||||||
|
# baseline, but not worth a message.
|
||||||
|
_log_alert(db, CONFLUENCE_TYPE, confluence)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Dispatch
|
# Dispatch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -961,6 +1064,11 @@ async def dispatch_alerts(db: AsyncSession) -> dict:
|
|||||||
# cooldown/hysteresis handled in the collector (like score drops)
|
# cooldown/hysteresis handled in the collector (like score drops)
|
||||||
for key, text in await _collect_regime_quadrant(db):
|
for key, text in await _collect_regime_quadrant(db):
|
||||||
outgoing.append((QUAD_TYPE, key, text))
|
outgoing.append((QUAD_TYPE, key, text))
|
||||||
|
# Deliberately three separate messages off one toggle, not one fused
|
||||||
|
# signal: the market axes and the fundamental channel are different kinds
|
||||||
|
# of evidence, and an operator needs to know which one moved.
|
||||||
|
for alert_type, key, text in await _collect_regime_fundamental(db):
|
||||||
|
outgoing.append((alert_type, key, text))
|
||||||
|
|
||||||
if cfg["trade_closed"]:
|
if cfg["trade_closed"]:
|
||||||
for key, text, pnl_usd in await _collect_closed_trades(db):
|
for key, text, pnl_usd in await _collect_closed_trades(db):
|
||||||
|
|||||||
@@ -2625,6 +2625,19 @@ def _simulate_portfolio(
|
|||||||
diag = sharpe_diagnostics(rets)
|
diag = sharpe_diagnostics(rets)
|
||||||
sharpe = diag["sharpe"]
|
sharpe = diag["sharpe"]
|
||||||
|
|
||||||
|
# Sortino: the same numerator as Sharpe over downside deviation about a zero
|
||||||
|
# target. The denominator divides by len(rets) — the full-sample lower partial
|
||||||
|
# moment — NOT by the count of down days, which would shrink the denominator
|
||||||
|
# and inflate the ratio. n >= 3 matches sharpe_diagnostics so the two appear
|
||||||
|
# together or not at all. No down days is +inf, reported as None.
|
||||||
|
sortino = None
|
||||||
|
downside = [r for r in rets if r < 0.0]
|
||||||
|
if len(rets) >= 3 and downside:
|
||||||
|
mean_ret = sum(rets) / len(rets)
|
||||||
|
dd = math.sqrt(sum(r * r for r in downside) / len(rets))
|
||||||
|
if dd > 0:
|
||||||
|
sortino = round(mean_ret / dd * math.sqrt(252.0), 2)
|
||||||
|
|
||||||
# Per-calendar-year returns off the equity curve — shows whether every year
|
# Per-calendar-year returns off the equity curve — shows whether every year
|
||||||
# contributed or one exceptional stretch carried the result.
|
# contributed or one exceptional stretch carried the result.
|
||||||
yearly: list[dict] = []
|
yearly: list[dict] = []
|
||||||
@@ -2650,8 +2663,40 @@ def _simulate_portfolio(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Gain-to-Pain off the same curve, on MONTHLY returns: Schwager's ratio is
|
||||||
|
# defined monthly and the daily variant is not comparable to published
|
||||||
|
# figures. Distinct loop variables from the yearly pass above — that one exits
|
||||||
|
# with last_eq at final equity, so reusing its names silently corrupts the
|
||||||
|
# first month. The monthly series itself is not emitted: 36-120 floats per
|
||||||
|
# strategy per lookback would bloat the single stored report blob.
|
||||||
|
monthly: list[float] = []
|
||||||
|
month_start_eq = curve[0][1]
|
||||||
|
month_last_eq = curve[0][1]
|
||||||
|
cur_month = date.fromordinal(curve[0][0]).replace(day=1)
|
||||||
|
for o, eq in curve:
|
||||||
|
m = date.fromordinal(o).replace(day=1)
|
||||||
|
if m != cur_month:
|
||||||
|
if month_start_eq > 0:
|
||||||
|
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||||
|
cur_month = m
|
||||||
|
month_start_eq = month_last_eq
|
||||||
|
month_last_eq = eq
|
||||||
|
if month_start_eq > 0:
|
||||||
|
monthly.append(month_last_eq / month_start_eq - 1.0)
|
||||||
|
|
||||||
|
# Schwager: SUM OF ALL monthly returns over the absolute sum of the negative
|
||||||
|
# ones. Not sum(positive)/|sum(negative)| — that is profit-factor-shaped and
|
||||||
|
# sits exactly 1.0 higher for every input, since sum(all) = sum(pos) - |sum(neg)|.
|
||||||
|
monthly_pain = -sum(r for r in monthly if r < 0.0)
|
||||||
|
gain_to_pain = round(sum(monthly) / monthly_pain, 2) if monthly_pain > 0 else None
|
||||||
|
|
||||||
pnls = [t["pnl"] for t in trades]
|
pnls = [t["pnl"] for t in trades]
|
||||||
wins = sum(1 for p in pnls if p > 0)
|
wins = sum(1 for p in pnls if p > 0)
|
||||||
|
# Dollar-based, over closed-trade P&L. Distinct from the R-based profit_factor
|
||||||
|
# in _robustness_stats; the two never share an object.
|
||||||
|
gross_win = sum(p for p in pnls if p > 0)
|
||||||
|
gross_loss = -sum(p for p in pnls if p < 0)
|
||||||
|
profit_factor = round(gross_win / gross_loss, 2) if gross_loss > 0 else None
|
||||||
reason_counts = {
|
reason_counts = {
|
||||||
reason: sum(1 for t in trades if t["reason"] == reason)
|
reason: sum(1 for t in trades if t["reason"] == reason)
|
||||||
for reason in sorted({t["reason"] for t in trades})
|
for reason in sorted({t["reason"] for t in trades})
|
||||||
@@ -2706,7 +2751,13 @@ def _simulate_portfolio(
|
|||||||
"total_return_pct": round(total_return_pct, 1),
|
"total_return_pct": round(total_return_pct, 1),
|
||||||
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
||||||
"max_drawdown_pct": round(max_dd_pct, 1),
|
"max_drawdown_pct": round(max_dd_pct, 1),
|
||||||
|
# calmar IS MAR here (CAGR / max drawdown) — one field, two names.
|
||||||
"calmar": round(calmar, 2) if calmar is not None else None,
|
"calmar": round(calmar, 2) if calmar is not None else None,
|
||||||
|
# Emitted unconditionally even when None: the UI treats an ABSENT key as
|
||||||
|
# "report predates these metrics", so presence is a contract.
|
||||||
|
"sortino": sortino,
|
||||||
|
"gain_to_pain": gain_to_pain,
|
||||||
|
"profit_factor": profit_factor,
|
||||||
"sharpe": sharpe,
|
"sharpe": sharpe,
|
||||||
"sharpe_se": diag["sharpe_se"],
|
"sharpe_se": diag["sharpe_se"],
|
||||||
"psr": diag["psr"],
|
"psr": diag["psr"],
|
||||||
@@ -3893,40 +3944,11 @@ def _build_recommendation(report: dict) -> dict:
|
|||||||
})
|
})
|
||||||
|
|
||||||
q = report.get("overall_qualified") or {}
|
q = report.get("overall_qualified") or {}
|
||||||
target_net = q.get("net_avg_r")
|
|
||||||
|
|
||||||
# Legacy diagnostic: target/stop race vs the best fixed hold.
|
# Nothing here reads time_exit_sweep any more. The hold-vs-target comparison
|
||||||
time_rows = [r for r in report.get("time_exit_sweep") or [] if r.get("net_avg_r") is not None]
|
# is not reported (both are exits the production book replaced, so choosing
|
||||||
best_hold = max(time_rows, key=lambda r: r["net_avg_r"], default=None)
|
# between them cannot lead to an action), and the robustness check below no
|
||||||
sim_rows = {
|
# longer picks its basis from them either.
|
||||||
p.get("policy"): p
|
|
||||||
for p in (report.get("portfolio_sim") or {}).get("policies", [])
|
|
||||||
}
|
|
||||||
hold_sim = sim_rows.get("hold")
|
|
||||||
if best_hold is not None and target_net is not None:
|
|
||||||
if best_hold["net_avg_r"] > target_net + _EXIT_SWITCH_THRESHOLD:
|
|
||||||
text = (
|
|
||||||
f"Legacy exit diagnostic: hold {best_hold['hold_days']} trading days with the initial stop "
|
|
||||||
f"({best_hold['net_avg_r']:+.2f}R net/trade vs {target_net:+.2f}R for the S/R target exit)."
|
|
||||||
)
|
|
||||||
target_sim = sim_rows.get("target")
|
|
||||||
if (
|
|
||||||
hold_sim is not None and target_sim is not None
|
|
||||||
and hold_sim.get("cagr_pct") is not None and target_sim.get("cagr_pct") is not None
|
|
||||||
):
|
|
||||||
text += (
|
|
||||||
f" The simulated book agrees: {hold_sim['cagr_pct']:+.1f}% vs "
|
|
||||||
f"{target_sim['cagr_pct']:+.1f}% CAGR at similar drawdown."
|
|
||||||
)
|
|
||||||
items.append({"topic": "exit", "text": text})
|
|
||||||
else:
|
|
||||||
items.append({
|
|
||||||
"topic": "exit",
|
|
||||||
"text": (
|
|
||||||
f"Legacy exit diagnostic: keep the S/R target exit ({target_net:+.2f}R net/trade) — "
|
|
||||||
"no fixed hold beats it by a meaningful margin."
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Gate floors, judged under the hold exit (the ablation's Hold column).
|
# Gate floors, judged under the hold exit (the ablation's Hold column).
|
||||||
ablation = {r["variant"]: r for r in report.get("gate_ablation") or []}
|
ablation = {r["variant"]: r for r in report.get("gate_ablation") or []}
|
||||||
@@ -3974,33 +3996,32 @@ def _build_recommendation(report: dict) -> dict:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Book vs benchmark.
|
# Book vs benchmark — read from the SAME production monitor row the page
|
||||||
book = hold_sim or sim_rows.get("target")
|
# shows in its tiles. It used to read the hold/target policy sim, so the
|
||||||
if book is not None and book.get("spy_return_pct") is not None:
|
# recommendation quoted a different portfolio return than the tile directly
|
||||||
edge = book["total_return_pct"] - book["spy_return_pct"]
|
# above it, against an identical SPY figure. Those policies are legacy
|
||||||
|
# diagnostics; the production book is the ATR trail.
|
||||||
|
if production_row is not None and production_row.get("spy_return_pct") is not None:
|
||||||
|
edge = production_row["total_return_pct"] - production_row["spy_return_pct"]
|
||||||
verdict = "beats" if edge > 0 else "LAGS"
|
verdict = "beats" if edge > 0 else "LAGS"
|
||||||
items.append({
|
items.append({
|
||||||
"topic": "benchmark",
|
"topic": "benchmark",
|
||||||
"text": (
|
"text": (
|
||||||
f"Book vs SPY: {verdict} buy-and-hold by {edge:+.1f} points "
|
f"Book vs SPY: {verdict} buy-and-hold by {edge:+.1f} points "
|
||||||
f"({book['total_return_pct']:+.1f}% vs {book['spy_return_pct']:+.1f}%), "
|
f"({production_row['total_return_pct']:+.1f}% vs "
|
||||||
f"max drawdown −{book['max_drawdown_pct']:.1f}%."
|
f"{production_row['spy_return_pct']:+.1f}%)."
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Robustness: does the edge survive without the biggest winners? Judged on
|
# Robustness: does the edge survive without the biggest winners?
|
||||||
# the RECOMMENDED exit — outlier dependence under an exit we'd abandon
|
#
|
||||||
# would be the wrong warning.
|
# There is no ATR-trail equivalent of this number in the report — the only
|
||||||
hold_recommended = (
|
# ex-top-5% figure is the gate-level target/stop grading. So it is reported
|
||||||
best_hold is not None and target_net is not None
|
# on that basis and SAYS SO, rather than being dressed up as a verdict on the
|
||||||
and best_hold["net_avg_r"] > target_net + _EXIT_SWITCH_THRESHOLD
|
# production book. It used to pick between "the recommended Nd hold" and "the
|
||||||
)
|
# S/R target exit", naming a rejected exit as recommended.
|
||||||
if hold_recommended and best_hold.get("net_avg_r_ex_top5") is not None:
|
|
||||||
trimmed = best_hold["net_avg_r_ex_top5"]
|
|
||||||
basis = f"under the recommended {best_hold['hold_days']}d hold"
|
|
||||||
else:
|
|
||||||
trimmed = q.get("net_avg_r_ex_top5")
|
trimmed = q.get("net_avg_r_ex_top5")
|
||||||
basis = "under the S/R target exit"
|
basis = "gate-level grading, not the production ATR-trail book"
|
||||||
if trimmed is not None:
|
if trimmed is not None:
|
||||||
if trimmed > 0:
|
if trimmed > 0:
|
||||||
items.append({
|
items.append({
|
||||||
@@ -4021,20 +4042,20 @@ def _build_recommendation(report: dict) -> dict:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
if headline is None and hold_recommended:
|
# No fallback headline. It used to recommend the fixed-hold exit whenever the
|
||||||
cagr_note = (
|
# portfolio monitor was missing, which meant a report without a production
|
||||||
f" (~{hold_sim['cagr_pct']:.0f}% CAGR simulated)"
|
# row advised an exit the production book had already replaced. A report that
|
||||||
if hold_sim is not None and hold_sim.get("cagr_pct") is not None
|
# cannot describe the production baseline states no baseline.
|
||||||
else ""
|
|
||||||
)
|
|
||||||
headline = (
|
|
||||||
f"Trade the qualified list long-only; hold {best_hold['hold_days']} trading days "
|
|
||||||
f"with the initial ATR stop{cagr_note}."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"items": items,
|
"items": items,
|
||||||
|
# Which monitor row every production/benchmark figure above was read
|
||||||
|
# from. The page defaults its lookback selector to this, so the tiles and
|
||||||
|
# the recommendation cannot open on different windows — they used to,
|
||||||
|
# because this preferred "all" while the UI defaulted to "3y".
|
||||||
|
"basis_lookback": (production_row or {}).get("lookback"),
|
||||||
|
"basis_lookback_label": (production_row or {}).get("lookback_label"),
|
||||||
"note": "Derived from this report's numbers on every run — the advice flips if the data does.",
|
"note": "Derived from this report's numbers on every run — the advice flips if the data does.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4415,11 +4436,38 @@ async def run_and_store(
|
|||||||
|
|
||||||
|
|
||||||
async def get_backtest_report(db: AsyncSession) -> dict | None:
|
async def get_backtest_report(db: AsyncSession) -> dict | None:
|
||||||
"""Return the last cached backtest report, or None if never run."""
|
"""Return the last cached backtest report, or None if never run.
|
||||||
|
|
||||||
|
The recommendation is **re-derived from the cached report** rather than
|
||||||
|
served as stored. It is a pure function of the numbers already in the
|
||||||
|
report — the payload's own note says it is derived from them on every run —
|
||||||
|
so recomputing costs nothing and keeps one class of bug out:
|
||||||
|
|
||||||
|
A report cached by an older build carries that build's recommendation. After
|
||||||
|
a change to how the recommendation is sourced, the page would keep showing
|
||||||
|
the old one — quoting the legacy policy book, naming a rejected exit as
|
||||||
|
"recommended", and omitting ``basis_lookback``, which in turn let the
|
||||||
|
lookback selector default somewhere else. The result was the exact
|
||||||
|
tiles-disagree-with-recommendation contradiction this rebuild exists to
|
||||||
|
prevent, silently, until the next scheduled run happened to overwrite it.
|
||||||
|
|
||||||
|
Re-deriving means a corrected recommendation appears on the first page load
|
||||||
|
after deploy instead of after the next backtest.
|
||||||
|
"""
|
||||||
setting = await settings_store.get_setting(db, KEY_REPORT)
|
setting = await settings_store.get_setting(db, KEY_REPORT)
|
||||||
if setting is None:
|
if setting is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return json.loads(setting.value)
|
report = json.loads(setting.value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
report["recommendation"] = _build_recommendation(report)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: drop it rather than fall back to the stored one, which is
|
||||||
|
# precisely the stale derivation this rebuild is here to replace.
|
||||||
|
logger.exception("Could not rebuild the backtest recommendation; omitting it")
|
||||||
|
report.pop("recommendation", None)
|
||||||
|
return report
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
"""Compact chronological validation for the AI/Tech Risk Monitor warning score.
|
"""Chronological validation for the AI/Tech Risk Monitor warning score.
|
||||||
|
|
||||||
The study calls its outcome a 10% correction, uses the first 70% of sessions to
|
The outcome is a 10% correction in the leader, never a regime break. Two rules
|
||||||
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
|
are measured against it, and they answer different questions:
|
||||||
the final 30%. It is still labelled exploratory while the fixed breadth basket
|
|
||||||
is reconstructed before its freeze date.
|
* **shipped** -- the quadrant-change rule that actually reaches Telegram
|
||||||
|
(``alert_service._collect_regime_quadrant``). Its thresholds are fixed
|
||||||
|
constants chosen by scenario arithmetic, so nothing is fitted, so there is no
|
||||||
|
training set to protect and the whole sample is evaluable. This is the
|
||||||
|
headline.
|
||||||
|
* **fitted** -- the original study: an 80th-percentile Warning threshold frozen
|
||||||
|
on the first 70% of sessions and measured on the last 30%. Kept because it is
|
||||||
|
what the methodology document reports, and because a fitted threshold is a
|
||||||
|
genuinely different question -- but it is measured on the four corrections that
|
||||||
|
happen to fall in the holdout, which is too few to read as a property of the
|
||||||
|
score.
|
||||||
|
|
||||||
|
Both are scored by the same ``evaluate_alarms`` harness, alongside ablations
|
||||||
|
(does the quadrant machinery earn its place?), external baselines (does the
|
||||||
|
score earn its complexity?), and a random-alarm null (is any of this better than
|
||||||
|
chance?). Without those rows a bare "2 of 4" is unreadable in either direction.
|
||||||
|
|
||||||
|
The fundamental channel is compared, never fused. It appears as its own rule
|
||||||
|
(transitions into an adverse state), as a confluence gate (a market crossing kept
|
||||||
|
only when the state agrees), and as a market-only comparator over the identical
|
||||||
|
window -- because with ~10 correction events and almost no fundamental history,
|
||||||
|
any weight that combined it with the market axes would be a policy preference
|
||||||
|
presented as a measurement.
|
||||||
|
|
||||||
|
Those three rows are **coverage-matched**: scored only on the sessions where the
|
||||||
|
channel had usable context and on the corrections whose warning horizon fell
|
||||||
|
inside it, and marked ``measurable: false`` until enough corrections are covered.
|
||||||
|
A fundamental rule scores zero whether it is wrong or merely absent, so scoring
|
||||||
|
it over the market rows' full sample would turn a fortnight of observations into
|
||||||
|
a 0/10 that reads as a failed test.
|
||||||
|
|
||||||
|
Still labelled exploratory while the fixed breadth basket is reconstructed
|
||||||
|
before its freeze date.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -17,11 +50,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.services import breadth_service, settings_store
|
from app.services import breadth_service, settings_store
|
||||||
from app.services import regime_monitor_service as rms
|
from app.services import regime_monitor_service as rms
|
||||||
from app.services.admin_service import update_setting
|
from app.services.admin_service import update_setting
|
||||||
|
from app.services.alert_service import (
|
||||||
|
QUAD_COOLDOWN_DAYS,
|
||||||
|
QUAD_MARGIN,
|
||||||
|
QUAD_X_DIV,
|
||||||
|
QUAD_Y_DIV,
|
||||||
|
_classify_quadrant,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
KEY_REPORT = "regime_event_study"
|
KEY_REPORT = "regime_event_study"
|
||||||
|
|
||||||
|
# Report shape, independent of METHODOLOGY. A cached report from an older shape
|
||||||
|
# parses fine and reports the current methodology, so without this check the
|
||||||
|
# panel would render a report missing half its blocks. Bumping discards the cache
|
||||||
|
# the way a methodology change does -- and it is the *only* thing that does so
|
||||||
|
# here, because the fundamental-channel rework left METHODOLOGY on v4 (the scores
|
||||||
|
# did not change), so the methodology check cannot catch a stale report.
|
||||||
|
STUDY_SCHEMA = 3
|
||||||
|
|
||||||
EVENT_THRESHOLD_PCT = 10.0
|
EVENT_THRESHOLD_PCT = 10.0
|
||||||
EVENT_COOLDOWN_DAYS = 40
|
EVENT_COOLDOWN_DAYS = 40
|
||||||
DRAWDOWN_LOOKBACK = 252
|
DRAWDOWN_LOOKBACK = 252
|
||||||
@@ -33,6 +81,21 @@ TRAIN_FRACTION = 0.70
|
|||||||
MIN_EVENTS_FOR_CONFIDENCE = 8
|
MIN_EVENTS_FOR_CONFIDENCE = 8
|
||||||
SENSOR_MISMATCH_TOLERANCE = 0.10
|
SENSOR_MISMATCH_TOLERANCE = 0.10
|
||||||
|
|
||||||
|
# _collect_regime_quadrant confirms against get_regime_history(db, days=14), so a
|
||||||
|
# prior session older than that window is not available to confirm with.
|
||||||
|
QUAD_HISTORY_DAYS = 14
|
||||||
|
# Quadrants with Warning above its divider: "1" early warning, "2" active stress.
|
||||||
|
WARNING_QUADRANTS = ("1", "2")
|
||||||
|
STRESS_QUADRANT = ("2",)
|
||||||
|
|
||||||
|
# Draws for the random-alarm null. Seeded, because a cached report that moves
|
||||||
|
# on re-run for RNG reasons is worse than no report.
|
||||||
|
NULL_DRAWS = 2000
|
||||||
|
NULL_SEED = 20260812
|
||||||
|
|
||||||
|
BASELINE_SMA_WINDOW = 50
|
||||||
|
BASELINE_VIX_LEVEL = 20.0
|
||||||
|
|
||||||
|
|
||||||
def _median(values: list[float]) -> float | None:
|
def _median(values: list[float]) -> float | None:
|
||||||
if not values:
|
if not values:
|
||||||
@@ -148,41 +211,355 @@ def evaluate_alarms(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _warning_series(
|
def _score_rule(
|
||||||
|
alarm_indices: list[int],
|
||||||
|
event_indices: list[int],
|
||||||
|
dates: list[date],
|
||||||
|
horizon: int,
|
||||||
|
sessions: int,
|
||||||
|
) -> dict:
|
||||||
|
"""``evaluate_alarms`` plus the annualised false-alarm rate for one rule.
|
||||||
|
|
||||||
|
The rate is ``None`` when the rule had no eligible sessions. Dividing by a
|
||||||
|
tiny floor instead produced 5e9 alarms/year for a coverage-matched rule with
|
||||||
|
an empty window -- a number that means "undefined" while looking like a
|
||||||
|
measurement, which is the failure mode this whole panel is built to avoid.
|
||||||
|
"""
|
||||||
|
metrics = evaluate_alarms(alarm_indices, event_indices, dates, horizon)
|
||||||
|
metrics["false_alarms_per_year"] = (
|
||||||
|
round(metrics["false_alarms"] / (sessions / 252.0), 2) if sessions > 0 else None
|
||||||
|
)
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The shipped rule
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _axis_rows(
|
||||||
prices: dict[str, rms.Series],
|
prices: dict[str, rms.Series],
|
||||||
breadth_divergence: dict[date, float],
|
vix_series: rms.Series | None,
|
||||||
|
oas_series: rms.Series | None,
|
||||||
|
breadth_series: rms.Series | None,
|
||||||
|
divergence_series: rms.Series | None,
|
||||||
dates: list[date],
|
dates: list[date],
|
||||||
config: dict,
|
config: dict,
|
||||||
oas_series: rms.Series | None = None,
|
observations: list[dict] | None = None,
|
||||||
) -> tuple[dict[date, float], dict[date, int]]:
|
) -> dict[date, dict]:
|
||||||
"""Warning score per session plus how many sensors backed it.
|
"""State and Warning per session, from the function that writes snapshots.
|
||||||
|
|
||||||
v2 re-derived this by hand from ``WARNING_WEIGHTS`` and so would have kept
|
Calling ``_compute_index`` rather than re-deriving the two axes is the same
|
||||||
measuring the old construct after a scoring change. Since v3 dropped
|
anti-drift argument that produced ``warning_sensor_scores``: the v2 study
|
||||||
fundamentals from the score, this is now exactly the live Warning score
|
re-derived Warning by hand and would have kept measuring the old construct
|
||||||
rather than a technical-only approximation of it.
|
through a scoring change. State has no such shared helper, so the whole
|
||||||
|
snapshot builder is the shared definition.
|
||||||
|
|
||||||
The sensor count matters because the score renormalises over whatever is
|
``observations`` is the point-in-time fundamental series. It does not enter
|
||||||
available: a session backed by two sensors is not drawn from the same
|
either score -- the fundamental channel is categorical and read by confluence
|
||||||
distribution as one backed by three, and the frozen threshold assumes it is.
|
-- but the per-session ``fundamental_state`` it produces is what the
|
||||||
|
confluence rule below is measured on, so it has to be the same series
|
||||||
|
production reports from. Every variant in this module reads its Warning from
|
||||||
|
these rows, so there is no second derivation to fall out of step.
|
||||||
"""
|
"""
|
||||||
tickers = config["tickers"]
|
rows: dict[date, dict] = {}
|
||||||
smh_full = prices.get(tickers["leaders"][0], [])
|
|
||||||
spy_full = prices.get(tickers["market"], [])
|
|
||||||
out: dict[date, float] = {}
|
|
||||||
backing: dict[date, int] = {}
|
|
||||||
for session in dates:
|
for session in dates:
|
||||||
sensors = rms.warning_sensor_scores(
|
snapshot = rms._compute_index(
|
||||||
breadth_divergence.get(session),
|
prices,
|
||||||
rms._closes_asof(smh_full, session),
|
vix_series,
|
||||||
rms._closes_asof(spy_full, session),
|
oas_series,
|
||||||
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
|
{},
|
||||||
|
config,
|
||||||
|
session,
|
||||||
|
breadth_series=breadth_series,
|
||||||
|
divergence_series=divergence_series,
|
||||||
|
observations=observations or [],
|
||||||
)
|
)
|
||||||
score = rms.score_warning_sensors(sensors)
|
state = snapshot["state"]
|
||||||
if score is not None:
|
warning = snapshot["warning"]
|
||||||
out[session] = round(score, 2)
|
rows[session] = {
|
||||||
backing[session] = sum(1 for value in sensors.values() if value is not None)
|
"state": state.get("score"),
|
||||||
return out, backing
|
"warning": warning.get("score"),
|
||||||
|
"fundamental_state": (snapshot.get("fundamental_context") or {}).get("state"),
|
||||||
|
# `usable`, not `available`: a stale observation keeps its state for
|
||||||
|
# display but stops counting as evidence, and an observation whose
|
||||||
|
# extraction failed on everything is fresh but knows nothing. Either
|
||||||
|
# one counted here would inflate the covered window with sessions the
|
||||||
|
# channel could not have contributed to.
|
||||||
|
"fundamental_usable": bool(
|
||||||
|
(snapshot.get("fundamental_context") or {}).get("usable")
|
||||||
|
),
|
||||||
|
"state_coverage": state.get("coverage") or 0.0,
|
||||||
|
"warning_coverage": warning.get("coverage") or 0.0,
|
||||||
|
# The score renormalises over available sensors, so a session backed
|
||||||
|
# by two is not drawn from the same distribution as one backed by
|
||||||
|
# three, and a frozen threshold assumes it is.
|
||||||
|
"warning_sensors": len(warning.get("available_pillars") or []),
|
||||||
|
"inputs_fresh": bool((snapshot.get("data_quality") or {}).get("inputs_fresh")),
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _publishable(row: dict | None) -> bool:
|
||||||
|
"""What ``get_regime_history`` leaves for the alert to confirm against.
|
||||||
|
|
||||||
|
Deliberately not freshness-gated: ``_collect_regime_quadrant`` checks
|
||||||
|
``is_fresh`` on today's live reading only, while the prior session comes from
|
||||||
|
stored history where the only filter is a published band on both axes.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
row is not None
|
||||||
|
and row["state"] is not None
|
||||||
|
and row["warning"] is not None
|
||||||
|
and row["state_coverage"] >= rms.MIN_COVERAGE
|
||||||
|
and row["warning_coverage"] >= rms.MIN_COVERAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prior_publishable(
|
||||||
|
rows: dict[date, dict], dates: list[date], index: int, history_days: int
|
||||||
|
) -> dict | None:
|
||||||
|
"""``valid[-2]``: the previous published session inside the 14-day window.
|
||||||
|
|
||||||
|
The monitor writes today's snapshot before the alert step runs
|
||||||
|
(``job_catalog._DAILY_PIPELINE_STEPS``), so ``valid[-1]`` is today and this
|
||||||
|
is genuinely the prior session rather than t-2.
|
||||||
|
"""
|
||||||
|
cutoff = dates[index] - timedelta(days=history_days)
|
||||||
|
for position in range(index - 1, -1, -1):
|
||||||
|
if dates[position] < cutoff:
|
||||||
|
return None
|
||||||
|
candidate = rows.get(dates[position])
|
||||||
|
if _publishable(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def replay_quadrant_changes(
|
||||||
|
rows: dict[date, dict],
|
||||||
|
dates: list[date],
|
||||||
|
state_divider: float = QUAD_X_DIV,
|
||||||
|
warning_divider: float = QUAD_Y_DIV,
|
||||||
|
margin: float = QUAD_MARGIN,
|
||||||
|
cooldown_days: int = QUAD_COOLDOWN_DAYS,
|
||||||
|
history_days: int = QUAD_HISTORY_DAYS,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Every quadrant change the shipped alert would have sent, in order.
|
||||||
|
|
||||||
|
A faithful replay of ``_collect_regime_quadrant``, including three details a
|
||||||
|
state machine written from first principles gets wrong:
|
||||||
|
|
||||||
|
* the prior session is classified against the *current baseline*, not against
|
||||||
|
its own predecessor, so confirmation asks "did yesterday already look like
|
||||||
|
this change" rather than "did yesterday change too";
|
||||||
|
* the baseline advances only when an alert actually fires, so a change that
|
||||||
|
fails confirmation or cooldown is re-evaluated against the old quadrant on
|
||||||
|
the next session rather than being forgotten;
|
||||||
|
* one cooldown is shared by every quadrant change, so a 3->4 alert can
|
||||||
|
swallow a 4->2 alert three days later.
|
||||||
|
|
||||||
|
Returns the fires themselves rather than alarm indices, because which
|
||||||
|
transitions count as a *warning* is the caller's question: entering
|
||||||
|
Warning-high territory and entering both-high territory are different rules
|
||||||
|
over the same replay.
|
||||||
|
"""
|
||||||
|
fires: list[dict] = []
|
||||||
|
baseline: str | None = None
|
||||||
|
baseline_date: date | None = None
|
||||||
|
|
||||||
|
for index, session in enumerate(dates):
|
||||||
|
row = rows.get(session)
|
||||||
|
if not _publishable(row) or not row["inputs_fresh"]:
|
||||||
|
continue
|
||||||
|
x, y = float(row["state"]), float(row["warning"])
|
||||||
|
|
||||||
|
if baseline is None: # seeds silently, exactly as a fresh install does
|
||||||
|
baseline = _classify_quadrant(x, y, None, margin, state_divider, warning_divider)
|
||||||
|
baseline_date = session
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_quadrant = _classify_quadrant(x, y, baseline, margin, state_divider, warning_divider)
|
||||||
|
if new_quadrant == baseline:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prior = _prior_publishable(rows, dates, index, history_days)
|
||||||
|
if prior is None:
|
||||||
|
continue
|
||||||
|
prior_quadrant = _classify_quadrant(
|
||||||
|
float(prior["state"]), float(prior["warning"]),
|
||||||
|
baseline, margin, state_divider, warning_divider,
|
||||||
|
)
|
||||||
|
if prior_quadrant != new_quadrant:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if baseline_date is not None and (session - baseline_date).days < cooldown_days:
|
||||||
|
continue
|
||||||
|
|
||||||
|
fires.append({
|
||||||
|
"index": index,
|
||||||
|
"date": session.isoformat(),
|
||||||
|
"from": baseline,
|
||||||
|
"to": new_quadrant,
|
||||||
|
"state": x,
|
||||||
|
"warning": y,
|
||||||
|
})
|
||||||
|
baseline, baseline_date = new_quadrant, session
|
||||||
|
|
||||||
|
return fires
|
||||||
|
|
||||||
|
|
||||||
|
def entry_alarms(fires: list[dict], entry: tuple[str, ...]) -> list[int]:
|
||||||
|
"""Fires that *enter* the given quadrant set from outside it."""
|
||||||
|
return [f["index"] for f in fires if f["to"] in entry and f["from"] not in entry]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ablations, baselines, null
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _usable_adverse(rows: dict[date, dict], session: date) -> bool:
|
||||||
|
"""Adverse *and* still within its staleness horizon.
|
||||||
|
|
||||||
|
Both callers need this pair, and neither may use the state alone: the state
|
||||||
|
survives going stale so the card can show it, which would otherwise let a
|
||||||
|
months-old read confirm crossings indefinitely.
|
||||||
|
"""
|
||||||
|
row = rows.get(session) or {}
|
||||||
|
return row.get("fundamental_state") == "adverse" and bool(row.get("fundamental_usable"))
|
||||||
|
|
||||||
|
|
||||||
|
def adverse_episodes(
|
||||||
|
rows: dict[date, dict], dates: list[date], start_index: int
|
||||||
|
) -> list[int]:
|
||||||
|
"""Sessions where the fundamental state *becomes* usably adverse.
|
||||||
|
|
||||||
|
The market rules alarm on a rising-edge crossing; a categorical state has no
|
||||||
|
crossing, so its analogue is the transition into ``adverse``. That keeps the
|
||||||
|
row comparable with every other row in the table rather than counting every
|
||||||
|
day the state happens to sit there.
|
||||||
|
"""
|
||||||
|
alarms: list[int] = []
|
||||||
|
was_adverse = start_index > 0 and _usable_adverse(rows, dates[start_index - 1])
|
||||||
|
for index in range(start_index, len(dates)):
|
||||||
|
if dates[index] not in rows:
|
||||||
|
continue
|
||||||
|
adverse = _usable_adverse(rows, dates[index])
|
||||||
|
if adverse and not was_adverse:
|
||||||
|
alarms.append(index)
|
||||||
|
was_adverse = adverse
|
||||||
|
return alarms
|
||||||
|
|
||||||
|
|
||||||
|
def confluence_episodes(
|
||||||
|
warning_alarms: list[int], rows: dict[date, dict], dates: list[date]
|
||||||
|
) -> list[int]:
|
||||||
|
"""Warning crossings that happen while the fundamental state is usably adverse.
|
||||||
|
|
||||||
|
Deliberately gated on the market crossing rather than on either channel
|
||||||
|
moving: it preserves the rising-edge semantics every other row uses, so the
|
||||||
|
column measures "does requiring fundamental agreement help?" instead of a
|
||||||
|
differently-shaped rule that cannot be compared with the others.
|
||||||
|
"""
|
||||||
|
return [index for index in warning_alarms if _usable_adverse(rows, dates[index])]
|
||||||
|
|
||||||
|
|
||||||
|
def covered_events(
|
||||||
|
event_indices: list[int],
|
||||||
|
rows: dict[date, dict],
|
||||||
|
dates: list[date],
|
||||||
|
horizon: int,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Corrections a fundamental rule actually had a chance to warn about.
|
||||||
|
|
||||||
|
An alarm counts only if it fires in ``[event - horizon, event - 1]``, so a
|
||||||
|
correction is *coverable* only if the channel had usable context somewhere in
|
||||||
|
that window. Scoring these rules against every correction instead would make
|
||||||
|
one day of observation render as 0/10 -- an untested rule reported as a
|
||||||
|
failed one, which is the exact mistake the ``measurable`` flag exists to
|
||||||
|
prevent for the empty-table case.
|
||||||
|
"""
|
||||||
|
covered: list[int] = []
|
||||||
|
for event_index in event_indices:
|
||||||
|
window = range(max(0, event_index - horizon), event_index)
|
||||||
|
if any(
|
||||||
|
bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
|
||||||
|
for index in window
|
||||||
|
):
|
||||||
|
covered.append(event_index)
|
||||||
|
return covered
|
||||||
|
|
||||||
|
|
||||||
|
def eligible_sessions(
|
||||||
|
rows: dict[date, dict], dates: list[date], start_index: int
|
||||||
|
) -> int:
|
||||||
|
"""Sessions a fundamental rule could have fired on, for the FA/year rate.
|
||||||
|
|
||||||
|
Annualising over the whole window instead would divide a rule's false alarms
|
||||||
|
by years in which it was structurally incapable of firing, reporting a
|
||||||
|
flattering rate that means nothing.
|
||||||
|
"""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for session in dates[start_index:]
|
||||||
|
if bool((rows.get(session) or {}).get("fundamental_usable"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def below_average_series(
|
||||||
|
series: rms.Series, window: int = BASELINE_SMA_WINDOW
|
||||||
|
) -> dict[date, float]:
|
||||||
|
"""100 while the close sits under its ``window``-session average, else 0."""
|
||||||
|
out: dict[date, float] = {}
|
||||||
|
closes = [value for _, value in series]
|
||||||
|
for index, (session, close) in enumerate(series):
|
||||||
|
if index + 1 < window:
|
||||||
|
continue
|
||||||
|
average = sum(closes[index + 1 - window: index + 1]) / window
|
||||||
|
out[session] = 100.0 if close < average else 0.0
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _null_model(
|
||||||
|
alarm_count: int,
|
||||||
|
event_indices: list[int],
|
||||||
|
dates: list[date],
|
||||||
|
horizon: int,
|
||||||
|
start_index: int,
|
||||||
|
observed_warned: int,
|
||||||
|
draws: int = NULL_DRAWS,
|
||||||
|
seed: int = NULL_SEED,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Recall from alarms scattered at random over the same evaluable sessions.
|
||||||
|
|
||||||
|
Drawn only from sessions a real rule could have fired on: over the whole
|
||||||
|
sample the null would be diluted by warm-up sessions and would understate
|
||||||
|
what chance achieves. That matters here -- with ~11 events and a 20-session
|
||||||
|
horizon, a sixth of the sample already sits inside a hit window.
|
||||||
|
|
||||||
|
Corrections cluster, and uniform placement does not, so this is the floor
|
||||||
|
rather than the bar: an alarm process that clusters would beat it for
|
||||||
|
reasons that have nothing to do with foresight.
|
||||||
|
"""
|
||||||
|
population = range(start_index, len(dates))
|
||||||
|
if alarm_count <= 0 or not event_indices or alarm_count > len(population):
|
||||||
|
return None
|
||||||
|
rng = random.Random(seed)
|
||||||
|
recalls: list[int] = []
|
||||||
|
for _ in range(draws):
|
||||||
|
picks = sorted(rng.sample(population, alarm_count))
|
||||||
|
recalls.append(evaluate_alarms(picks, event_indices, dates, horizon)["events_warned"])
|
||||||
|
mean = sum(recalls) / len(recalls)
|
||||||
|
variance = sum((value - mean) ** 2 for value in recalls) / len(recalls)
|
||||||
|
return {
|
||||||
|
"draws": draws,
|
||||||
|
"alarms_per_draw": alarm_count,
|
||||||
|
"events": len(event_indices),
|
||||||
|
"mean_warned": round(mean, 2),
|
||||||
|
"sd_warned": round(variance ** 0.5, 2),
|
||||||
|
"observed_warned": observed_warned,
|
||||||
|
"p_at_least_observed": round(
|
||||||
|
sum(1 for value in recalls if value >= observed_warned) / len(recalls), 3
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _reliability(
|
def _reliability(
|
||||||
@@ -192,9 +569,9 @@ def _reliability(
|
|||||||
events_detected: int,
|
events_detected: int,
|
||||||
events_in_holdout: int,
|
events_in_holdout: int,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""How far the headline metrics can actually be trusted.
|
"""How far the *fitted* variant's headline metrics can be trusted.
|
||||||
|
|
||||||
Two things repeatedly invite over-reading this report:
|
Two things repeatedly invite over-reading it:
|
||||||
|
|
||||||
* The holdout carries only the corrections that fall in the last 30% of the
|
* The holdout carries only the corrections that fall in the last 30% of the
|
||||||
sample. A "2/4" is one event away from "3/4", and in practice the events
|
sample. A "2/4" is one event away from "3/4", and in practice the events
|
||||||
@@ -203,6 +580,9 @@ def _reliability(
|
|||||||
* The score renormalises over available sensors, so a training window that
|
* The score renormalises over available sensors, so a training window that
|
||||||
predates a sensor's history freezes a threshold on a different construct
|
predates a sensor's history freezes a threshold on a different construct
|
||||||
than the holdout is measured against.
|
than the holdout is measured against.
|
||||||
|
|
||||||
|
Neither applies to the shipped rule, whose thresholds are fixed constants --
|
||||||
|
but the second one does not vanish, it relocates: see ``_era_split``.
|
||||||
"""
|
"""
|
||||||
expected = len(rms.WARNING_WEIGHTS)
|
expected = len(rms.WARNING_WEIGHTS)
|
||||||
train = [backing[d] for d in dates[:split] if d in backing]
|
train = [backing[d] for d in dates[:split] if d in backing]
|
||||||
@@ -221,6 +601,126 @@ def _reliability(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _era_split(
|
||||||
|
alarms: list[int],
|
||||||
|
event_indices: list[int],
|
||||||
|
dates: list[date],
|
||||||
|
horizon: int,
|
||||||
|
start_index: int,
|
||||||
|
credit_from: date | None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Shipped-rule metrics either side of the credit sensor's first session.
|
||||||
|
|
||||||
|
Dropping the fitted threshold makes the whole sample evaluable, which is the
|
||||||
|
point -- but most of the extra events sit before 2023-08, where W3 does not
|
||||||
|
exist and Warning renormalises to ``(W1*45 + W2*30)/75``. The fixed 40
|
||||||
|
divider is then applied to a different construct than it was reasoned about,
|
||||||
|
so the coverage caveat does not disappear with the split; it relocates from
|
||||||
|
the threshold to the score. Reporting the two eras separately is what keeps
|
||||||
|
the fuller sample from being a differently misleading headline.
|
||||||
|
|
||||||
|
The pre-credit era is close to a "Warning without W3" ablation on real
|
||||||
|
sessions -- and a clean one, because the fundamental channel is not a term in
|
||||||
|
Warning at all, so the two eras differ by W3 and nothing else. That stays
|
||||||
|
true however much fundamental history accumulates.
|
||||||
|
|
||||||
|
Alarms and events are assigned to eras by index, so an alarm days before the
|
||||||
|
boundary that matched an event days after it lands in the earlier era. With
|
||||||
|
the eras years long and the events sparse, that costs nothing.
|
||||||
|
"""
|
||||||
|
if credit_from is None:
|
||||||
|
return None
|
||||||
|
boundary = next(
|
||||||
|
(index for index, session in enumerate(dates) if session >= credit_from), None
|
||||||
|
)
|
||||||
|
if boundary is None or boundary <= start_index or boundary >= len(dates):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def slice_metrics(low: int, high: int) -> dict:
|
||||||
|
sessions = max(0, high - low)
|
||||||
|
metrics = _score_rule(
|
||||||
|
[a for a in alarms if low <= a < high],
|
||||||
|
[e for e in event_indices if low <= e < high],
|
||||||
|
dates, horizon, sessions,
|
||||||
|
)
|
||||||
|
metrics.pop("per_event", None)
|
||||||
|
metrics["sessions"] = sessions
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
return {
|
||||||
|
"credit_from": credit_from.isoformat(),
|
||||||
|
"pre_credit": {
|
||||||
|
"label": "W1+W2 only",
|
||||||
|
"start": dates[start_index].isoformat(),
|
||||||
|
"end": dates[boundary - 1].isoformat(),
|
||||||
|
**slice_metrics(start_index, boundary),
|
||||||
|
},
|
||||||
|
"full_coverage": {
|
||||||
|
"label": "all three sensors",
|
||||||
|
"start": dates[boundary].isoformat(),
|
||||||
|
"end": dates[-1].isoformat(),
|
||||||
|
**slice_metrics(boundary, len(dates)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _warning_from_rows(
|
||||||
|
rows: dict[date, dict], dates: list[date]
|
||||||
|
) -> tuple[dict[date, float], dict[date, int]]:
|
||||||
|
"""Published Warning per session plus how many sensors backed it.
|
||||||
|
|
||||||
|
Read off ``_axis_rows`` rather than recomputed. v2 re-derived Warning by hand
|
||||||
|
from ``WARNING_WEIGHTS`` and would have kept measuring the old construct
|
||||||
|
after a scoring change; a second derivation here would have done the same to
|
||||||
|
any later change to how Warning is assembled -- silently, in the fitted
|
||||||
|
variant and the ``warning_bare`` ablation, while the shipped replay moved on
|
||||||
|
without it.
|
||||||
|
"""
|
||||||
|
out: dict[date, float] = {}
|
||||||
|
backing: dict[date, int] = {}
|
||||||
|
for session in dates:
|
||||||
|
row = rows.get(session)
|
||||||
|
if row is None or row["warning"] is None:
|
||||||
|
continue
|
||||||
|
out[session] = float(row["warning"])
|
||||||
|
backing[session] = int(row["warning_sensors"])
|
||||||
|
return out, backing
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_row(
|
||||||
|
rule_id: str,
|
||||||
|
label: str,
|
||||||
|
kind: str,
|
||||||
|
note: str,
|
||||||
|
alarms: list[int],
|
||||||
|
event_indices: list[int],
|
||||||
|
dates: list[date],
|
||||||
|
horizon: int,
|
||||||
|
sessions: int,
|
||||||
|
measurable: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""One comparison row.
|
||||||
|
|
||||||
|
``measurable=False`` marks a rule whose *input* is too thin to have been
|
||||||
|
tested, not one that failed. A fundamental rule scores 0/N whether it is
|
||||||
|
wrong or merely absent, and a 0/N sitting in this table would read as
|
||||||
|
tested-and-failed -- the same false precision the whole restructure exists to
|
||||||
|
remove. It stays false until the channel has covered
|
||||||
|
``MIN_EVENTS_FOR_CONFIDENCE`` corrections, because a 1/1 or 0/2 over a
|
||||||
|
two-week exposure is not a result either.
|
||||||
|
"""
|
||||||
|
metrics = _score_rule(alarms, event_indices, dates, horizon, sessions)
|
||||||
|
metrics.pop("per_event", None)
|
||||||
|
return {
|
||||||
|
"id": rule_id,
|
||||||
|
"label": label,
|
||||||
|
"kind": kind,
|
||||||
|
"note": note,
|
||||||
|
"measurable": measurable,
|
||||||
|
**metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def run_event_study(
|
async def run_event_study(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
||||||
@@ -242,55 +742,202 @@ async def run_event_study(
|
|||||||
)
|
)
|
||||||
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
|
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
|
||||||
oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
|
oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
|
||||||
warning, backing = _warning_series(prices, divergence, dates, config, oas_series)
|
# State needs volatility, which the Warning-only study never fetched.
|
||||||
|
vix_series = await rms._fetch_fred_series("VIXCLS", start, end)
|
||||||
|
# The point-in-time fundamental series. It is not in either score; it drives
|
||||||
|
# the categorical channel the confluence rule below is measured on.
|
||||||
|
observations = await rms.get_fundamental_observations(db)
|
||||||
# The credit sensor cannot reach back as far as the price history does (the
|
# The credit sensor cannot reach back as far as the price history does (the
|
||||||
# upstream series is capped at ~3 years), so the earlier part of the sample
|
# upstream series is capped at ~3 years), so the earlier part of the sample
|
||||||
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
|
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
|
||||||
# than letting the threshold quietly straddle two sensor sets.
|
# than letting the threshold quietly straddle two sensor sets.
|
||||||
credit_from = oas_series[0][0].isoformat() if oas_series else None
|
credit_from = oas_series[0][0] if oas_series else None
|
||||||
|
|
||||||
|
all_events = detect_events(closes, dates, threshold_pct)
|
||||||
|
all_event_indices = [event["index"] for event in all_events]
|
||||||
|
|
||||||
|
# --- one pass; every rule below reads its Warning from these rows ----
|
||||||
|
rows = _axis_rows(
|
||||||
|
prices,
|
||||||
|
vix_series,
|
||||||
|
oas_series,
|
||||||
|
rms._mapping_series(breadth),
|
||||||
|
rms._mapping_series(divergence),
|
||||||
|
dates,
|
||||||
|
config,
|
||||||
|
observations,
|
||||||
|
)
|
||||||
|
warning, backing = _warning_from_rows(rows, dates)
|
||||||
|
fires = replay_quadrant_changes(rows, dates)
|
||||||
|
# Nothing can alarm before the baseline seeds, so every rule is measured from
|
||||||
|
# the same session and the comparison stays like-for-like.
|
||||||
|
seeded = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, session in enumerate(dates)
|
||||||
|
if _publishable(rows.get(session)) and rows[session]["inputs_fresh"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if seeded is None:
|
||||||
|
return {"available": False, "reason": "no session with publishable coverage"}
|
||||||
|
evaluable_start = seeded + 1
|
||||||
|
evaluable_sessions = max(1, len(dates) - evaluable_start)
|
||||||
|
evaluable_events = [index for index in all_event_indices if index >= evaluable_start]
|
||||||
|
|
||||||
|
warning_alarms = entry_alarms(fires, WARNING_QUADRANTS)
|
||||||
|
shipped_metrics = _score_rule(
|
||||||
|
warning_alarms, evaluable_events, dates, horizon, evaluable_sessions
|
||||||
|
)
|
||||||
|
shipped_events = shipped_metrics.pop("per_event")
|
||||||
|
|
||||||
|
# --- the fitted variant, kept for continuity -------------------------
|
||||||
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
|
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
|
||||||
train_values = [warning[d] for d in dates[:split] if d in warning]
|
train_values = [warning[d] for d in dates[:split] if d in warning]
|
||||||
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
|
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
|
||||||
if warn_threshold is None:
|
if warn_threshold is None:
|
||||||
return {"available": False, "reason": "insufficient warning history"}
|
return {"available": False, "reason": "insufficient warning history"}
|
||||||
|
holdout_events = [index for index in all_event_indices if index >= split]
|
||||||
all_events = detect_events(closes, dates, threshold_pct)
|
fitted_alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
|
||||||
holdout_events = [event["index"] for event in all_events if event["index"] >= split]
|
|
||||||
alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
|
|
||||||
metrics = evaluate_alarms(alarms, holdout_events, dates, horizon)
|
|
||||||
holdout_sessions = max(1, len(dates) - split)
|
holdout_sessions = max(1, len(dates) - split)
|
||||||
metrics["false_alarms_per_year"] = round(
|
fitted_metrics = _score_rule(
|
||||||
metrics["false_alarms"] / (holdout_sessions / 252.0), 2
|
fitted_alarms, holdout_events, dates, horizon, holdout_sessions
|
||||||
)
|
)
|
||||||
|
fitted_events = fitted_metrics.pop("per_event")
|
||||||
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
|
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
|
||||||
|
|
||||||
|
# --- ablations and baselines, all on fixed thresholds ----------------
|
||||||
|
# Fitted thresholds are deliberately excluded here: a threshold fitted on the
|
||||||
|
# full sample would have lookahead the shipped rule does not, and one fitted
|
||||||
|
# on a training split could only be scored on the four holdout events. Fixed
|
||||||
|
# constants keep every row on the same events over the same sessions.
|
||||||
|
state_series = {
|
||||||
|
session: row["state"] for session, row in rows.items() if row["state"] is not None
|
||||||
|
}
|
||||||
|
vix_indicator = {
|
||||||
|
session: value
|
||||||
|
for session in dates
|
||||||
|
if (value := rms._value_asof(vix_series, session)) is not None
|
||||||
|
}
|
||||||
|
# The fundamental channel is categorical and never enters a score, so it is
|
||||||
|
# compared as its own rule and as a confluence gate rather than tuned as a
|
||||||
|
# weight. With an empty observation series both are unmeasurable, and say so.
|
||||||
|
fundamental_alarms = adverse_episodes(rows, dates, evaluable_start)
|
||||||
|
confluence_alarms = confluence_episodes(warning_alarms, rows, dates)
|
||||||
|
# Coverage-matched denominators. These rules only existed on the sessions the
|
||||||
|
# channel had usable context, so scoring them over the whole window would
|
||||||
|
# report an exposure they never had -- and one day of coverage would render
|
||||||
|
# as 0/10.
|
||||||
|
fundamental_events = covered_events(evaluable_events, rows, dates, horizon)
|
||||||
|
fundamental_sessions = eligible_sessions(rows, dates, evaluable_start)
|
||||||
|
fundamental_measurable = len(fundamental_events) >= MIN_EVENTS_FOR_CONFIDENCE
|
||||||
|
comparison = [
|
||||||
|
_rule_row(
|
||||||
|
"fundamental_adverse", "Fundamental context turns adverse", "fundamental",
|
||||||
|
"The third channel on its own: transitions into an adverse capex / "
|
||||||
|
"earnings-reaction state, with no market input at all.",
|
||||||
|
fundamental_alarms, fundamental_events, dates, horizon, fundamental_sessions,
|
||||||
|
measurable=fundamental_measurable,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"confluence", "Confluence: Warning crossing while adverse", "fundamental",
|
||||||
|
"The shipped market crossing, kept only when the fundamental channel "
|
||||||
|
"agrees. Answers whether requiring agreement buys precision, at what "
|
||||||
|
"cost in recall.",
|
||||||
|
confluence_alarms, fundamental_events, dates, horizon, fundamental_sessions,
|
||||||
|
measurable=fundamental_measurable,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"market_over_covered", "Quadrant alert, covered window only", "fundamental",
|
||||||
|
"The shipped market rule scored on exactly the events, sessions and "
|
||||||
|
"alarms the two rows above were scored on. Without it, any difference "
|
||||||
|
"between them and the headline could be the window rather than the "
|
||||||
|
"channel.",
|
||||||
|
# Alarms are restricted to the covered window too: counting crossings
|
||||||
|
# that fired when the channel had no context would compare the market
|
||||||
|
# rule's full exposure against the channel's partial one.
|
||||||
|
[
|
||||||
|
index
|
||||||
|
for index in warning_alarms
|
||||||
|
if index >= evaluable_start
|
||||||
|
and bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
|
||||||
|
],
|
||||||
|
fundamental_events, dates, horizon, fundamental_sessions,
|
||||||
|
measurable=fundamental_measurable,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"quadrant_stress_entry", "Quadrant alert, both axes high", "ablation",
|
||||||
|
"The same replay, recording only entries into the both-high quadrant. "
|
||||||
|
"State is coincident by construction, so requiring it should convert "
|
||||||
|
"leads into confirmations.",
|
||||||
|
entry_alarms(fires, STRESS_QUADRANT),
|
||||||
|
evaluable_events, dates, horizon, evaluable_sessions,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"warning_bare", f"Warning >= {QUAD_Y_DIV:.0f} (bare crossing)", "ablation",
|
||||||
|
"The shipped divider with none of the quadrant machinery: no State "
|
||||||
|
"condition, no hysteresis, no confirmation, no cooldown.",
|
||||||
|
alarm_episodes(warning, dates, QUAD_Y_DIV, start_index=evaluable_start),
|
||||||
|
evaluable_events, dates, horizon, evaluable_sessions,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"state_bare", f"State >= {QUAD_X_DIV:.0f} (bare crossing)", "ablation",
|
||||||
|
"The coincident axis alone. State measures stress that has already "
|
||||||
|
"arrived, so a competitive lead here would be surprising.",
|
||||||
|
alarm_episodes(state_series, dates, QUAD_X_DIV, start_index=evaluable_start),
|
||||||
|
evaluable_events, dates, horizon, evaluable_sessions,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"smh_below_50dma", f"{leader} below its {BASELINE_SMA_WINDOW}-DMA", "baseline",
|
||||||
|
"The crudest possible trend rule, and free.",
|
||||||
|
alarm_episodes(
|
||||||
|
below_average_series(benchmark, BASELINE_SMA_WINDOW), dates,
|
||||||
|
50.0, start_index=evaluable_start,
|
||||||
|
),
|
||||||
|
evaluable_events, dates, horizon, evaluable_sessions,
|
||||||
|
),
|
||||||
|
_rule_row(
|
||||||
|
"vix_level", f"VIX >= {BASELINE_VIX_LEVEL:.0f}", "baseline",
|
||||||
|
"The market's own risk gauge, unweighted and unmodelled.",
|
||||||
|
alarm_episodes(
|
||||||
|
vix_indicator, dates, BASELINE_VIX_LEVEL, start_index=evaluable_start
|
||||||
|
),
|
||||||
|
evaluable_events, dates, horizon, evaluable_sessions,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
null_model = _null_model(
|
||||||
|
len(warning_alarms), evaluable_events, dates, horizon,
|
||||||
|
evaluable_start, shipped_metrics["events_warned"],
|
||||||
|
# Passed rather than defaulted: a default argument binds the constant at
|
||||||
|
# import, so overriding it (in tests) would silently do nothing.
|
||||||
|
draws=NULL_DRAWS, seed=NULL_SEED,
|
||||||
|
)
|
||||||
|
eras = _era_split(
|
||||||
|
warning_alarms, evaluable_events, dates, horizon, evaluable_start, credit_from
|
||||||
|
)
|
||||||
basket_asof = date.fromisoformat(config["basket_asof"])
|
basket_asof = date.fromisoformat(config["basket_asof"])
|
||||||
retrospective = dates[split] < basket_asof
|
retrospective = dates[evaluable_start] < basket_asof
|
||||||
evaluation = "exploratory" if retrospective else "holdout"
|
evaluation = "exploratory" if retrospective else "holdout"
|
||||||
lead_text = (
|
lead_text = (
|
||||||
f"median lead {metrics['median_lead_days']:.0f} sessions"
|
f"median lead {shipped_metrics['median_lead_days']:.0f} sessions"
|
||||||
if metrics["median_lead_days"] is not None
|
if shipped_metrics["median_lead_days"] is not None
|
||||||
else "no successful warning lead"
|
else "no successful warning lead"
|
||||||
)
|
)
|
||||||
summary = (
|
summary = (
|
||||||
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
|
f"{evaluation.capitalize()} replay of the shipped quadrant alert over "
|
||||||
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
|
f"{evaluable_sessions} sessions: it entered Warning-high territory ahead of "
|
||||||
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
|
f"{shipped_metrics['events_warned']} of {shipped_metrics['events']} 10% "
|
||||||
f"false alarms/year, {lead_text}. "
|
f"corrections, with {shipped_metrics['false_alarms_per_year']:.1f} false "
|
||||||
f"{metrics['events']} of {reliability['events_detected']} detected corrections "
|
f"alarms/year and {lead_text}. Its dividers are fixed constants rather than "
|
||||||
f"fall in the test period"
|
f"fitted, so there is no training split and every detected correction is "
|
||||||
+ (
|
f"evaluable — compare it against the ablations and baselines below before "
|
||||||
"; too few to read recall as a property of the score."
|
f"reading the ratio as good or bad."
|
||||||
if reliability["underpowered"]
|
|
||||||
else "."
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
per_event = metrics.pop("per_event")
|
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"available": True,
|
"available": True,
|
||||||
|
"schema": STUDY_SCHEMA,
|
||||||
"methodology": rms.METHODOLOGY,
|
"methodology": rms.METHODOLOGY,
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"evaluation": evaluation,
|
"evaluation": evaluation,
|
||||||
@@ -301,24 +948,69 @@ async def run_event_study(
|
|||||||
"event_threshold_pct": threshold_pct,
|
"event_threshold_pct": threshold_pct,
|
||||||
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
||||||
"horizon_days": horizon,
|
"horizon_days": horizon,
|
||||||
"train_fraction": TRAIN_FRACTION,
|
"credit_sensor_from": credit_from.isoformat() if credit_from else None,
|
||||||
"warn_percentile": WARN_PERCENTILE,
|
|
||||||
"warn_threshold": round(warn_threshold, 1),
|
|
||||||
"credit_sensor_from": credit_from,
|
|
||||||
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
||||||
"basket_asof": config["basket_asof"],
|
"basket_asof": config["basket_asof"],
|
||||||
},
|
},
|
||||||
|
# The channel's actual exposure, which is what its rows are scored on.
|
||||||
|
# The series starts empty -- the observation lived in a single
|
||||||
|
# overwritten settings slot until 2026-08-12 -- and it accumulates one
|
||||||
|
# observation at a time, so for a long while these rows are unmeasurable
|
||||||
|
# rather than unsuccessful. Stating the exposure is what stops the table
|
||||||
|
# inventing a failed result out of a thin one.
|
||||||
|
"fundamental_coverage": {
|
||||||
|
"observations": len(observations),
|
||||||
|
"sessions_eligible": fundamental_sessions,
|
||||||
|
"evaluable_sessions": evaluable_sessions,
|
||||||
|
"events_covered": len(fundamental_events),
|
||||||
|
"events_evaluable": len(evaluable_events),
|
||||||
|
"minimum_events": MIN_EVENTS_FOR_CONFIDENCE,
|
||||||
|
"measurable": fundamental_measurable,
|
||||||
|
},
|
||||||
"sample": {
|
"sample": {
|
||||||
"start": dates[0].isoformat(),
|
"start": dates[0].isoformat(),
|
||||||
"end": dates[-1].isoformat(),
|
"end": dates[-1].isoformat(),
|
||||||
|
"sessions": len(dates),
|
||||||
|
# Not "test_start": the shipped rule fits nothing, so this is where
|
||||||
|
# the baseline seeds and every rule becomes measurable, not where a
|
||||||
|
# holdout begins. The fitted variant's split lives under "fitted".
|
||||||
|
"evaluable_from": dates[evaluable_start].isoformat(),
|
||||||
|
"evaluable_sessions": evaluable_sessions,
|
||||||
|
"events_detected": len(all_events),
|
||||||
|
"events_evaluable": len(evaluable_events),
|
||||||
|
},
|
||||||
|
"shipped": {
|
||||||
|
"rule": {
|
||||||
|
"state_divider": QUAD_X_DIV,
|
||||||
|
"warning_divider": QUAD_Y_DIV,
|
||||||
|
"margin": QUAD_MARGIN,
|
||||||
|
"confirm_sessions": 2,
|
||||||
|
"cooldown_days": QUAD_COOLDOWN_DAYS,
|
||||||
|
"entry": "Warning-high quadrant (early warning or active stress)",
|
||||||
|
},
|
||||||
|
"metrics": shipped_metrics,
|
||||||
|
"events": shipped_events,
|
||||||
|
"quadrant_changes": len(fires),
|
||||||
|
"fires": fires,
|
||||||
|
"by_era": eras,
|
||||||
|
},
|
||||||
|
"comparison": comparison,
|
||||||
|
"null_model": null_model,
|
||||||
|
"fitted": {
|
||||||
|
"params": {
|
||||||
|
"train_fraction": TRAIN_FRACTION,
|
||||||
|
"warn_percentile": WARN_PERCENTILE,
|
||||||
|
"warn_threshold": round(warn_threshold, 1),
|
||||||
|
},
|
||||||
|
"sample": {
|
||||||
"train_end": dates[split - 1].isoformat(),
|
"train_end": dates[split - 1].isoformat(),
|
||||||
"test_start": dates[split].isoformat(),
|
"test_start": dates[split].isoformat(),
|
||||||
"sessions": len(dates),
|
|
||||||
"holdout_sessions": holdout_sessions,
|
"holdout_sessions": holdout_sessions,
|
||||||
},
|
},
|
||||||
"metrics": metrics,
|
"metrics": fitted_metrics,
|
||||||
|
"events": fitted_events,
|
||||||
|
},
|
||||||
"reliability": reliability,
|
"reliability": reliability,
|
||||||
"events": per_event,
|
|
||||||
"recent_breadth": [
|
"recent_breadth": [
|
||||||
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
|
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
|
||||||
for d in dates[-90:]
|
for d in dates[-90:]
|
||||||
@@ -328,10 +1020,13 @@ async def run_event_study(
|
|||||||
logger.info(json.dumps({
|
logger.info(json.dumps({
|
||||||
"event": "regime_event_study_complete",
|
"event": "regime_event_study_complete",
|
||||||
"evaluation": evaluation,
|
"evaluation": evaluation,
|
||||||
"events": metrics["events"],
|
"shipped_events": shipped_metrics["events"],
|
||||||
"events_detected": reliability["events_detected"],
|
"shipped_warned": shipped_metrics["events_warned"],
|
||||||
"warned": metrics["events_warned"],
|
"shipped_false_alarms_per_year": shipped_metrics["false_alarms_per_year"],
|
||||||
"false_alarms_per_year": metrics["false_alarms_per_year"],
|
"quadrant_changes": len(fires),
|
||||||
|
"fitted_events": fitted_metrics["events"],
|
||||||
|
"fitted_warned": fitted_metrics["events_warned"],
|
||||||
|
"null_p_at_least_observed": (null_model or {}).get("p_at_least_observed"),
|
||||||
"underpowered": reliability["underpowered"],
|
"underpowered": reliability["underpowered"],
|
||||||
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
|
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
|
||||||
}))
|
}))
|
||||||
@@ -352,4 +1047,8 @@ async def get_event_study_report(db: AsyncSession) -> dict | None:
|
|||||||
report = json.loads(setting.value)
|
report = json.loads(setting.value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return report if report.get("methodology") == rms.METHODOLOGY else None
|
if report.get("methodology") != rms.METHODOLOGY:
|
||||||
|
return None
|
||||||
|
# A pre-replay report parses fine and carries the current methodology, so the
|
||||||
|
# shape has to be checked separately or the panel renders a headline-less v4.
|
||||||
|
return report if report.get("schema") == STUDY_SCHEMA else None
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import exists, func, select
|
from sqlalchemy import exists, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -15,6 +17,23 @@ from app.models.ticker import Ticker
|
|||||||
|
|
||||||
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
|
_SEC_FORMS = ("10-K", "10-Q", "10-K/A", "10-Q/A")
|
||||||
|
|
||||||
|
# How recent the issuer's own newest filing must be for an *escalated* gap to
|
||||||
|
# stop pausing setups. A gap pauses an issuer until it is either resolved or
|
||||||
|
# superseded by a later ingested filing — which assumes the gap is temporary.
|
||||||
|
# It is not always: SEC's per-company Company-Facts files can go stale
|
||||||
|
# indefinitely (2026-08, 43 large caps whose Q2 10-Qs the frames API carried but
|
||||||
|
# whose companyfacts files never received), and since the supersede rule needs a
|
||||||
|
# *successfully ingested* later filing, a stale file also swallows the next
|
||||||
|
# quarter. The pause is then open-ended rather than seasonal.
|
||||||
|
#
|
||||||
|
# So the pause hands off to the alert: once `filing_gap_aged` has escalated a gap
|
||||||
|
# to an operator (`escalated_at`), the issuer resumes on the fundamentals it does
|
||||||
|
# have — provided those are recent. An issuer with nothing this fresh has no
|
||||||
|
# usable fundamentals at all and stays paused, which is the case the gate was
|
||||||
|
# built for. The retry queue is untouched: `active_gaps` still returns these, so
|
||||||
|
# the importer keeps retrying and a recovered filing still resolves normally.
|
||||||
|
GAP_GATE_RECENT_FILING_DAYS = 180
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SetupQuality:
|
class SetupQuality:
|
||||||
@@ -51,6 +70,42 @@ async def active_gaps(
|
|||||||
return list((await db.execute(stmt)).scalars().all())
|
return list((await db.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def gap_exempt_ciks(
|
||||||
|
db: AsyncSession, gaps: list[SecFilingGap]
|
||||||
|
) -> set[str]:
|
||||||
|
"""CIKs whose gaps have stopped pausing setups (see GAP_GATE_RECENT_FILING_DAYS).
|
||||||
|
|
||||||
|
Every one of a CIK's active gaps must be escalated: one fresh gap alongside an
|
||||||
|
old one still means a filing we might yet ingest, which is worth pausing for.
|
||||||
|
|
||||||
|
Public because the importer alerts on this exact transition (a CIK dropping
|
||||||
|
out of this set is a pause coming back on) and the rule must not exist twice.
|
||||||
|
"""
|
||||||
|
by_cik: dict[str, list[SecFilingGap]] = defaultdict(list)
|
||||||
|
for gap in gaps:
|
||||||
|
by_cik[gap.cik].append(gap)
|
||||||
|
escalated = {
|
||||||
|
cik
|
||||||
|
for cik, items in by_cik.items()
|
||||||
|
if all(gap.escalated_at is not None for gap in items)
|
||||||
|
}
|
||||||
|
if not escalated:
|
||||||
|
return set()
|
||||||
|
cutoff = (
|
||||||
|
datetime.now(timezone.utc) - timedelta(days=GAP_GATE_RECENT_FILING_DAYS)
|
||||||
|
).date()
|
||||||
|
rows = await db.execute(
|
||||||
|
select(FundamentalSnapshot.cik)
|
||||||
|
.where(
|
||||||
|
FundamentalSnapshot.cik.in_(escalated),
|
||||||
|
FundamentalSnapshot.form.in_(_SEC_FORMS),
|
||||||
|
FundamentalSnapshot.filed_date >= cutoff,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
return set(rows.scalars())
|
||||||
|
|
||||||
|
|
||||||
async def _latest_validation(db: AsyncSession) -> dict:
|
async def _latest_validation(db: AsyncSession) -> dict:
|
||||||
payload = (
|
payload = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -80,8 +135,12 @@ async def blocked_reasons_by_cik(
|
|||||||
if ciks is not None and not ciks:
|
if ciks is not None and not ciks:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
gaps = await active_gaps(db, ciks)
|
||||||
|
# Escalated gaps on issuers that still have recent fundamentals no longer
|
||||||
|
# pause setups, on either path below — the summary mirrors the same filings.
|
||||||
|
exempt = await gap_exempt_ciks(db, gaps)
|
||||||
reasons = {
|
reasons = {
|
||||||
gap.cik: "sec_filing_gap" for gap in await active_gaps(db, ciks)
|
gap.cik: "sec_filing_gap" for gap in gaps if gap.cik not in exempt
|
||||||
}
|
}
|
||||||
summary = await _latest_validation(db)
|
summary = await _latest_validation(db)
|
||||||
|
|
||||||
@@ -92,11 +151,11 @@ async def blocked_reasons_by_cik(
|
|||||||
# stay capped for audit readability. Detailed entries supply the reason.
|
# stay capped for audit readability. Detailed entries supply the reason.
|
||||||
for cik in summary.get("setup_blocked_ciks") or []:
|
for cik in summary.get("setup_blocked_ciks") or []:
|
||||||
normalized = str(cik) if cik else ""
|
normalized = str(cik) if cik else ""
|
||||||
if normalized and wanted(normalized):
|
if normalized and wanted(normalized) and normalized not in exempt:
|
||||||
reasons.setdefault(normalized, "sec_filing_gap")
|
reasons.setdefault(normalized, "sec_filing_gap")
|
||||||
for item in summary.get("missing_xbrl") or []:
|
for item in summary.get("missing_xbrl") or []:
|
||||||
normalized = str(item.get("cik") or "")
|
normalized = str(item.get("cik") or "")
|
||||||
if normalized and wanted(normalized):
|
if normalized and wanted(normalized) and normalized not in exempt:
|
||||||
reasons.setdefault(normalized, "sec_filing_gap")
|
reasons.setdefault(normalized, "sec_filing_gap")
|
||||||
for cik in summary.get("no_xbrl_ciks") or []:
|
for cik in summary.get("no_xbrl_ciks") or []:
|
||||||
normalized = str(cik) if cik else ""
|
normalized = str(cik) if cik else ""
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ two deliberately separate outputs:
|
|||||||
* Warning: deterioration/divergence that may precede State (breadth divergence,
|
* Warning: deterioration/divergence that may precede State (breadth divergence,
|
||||||
relative strength, credit impulse).
|
relative strength, credit impulse).
|
||||||
|
|
||||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
* Fundamental context: a categorical channel (supportive / neutral / adverse /
|
||||||
earnings-reaction observations are a qualitative *overlay* since v3 rather than
|
unknown) with an evidence-quality grade, derived by fixed rules from the
|
||||||
weighted sensors: at a combined 20 points they could not reach the event
|
sourced hyperscaler capex and earnings-reaction observations.
|
||||||
study's alarm threshold even when both pegged, so refreshing them appeared to
|
|
||||||
do nothing. They are reported next to the scores instead of inside them.
|
Both scores are quantitative and daily. The fundamental channel is deliberately
|
||||||
|
**not** a term in either: the three are read together by confluence, because
|
||||||
|
adding a slow categorical judgement to a fast continuous score manufactures
|
||||||
|
precision by summing unlike things, and any fusion weight would be a policy
|
||||||
|
preference presented as a measurement until there is enough point-in-time
|
||||||
|
history to fit one. A missing observation therefore stays ``unknown`` instead of
|
||||||
|
silently redistributing its weight onto the technical sensors.
|
||||||
|
|
||||||
Daily snapshots are the point-in-time record. The first run under a new
|
Daily snapshots are the point-in-time record. The first run under a new
|
||||||
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
||||||
@@ -35,6 +41,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.exceptions import ProviderError, ValidationError
|
from app.exceptions import ProviderError, ValidationError
|
||||||
|
from app.models.regime_fundamental_observation import RegimeFundamentalObservation
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.services import breadth_service, settings_store
|
from app.services import breadth_service, settings_store
|
||||||
@@ -55,7 +62,11 @@ METHODOLOGY = "v4"
|
|||||||
# against the *stored* blob, so omitting the current one discards the observation
|
# against the *stored* blob, so omitting the current one discards the observation
|
||||||
# on its first write, which leaves fetched_at null and locked false -- and then
|
# on its first write, which leaves fetched_at null and locked false -- and then
|
||||||
# update_regime_monitor refreshes it via the LLM on every single run, forever.
|
# update_regime_monitor refreshes it via the LLM on every single run, forever.
|
||||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
|
# "v5" is listed although no v5 scoring exists: a v5 was briefly built (a weighted
|
||||||
|
# fundamental modifier on Warning) and reverted, so a development box can have
|
||||||
|
# that string sitting in its settings blob. Keeping it costs nothing; omitting it
|
||||||
|
# costs the failure above.
|
||||||
|
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4", "v5"})
|
||||||
|
|
||||||
# Bumped when a fix changes what historical rows *should* contain without
|
# Bumped when a fix changes what historical rows *should* contain without
|
||||||
# changing the live formula, so stored history needs one reseed. Deliberately
|
# changing the live formula, so stored history needs one reseed. Deliberately
|
||||||
@@ -173,6 +184,34 @@ WARNING_WEIGHTS = {
|
|||||||
"credit_impulse": 25.0,
|
"credit_impulse": 25.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The sourced fundamental read is a **separate channel**, never a term in either
|
||||||
|
# score. It is reported as a categorical state beside State and Warning, and the
|
||||||
|
# three are read together by confluence rather than added up.
|
||||||
|
#
|
||||||
|
# Two things had to be true at once and only this shape gets both.
|
||||||
|
#
|
||||||
|
# **v3's reason for removing it was wrong.** v3 argued that F1+F3, at 12+8 of 100
|
||||||
|
# Warning points, "could not change any published conclusion" because pegged they
|
||||||
|
# produced a Warning of exactly 20.0. That holds only when every technical sensor
|
||||||
|
# reads exactly zero. Weighted, those points added +10 to +20 across the
|
||||||
|
# realistic range and moved the technical score needed to reach the 40 quadrant
|
||||||
|
# divider from 40 to 25. So the observation was not inert, and demoting it to
|
||||||
|
# decoration was not justified by that argument.
|
||||||
|
#
|
||||||
|
# **But no weight is measurable either.** A weighted modifier was built (v5,
|
||||||
|
# reverted) and its size could not be derived from anything: with ~10 correction
|
||||||
|
# events and essentially no fundamental history, any fusion weight is a policy
|
||||||
|
# preference presented as a measurement. Adding a slow categorical judgement to a
|
||||||
|
# fast continuous score also manufactures precision by summing unlike things, and
|
||||||
|
# it forces a missing observation to silently redistribute its weight onto the
|
||||||
|
# technical sensors -- the opposite of leaving it unknown.
|
||||||
|
#
|
||||||
|
# So the read gets a channel, not a coefficient. Revisit only with enough
|
||||||
|
# point-in-time history to test whether the state improves prediction
|
||||||
|
# *conditional on* Warning; a fitted model then has something to fit.
|
||||||
|
FUNDAMENTAL_STATES = ("supportive", "neutral", "adverse", "unknown")
|
||||||
|
EVIDENCE_QUALITY = ("complete", "partial", "stale", "manual", "unavailable")
|
||||||
|
|
||||||
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
|
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
|
||||||
# infrastructure, cloud, and enterprise-software names that the platform's
|
# infrastructure, cloud, and enterprise-software names that the platform's
|
||||||
# normal universe sync already stores.
|
# normal universe sync already stores.
|
||||||
@@ -196,7 +235,12 @@ DEFAULT_CONFIG: dict = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
|
CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
|
||||||
GNSD_STATES = ("yes", "no", "mixed")
|
# "mixed" is a genuinely observed mixed reaction; "unknown" is nobody looked or
|
||||||
|
# the extraction failed. They were the same value until 2026-08-13, so a failed
|
||||||
|
# LLM parse silently became neutral *evidence* -- an observation of normality
|
||||||
|
# manufactured out of a parse error. Same distinction the capex map already made
|
||||||
|
# with its own "unknown", and the same one the whole channel is built on.
|
||||||
|
GNSD_STATES = ("yes", "no", "mixed", "unknown")
|
||||||
# v2 scored raising and holding identically at 0, so in a capex boom the reading
|
# v2 scored raising and holding identically at 0, so in a capex boom the reading
|
||||||
# was pinned at 0 and could not express the raising -> holding deceleration that
|
# was pinned at 0 and could not express the raising -> holding deceleration that
|
||||||
# is the actual early warning. Display-only in v3, but it should still describe.
|
# is the actual early warning. Display-only in v3, but it should still describe.
|
||||||
@@ -435,6 +479,104 @@ def score_warning_sensors(sensors: dict[str, float | None]) -> float | None:
|
|||||||
return sum(s * w for s, w in live) / sum(w for _, w in live)
|
return sum(s * w for s, w in live) / sum(w for _, w in live)
|
||||||
|
|
||||||
|
|
||||||
|
def _capex_signal(capex: dict[str, str] | None, names: list[str]) -> str:
|
||||||
|
"""Categorical read of hyperscaler capex direction. Never an average.
|
||||||
|
|
||||||
|
Averaging is what this must not do: it would let two ``cutting`` reads and
|
||||||
|
two ``unknown`` ones land on "neutral", presenting missing evidence as
|
||||||
|
evidence of normality. Any cut is adverse on partial evidence; only a fully
|
||||||
|
known, uniformly rising basket is supportive.
|
||||||
|
"""
|
||||||
|
states = [str((capex or {}).get(name, "unknown")).strip().lower() for name in names]
|
||||||
|
known = [state for state in states if state in ("raising", "holding", "cutting")]
|
||||||
|
if not known:
|
||||||
|
return "unknown"
|
||||||
|
if "cutting" in known:
|
||||||
|
return "adverse"
|
||||||
|
if "holding" in known:
|
||||||
|
return "neutral"
|
||||||
|
return "supportive"
|
||||||
|
|
||||||
|
|
||||||
|
def _reaction_signal(good_news_stock_down: str | None) -> str:
|
||||||
|
"""Good earnings being sold is a late-cycle tell; not being sold is healthy.
|
||||||
|
|
||||||
|
Anything that is not one of the three observed categories -- including the
|
||||||
|
explicit ``"unknown"`` an extraction failure now writes -- falls through to
|
||||||
|
``unknown`` rather than to ``mixed``. A parse error is not a reading.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"yes": "adverse",
|
||||||
|
"no": "supportive",
|
||||||
|
"mixed": "neutral",
|
||||||
|
}.get(str(good_news_stock_down or "").strip().lower(), "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def combine_fundamental_signals(capex_signal: str, reaction_signal: str) -> str:
|
||||||
|
"""Confluence, not arithmetic: precedence over the two categorical reads.
|
||||||
|
|
||||||
|
``unknown`` is deliberately unreachable by combination -- it survives only
|
||||||
|
when *nothing* was observed. A single adverse read carries, because partial
|
||||||
|
evidence of deterioration is still evidence of deterioration; supportive
|
||||||
|
requires every observed signal to agree.
|
||||||
|
"""
|
||||||
|
signals = (capex_signal, reaction_signal)
|
||||||
|
if "adverse" in signals:
|
||||||
|
return "adverse"
|
||||||
|
observed = [signal for signal in signals if signal != "unknown"]
|
||||||
|
if not observed:
|
||||||
|
return "unknown"
|
||||||
|
return "supportive" if all(signal == "supportive" for signal in observed) else "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def _usable_context(observed: bool, pending: bool, stale: bool, state: str) -> bool:
|
||||||
|
"""Whether a fundamental reading may count as evidence.
|
||||||
|
|
||||||
|
One definition, called by both the point-in-time record and the live
|
||||||
|
reading, because they publish the same field name to the same consumers and
|
||||||
|
a second copy would drift. Distinct from `available`, which is about timing
|
||||||
|
alone: an observation whose extraction failed on everything is effective and
|
||||||
|
fresh, and still knows nothing.
|
||||||
|
"""
|
||||||
|
return observed and not pending and not stale and state != "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_quality(
|
||||||
|
capex: dict[str, str] | None,
|
||||||
|
good_news_stock_down: str | None,
|
||||||
|
names: list[str],
|
||||||
|
*,
|
||||||
|
observed: bool,
|
||||||
|
stale: bool,
|
||||||
|
source: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""How much to trust the state above, as one field the reader can act on.
|
||||||
|
|
||||||
|
Ordered by what an operator most needs to know: nothing collected beats
|
||||||
|
everything else, then a reading too old to be current, then a hand override,
|
||||||
|
then completeness.
|
||||||
|
"""
|
||||||
|
if not observed:
|
||||||
|
return "unavailable"
|
||||||
|
if stale:
|
||||||
|
return "stale"
|
||||||
|
if str(source or "").strip().lower() == "manual":
|
||||||
|
return "manual"
|
||||||
|
known = sum(
|
||||||
|
1
|
||||||
|
for name in names
|
||||||
|
if str((capex or {}).get(name, "unknown")).strip().lower() != "unknown"
|
||||||
|
)
|
||||||
|
# `bool(names)` matters: with an empty basket `known == len(names)` is
|
||||||
|
# vacuously true, so nothing observed would grade as complete.
|
||||||
|
complete = (
|
||||||
|
bool(names)
|
||||||
|
and known == len(names)
|
||||||
|
and _reaction_signal(good_news_stock_down) != "unknown"
|
||||||
|
)
|
||||||
|
return "complete" if complete else "partial"
|
||||||
|
|
||||||
|
|
||||||
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
|
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sensor_id,
|
"id": sensor_id,
|
||||||
@@ -572,26 +714,66 @@ def _overlay_timing(
|
|||||||
return effective, pending, age, stale
|
return effective, pending, age, stale
|
||||||
|
|
||||||
|
|
||||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
def fundamental_context(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
|
"""Point-in-time fundamental channel. Never a term in State or Warning.
|
||||||
|
|
||||||
|
Called an "overlay" until 2026-08-12, which undersold it: it is the third
|
||||||
|
channel of the model, read alongside the two scores by confluence rather than
|
||||||
|
decorating them. The categorical ``state`` is what a reader and the chart
|
||||||
|
consume; ``evidence_quality`` is how far to trust it.
|
||||||
|
|
||||||
|
Both are derived from the stored categorical facts by fixed rules, not from
|
||||||
|
an LLM's numeric judgement. The LLM's job is extraction and explanation --
|
||||||
|
find the capex guidance, classify it, cite it -- and the rules turn those
|
||||||
|
facts into a state, so the same observation always yields the same category.
|
||||||
|
|
||||||
The effective-date gate stays even though nothing is scored from this: the
|
The effective-date gate stays even though nothing is scored from this: the
|
||||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
rebuild replays historical dates, and stamping today's read onto 2024
|
||||||
onto 2024 snapshots would be plain lookahead in the stored record.
|
snapshots would be plain lookahead in the stored record.
|
||||||
|
|
||||||
This is the *record*. For "what do we know right now", use
|
This is the *record*. For "what do we know right now", use
|
||||||
``current_observation`` -- do not add a bypass flag here, because this runs
|
``current_observation`` -- do not add a bypass flag here, because this runs
|
||||||
for every replayed date during a rebuild.
|
for every replayed date during a rebuild.
|
||||||
"""
|
"""
|
||||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
|
names = list(config["tickers"]["hyperscalers"])
|
||||||
|
capex = None if pending else overrides.get("capex")
|
||||||
|
reaction = None if pending else overrides.get("good_news_stock_down")
|
||||||
|
observed = not pending and bool(overrides.get("fetched_at"))
|
||||||
|
|
||||||
|
capex_signal = _capex_signal(capex, names) if observed else "unknown"
|
||||||
|
reaction_signal = _reaction_signal(reaction) if observed else "unknown"
|
||||||
|
state = combine_fundamental_signals(capex_signal, reaction_signal)
|
||||||
return {
|
return {
|
||||||
|
"state": state,
|
||||||
|
"evidence_quality": _evidence_quality(
|
||||||
|
capex, reaction, names,
|
||||||
|
observed=observed, stale=stale, source=overrides.get("source"),
|
||||||
|
),
|
||||||
|
"capex_signal": capex_signal,
|
||||||
|
"reaction_signal": reaction_signal,
|
||||||
|
# Two different questions, and conflating them is a trap:
|
||||||
|
#
|
||||||
|
# `available` is about *timing* -- there is an effective, non-stale record
|
||||||
|
# to display. `usable` is about *content* -- it also actually says
|
||||||
|
# something. A collected observation whose extraction failed on every
|
||||||
|
# hyperscaler is available (show it, with its date) but not usable: it
|
||||||
|
# knows nothing, so it must never count as evidence.
|
||||||
|
#
|
||||||
|
# The distinction is load-bearing for the event study. Coverage is
|
||||||
|
# measured in sessions with usable context, and if repeated extraction
|
||||||
|
# failures counted, they would slowly accumulate "exposure" until the
|
||||||
|
# fundamental rows flipped to measurable 0/8 -- a failed result reported
|
||||||
|
# for a channel that never knew anything, which is the exact confusion
|
||||||
|
# coverage-matching exists to prevent.
|
||||||
"available": not pending and not stale,
|
"available": not pending and not stale,
|
||||||
|
"usable": _usable_context(observed, pending, stale, state),
|
||||||
"pending": pending,
|
"pending": pending,
|
||||||
"stale": stale,
|
"stale": stale,
|
||||||
"effective_date": effective.isoformat() if effective else None,
|
"effective_date": effective.isoformat() if effective else None,
|
||||||
"age_days": age,
|
"age_days": age,
|
||||||
"capex": None if pending else overrides.get("capex"),
|
"capex": capex,
|
||||||
"good_news_stock_down": None if pending else overrides.get("good_news_stock_down"),
|
"good_news_stock_down": reaction,
|
||||||
"capex_stress": None if pending else overrides.get("f1_score"),
|
"capex_stress": None if pending else overrides.get("f1_score"),
|
||||||
"earnings_stress": None if pending else overrides.get("f3_score"),
|
"earnings_stress": None if pending else overrides.get("f3_score"),
|
||||||
"reasoning": None if pending else overrides.get("reasoning"),
|
"reasoning": None if pending else overrides.get("reasoning"),
|
||||||
@@ -603,7 +785,7 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
|||||||
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""The observation as it stands now, for the live reading only.
|
"""The observation as it stands now, for the live reading only.
|
||||||
|
|
||||||
Same shape as ``fundamental_overlay``, but the effective date is *reported*
|
Same shape as ``fundamental_context``, but the effective date is *reported*
|
||||||
rather than used to blank the content. A refresh stamps
|
rather than used to blank the content. A refresh stamps
|
||||||
``_next_weekday(today)``, so gating the live card hid a just-collected read
|
``_next_weekday(today)``, so gating the live card hid a just-collected read
|
||||||
for one day -- three over a weekend -- and refreshing appeared to do
|
for one day -- three over a weekend -- and refreshing appeared to do
|
||||||
@@ -611,14 +793,36 @@ def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
|
|||||||
published number; the stored snapshot keeps the gate.
|
published number; the stored snapshot keeps the gate.
|
||||||
"""
|
"""
|
||||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||||
# The default override carries "unknown"/"mixed" placeholders for every
|
# The default override carries "unknown" placeholders for every
|
||||||
# hyperscaler. Those are the absence of an observation, not an observation
|
# hyperscaler. Those are the absence of an observation, not an observation
|
||||||
# of absence, and must never be presented as collected. ``fetched_at`` is
|
# of absence, and must never be presented as collected. ``fetched_at`` is
|
||||||
# the collection timestamp and is the only field written on every path that
|
# the collection timestamp and is the only field written on every path that
|
||||||
# produces real content (LLM refresh and manual save both stamp it).
|
# produces real content (LLM refresh and manual save both stamp it).
|
||||||
observed = bool(overrides.get("fetched_at"))
|
observed = bool(overrides.get("fetched_at"))
|
||||||
|
names = list(config["tickers"]["hyperscalers"])
|
||||||
|
capex_signal = _capex_signal(overrides.get("capex"), names) if observed else "unknown"
|
||||||
|
reaction_signal = (
|
||||||
|
_reaction_signal(overrides.get("good_news_stock_down")) if observed else "unknown"
|
||||||
|
)
|
||||||
|
state = combine_fundamental_signals(capex_signal, reaction_signal)
|
||||||
return {
|
return {
|
||||||
"observed": observed,
|
"observed": observed,
|
||||||
|
"state": state,
|
||||||
|
"evidence_quality": _evidence_quality(
|
||||||
|
overrides.get("capex"), overrides.get("good_news_stock_down"), names,
|
||||||
|
observed=observed, stale=stale, source=overrides.get("source"),
|
||||||
|
),
|
||||||
|
"capex_signal": capex_signal,
|
||||||
|
"reaction_signal": reaction_signal,
|
||||||
|
# Same shape as the record means the same *fields*, not just the same
|
||||||
|
# ones this function happens to need: the frontend types both payloads
|
||||||
|
# identically, so an omission here is an undefined at runtime that
|
||||||
|
# TypeScript cannot catch across a trusted server boundary.
|
||||||
|
#
|
||||||
|
# Note this is stricter than the `available` directly below: a pending
|
||||||
|
# observation is the freshest thing we have and worth showing, but it is
|
||||||
|
# not yet in force, so it is not yet evidence.
|
||||||
|
"usable": _usable_context(observed, pending, stale, state),
|
||||||
# Live availability is about usefulness, not effectiveness: a pending
|
# Live availability is about usefulness, not effectiveness: a pending
|
||||||
# observation is the freshest thing we have -- but nothing collected is
|
# observation is the freshest thing we have -- but nothing collected is
|
||||||
# never available.
|
# never available.
|
||||||
@@ -656,8 +860,16 @@ def _compute_index(
|
|||||||
breadth_series: Series | None = None,
|
breadth_series: Series | None = None,
|
||||||
divergence_series: Series | None = None,
|
divergence_series: Series | None = None,
|
||||||
breadth_counts: dict[date, int] | None = None,
|
breadth_counts: dict[date, int] | None = None,
|
||||||
|
observations: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Compute the complete State/Warning snapshot as of one trading date."""
|
"""Compute the complete State/Warning snapshot as of one trading date.
|
||||||
|
|
||||||
|
``observations`` is the point-in-time fundamental series and is authoritative
|
||||||
|
when supplied; ``overrides`` is the single-slot fallback for callers that
|
||||||
|
predate the table (the calibration harness). Either way the reading is scored
|
||||||
|
into the same ``fundamental_context`` -- only where it is read from differs,
|
||||||
|
so the live monitor and the event study cannot report different states.
|
||||||
|
"""
|
||||||
tickers = config["tickers"]
|
tickers = config["tickers"]
|
||||||
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
||||||
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
||||||
@@ -682,7 +894,10 @@ def _compute_index(
|
|||||||
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
|
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
|
||||||
relative_strength = sensors["relative_strength"]
|
relative_strength = sensors["relative_strength"]
|
||||||
credit_impulse = sensors["credit_impulse"]
|
credit_impulse = sensors["credit_impulse"]
|
||||||
overlay = fundamental_overlay(overrides, config, as_of)
|
observation = (
|
||||||
|
observation_asof(observations, as_of) if observations is not None else overrides
|
||||||
|
) or {}
|
||||||
|
context = fundamental_context(observation, config, as_of)
|
||||||
|
|
||||||
state_pillars = [
|
state_pillars = [
|
||||||
{
|
{
|
||||||
@@ -768,7 +983,7 @@ def _compute_index(
|
|||||||
"date": as_of.isoformat(),
|
"date": as_of.isoformat(),
|
||||||
"state": state,
|
"state": state,
|
||||||
"warning": warning,
|
"warning": warning,
|
||||||
"fundamental_overlay": overlay,
|
"fundamental_context": context,
|
||||||
"quadrant_config": {
|
"quadrant_config": {
|
||||||
"state_divider": QUADRANT_STATE_DIVIDER,
|
"state_divider": QUADRANT_STATE_DIVIDER,
|
||||||
"warning_divider": QUADRANT_WARNING_DIVIDER,
|
"warning_divider": QUADRANT_WARNING_DIVIDER,
|
||||||
@@ -790,8 +1005,8 @@ def _compute_index(
|
|||||||
"breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None,
|
"breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None,
|
||||||
"breadth_date": breadth_item[0].isoformat() if breadth_item else None,
|
"breadth_date": breadth_item[0].isoformat() if breadth_item else None,
|
||||||
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
||||||
"fundamentals_effective_date": overlay.get("effective_date"),
|
"fundamentals_effective_date": context.get("effective_date"),
|
||||||
"fundamentals_age_days": overlay.get("age_days"),
|
"fundamentals_age_days": context.get("age_days"),
|
||||||
},
|
},
|
||||||
"data_quality": {
|
"data_quality": {
|
||||||
"minimum_coverage": MIN_COVERAGE,
|
"minimum_coverage": MIN_COVERAGE,
|
||||||
@@ -859,7 +1074,7 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
|
|||||||
"f1_score": None,
|
"f1_score": None,
|
||||||
"f3_score": None,
|
"f3_score": None,
|
||||||
"capex": {name: "unknown" for name in names},
|
"capex": {name: "unknown" for name in names},
|
||||||
"good_news_stock_down": "mixed",
|
"good_news_stock_down": "unknown",
|
||||||
"locked": False,
|
"locked": False,
|
||||||
"reasoning": None,
|
"reasoning": None,
|
||||||
"fetched_at": None,
|
"fetched_at": None,
|
||||||
@@ -880,9 +1095,9 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
|
|||||||
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
|
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
|
||||||
return default
|
return default
|
||||||
capex = _normalise_capex_states(stored.get("capex"), names)
|
capex = _normalise_capex_states(stored.get("capex"), names)
|
||||||
reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower()
|
reaction = str(stored.get("good_news_stock_down", "unknown")).strip().lower()
|
||||||
if reaction not in GNSD_STATES:
|
if reaction not in GNSD_STATES:
|
||||||
reaction = "mixed"
|
reaction = "unknown"
|
||||||
return {
|
return {
|
||||||
**default,
|
**default,
|
||||||
**stored,
|
**stored,
|
||||||
@@ -922,6 +1137,100 @@ def _score_capex_states(capex: dict[str, str], names: list[str]) -> float | None
|
|||||||
return round(score, 1) if score is not None else None
|
return round(score, 1) if score is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def record_fundamental_observation(db: AsyncSession, observation: dict) -> None:
|
||||||
|
"""Append the observation to the point-in-time series, keyed on effective date.
|
||||||
|
|
||||||
|
Upsert rather than insert: re-saving on the same effective date is a
|
||||||
|
correction to that day's reading, not a second observation of it.
|
||||||
|
|
||||||
|
Silently does nothing without an effective date or a ``fetched_at``. Those
|
||||||
|
are the default placeholder blob -- the absence of an observation, which must
|
||||||
|
never enter the series as though someone had looked.
|
||||||
|
|
||||||
|
Deliberately does **not** commit. ``update_regime_monitor`` calls this inside
|
||||||
|
a run that owns its transaction and commits once after the snapshot loop;
|
||||||
|
committing here would take that boundary away from it. The two override
|
||||||
|
writers commit for themselves.
|
||||||
|
"""
|
||||||
|
effective = _parse_date(observation.get("effective_date"))
|
||||||
|
fetched_raw = observation.get("fetched_at")
|
||||||
|
if effective is None or not fetched_raw:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
fetched = datetime.fromisoformat(str(fetched_raw))
|
||||||
|
except ValueError:
|
||||||
|
fetched = datetime.now(timezone.utc)
|
||||||
|
if fetched.tzinfo is None:
|
||||||
|
fetched = fetched.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
existing = await db.execute(
|
||||||
|
select(RegimeFundamentalObservation).where(
|
||||||
|
RegimeFundamentalObservation.effective_date == effective
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = existing.scalar_one_or_none()
|
||||||
|
payload = {
|
||||||
|
"f1_score": observation.get("f1_score"),
|
||||||
|
"f3_score": observation.get("f3_score"),
|
||||||
|
"capex_json": json.dumps(observation.get("capex") or {}),
|
||||||
|
"good_news_stock_down": str(observation.get("good_news_stock_down") or "unknown")[:10],
|
||||||
|
"reasoning": observation.get("reasoning"),
|
||||||
|
"source": str(observation.get("source") or "unknown")[:30],
|
||||||
|
"fetched_at": fetched,
|
||||||
|
}
|
||||||
|
if row is None:
|
||||||
|
db.add(RegimeFundamentalObservation(
|
||||||
|
effective_date=effective,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
**payload,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
for key, value in payload.items():
|
||||||
|
setattr(row, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fundamental_observations(db: AsyncSession) -> list[dict]:
|
||||||
|
"""The whole observation series, oldest first, for point-in-time scoring."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RegimeFundamentalObservation).order_by(
|
||||||
|
RegimeFundamentalObservation.effective_date.asc()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
for row in result.scalars().all():
|
||||||
|
try:
|
||||||
|
capex = json.loads(row.capex_json)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
capex = {}
|
||||||
|
out.append({
|
||||||
|
"effective_date": row.effective_date,
|
||||||
|
"f1_score": row.f1_score,
|
||||||
|
"f3_score": row.f3_score,
|
||||||
|
"capex": capex,
|
||||||
|
"good_news_stock_down": row.good_news_stock_down,
|
||||||
|
"reasoning": row.reasoning,
|
||||||
|
"source": row.source,
|
||||||
|
"fetched_at": row.fetched_at.isoformat() if row.fetched_at else None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def observation_asof(observations: list[dict] | None, as_of: date) -> dict | None:
|
||||||
|
"""Latest observation effective on or before ``as_of``.
|
||||||
|
|
||||||
|
This *is* the effective-date gate now. The settings-blob version had to
|
||||||
|
recompute it per call because there was only ever one observation to gate;
|
||||||
|
with a series, "which reading was live that day" is just a lookup.
|
||||||
|
"""
|
||||||
|
chosen: dict | None = None
|
||||||
|
for observation in observations or []:
|
||||||
|
if observation["effective_date"] <= as_of:
|
||||||
|
chosen = observation
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
async def set_fundamental_overrides(
|
async def set_fundamental_overrides(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
capex: dict[str, str] | None = None,
|
capex: dict[str, str] | None = None,
|
||||||
@@ -954,7 +1263,15 @@ async def set_fundamental_overrides(
|
|||||||
"fetched_at": now.isoformat(),
|
"fetched_at": now.isoformat(),
|
||||||
"effective_date": _next_weekday(now.date()).isoformat(),
|
"effective_date": _next_weekday(now.date()).isoformat(),
|
||||||
})
|
})
|
||||||
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(current))
|
# The blob (what the live card reads) and the series row (what the
|
||||||
|
# point-in-time replay reads) are the same observation. Committed together:
|
||||||
|
# `update_setting` commits internally, so using it here would leave a window
|
||||||
|
# where a failure publishes the reading to the card but not to the record,
|
||||||
|
# and the two would disagree permanently with nothing to detect it.
|
||||||
|
await settings_store.upsert_setting(db, KEY_FUNDAMENTALS, json.dumps(current))
|
||||||
|
if observation_changed:
|
||||||
|
await record_fundamental_observation(db, current)
|
||||||
|
await db.commit()
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
|
||||||
@@ -1058,12 +1375,59 @@ def _snapshot_revision(snapshot: dict) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _context_from_legacy_overlay(overlay: dict) -> dict:
|
||||||
|
"""Rebuild the categorical channel from a pre-rename snapshot's overlay.
|
||||||
|
|
||||||
|
The channel was called ``fundamental_overlay`` until 2026-08-12 and stored
|
||||||
|
the same underlying facts -- the capex map, the earnings reaction, the
|
||||||
|
effective date. The rename shipped without a methodology bump (no score
|
||||||
|
changed), so those rows are still served and were never reseeded: reading
|
||||||
|
only the new key would turn every one of them into ``unknown`` and silently
|
||||||
|
discard real recorded evidence -- historical Path colours, and any exposure
|
||||||
|
the event study could legitimately count.
|
||||||
|
|
||||||
|
Derived, not guessed. The hyperscaler list comes from the overlay's own
|
||||||
|
capex keys, which is exactly the basket that was observed at the time rather
|
||||||
|
than today's configured one.
|
||||||
|
"""
|
||||||
|
capex = overlay.get("capex") or {}
|
||||||
|
reaction = overlay.get("good_news_stock_down")
|
||||||
|
names = list(capex)
|
||||||
|
pending = bool(overlay.get("pending"))
|
||||||
|
stale = bool(overlay.get("stale"))
|
||||||
|
observed = not pending and bool(overlay.get("fetched_at"))
|
||||||
|
|
||||||
|
capex_signal = _capex_signal(capex, names) if observed else "unknown"
|
||||||
|
reaction_signal = _reaction_signal(reaction) if observed else "unknown"
|
||||||
|
state = combine_fundamental_signals(capex_signal, reaction_signal)
|
||||||
|
return {
|
||||||
|
**overlay,
|
||||||
|
"state": state,
|
||||||
|
"evidence_quality": _evidence_quality(
|
||||||
|
capex, reaction, names,
|
||||||
|
observed=observed, stale=stale, source=overlay.get("source"),
|
||||||
|
),
|
||||||
|
"capex_signal": capex_signal,
|
||||||
|
"reaction_signal": reaction_signal,
|
||||||
|
"usable": _usable_context(observed, pending, stale, state),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_snapshot(raw: str) -> dict | None:
|
def _parse_snapshot(raw: str) -> dict | None:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return parsed if parsed.get("methodology") == METHODOLOGY else None
|
if parsed.get("methodology") != METHODOLOGY:
|
||||||
|
return None
|
||||||
|
# Normalise here rather than at each call site: every reader of a stored
|
||||||
|
# snapshot goes through this function, so a legacy row cannot reach one of
|
||||||
|
# them un-adapted.
|
||||||
|
if "fundamental_context" not in parsed and "fundamental_overlay" in parsed:
|
||||||
|
parsed["fundamental_context"] = _context_from_legacy_overlay(
|
||||||
|
parsed["fundamental_overlay"] or {}
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
|
async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
|
||||||
@@ -1082,6 +1446,10 @@ async def update_regime_monitor(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
|
# Carries the pre-v5 single-slot observation into the series on first run, so
|
||||||
|
# a deployment does not lose the live reading. A no-op once recorded, and a
|
||||||
|
# no-op for the placeholder blob (no fetched_at).
|
||||||
|
await record_fundamental_observation(db, overrides)
|
||||||
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
|
||||||
try:
|
try:
|
||||||
overrides = await refresh_fundamental_overrides(db, config=config)
|
overrides = await refresh_fundamental_overrides(db, config=config)
|
||||||
@@ -1131,6 +1499,9 @@ async def update_regime_monitor(
|
|||||||
|
|
||||||
breadth_series = _mapping_series(breadth)
|
breadth_series = _mapping_series(breadth)
|
||||||
divergence_series = _mapping_series(divergence)
|
divergence_series = _mapping_series(divergence)
|
||||||
|
# Loaded once, after any refresh, so a reseed scores each replayed date with
|
||||||
|
# the observation that was effective on it rather than with today's.
|
||||||
|
observations = await get_fundamental_observations(db)
|
||||||
latest_result: dict | None = None
|
latest_result: dict | None = None
|
||||||
snapshots_written = 0
|
snapshots_written = 0
|
||||||
for snapshot_date in dates:
|
for snapshot_date in dates:
|
||||||
@@ -1144,6 +1515,7 @@ async def update_regime_monitor(
|
|||||||
breadth_series,
|
breadth_series,
|
||||||
divergence_series,
|
divergence_series,
|
||||||
breadth_counts,
|
breadth_counts,
|
||||||
|
observations=observations,
|
||||||
)
|
)
|
||||||
written, latest_result = await _upsert_snapshot(
|
written, latest_result = await _upsert_snapshot(
|
||||||
db,
|
db,
|
||||||
@@ -1221,16 +1593,22 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
|||||||
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
|
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
|
||||||
result["data_quality"] = quality
|
result["data_quality"] = quality
|
||||||
|
|
||||||
# The snapshot's overlay is the point-in-time record; the reader also wants
|
# The snapshot's `fundamental_context` is the point-in-time record; the
|
||||||
# the current observation even when it is not effective until the next
|
# reader also wants the current observation even when it is not effective
|
||||||
# session, because otherwise refreshing it looks like it did nothing.
|
# until the next session, or refreshing it looks like it did nothing.
|
||||||
config = await get_regime_config(db)
|
config = await get_regime_config(db)
|
||||||
overrides = await get_fundamental_overrides(db)
|
overrides = await get_fundamental_overrides(db)
|
||||||
live = current_observation(overrides, config, date.today())
|
live = current_observation(overrides, config, date.today())
|
||||||
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
|
# Deliberately reads the *snapshot's* record, not the live one: this is how
|
||||||
# the reader tells "shown here" from "in the stored record".
|
# the reader tells "shown here" from "in the stored record".
|
||||||
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
|
live["observed_in_snapshot"] = bool(
|
||||||
result["fundamental_context"] = live
|
(result.get("fundamental_context") or {}).get("available")
|
||||||
|
)
|
||||||
|
# `fundamental_context` is the stored channel and stays the snapshot's;
|
||||||
|
# `fundamental_live` is what we know right now. Collapsing the two under one
|
||||||
|
# key is what made a just-collected observation look like it had been
|
||||||
|
# backdated into history.
|
||||||
|
result["fundamental_live"] = live
|
||||||
result["available"] = True
|
result["available"] = True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1248,10 +1626,17 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
|
|||||||
if data is None:
|
if data is None:
|
||||||
continue
|
continue
|
||||||
state, warning = data.get("state") or {}, data.get("warning") or {}
|
state, warning = data.get("state") or {}, data.get("warning") or {}
|
||||||
|
context = data.get("fundamental_context") or {}
|
||||||
out.append({
|
out.append({
|
||||||
"date": row.date.isoformat(),
|
"date": row.date.isoformat(),
|
||||||
"state": state.get("score") if state.get("band") is not None else None,
|
"state": state.get("score") if state.get("band") is not None else None,
|
||||||
"warning": warning.get("score") if warning.get("band") is not None else None,
|
"warning": warning.get("score") if warning.get("band") is not None else None,
|
||||||
|
# The third channel, carried per point so the Path view can colour a
|
||||||
|
# dot by the fundamental context that was on the record that day.
|
||||||
|
# Rows written before the channel existed carry nothing, which reads
|
||||||
|
# as "unknown" -- correct, since nothing was observed then either.
|
||||||
|
"fundamental_state": context.get("state") or "unknown",
|
||||||
|
"evidence_quality": context.get("evidence_quality") or "unavailable",
|
||||||
"state_coverage": state.get("coverage"),
|
"state_coverage": state.get("coverage"),
|
||||||
"warning_coverage": warning.get("coverage"),
|
"warning_coverage": warning.get("coverage"),
|
||||||
"basket_hash": (data.get("basket") or {}).get("hash"),
|
"basket_hash": (data.get("basket") or {}).get("hash"),
|
||||||
@@ -1383,7 +1768,7 @@ async def refresh_fundamental_overrides(
|
|||||||
f1 = _score_capex_states(capex, names)
|
f1 = _score_capex_states(capex, names)
|
||||||
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
|
reaction = str(parsed.get("good_news_stock_down", "")).strip().lower()
|
||||||
if reaction not in GNSD_STATES:
|
if reaction not in GNSD_STATES:
|
||||||
reaction = "mixed"
|
reaction = "unknown"
|
||||||
f3 = _GNSD_SCORES.get(reaction)
|
f3 = _GNSD_SCORES.get(reaction)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = {
|
result = {
|
||||||
@@ -1398,7 +1783,11 @@ async def refresh_fundamental_overrides(
|
|||||||
"locked": False,
|
"locked": False,
|
||||||
"source": llm.get("provider"),
|
"source": llm.get("provider"),
|
||||||
}
|
}
|
||||||
await update_setting(db, KEY_FUNDAMENTALS, json.dumps(result))
|
# One transaction: see set_fundamental_overrides on why these two writes must
|
||||||
|
# not be able to land separately.
|
||||||
|
await settings_store.upsert_setting(db, KEY_FUNDAMENTALS, json.dumps(result))
|
||||||
|
await record_fundamental_observation(db, result)
|
||||||
|
await db.commit()
|
||||||
logger.info(json.dumps({
|
logger.info(json.dumps({
|
||||||
"event": "regime_fundamentals_refreshed",
|
"event": "regime_fundamentals_refreshed",
|
||||||
"f1": result["f1_score"],
|
"f1": result["f1_score"],
|
||||||
|
|||||||
@@ -113,8 +113,41 @@ _WEIGHTED_AVG_SHARE_CONCEPTS = [
|
|||||||
# us-gaap instant (balance-sheet) concepts, at end == reportDate.
|
# us-gaap instant (balance-sheet) concepts, at end == reportDate.
|
||||||
_CASH = ["CashAndCashEquivalentsAtCarryingValue"]
|
_CASH = ["CashAndCashEquivalentsAtCarryingValue"]
|
||||||
_ST_INVESTMENTS = ["ShortTermInvestments", "MarketableSecuritiesCurrent"] # pick one
|
_ST_INVESTMENTS = ["ShortTermInvestments", "MarketableSecuritiesCurrent"] # pick one
|
||||||
|
# Debt is tagged in four mutually exclusive styles across large filers, and
|
||||||
|
# composing a total means knowing which span each concept covers (measured
|
||||||
|
# 2026-08 over a 20-issuer sample; the counts below are from it).
|
||||||
|
#
|
||||||
|
# ``LongTermDebt`` already spans current + noncurrent maturities — Apple tags all
|
||||||
|
# three and 71.34bn + 11.01bn = 82.30bn confirms it — so its complement is only
|
||||||
|
# genuinely short-term borrowing.
|
||||||
_LONG_TERM_DEBT_AGG = ["LongTermDebt"]
|
_LONG_TERM_DEBT_AGG = ["LongTermDebt"]
|
||||||
_LONG_TERM_DEBT_PARTS = ["LongTermDebtNoncurrent", "LongTermDebtCurrent"]
|
# Noncurrent-only balance-sheet lines, needing a current complement added.
|
||||||
|
# ``LongTermDebtAndCapitalLeaseObligations`` is what KO, HD, T, XOM and CVX tag
|
||||||
|
# and nothing read it before: AT&T reported no total_debt at all against 134bn
|
||||||
|
# tagged, and Coca-Cola reported 0.25bn of commercial paper against 39bn.
|
||||||
|
_LONG_TERM_DEBT_NONCURRENT = [
|
||||||
|
"LongTermDebtNoncurrent",
|
||||||
|
"LongTermDebtAndCapitalLeaseObligations",
|
||||||
|
]
|
||||||
|
_LONG_TERM_DEBT_CURRENT = ["LongTermDebtCurrent"]
|
||||||
|
# REITs that tag no aggregate at all, carrying a secured and an unsecured side
|
||||||
|
# instead. Both sides are required, because ``NotesPayable`` does not mean the
|
||||||
|
# same thing across issuers (measured 2026-08 over 14 REITs):
|
||||||
|
# - MAA tags NotesPayable 5.66bn = UnsecuredDebt 5.30bn + SecuredDebt 0.36bn
|
||||||
|
# exactly, so there it IS the total and adding SecuredDebt double-counts.
|
||||||
|
# - EQR/VMRK tags NotesPayable alongside a *larger* SecuredDebt (5.38bn vs
|
||||||
|
# 6.38bn in 2013), so there it is only the unsecured component.
|
||||||
|
# ``UnsecuredDebt`` is what separates them: where it is tagged it is the
|
||||||
|
# unambiguous unsecured side and NotesPayable is ignored; where it is absent,
|
||||||
|
# NotesPayable is that side. Requiring both sides is also what keeps this branch
|
||||||
|
# from inventing a total out of a fragment — Boston Properties tags SecuredDebt
|
||||||
|
# 4.28bn and nothing else against ~15bn of real debt, and Regency tags an
|
||||||
|
# UnsecuredDebt of 0.03bn that is a credit-line draw, not its 5bn of notes.
|
||||||
|
_SECURED_DEBT = ["SecuredDebt"]
|
||||||
|
_UNSECURED_DEBT = ["UnsecuredDebt", "NotesPayable"] # first present wins
|
||||||
|
# ``DebtCurrent`` spans short-term borrowing AND current maturities, so it is the
|
||||||
|
# whole current complement where present and must never be added alongside them.
|
||||||
|
_ALL_CURRENT_DEBT = ["DebtCurrent"]
|
||||||
_SHORT_TERM_DEBT = ["ShortTermBorrowings", "CommercialPaper"] # pick one
|
_SHORT_TERM_DEBT = ["ShortTermBorrowings", "CommercialPaper"] # pick one
|
||||||
|
|
||||||
|
|
||||||
@@ -459,15 +492,35 @@ def _compose_cash(facts: list[Fact], report_date: date) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _compose_debt(facts: list[Fact], report_date: date) -> float | None:
|
def _compose_debt(facts: list[Fact], report_date: date) -> float | None:
|
||||||
long_term = _select_instant(facts, _LONG_TERM_DEBT_AGG, report_date)
|
"""Total debt at ``report_date``, or None when no long-term component is found.
|
||||||
if long_term is None:
|
|
||||||
nc = _select_instant(facts, ["LongTermDebtNoncurrent"], report_date)
|
**A short-term component alone is never a total.** Chevron tags its full debt
|
||||||
cur = _select_instant(facts, ["LongTermDebtCurrent"], report_date)
|
only in the 10-K, so its 10-Q carries ``ShortTermBorrowings`` of 0.40bn and
|
||||||
long_term = None if nc is None and cur is None else (nc or 0.0) + (cur or 0.0)
|
nothing else; returning that as total debt reads as a near-unlevered issuer
|
||||||
short_term = _select_instant(facts, _SHORT_TERM_DEBT, report_date)
|
carrying 50bn. Since ``_net_debt`` needs both sides and yields nothing when
|
||||||
if long_term is None and short_term is None:
|
either is missing, None costs a leverage read while the partial value
|
||||||
return None
|
produces a confidently wrong one.
|
||||||
return (long_term or 0.0) + (short_term or 0.0)
|
"""
|
||||||
|
# An aggregate spanning current + noncurrent: only true short-term is missing.
|
||||||
|
total = _select_instant(facts, _LONG_TERM_DEBT_AGG, report_date)
|
||||||
|
if total is not None:
|
||||||
|
return total + (_select_instant(facts, _SHORT_TERM_DEBT, report_date) or 0.0)
|
||||||
|
|
||||||
|
noncurrent = _select_instant(facts, _LONG_TERM_DEBT_NONCURRENT, report_date)
|
||||||
|
if noncurrent is None:
|
||||||
|
secured = _select_instant(facts, _SECURED_DEBT, report_date)
|
||||||
|
unsecured = _select_instant(facts, _UNSECURED_DEBT, report_date)
|
||||||
|
if secured is None or unsecured is None:
|
||||||
|
return None # one side of a REIT's debt is not its total
|
||||||
|
noncurrent = secured + unsecured
|
||||||
|
|
||||||
|
current = _select_instant(facts, _ALL_CURRENT_DEBT, report_date)
|
||||||
|
if current is None:
|
||||||
|
current = (
|
||||||
|
(_select_instant(facts, _LONG_TERM_DEBT_CURRENT, report_date) or 0.0)
|
||||||
|
+ (_select_instant(facts, _SHORT_TERM_DEBT, report_date) or 0.0)
|
||||||
|
)
|
||||||
|
return noncurrent + current
|
||||||
|
|
||||||
|
|
||||||
def _select_shares(
|
def _select_shares(
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ Guardrails (design + reviews):
|
|||||||
excluded from actionable setups until its filing is recovered.
|
excluded from actionable setups until its filing is recovered.
|
||||||
- ``promote`` inserts snapshots ``ON CONFLICT (accession) DO NOTHING`` (immutable),
|
- ``promote`` inserts snapshots ``ON CONFLICT (accession) DO NOTHING`` (immutable),
|
||||||
reports differing existing accessions, and applies ticker updates in the same
|
reports differing existing accessions, and applies ticker updates in the same
|
||||||
transaction.
|
transaction. A difference in ``cik`` **alone** is reported separately as an
|
||||||
|
``accession_cik_collision``: every fact matched, so two tracked CIKs are
|
||||||
|
claiming one filing and the fix is the universe, not the parser. It never
|
||||||
|
self-heals on its own — the losing CIK stores no row, so it is backfilled and
|
||||||
|
re-reported every run until its ticker is re-pointed or retired.
|
||||||
- ``reparse=True`` is the one exception to immutability, and it is deliberate:
|
- ``reparse=True`` is the one exception to immutability, and it is deliberate:
|
||||||
it restages every accession with the current parser and **rewrites** the rows
|
it restages every accession with the current parser and **rewrites** the rows
|
||||||
that now reconstruct differently. Immutability protects SEC's record (one row
|
that now reconstruct differently. Immutability protects SEC's record (one row
|
||||||
@@ -67,7 +71,7 @@ from app.services import sec_universe
|
|||||||
from app.services.data_import import STATUS_PROMOTED, ValidationResult
|
from app.services.data_import import STATUS_PROMOTED, ValidationResult
|
||||||
from app.services.sec_client import SecClient, SecError, cik10
|
from app.services.sec_client import SecClient, SecError, cik10
|
||||||
from app.services.sec_facts_parser import FilingMeta, SnapshotRow
|
from app.services.sec_facts_parser import FilingMeta, SnapshotRow
|
||||||
from app.services.sec_universe import ResolvedUniverse
|
from app.services.sec_universe import CIK_OVERRIDES_KEY, ResolvedUniverse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -281,7 +285,18 @@ class SecFundamentalsImporter:
|
|||||||
if old is not None:
|
if old is not None:
|
||||||
fields = _diff_fields(row, old)
|
fields = _diff_fields(row, old)
|
||||||
if fields:
|
if fields:
|
||||||
staged.discrepancies.append({"accession": row.accession, "fields": fields})
|
# Carry both CIKs. promote() reads a bare ["cik"] as an
|
||||||
|
# attribution collision rather than a changed
|
||||||
|
# reconstruction, which holds only because _COMPARE_COLS
|
||||||
|
# spans every stored fact: a fact column added to the
|
||||||
|
# model but not to _SNAPSHOT_COLS would go uncompared and
|
||||||
|
# let a real difference through as a collision.
|
||||||
|
staged.discrepancies.append({
|
||||||
|
"accession": row.accession,
|
||||||
|
"fields": fields,
|
||||||
|
"cik": row.cik,
|
||||||
|
"stored_cik": old.cik,
|
||||||
|
})
|
||||||
return staged
|
return staged
|
||||||
|
|
||||||
async def _stage_issuer(
|
async def _stage_issuer(
|
||||||
@@ -555,8 +570,15 @@ class SecFundamentalsImporter:
|
|||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
# Only accessions whose reconstruction actually changed are rewritten;
|
# Only accessions whose reconstruction actually changed are rewritten;
|
||||||
# an unchanged stored row is left completely alone.
|
# an unchanged stored row is left completely alone. A cik-only difference
|
||||||
changed = {d["accession"] for d in staged.discrepancies} if self.reparse else set()
|
# is excluded on purpose: the facts are identical there, so rewriting
|
||||||
|
# would re-stamp the filing onto the colliding co-registrant — taking it
|
||||||
|
# from the issuer that actually filed it, which no parser fix asks for.
|
||||||
|
changed = (
|
||||||
|
{d["accession"] for d in staged.discrepancies if d["fields"] != ["cik"]}
|
||||||
|
if self.reparse
|
||||||
|
else set()
|
||||||
|
)
|
||||||
for row in staged.rows:
|
for row in staged.rows:
|
||||||
if row.accession in staged.existing_accessions:
|
if row.accession in staged.existing_accessions:
|
||||||
if row.accession in changed:
|
if row.accession in changed:
|
||||||
@@ -641,10 +663,51 @@ class SecFundamentalsImporter:
|
|||||||
if gap["accession"] not in existing_gap_accessions
|
if gap["accession"] not in existing_gap_accessions
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Two tracked issuers claiming one filing is not a reconstruction change:
|
||||||
|
# every fact matched and only the CIK stamp differs, so re-parsing or
|
||||||
|
# reparsing fixes nothing — the universe resolution does. It is reported
|
||||||
|
# separately because it also does not self-heal: the loser of the
|
||||||
|
# collision never stores a row, so `_ciks_with_snapshots` never sees it,
|
||||||
|
# and it is full-history backfilled (and re-reported) on every run until
|
||||||
|
# a human re-points or retires the ticker. Observed 2026-08 for EQR,
|
||||||
|
# which SEC's own company_tickers.json maps to ERP Operating LP, the
|
||||||
|
# non-traded co-registrant of the issuer now trading as VMRK.
|
||||||
|
collisions = [d for d in staged.discrepancies if d["fields"] == ["cik"]]
|
||||||
|
if collisions:
|
||||||
|
named = ", ".join(
|
||||||
|
f"{d['accession']} (stored {d['stored_cik']}, parsed {d['cik']})"
|
||||||
|
for d in collisions[:10]
|
||||||
|
)
|
||||||
|
db.add(SystemEvent(
|
||||||
|
severity="warning",
|
||||||
|
source="sec_facts",
|
||||||
|
code="accession_cik_collision",
|
||||||
|
message=(
|
||||||
|
f"{len(collisions)} filing(s) are claimed by two tracked CIKs — "
|
||||||
|
"the reconstruction is identical, only the attribution differs, "
|
||||||
|
"so one of the two is a co-registrant the universe should not "
|
||||||
|
f"track. Re-point or retire the ticker (see {CIK_OVERRIDES_KEY}); "
|
||||||
|
f"this repeats every run until then: {named}"
|
||||||
|
)[:4000],
|
||||||
|
dedup_key=f"sec_facts:accession_cik_collision:{run_id}",
|
||||||
|
created_at=_now(),
|
||||||
|
))
|
||||||
|
|
||||||
# Warn (in-transaction, so it commits atomically with the promotion) when
|
# Warn (in-transaction, so it commits atomically with the promotion) when
|
||||||
# any existing accession reconstructed differently — kept immutable.
|
# any existing accession reconstructed differently — kept immutable.
|
||||||
if staged.discrepancies:
|
reconstruction_diffs = [
|
||||||
accns = ", ".join(d["accession"] for d in staged.discrepancies[:10])
|
d for d in staged.discrepancies if d["fields"] != ["cik"]
|
||||||
|
]
|
||||||
|
if reconstruction_diffs:
|
||||||
|
# Name the columns, not just the accession: "differs in revenue"
|
||||||
|
# (our numbers moved) and "differs in period_start" (the filing was
|
||||||
|
# re-placed in the calendar) need different responses, and the alert
|
||||||
|
# is where that call gets made. The fields are already computed for
|
||||||
|
# validation_json — they were simply dropped from the message.
|
||||||
|
accns = ", ".join(
|
||||||
|
f"{d['accession']} ({', '.join(d['fields'])})"
|
||||||
|
for d in reconstruction_diffs[:10]
|
||||||
|
)
|
||||||
disposition = (
|
disposition = (
|
||||||
f"REWRITTEN by reparse run {run_id}" if self.reparse else "kept immutable"
|
f"REWRITTEN by reparse run {run_id}" if self.reparse else "kept immutable"
|
||||||
)
|
)
|
||||||
@@ -653,7 +716,7 @@ class SecFundamentalsImporter:
|
|||||||
source="sec_facts",
|
source="sec_facts",
|
||||||
code="snapshot_reparse" if self.reparse else "snapshot_discrepancy",
|
code="snapshot_reparse" if self.reparse else "snapshot_discrepancy",
|
||||||
message=(
|
message=(
|
||||||
f"{len(staged.discrepancies)} stored accession(s) reconstructed "
|
f"{len(reconstruction_diffs)} stored accession(s) reconstructed "
|
||||||
f"differently; {disposition}: {accns}"
|
f"differently; {disposition}: {accns}"
|
||||||
)[:4000],
|
)[:4000],
|
||||||
dedup_key=f"sec_facts:discrepancy:{run_id}",
|
dedup_key=f"sec_facts:discrepancy:{run_id}",
|
||||||
@@ -713,6 +776,60 @@ class SecFundamentalsImporter:
|
|||||||
.values(escalated_at=now)
|
.values(escalated_at=now)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The escalation above fires once per gap, so nothing would report the
|
||||||
|
# *end* of the reprieve it grants. An escalated gap stops pausing setups
|
||||||
|
# while the issuer's own fundamentals are still recent, and that lapses
|
||||||
|
# on its own — the stored filings age past the window, or a newer gap
|
||||||
|
# appears — putting the pause back on with no alert anywhere. Track the
|
||||||
|
# exemption as state and alert on the transition, once per lapse.
|
||||||
|
current_gaps = await fundamentals_quality_service.active_gaps(db)
|
||||||
|
escalated_gaps = [g for g in current_gaps if g.escalated_at is not None]
|
||||||
|
if escalated_gaps:
|
||||||
|
exempt_ciks = await fundamentals_quality_service.gap_exempt_ciks(
|
||||||
|
db, escalated_gaps
|
||||||
|
)
|
||||||
|
newly_exempt = [
|
||||||
|
g for g in escalated_gaps
|
||||||
|
if g.cik in exempt_ciks and g.exempted_at is None
|
||||||
|
]
|
||||||
|
lapsed = [
|
||||||
|
g for g in escalated_gaps
|
||||||
|
if g.cik not in exempt_ciks and g.exempted_at is not None
|
||||||
|
]
|
||||||
|
if newly_exempt:
|
||||||
|
# Silent on purpose: filing_gap_aged already announced this gap,
|
||||||
|
# and setups resuming is the behaviour that alert describes.
|
||||||
|
await db.execute(
|
||||||
|
update(SecFilingGap)
|
||||||
|
.where(SecFilingGap.id.in_([g.id for g in newly_exempt]))
|
||||||
|
.values(exempted_at=now)
|
||||||
|
)
|
||||||
|
if lapsed:
|
||||||
|
named = ", ".join(
|
||||||
|
f"{gap.cik}/{gap.accession}" for gap in lapsed[:10]
|
||||||
|
)
|
||||||
|
db.add(SystemEvent(
|
||||||
|
severity="warning",
|
||||||
|
source="sec_facts",
|
||||||
|
code="filing_gap_repaused",
|
||||||
|
message=(
|
||||||
|
f"{len(lapsed)} SEC filing gap(s) pause setups again: the "
|
||||||
|
"issuer's own fundamentals have aged out of the "
|
||||||
|
f"{fundamentals_quality_service.GAP_GATE_RECENT_FILING_DAYS}"
|
||||||
|
"-day window, or a newer gap arrived, so there is nothing "
|
||||||
|
f"recent left to score on: {named}"
|
||||||
|
)[:4000],
|
||||||
|
dedup_key=f"sec_facts:filing_gap_repaused:{run_id}",
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
# Cleared, not stamped: the issuer can recover and age out again,
|
||||||
|
# and each lapse is worth its own alert.
|
||||||
|
await db.execute(
|
||||||
|
update(SecFilingGap)
|
||||||
|
.where(SecFilingGap.id.in_([g.id for g in lapsed]))
|
||||||
|
.values(exempted_at=None)
|
||||||
|
)
|
||||||
|
|
||||||
# Recovered rows are real data from an unexpected place — record where they
|
# Recovered rows are real data from an unexpected place — record where they
|
||||||
# came from, so a wrong recovery is auditable rather than invisible.
|
# came from, so a wrong recovery is auditable rather than invisible.
|
||||||
if staged.recovered:
|
if staged.recovered:
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ entries: the application scheduler owns both jobs.
|
|||||||
tickers are excluded from actionable setups until a snapshot is recovered or
|
tickers are excluded from actionable setups until a snapshot is recovered or
|
||||||
a later valid 10-K/10-Q supersedes the gap. Migration `028` materializes older
|
a later valid 10-K/10-Q supersedes the gap. Migration `028` materializes older
|
||||||
promoted gaps into this queue once, so setup reads never scan import history.
|
promoted gaps into this queue once, so setup reads never scan import history.
|
||||||
|
- A gap that survives 14 days raises `filing_gap_aged` and, from that point,
|
||||||
|
stops pausing setups **if** the issuer's own newest stored 10-K/10-Q is less
|
||||||
|
than `GAP_GATE_RECENT_FILING_DAYS` (180) old. This is the hand-off from pause
|
||||||
|
to alert, and it exists because the pause would otherwise be open-ended:
|
||||||
|
SEC's per-company Company-Facts files can go stale indefinitely (2026-08: 43
|
||||||
|
large caps whose Q2 10-Qs the `frames` API carried but whose
|
||||||
|
`companyfacts/CIK*.json` never received), and the supersede rule needs a
|
||||||
|
*successfully ingested* later filing, so a stale file swallows the next
|
||||||
|
quarter too. Retrying is unaffected — the gap stays queued and a recovered
|
||||||
|
filing still resolves it normally. An issuer with no filing that recent has no
|
||||||
|
usable fundamentals at all and stays paused.
|
||||||
|
|
||||||
The systemd service uses one application worker. The import framework also holds
|
The systemd service uses one application worker. The import framework also holds
|
||||||
a PostgreSQL advisory lock per source, so an overlapping manual/scheduled run is
|
a PostgreSQL advisory lock per source, so an overlapping manual/scheduled run is
|
||||||
|
|||||||
@@ -39,6 +39,162 @@ session, the calendar anchors, 100% coverage on every row, and a row-wise
|
|||||||
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
|
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
|
||||||
pipeline did not validate is meant to be structurally impossible.
|
pipeline did not validate is meant to be structurally impossible.
|
||||||
|
|
||||||
|
## The fundamental channel (2026-08-12)
|
||||||
|
|
||||||
|
The monitor has **three channels**, not two scores with a decoration:
|
||||||
|
|
||||||
|
- **State** — current observable technical stress (price, breadth, credit, volatility).
|
||||||
|
- **Warning** — observable deterioration that may precede stress (breadth
|
||||||
|
divergence, relative strength, credit impulse).
|
||||||
|
- **Fundamental context** — a categorical state (`supportive` / `neutral` /
|
||||||
|
`adverse` / `unknown`) with an `evidence_quality` grade.
|
||||||
|
|
||||||
|
The third is **never a term in the other two**. They are read together by
|
||||||
|
confluence:
|
||||||
|
|
||||||
|
| Warning | Fundamentals | Reading |
|
||||||
|
|---|---|---|
|
||||||
|
| Calm | Supportive/neutral | Normal |
|
||||||
|
| Elevated | Supportive/neutral | Technical warning, not fundamentally confirmed |
|
||||||
|
| Calm | Adverse | Fundamental concern; tape has not confirmed |
|
||||||
|
| Elevated | Adverse | Confluence — highest attention |
|
||||||
|
|
||||||
|
`METHODOLOGY` stays **v4**: no score changed, so partitioning the history API and
|
||||||
|
discarding the event study cache would be churn. `STUDY_SCHEMA` moved to 3
|
||||||
|
instead, and is now the only thing that discards a stale report.
|
||||||
|
|
||||||
|
### Why the read is a channel and not a weight
|
||||||
|
|
||||||
|
Two things are true at once, and only this shape honours both.
|
||||||
|
|
||||||
|
**v3's reason for removing fundamentals from the score was wrong.** Not stale —
|
||||||
|
wrong. v3 argued that F1 (capex) and F3 (good-news-stock-down), carrying 12 + 8
|
||||||
|
of 100 Warning points, "could not change any published conclusion" because pegged
|
||||||
|
they produced a Warning of exactly 20.0, below the alarm threshold. That
|
||||||
|
arithmetic holds only when *every* technical sensor reads exactly zero, which is
|
||||||
|
the one case that never matters. Warning is a weighted average, so the sensors
|
||||||
|
add:
|
||||||
|
|
||||||
|
| technical Warning | without fundamentals | with them pegged | delta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | 0.0 | 20.0 | +20.0 |
|
||||||
|
| 20 | 20.0 | 36.0 | +16.0 |
|
||||||
|
| 25 | 25.0 | **40.0** | +15.0 |
|
||||||
|
| 35 | 35.0 | **48.0** | +13.0 |
|
||||||
|
| 50 | 50.0 | 60.0 | +10.0 |
|
||||||
|
| 80 | 80.0 | 84.0 | +4.0 |
|
||||||
|
|
||||||
|
Pegged fundamentals lowered the technical Warning needed to reach the 40 quadrant
|
||||||
|
divider from 40 to 25. That is a 15-point shift in where the alert fires, which
|
||||||
|
is emphatically a changed conclusion. The v3 section below is kept as written,
|
||||||
|
with this correction attached, because its reasoning is cited elsewhere in this
|
||||||
|
file and a silent overwrite would hide that the error was ever made.
|
||||||
|
|
||||||
|
**But no weight is measurable either.** A weighted modifier was built and
|
||||||
|
reverted: 0–25 points added onto the technical Warning, sized so a maxed-out read
|
||||||
|
carried a calm tape over the 40 divider on its own. Nothing could justify the 25.
|
||||||
|
With ~10 correction events and essentially no fundamental history, any fusion
|
||||||
|
weight is a policy preference presented as a measurement — and the debate it
|
||||||
|
invites ("does the read deserve 10%, 20%, 30%?") has no evidence that can settle
|
||||||
|
it. Adding a slow categorical judgement to a fast continuous score also
|
||||||
|
manufactures precision by summing unlike things, and it forces a missing
|
||||||
|
observation to silently redistribute its weight onto the technical sensors, which
|
||||||
|
is the opposite of leaving it unknown.
|
||||||
|
|
||||||
|
So: the read gets a channel, not a coefficient. Both facts survive — the v3
|
||||||
|
removal was badly argued *and* no weight is defensible — because "report it
|
||||||
|
separately" is the only design that neither buries the observation nor invents a
|
||||||
|
number for it.
|
||||||
|
|
||||||
|
### Derivation
|
||||||
|
|
||||||
|
Deterministic, from the stored categorical facts. The LLM is an **extraction and
|
||||||
|
explanation layer**: it finds the capex guidance, classifies it, and cites it.
|
||||||
|
Fixed rules turn those facts into a state, so the same observation always yields
|
||||||
|
the same category.
|
||||||
|
|
||||||
|
`capex_signal`: any `cutting` → adverse; else any `holding` → neutral; else all
|
||||||
|
known `raising` → supportive; nothing known → unknown.
|
||||||
|
`reaction_signal`: `yes` → adverse, `mixed` → neutral, `no` → supportive,
|
||||||
|
`unknown` → unknown.
|
||||||
|
|
||||||
|
`mixed` and `unknown` are different reaction states and were merged until
|
||||||
|
2026-08-13. A failed LLM parse fell back to `mixed`, so an extraction error
|
||||||
|
became *neutral evidence* — an observation of normality manufactured out of a
|
||||||
|
bug. `mixed` now means an observed mixed reaction; anything unreadable, missing
|
||||||
|
or unattempted is `unknown` and contributes nothing.
|
||||||
|
|
||||||
|
Combined by precedence, never by averaging: **any adverse read carries**; both
|
||||||
|
unknown → unknown; every observed signal supportive → supportive; otherwise
|
||||||
|
neutral.
|
||||||
|
|
||||||
|
`unknown` is deliberately unreachable by combination. Averaging would let two
|
||||||
|
`cutting` reads and two `unknown` ones land on "neutral", presenting missing
|
||||||
|
evidence as evidence of normality — the same conflation `current_observation`
|
||||||
|
already refuses between "no observation" and "an observation of zero". Two cuts
|
||||||
|
and two unknowns read **adverse with `evidence_quality: partial`**.
|
||||||
|
|
||||||
|
`evidence_quality` is ordered by what an operator needs first: `unavailable`
|
||||||
|
(nothing collected) → `stale` (past `fundamental_staleness_days`) → `manual`
|
||||||
|
(hand override) → `complete` / `partial`.
|
||||||
|
|
||||||
|
### Presentation and alerts
|
||||||
|
|
||||||
|
The Path view colours each dot by the fundamental state recorded that day; the
|
||||||
|
axes are untouched, because context is confluence information rather than a
|
||||||
|
position on either axis. The card leads with the state and evidence grade.
|
||||||
|
|
||||||
|
Alerts stay **separate**, off one toggle:
|
||||||
|
|
||||||
|
- quadrant change — the market axes moved (existing);
|
||||||
|
- `regime_fundamental` — the context changed, e.g. neutral → adverse;
|
||||||
|
- `regime_confluence` — Warning elevated *and* fundamentals adverse.
|
||||||
|
|
||||||
|
`unknown` never alerts: an absence of evidence is not a change in the evidence,
|
||||||
|
and alerting on it would train the reader to ignore the channel. Both new
|
||||||
|
triggers seed silently on first run, as the quadrant alert does.
|
||||||
|
|
||||||
|
### The observation is now a real time series
|
||||||
|
|
||||||
|
`regime_fundamental_observations` (migration 033), one row per `effective_date`,
|
||||||
|
upserted. Before this it lived in a single `SystemSetting` slot that every
|
||||||
|
refresh overwrote, so no history existed at all — which made the read impossible
|
||||||
|
to replay, impossible to backtest, and meant a rebuild recorded every historical
|
||||||
|
session as if nothing had been observed. `update_regime_monitor` carries the
|
||||||
|
pre-existing single-slot observation into the series on its next run.
|
||||||
|
|
||||||
|
### What this does not establish
|
||||||
|
|
||||||
|
The table starts empty and fills one observation at a time, so the fundamental
|
||||||
|
rows are **untested, not failed**. Two things enforce that rather than one:
|
||||||
|
|
||||||
|
- they are **coverage-matched** — scored only on sessions where the channel had
|
||||||
|
usable context and on corrections whose warning horizon fell inside it, with a
|
||||||
|
market-only comparator over the identical window so any difference between them
|
||||||
|
is the channel and not the window;
|
||||||
|
- `measurable` stays false until `MIN_EVENTS_FOR_CONFIDENCE` corrections are
|
||||||
|
covered, and the panel prints "insufficient exposure" rather than a ratio.
|
||||||
|
|
||||||
|
Without the first, one day of coverage would render as 0/10 — recreating, one
|
||||||
|
observation later, exactly the tested-versus-unavailable confusion the flag was
|
||||||
|
added to prevent. The market rows are unchanged, and the 1/10 shipped-rule figure
|
||||||
|
remains a verdict on the technical sensors and the alert machinery alone.
|
||||||
|
|
||||||
|
The rationale for expecting the read to matter is the operator's: hyperscaler
|
||||||
|
capex is the demand side of the entire AI trade, and good earnings being sold is
|
||||||
|
a classic late-cycle tell. Both are plausible. Neither is measured here, and this
|
||||||
|
file's convention is that published numbers are reproducible.
|
||||||
|
|
||||||
|
**The path forward is accumulation, then a test — in that order.** Once enough
|
||||||
|
point-in-time observations exist, test whether the state improves prediction
|
||||||
|
*conditional on* Warning. If it does, a fitted and calibrated model has something
|
||||||
|
to fit; until then there is nothing to calibrate against. Backfilling would get
|
||||||
|
there faster: capex direction is derivable from the 10-Q/10-K capex line, which
|
||||||
|
the SEC fundamentals import already carries, and "good news, stock down" from
|
||||||
|
earnings dates plus next-day returns, which the Dolt earnings import already
|
||||||
|
carries. That last one is worth computing deterministically rather than asking
|
||||||
|
the LLM to judge, for the same reason the state derivation is rule-based.
|
||||||
|
|
||||||
## What changed in v4
|
## What changed in v4
|
||||||
|
|
||||||
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
|
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
|
||||||
@@ -81,6 +237,16 @@ a qualitative overlay reported beside the scores. Capex also stopped scoring
|
|||||||
`raising` and `holding` identically at 0: `holding` is the deceleration case and
|
`raising` and `holding` identically at 0: `holding` is the deceleration case and
|
||||||
now scores 50, so a boom no longer reads the same as a stall.
|
now scores 50, so a boom no longer reads the same as a stall.
|
||||||
|
|
||||||
|
> **Corrected 2026-08-12.** The claim in this paragraph is false. "Pegged
|
||||||
|
> they produced a Warning of exactly 20.0" describes only the case where every
|
||||||
|
> technical sensor reads zero; Warning is a weighted average, so in the general
|
||||||
|
> case those 20 points added +10 to +20 and moved the technical score needed to
|
||||||
|
> reach the 40 quadrant divider from 40 to 25. The observation was removed for
|
||||||
|
> being *underweighted*, on reasoning that mistook a corner case for the whole
|
||||||
|
> range. See "The fundamental channel" above for what replaced it — a separate
|
||||||
|
> categorical channel, not a restored weight. The capex `holding` rescale in the second half
|
||||||
|
> of this paragraph stands and is still live.
|
||||||
|
|
||||||
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
|
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
|
||||||
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
|
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
|
||||||
sessions sat at exactly 100 with no resolution left, and the price pillar showed
|
sessions sat at exactly 100 with no resolution left, and the price pillar showed
|
||||||
@@ -126,7 +292,11 @@ upper half of the Warning axis was unreachable.
|
|||||||
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
||||||
- HY OAS 20-session widening, 25%.
|
- HY OAS 20-session widening, 25%.
|
||||||
|
|
||||||
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3 or v4.
|
**Fundamental context** — a categorical third channel, not a term in either
|
||||||
|
score. See "The fundamental channel" above.
|
||||||
|
|
||||||
|
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3
|
||||||
|
or v4.
|
||||||
|
|
||||||
## Calibration
|
## Calibration
|
||||||
|
|
||||||
@@ -279,31 +449,73 @@ reseed exists to close. The history API and main chart show only snapshots match
|
|||||||
the current methodology, so a bump reseeds the series rather than splicing two
|
the current methodology, so a bump reseeds the series rather than splicing two
|
||||||
formulas into one line.
|
formulas into one line.
|
||||||
|
|
||||||
The fundamental overlay keeps its effective date (normally the next session after
|
The fundamental channel keeps its effective date (normally the next session after
|
||||||
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
collection) and is never replayed backward, so a rebuild cannot stamp today's
|
||||||
observation onto historical snapshots. Because the observation is stored in a
|
observation onto historical snapshots. Since the observations became a real
|
||||||
single slot, a refresh replaces the previously effective record: the snapshot
|
series (`regime_fundamental_observations`, migration 033), the effective-date
|
||||||
therefore reports the overlay as `pending` until the new effective date.
|
lookup *is* the gate: a replayed session gets whichever observation was live on
|
||||||
|
it, and sessions before the first one read `unknown`.
|
||||||
|
|
||||||
Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
|
Two functions, deliberately: `fundamental_context` is the **record** and keeps
|
||||||
the gate — it runs for every replayed date during a rebuild, so it must never
|
the gate — it runs for every replayed date during a rebuild, so it must never
|
||||||
grow a bypass flag. `current_observation` is the **live reading** behind
|
grow a bypass flag. `current_observation` is the **live reading** behind
|
||||||
`fundamental_context`, and *reports* the effective date instead of blanking the
|
`fundamental_live`, and *reports* the effective date instead of blanking the
|
||||||
content.
|
content.
|
||||||
|
|
||||||
Until 2026-08-07 the live reading called the gated function, so a just-collected
|
Until 2026-08-07 the live reading called the gated function, so a just-collected
|
||||||
observation stayed hidden until the next weekday — three days over a weekend —
|
observation stayed hidden until the next weekday — three days over a weekend —
|
||||||
and refreshing appeared to do nothing. That was the opposite of what this section
|
and refreshing appeared to do nothing. That was the opposite of what this section
|
||||||
already claimed. Showing it early cannot leak into a published number, because
|
already claimed. Showing it early cannot leak into a published score, because
|
||||||
nothing in the overlay is scored (see "Fundamentals left the score").
|
nothing in the channel is scored.
|
||||||
|
|
||||||
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
|
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
|
||||||
every path writing real content stamps). Without it, the default override —
|
every path writing real content stamps). Without it, the default override —
|
||||||
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
|
`unknown` for every hyperscaler and, since 2026-08-13, `unknown` for the reaction
|
||||||
live observation with `available: true`, so the card presented placeholders as a
|
— was reported as a live observation with `available: true`, so the card
|
||||||
collected reading. Those are the absence of an observation, not an observation of
|
presented placeholders as a collected reading. Those are the absence of an
|
||||||
absence. `fundamental_overlay` never had this problem: no observation means no
|
observation, not an observation of absence. `fundamental_context` never had this
|
||||||
effective date, which means `pending`, which already blanks the content.
|
problem: no observation means no effective date, which means `pending`, which
|
||||||
|
already blanks the content.
|
||||||
|
|
||||||
|
**`usable` is what may confirm; `available` is only what to display.** Three
|
||||||
|
distinct things, and collapsing any two of them is a bug:
|
||||||
|
|
||||||
|
- `state` — the last thing observed. Survives going stale, so the card can show it.
|
||||||
|
- `available` — *timing*: there is an effective, non-stale record to display.
|
||||||
|
- `usable` — *content*: available **and** the observation actually determined
|
||||||
|
something (`state != "unknown"`).
|
||||||
|
|
||||||
|
The confluence alert and all three coverage-matched study rules gate on `usable`.
|
||||||
|
Gating on `available` instead has two failure modes, and both were live at some
|
||||||
|
point in this design:
|
||||||
|
|
||||||
|
1. a reading past `fundamental_staleness_days` would corroborate every Warning
|
||||||
|
crossing indefinitely — the strongest claim this channel makes, from the data
|
||||||
|
with the least right to make it;
|
||||||
|
2. an LLM run that failed to extract anything produces a perfectly fresh
|
||||||
|
observation that knows nothing. Counting it as exposure means repeated
|
||||||
|
extraction failures slowly accumulate coverage until the fundamental rows flip
|
||||||
|
to a *measurable* 0/8 — a failed result published for a channel that never saw
|
||||||
|
a thing, which is precisely what coverage-matching exists to prevent.
|
||||||
|
|
||||||
|
**Pre-rename snapshots are adapted, not discarded.** The channel was stored as
|
||||||
|
`fundamental_overlay` until 2026-08-12. The rename shipped without a methodology
|
||||||
|
bump — no score changed — so those rows are still served and were never reseeded.
|
||||||
|
Reading only the new key would have turned every one of them into `unknown`,
|
||||||
|
silently dropping real recorded evidence: historical Path colours, and exposure
|
||||||
|
the event study can legitimately count. `_parse_snapshot` derives the channel
|
||||||
|
from a legacy overlay's own stored facts (its capex map supplies the basket, so
|
||||||
|
the derivation uses the names observed at the time rather than today's config).
|
||||||
|
Normalising there rather than at each call site means no reader can receive an
|
||||||
|
un-adapted row. Delete only after a reseed has rewritten the whole window.
|
||||||
|
|
||||||
|
**The blob and the series row are one transaction.** They are the same
|
||||||
|
observation seen by the live card and by the point-in-time replay; committing
|
||||||
|
them separately leaves a window where a failure publishes one and not the other,
|
||||||
|
and the two then disagree permanently with nothing to detect it. Both writers use
|
||||||
|
`settings_store.upsert_setting` (which does not commit) plus a single commit;
|
||||||
|
`record_fundamental_observation` deliberately takes no commit of its own so
|
||||||
|
`update_regime_monitor` keeps its own transaction boundary.
|
||||||
|
|
||||||
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
Each snapshot stores the fixed basket symbols, hash, and freeze date.
|
||||||
Reconstructed history before that freeze date is retrospective/exploratory.
|
Reconstructed history before that freeze date is retrospective/exploratory.
|
||||||
@@ -321,39 +533,185 @@ today's number. The quadrant dividers rendered in Path view come from
|
|||||||
|
|
||||||
## Warning study
|
## Warning study
|
||||||
|
|
||||||
The study calls the outcome a **10% correction**, not a regime break. The first
|
The study calls the outcome a **10% correction**, not a regime break. It measures
|
||||||
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
|
two rules against that outcome, plus enough context to tell whether either number
|
||||||
measured on the final 30%. Because v3 dropped fundamentals from the score, the
|
is any good.
|
||||||
study now measures exactly the live Warning score rather than a technical-only
|
|
||||||
approximation of it, and both are computed from one shared sensor definition
|
|
||||||
(`warning_sensor_scores`) so they cannot drift apart.
|
|
||||||
|
|
||||||
A cached report is discarded when its methodology no longer matches, so the panel
|
A cached report is discarded when its methodology no longer matches *or* when
|
||||||
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
|
`STUDY_SCHEMA` moves, so the panel reverts to "not run yet" rather than showing
|
||||||
the Event Study job after cutting over to v4.**
|
stale numbers or a report missing half its blocks. **Re-run the Event Study job
|
||||||
|
after a methodology cutover or a schema bump.**
|
||||||
|
|
||||||
|
### The headline is the rule that actually fires
|
||||||
|
|
||||||
|
Until 2026-08-12 the study measured a bare rising-edge crossing of an
|
||||||
|
80th-percentile threshold fitted on the first 70% of sessions. **Nothing consumes
|
||||||
|
that rule.** What reaches Telegram is `_collect_regime_quadrant`: a quadrant
|
||||||
|
change with State ≥ 50 and Warning ≥ 40 as fixed dividers, a ±5 hysteresis
|
||||||
|
deadband, a two-session confirmation, a 3-day cooldown, and a 75% coverage gate
|
||||||
|
on both axes. The two differ on every one of those axes, including the threshold
|
||||||
|
itself (a fitted ~32 against a shipped 40).
|
||||||
|
|
||||||
|
`replay_quadrant_changes` replays the shipped state machine over the whole
|
||||||
|
sample. Three details are reproduced rather than cleaned up, because a state
|
||||||
|
machine written from first principles gets each of them wrong:
|
||||||
|
|
||||||
|
- the prior session is classified against the **current baseline**, not against
|
||||||
|
its own predecessor, so confirmation asks "did yesterday already look like this
|
||||||
|
change" rather than "did yesterday change too";
|
||||||
|
- the baseline advances only when an alert actually fires, so a change blocked by
|
||||||
|
confirmation or cooldown is re-evaluated against the old quadrant next session;
|
||||||
|
- one cooldown is shared by every quadrant change, so a 3→4 alert can swallow a
|
||||||
|
4→2 alert three days later.
|
||||||
|
|
||||||
|
Two consequences worth stating. The alarm is dated at the **confirmation**, not
|
||||||
|
at the first crossing, which costs one session of lead by construction. And the
|
||||||
|
rule alerts on changes in both directions, so the replay's exits are recorded but
|
||||||
|
filtered out by `entry_alarms` — only entering a Warning-high quadrant is a
|
||||||
|
warning about anything.
|
||||||
|
|
||||||
|
The replay reuses `_compute_index` rather than re-deriving the axes. That is the
|
||||||
|
same anti-drift argument that produced `warning_sensor_scores`: the v2 study
|
||||||
|
re-derived Warning by hand and would have kept measuring the old construct
|
||||||
|
through a scoring change. State has no equivalent shared helper, so the snapshot
|
||||||
|
builder itself is the shared definition.
|
||||||
|
|
||||||
|
**Nothing is fitted, so nothing needs protecting from a training set.** There is
|
||||||
|
no split, and every detected correction is evaluable instead of the four that
|
||||||
|
happen to land in the last 30%. The `underpowered` and "threshold frozen on a
|
||||||
|
different construct" caveats do not apply to this variant.
|
||||||
|
|
||||||
### Reading the result
|
### Reading the result
|
||||||
|
|
||||||
The report carries a `reliability` block and the UI renders its warnings, because
|
A bare "2 of 4" is unreadable in either direction, so the report scores four more
|
||||||
the headline numbers invite over-reading in two specific ways.
|
rules through the same `evaluate_alarms` harness over the same events and
|
||||||
|
sessions, and adds a null. All use fixed thresholds — a threshold fitted on the
|
||||||
|
full sample would have lookahead the shipped rule does not, and one fitted on a
|
||||||
|
split could only be scored on the holdout events.
|
||||||
|
|
||||||
|
| kind | rules | the question |
|
||||||
|
|---|---|---|
|
||||||
|
| ablation | Warning ≥ 40 bare, State ≥ 50 bare | does the quadrant machinery earn its place? |
|
||||||
|
| baseline | leader below its 50-DMA, VIX ≥ 20 | does the score earn its complexity? |
|
||||||
|
| null | K random alarms at the observed firing rate | is any of this better than chance? |
|
||||||
|
|
||||||
|
The two kinds must not be read as one list. If a baseline matches the score, the
|
||||||
|
composite is not earning its complexity and that is the finding — it does not
|
||||||
|
mean the monitor is worthless, since State and Warning exist to be *read*, but it
|
||||||
|
caps how much further calibration is justified. If the bare Warning crossing
|
||||||
|
beats the shipped rule, the machinery (not the sensor) is what is costing recall.
|
||||||
|
|
||||||
|
The null draws only from sessions a rule could actually have fired on. Over the
|
||||||
|
whole sample it would be diluted by warm-up sessions and would understate what
|
||||||
|
chance achieves — which matters, because with ~11 events and a 20-session horizon
|
||||||
|
roughly a sixth of the sample already sits inside a hit window. It is seeded, so
|
||||||
|
a re-run cannot move the report. Corrections cluster and uniform placement does
|
||||||
|
not, so it is the **floor, not the bar**: an alarm process that clustered would
|
||||||
|
beat it for reasons unrelated to foresight.
|
||||||
|
|
||||||
|
### First result (2026-08-12): the shipped rule is not distinguishable from chance
|
||||||
|
|
||||||
|
Replayed over 2021-07-14 → 2026-08-12. The 200-DMA warm-up means the baseline
|
||||||
|
only seeds on 2022-05-26, so 1056 of 1276 sessions are evaluable and 10 of the 11
|
||||||
|
detected corrections fall inside them.
|
||||||
|
|
||||||
|
| rule | kind | warned | FA/yr | median lead |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Quadrant alert (shipped)** | | **1/10** | **0.9** | 19d |
|
||||||
|
| Quadrant alert, both axes high | ablation | 0/10 | 0.9 | — |
|
||||||
|
| Warning ≥ 40, bare crossing | ablation | 3/10 | 4.8 | 20d |
|
||||||
|
| State ≥ 50, bare crossing | ablation | 0/10 | 0.7 | — |
|
||||||
|
| SMH below its 50-DMA | baseline | 7/10 | 6.7 | 8d |
|
||||||
|
| VIX ≥ 20 | baseline | 4/10 | 7.2 | 9.5d |
|
||||||
|
| Random alarms, same firing rate | null | 0.9 ± 0.8 | — | — |
|
||||||
|
|
||||||
|
**P(chance ≥ 1/10) = 0.65.** Alarms scattered at random over the same sessions at
|
||||||
|
the rule's own firing rate match or beat it two times in three. Whatever the
|
||||||
|
score knows, this rule is not transmitting it.
|
||||||
|
|
||||||
|
Three readings, in order of how much they should change:
|
||||||
|
|
||||||
|
**The machinery costs more than it protects.** The bare Warning crossing catches
|
||||||
|
3 with a 20-session lead; wrapping it in the quadrant rule drops that to 1. The
|
||||||
|
State condition is the largest single cost — requiring both axes high catches
|
||||||
|
nothing at all, which is what a coincident axis gating a leading one predicts.
|
||||||
|
Hysteresis, the two-session confirmation and the shared cooldown between them
|
||||||
|
take the rest, and the cooldown is shared across *every* quadrant change, so
|
||||||
|
exits consume the budget that entries need. Only 5 of the 15 replayed changes are
|
||||||
|
Warning-high entries.
|
||||||
|
|
||||||
|
**The crude baselines beat everything on recall, at a price.** SMH below its
|
||||||
|
50-DMA catches 7 of 10 — but at 6.7 false alarms a year against the shipped
|
||||||
|
rule's 0.9. That is a 7× recall improvement for 7× the noise, so it is not a
|
||||||
|
clean dominance and this table cannot settle it; the missing axis is what a false
|
||||||
|
alarm actually costs, which nothing here measures. What it does settle is that
|
||||||
|
the composite is not buying recall the 50-DMA does not already have.
|
||||||
|
|
||||||
|
**The 0.9 false alarms/year is not the achievement it looks like.** A rule that
|
||||||
|
almost never fires has few false alarms by construction. Read the two columns
|
||||||
|
together or not at all.
|
||||||
|
|
||||||
|
Recorded from an offline replay (live Alpaca + FRED, no database, breadth
|
||||||
|
computed from the same Alpaca closes rather than the stored universe). The job in
|
||||||
|
Admin → Jobs is the canonical path and reads breadth from the DB, so re-run it to
|
||||||
|
confirm these figures before treating them as the record.
|
||||||
|
|
||||||
|
**This is a verdict on the market channels only.** The fundamental and confluence
|
||||||
|
rows in the same table are marked `measurable: false` and print "not measurable"
|
||||||
|
rather than a ratio: with an empty observation series they never fire, and a 0/10
|
||||||
|
sitting in a comparison column would read as tested-and-failed. `false` here means
|
||||||
|
the input does not exist yet, not that the rule lost.
|
||||||
|
|
||||||
|
(The figures above were also produced under a briefly-built weighted modifier and
|
||||||
|
came back bit-identical, which is what confirmed the modifier was inert over the
|
||||||
|
whole window — the numbers depend on the technical sensors alone either way.)
|
||||||
|
|
||||||
|
**Not acted on.** Nothing in the alert path was changed on the strength of this.
|
||||||
|
The obvious candidates — dropping the State condition from the entry test,
|
||||||
|
separating the entry and exit cooldowns, or lowering the Warning divider — are
|
||||||
|
threshold changes to a live alerting rule and want their own decision.
|
||||||
|
|
||||||
|
### The coverage gap relocates, it does not close
|
||||||
|
|
||||||
|
Dropping the fitted threshold makes the whole sample evaluable, but most of the
|
||||||
|
extra events predate 2023-08. W3 does not exist there, so Warning renormalises to
|
||||||
|
`(W1×45 + W2×30)/75` and the fixed 40 divider is applied to a different construct
|
||||||
|
than it was reasoned about. The report therefore splits shipped-rule metrics at
|
||||||
|
the credit sensor's first session and the panel states both, because replacing
|
||||||
|
one misleading headline with a differently misleading one would be no gain.
|
||||||
|
|
||||||
|
Convenient side effect: the pre-credit era *is* the "Warning without W3"
|
||||||
|
ablation, measured on real sessions rather than simulated ones, so that ablation
|
||||||
|
is not run separately.
|
||||||
|
|
||||||
|
Alarms and events are assigned to eras by index, so an alarm days before the
|
||||||
|
boundary matching an event days after it lands in the earlier era. With the eras
|
||||||
|
years long and the events sparse, that costs nothing.
|
||||||
|
|
||||||
|
### The fitted variant, kept for continuity
|
||||||
|
|
||||||
|
The 70/30 percentile study is still computed and still reported, collapsed, with
|
||||||
|
its `reliability` block intact — it is a genuinely different question, and it is
|
||||||
|
what earlier revisions of this document report. Its caveats stand:
|
||||||
|
|
||||||
**The holdout is thin.** The study detects 11 corrections across 5 years but the
|
**The holdout is thin.** The study detects 11 corrections across 5 years but the
|
||||||
70/30 split leaves only 4 in the test period. Recall is therefore one event away
|
70/30 split leaves only 4 in the test period. Recall is one event away from a
|
||||||
from a materially different headline, and in practice the event that flips is
|
materially different headline, and in practice the event that flips is decided by
|
||||||
decided by where the frozen threshold happens to land rather than by whether the
|
where the frozen threshold happens to land rather than by whether the score saw
|
||||||
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
|
anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's 3/4, but
|
||||||
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
|
"v3 without the credit sensor" scores 3/4 at a *higher* threshold (35.5) than
|
||||||
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
|
shipped v3 misses it at (32.3) — because the alarm rule needs a rising edge, and a
|
||||||
rising edge, and a lower threshold can mean the alarm already fired outside the
|
lower threshold can mean the alarm already fired outside the 20-session horizon
|
||||||
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
|
and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE` holdout events the
|
||||||
holdout events the report says so explicitly.
|
report says so explicitly.
|
||||||
|
|
||||||
Some events carry no information at all for comparison: in that run every
|
Some events carry no information at all for comparison: in that run every
|
||||||
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
|
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
|
||||||
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
|
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
|
||||||
warning.
|
warning. The headline recall does not currently discount those; a minimum-lead
|
||||||
|
rule is the obvious next change and has not been made.
|
||||||
|
|
||||||
**Sensor coverage can straddle the split.** The score renormalises over available
|
**Sensor coverage straddles the split.** The score renormalises over available
|
||||||
sensors, so a training window predating a sensor's history freezes the threshold
|
sensors, so a training window predating a sensor's history freezes the threshold
|
||||||
on a different construct than the holdout is measured against. At the v3 cutover
|
on a different construct than the holdout is measured against. At the v3 cutover
|
||||||
only 39% of training sessions had all three Warning sensors versus 100% of the
|
only 39% of training sessions had all three Warning sensors versus 100% of the
|
||||||
@@ -362,9 +720,22 @@ test period, because credit history begins 2023-07-25.
|
|||||||
Restricting the threshold to sensor-matched training sessions was tried and is
|
Restricting the threshold to sensor-matched training sessions was tried and is
|
||||||
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
|
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
|
||||||
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
|
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
|
||||||
coverage bias for a regime-selection bias. The honest position is that the
|
coverage bias for a regime-selection bias. The honest position is that a fitted
|
||||||
threshold is hypersensitive to window choice at this sample size; the report
|
threshold is hypersensitive to window choice at this sample size — which is the
|
||||||
states its limits rather than pretending to a precision it does not have.
|
strongest argument for making the unfitted shipped rule the headline.
|
||||||
|
|
||||||
|
### Considered and not done
|
||||||
|
|
||||||
|
**An ETF credit proxy (HYG/IEF) to extend W3 back over the whole sample.** It
|
||||||
|
would trade "two sensors versus three" for "proxy sensor versus real sensor" —
|
||||||
|
still a construct straddle, but no longer flagged by the coverage split. This is
|
||||||
|
the same objection that rejected `BAA10Y` as a percentile reference. If ever
|
||||||
|
revisited, check the impulse correlation on the three years of real-OAS overlap
|
||||||
|
first and report it as a sensitivity, never as the headline.
|
||||||
|
|
||||||
|
**A depth sweep (5%/7%/15% corrections) for more events.** `EVENT_COOLDOWN_DAYS`
|
||||||
|
is 40, so at shallower thresholds re-triggers inside a single decline merge or
|
||||||
|
drop and the denominator moves for cooldown reasons rather than market ones.
|
||||||
|
|
||||||
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
|
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
ReferenceArea,
|
ReferenceArea,
|
||||||
@@ -19,6 +18,8 @@ import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
|||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
import { SkeletonCard } from '../ui/Skeleton';
|
||||||
import { formatDate } from '../../lib/format';
|
import { formatDate } from '../../lib/format';
|
||||||
|
import { FUNDAMENTAL_VISUAL, QUADRANT_WASH, REGIME_VISUAL } from '../../lib/regime';
|
||||||
|
import type { EvidenceQuality, FundamentalState } from '../../lib/types';
|
||||||
|
|
||||||
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
||||||
// Time and Path are two projections of one series, so they share a card and a
|
// Time and Path are two projections of one series, so they share a card and a
|
||||||
@@ -38,8 +39,14 @@ type RangeKey = (typeof RANGES)[number]['key'];
|
|||||||
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
||||||
const PATH_TRAIL = 60;
|
const PATH_TRAIL = 60;
|
||||||
|
|
||||||
const STATE_COLOR = '#60a5fa';
|
const STATE_COLOR = REGIME_VISUAL.state;
|
||||||
const WARNING_COLOR = '#fb923c';
|
const WARNING_COLOR = REGIME_VISUAL.warning;
|
||||||
|
const FUNDAMENTAL_SYMBOL: Record<FundamentalState, string> = {
|
||||||
|
supportive: '▲',
|
||||||
|
neutral: '●',
|
||||||
|
adverse: '◆',
|
||||||
|
unknown: '○',
|
||||||
|
};
|
||||||
|
|
||||||
// Fall back to the shipped constants, not v2's shared 60/60, so a missing
|
// Fall back to the shipped constants, not v2's shared 60/60, so a missing
|
||||||
// quadrant_config cannot draw dividers that disagree with the alert path.
|
// quadrant_config cannot draw dividers that disagree with the alert path.
|
||||||
@@ -50,13 +57,19 @@ interface PathPoint {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
date: string;
|
date: string;
|
||||||
|
/** The third channel as recorded that day. Colours the dot; never moves it. */
|
||||||
|
fundamental: FundamentalState;
|
||||||
|
evidence: EvidenceQuality;
|
||||||
|
/** Raw dated observations are interactive dots; the smoothed copy is line-only. */
|
||||||
|
raw: boolean;
|
||||||
|
recency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
||||||
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
||||||
const n = points.length;
|
const n = points.length;
|
||||||
return points.map((p, i) => {
|
return points.map((p, i) => {
|
||||||
if (i === n - 1) return { ...p };
|
if (i === n - 1) return { ...p, raw: false };
|
||||||
let sx = 0;
|
let sx = 0;
|
||||||
let sy = 0;
|
let sy = 0;
|
||||||
let c = 0;
|
let c = 0;
|
||||||
@@ -65,14 +78,58 @@ function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
|||||||
sy += points[j].y;
|
sy += points[j].y;
|
||||||
c += 1;
|
c += 1;
|
||||||
}
|
}
|
||||||
return { x: sx / c, y: sy / c, date: p.date };
|
return { ...p, x: sx / c, y: sy / c, raw: false };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
|
function FundamentalGlyph({
|
||||||
function recencyColor(t: number): string {
|
cx,
|
||||||
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
cy,
|
||||||
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
|
state,
|
||||||
|
size,
|
||||||
|
opacity = 1,
|
||||||
|
}: {
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
state: FundamentalState;
|
||||||
|
size: number;
|
||||||
|
opacity?: number;
|
||||||
|
}) {
|
||||||
|
const visual = FUNDAMENTAL_VISUAL[state] ?? FUNDAMENTAL_VISUAL.unknown;
|
||||||
|
const common = { fill: visual.color, opacity, stroke: '#11131c', strokeWidth: 1 };
|
||||||
|
if (visual.glyph === 'up') {
|
||||||
|
return <polygon points={`${cx},${cy - size} ${cx - size},${cy + size} ${cx + size},${cy + size}`} {...common} />;
|
||||||
|
}
|
||||||
|
if (visual.glyph === 'diamond') {
|
||||||
|
return <polygon points={`${cx},${cy - size} ${cx - size},${cy} ${cx},${cy + size} ${cx + size},${cy}`} {...common} />;
|
||||||
|
}
|
||||||
|
if (visual.glyph === 'ring') {
|
||||||
|
return <circle cx={cx} cy={cy} r={size - 0.5} fill="transparent" opacity={opacity} stroke={visual.color} strokeWidth={1.5} />;
|
||||||
|
}
|
||||||
|
return <circle cx={cx} cy={cy} r={size - 0.5} {...common} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
|
||||||
|
if (!payload) return <g />;
|
||||||
|
return (
|
||||||
|
<FundamentalGlyph
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
state={payload.fundamental}
|
||||||
|
size={3.25 + payload.recency * 1.25}
|
||||||
|
opacity={0.58 + payload.recency * 0.42}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LatestPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
|
||||||
|
if (!payload) return <g />;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<circle cx={cx} cy={cy} r={7} fill="transparent" stroke="#ffffff" strokeWidth={1.75} />
|
||||||
|
<FundamentalGlyph cx={cx} cy={cy} state={payload.fundamental} size={4.5} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({
|
function SegmentedControl<T extends string>({
|
||||||
@@ -94,8 +151,8 @@ function SegmentedControl<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={value === option}
|
aria-pressed={value === option}
|
||||||
onClick={() => onChange(option)}
|
onClick={() => onChange(option)}
|
||||||
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
className={`min-h-9 rounded px-3 py-2 text-xs font-medium tabular-nums transition-colors ${
|
||||||
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-400 hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
@@ -107,14 +164,19 @@ function SegmentedControl<T extends string>({
|
|||||||
|
|
||||||
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const p = payload[0].payload;
|
const p = payload.find((item) => item.payload.raw)?.payload ?? payload[0].payload;
|
||||||
|
const visual = FUNDAMENTAL_VISUAL[p.fundamental] ?? FUNDAMENTAL_VISUAL.unknown;
|
||||||
|
const evidence = p.evidence === 'unavailable' ? 'Unavailable' : `${p.evidence.replace(/_/g, ' ')} evidence`;
|
||||||
return (
|
return (
|
||||||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
<div className="glass px-3 py-2 text-xs">
|
||||||
<div className="text-gray-300">{formatDate(p.date)}</div>
|
<div className="text-gray-300">{formatDate(p.date)}</div>
|
||||||
<div className="text-gray-400">
|
<div className="text-gray-400">
|
||||||
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
||||||
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-0.5 text-gray-400">
|
||||||
|
Fundamentals <span style={{ color: visual.color }}>{visual.label}</span> · {evidence}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,7 +206,15 @@ export default function RegimeChart() {
|
|||||||
}, [history.data, view, range]);
|
}, [history.data, view, range]);
|
||||||
|
|
||||||
const pathPoints = useMemo<PathPoint[]>(
|
const pathPoints = useMemo<PathPoint[]>(
|
||||||
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
|
() => series.map((p, index, points) => ({
|
||||||
|
x: p.state as number,
|
||||||
|
y: p.warning as number,
|
||||||
|
date: p.date,
|
||||||
|
fundamental: p.fundamental_state ?? 'unknown',
|
||||||
|
evidence: p.evidence_quality ?? 'unavailable',
|
||||||
|
raw: true,
|
||||||
|
recency: points.length <= 1 ? 1 : index / (points.length - 1),
|
||||||
|
})),
|
||||||
[series],
|
[series],
|
||||||
);
|
);
|
||||||
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
||||||
@@ -159,7 +229,7 @@ export default function RegimeChart() {
|
|||||||
<div className="glass p-5">
|
<div className="glass p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
<span className="text-xs uppercase tracking-wider text-gray-400">
|
||||||
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
|
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
|
||||||
</span>
|
</span>
|
||||||
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
|
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
|
||||||
@@ -168,7 +238,7 @@ export default function RegimeChart() {
|
|||||||
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
|
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
|
||||||
) : (
|
) : (
|
||||||
latest && (
|
latest && (
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-xs text-gray-400">
|
||||||
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
|
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
|
||||||
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -182,14 +252,18 @@ export default function RegimeChart() {
|
|||||||
<Callout variant="empty">Not enough coverage-qualified history yet — it accumulates as the daily job runs.</Callout>
|
<Callout variant="empty">Not enough coverage-qualified history yet — it accumulates as the daily job runs.</Callout>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mt-3 h-72">
|
<div
|
||||||
|
className="mt-3 h-72"
|
||||||
|
role="img"
|
||||||
|
aria-label={view === 'Time' ? 'State and Warning scores over time' : 'State by Warning path with fundamental context symbols'}
|
||||||
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
{view === 'Time' ? (
|
{view === 'Time' ? (
|
||||||
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
tickFormatter={(d) => formatDate(String(d))}
|
tickFormatter={(d) => formatDate(String(d))}
|
||||||
minTickGap={28}
|
minTickGap={28}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -200,7 +274,7 @@ export default function RegimeChart() {
|
|||||||
<YAxis
|
<YAxis
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 25, 50, 75, 100]}
|
ticks={[0, 25, 50, 75, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
width={34}
|
width={34}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
@@ -225,10 +299,13 @@ export default function RegimeChart() {
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
) : (
|
) : (
|
||||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||||||
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
|
{/* One neutral at four opacities: denser = more axes elevated.
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
|
Hue here would collide with the fundamental glyphs drawn
|
||||||
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
|
on top of it — see QUADRANT_WASH. */}
|
||||||
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
|
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.early_warning} stroke="none" />
|
||||||
|
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.active_stress} stroke="none" />
|
||||||
|
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.healthy} stroke="none" />
|
||||||
|
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.stabilizing} stroke="none" />
|
||||||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||||
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
||||||
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
||||||
@@ -237,36 +314,37 @@ export default function RegimeChart() {
|
|||||||
dataKey="x"
|
dataKey="x"
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||||
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#9aa0b0', fontSize: 10 }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="y"
|
dataKey="y"
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
ticks={[0, 20, 40, 60, 80, 100]}
|
ticks={[0, 20, 40, 60, 80, 100]}
|
||||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||||
width={30}
|
width={30}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#9aa0b0', fontSize: 10 }}
|
||||||
/>
|
/>
|
||||||
<ZAxis range={[13, 13]} />
|
<ZAxis range={[18, 18]} />
|
||||||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
|
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
|
||||||
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
|
<Scatter
|
||||||
{trail.map((_, i) => (
|
data={trail}
|
||||||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
line={{ stroke: 'rgba(255,255,255,0.18)', strokeWidth: 1.5 }}
|
||||||
))}
|
shape={(props: { cx?: number; cy?: number }) => <circle cx={props.cx} cy={props.cy} r={0} />}
|
||||||
</Scatter>
|
tooltipType="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Scatter data={pathPoints} shape={<PathPointShape />} isAnimationActive={false} />
|
||||||
{latest && (
|
{latest && (
|
||||||
<Scatter
|
<Scatter
|
||||||
data={[latest]}
|
data={[latest]}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
shape={(props: { cx?: number; cy?: number }) => (
|
shape={<LatestPointShape />}
|
||||||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ScatterChart>
|
</ScatterChart>
|
||||||
@@ -275,7 +353,7 @@ export default function RegimeChart() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'Time' ? (
|
{view === 'Time' ? (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
|
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
||||||
State
|
State
|
||||||
@@ -284,20 +362,43 @@ export default function RegimeChart() {
|
|||||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
||||||
Warning
|
Warning
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
|
<span className="text-gray-400">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
|
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
|
||||||
<span><span className="text-amber-400">Early warning</span> — calm, fragility rising</span>
|
{/* Swatches, not coloured words: the quadrant names used the
|
||||||
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
|
fundamental channel's colours, so "Stabilizing" was rendered in
|
||||||
<span><span className="text-emerald-400">Healthy</span> — calm, broadly supported</span>
|
the adverse hue while meaning damage receding. */}
|
||||||
<span><span className="text-red-400">Stabilizing</span> — damage remains, warning lower</span>
|
{([
|
||||||
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
|
['active_stress', 'Active stress', 'damaged and deteriorating'],
|
||||||
|
['early_warning', 'Early warning', 'calm, fragility rising'],
|
||||||
|
['stabilizing', 'Stabilizing', 'damage remains, warning lower'],
|
||||||
|
['healthy', 'Healthy', 'calm, broadly supported'],
|
||||||
|
] as const).map(([key, name, gloss]) => (
|
||||||
|
<span key={key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block h-3 w-3 shrink-0 rounded-sm border border-white/10"
|
||||||
|
style={{ background: `rgba(255,255,255,${QUADRANT_WASH[key] * 4})` }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300">{name}</span> — {gloss}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-gray-400 sm:col-span-2">Raw dated points grow toward today; the connecting line is smoothed. White ring = today.</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-gray-400 sm:col-span-2">
|
||||||
|
<span>symbol + colour = fundamentals:</span>
|
||||||
|
{(['supportive', 'neutral', 'adverse', 'unknown'] as const).map((state) => (
|
||||||
|
<span key={state} className="inline-flex items-center gap-1.5">
|
||||||
|
<span aria-hidden="true" style={{ color: FUNDAMENTAL_VISUAL[state].color }}>{FUNDAMENTAL_SYMBOL[state]}</span>
|
||||||
|
{FUNDAMENTAL_VISUAL[state].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{crossesFreeze && (
|
{crossesFreeze && (
|
||||||
<p className="mt-2 text-[11px] text-gray-600">
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
|
History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,35 +9,8 @@ import { Disclosure } from '../ui/Disclosure';
|
|||||||
import { Dropdown } from '../ui/Dropdown';
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
|
||||||
|
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
|
||||||
function fmtR(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
function fmtPct(v: number | null): string {
|
|
||||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtMoney(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
function fmtSignedPct(v: number | null | undefined): string {
|
|
||||||
if (v === null || v === undefined) return '—';
|
|
||||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtDrawdown(v: number | null | undefined): string {
|
|
||||||
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
function fmtDays(v: number | null | undefined): string {
|
|
||||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
|
||||||
}
|
|
||||||
function rColor(v: number | null): string {
|
|
||||||
if (v === null) return 'text-gray-400';
|
|
||||||
if (v > 0) return 'text-emerald-400';
|
|
||||||
if (v < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso: string): string {
|
function timeAgo(iso: string): string {
|
||||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
@@ -48,95 +21,14 @@ function timeAgo(iso: string): string {
|
|||||||
return `${Math.floor(hrs / 24)}d ago`;
|
return `${Math.floor(hrs / 24)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
const TARGET_MODEL_OPTIONS = [
|
||||||
label: string; value: string; valueClass?: string; sub?: string;
|
{ value: 'production_gtl', label: 'Live GTL — production' },
|
||||||
}) {
|
{ value: 'structural_sr', label: 'Structural S/R — comparison' },
|
||||||
return (
|
];
|
||||||
<div className="glass p-4">
|
const CADENCE_OPTIONS = [
|
||||||
<p className="section-index">{label}</p>
|
{ value: 'weekly', label: 'Weekly — default' },
|
||||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
{ value: 'daily', label: 'Daily — research' },
|
||||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
];
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function curvePath(
|
|
||||||
points: BacktestCurvePoint[],
|
|
||||||
min: number,
|
|
||||||
max: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
pad: number,
|
|
||||||
startMs: number,
|
|
||||||
endMs: number,
|
|
||||||
): string {
|
|
||||||
if (points.length < 2) return '';
|
|
||||||
const span = Math.max(max - min, 1);
|
|
||||||
const timeSpan = Math.max(endMs - startMs, 1);
|
|
||||||
return points
|
|
||||||
.map((p, i) => {
|
|
||||||
const t = new Date(p.date).getTime();
|
|
||||||
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
|
|
||||||
const value = p.return_pct ?? 0;
|
|
||||||
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
|
|
||||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
|
||||||
})
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
|
||||||
const portfolio = run.equity_curve ?? [];
|
|
||||||
const benchmark = run.benchmark_curve ?? [];
|
|
||||||
const values = [...portfolio, ...benchmark]
|
|
||||||
.map((p) => p.return_pct)
|
|
||||||
.filter((v): v is number => v !== null && v !== undefined);
|
|
||||||
if (portfolio.length < 2 || values.length === 0) {
|
|
||||||
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const min = Math.min(0, ...values);
|
|
||||||
const max = Math.max(0, ...values);
|
|
||||||
const times = [...portfolio, ...benchmark]
|
|
||||||
.map((p) => new Date(p.date).getTime())
|
|
||||||
.filter((v) => Number.isFinite(v));
|
|
||||||
if (times.length === 0) {
|
|
||||||
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
|
|
||||||
}
|
|
||||||
const startMs = Math.min(...times);
|
|
||||||
const endMs = Math.max(...times);
|
|
||||||
const w = 720;
|
|
||||||
const h = 240;
|
|
||||||
const pad = 28;
|
|
||||||
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
|
|
||||||
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
|
|
||||||
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
|
|
||||||
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass overflow-hidden">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
|
|
||||||
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 text-xs">
|
|
||||||
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
|
|
||||||
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
|
|
||||||
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
|
||||||
{benchmarkPath && (
|
|
||||||
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
|
|
||||||
)}
|
|
||||||
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
|
|
||||||
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
|
|
||||||
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacktestPanel() {
|
export function BacktestPanel() {
|
||||||
const { data: report, isLoading } = useBacktestReport();
|
const { data: report, isLoading } = useBacktestReport();
|
||||||
@@ -150,8 +42,19 @@ export function BacktestPanel() {
|
|||||||
const monitor = report?.portfolio_monitor ?? null;
|
const monitor = report?.portfolio_monitor ?? null;
|
||||||
const activeStrategy =
|
const activeStrategy =
|
||||||
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
|
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
|
||||||
|
// Default to the window the recommendation was computed on, so the tiles and
|
||||||
|
// the recommendation never open showing different numbers. They used to: the
|
||||||
|
// backend preferred "all" while this defaulted to "3y". The 3y fallback is
|
||||||
|
// only for reports predating basis_lookback.
|
||||||
|
const basisLookback = report?.recommendation?.basis_lookback ?? null;
|
||||||
const activeLookback =
|
const activeLookback =
|
||||||
selectedLookback || (monitor?.lookbacks.some((l) => l.lookback === '3y') ? '3y' : monitor?.lookbacks[0]?.lookback) || '';
|
selectedLookback ||
|
||||||
|
(basisLookback && monitor?.lookbacks.some((l) => l.lookback === basisLookback)
|
||||||
|
? basisLookback
|
||||||
|
: monitor?.lookbacks.some((l) => l.lookback === '3y')
|
||||||
|
? '3y'
|
||||||
|
: monitor?.lookbacks[0]?.lookback) ||
|
||||||
|
'';
|
||||||
const monitorRun = useMemo(
|
const monitorRun = useMemo(
|
||||||
() =>
|
() =>
|
||||||
monitor?.runs.find((row) => row.strategy === activeStrategy && row.lookback === activeLookback) ??
|
monitor?.runs.find((row) => row.strategy === activeStrategy && row.lookback === activeLookback) ??
|
||||||
@@ -178,7 +81,61 @@ export function BacktestPanel() {
|
|||||||
return (
|
return (
|
||||||
<Section title="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500">
|
<Section title="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
{/* Run status and the controls that start a new run, on one line. The
|
||||||
|
explainer sits BELOW this row rather than beside it — sharing a flex
|
||||||
|
row meant expanding it shoved every control down the page. */}
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="section-index">Last run</p>
|
||||||
|
{report ? (
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{timeAgo(report.generated_at)} · {report.tickers} tickers ·{' '}
|
||||||
|
{report.candidates} setups ({report.qualified} qualified) ·{' '}
|
||||||
|
{report.params.entry_cadence ?? 'weekly'},{' '}
|
||||||
|
{report.params.horizon_days}d horizon
|
||||||
|
{report.params.cost_per_side_pct != null && (
|
||||||
|
<> · net of {report.params.cost_per_side_pct}%/side</>
|
||||||
|
)}
|
||||||
|
{' · '}
|
||||||
|
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
|
||||||
|
{report.params.target_model_label ?? 'Unknown (legacy report)'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Never run</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
|
||||||
|
narrow viewport otherwise. */}
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="backtest-target-model">Target model</label>
|
||||||
|
<Dropdown
|
||||||
|
id="backtest-target-model"
|
||||||
|
className="w-56 normal-case tracking-normal"
|
||||||
|
value={targetModel}
|
||||||
|
onChange={(v) => setTargetModel(v as BacktestTargetModel)}
|
||||||
|
options={TARGET_MODEL_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="backtest-cadence">Entry cadence</label>
|
||||||
|
<Dropdown
|
||||||
|
id="backtest-cadence"
|
||||||
|
className="w-44 normal-case tracking-normal"
|
||||||
|
value={cadence}
|
||||||
|
onChange={(v) => setCadence(v as BacktestCadence)}
|
||||||
|
options={CADENCE_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||||
|
{run.isPending ? 'Starting…' : report ? 'Re-run' : 'Run backtest'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<Disclosure summary="How this is measured">
|
<Disclosure summary="How this is measured">
|
||||||
<p className="max-w-2xl text-xs text-gray-400">
|
<p className="max-w-2xl text-xs text-gray-400">
|
||||||
The backtest replays the current config at the selected cadence — at each point the setup is
|
The backtest replays the current config at the selected cadence — at each point the setup is
|
||||||
@@ -187,113 +144,29 @@ export function BacktestPanel() {
|
|||||||
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||||
so read it as directional.
|
so read it as directional.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-xs text-gray-400">
|
||||||
|
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
|
||||||
|
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
|
||||||
|
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
|
||||||
|
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
|
||||||
|
{' '}is roughly 5× the replay work.
|
||||||
|
</p>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
|
|
||||||
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
|
|
||||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Target model for this run
|
|
||||||
</legend>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
|
||||||
targetModel === 'production_gtl'
|
|
||||||
? 'border-blue-400/60 bg-blue-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-target-model"
|
|
||||||
value="production_gtl"
|
|
||||||
checked={targetModel === 'production_gtl'}
|
|
||||||
onChange={() => setTargetModel('production_gtl')}
|
|
||||||
/>
|
|
||||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
|
||||||
Live GTL
|
|
||||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
|
||||||
Production
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
|
||||||
Exact target path used by the live scanner and scheduled backtest.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
|
||||||
targetModel === 'structural_sr'
|
|
||||||
? 'border-amber-400/50 bg-amber-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-target-model"
|
|
||||||
value="structural_sr"
|
|
||||||
checked={targetModel === 'structural_sr'}
|
|
||||||
onChange={() => setTargetModel('structural_sr')}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-200">Structural S/R</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
|
||||||
Comparison only; uses chart structure as the target source.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="grid w-full grid-cols-2 gap-2 sm:w-[34rem]">
|
|
||||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Entry cadence
|
|
||||||
</legend>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
|
||||||
cadence === 'weekly'
|
|
||||||
? 'border-blue-400/60 bg-blue-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-cadence"
|
|
||||||
value="weekly"
|
|
||||||
checked={cadence === 'weekly'}
|
|
||||||
onChange={() => setCadence('weekly')}
|
|
||||||
/>
|
|
||||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
|
||||||
Weekly
|
|
||||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
|
||||||
Resource-safe server run at five-session intervals.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
|
||||||
cadence === 'daily'
|
|
||||||
? 'border-amber-400/50 bg-amber-500/10'
|
|
||||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="backtest-cadence"
|
|
||||||
value="daily"
|
|
||||||
checked={cadence === 'daily'}
|
|
||||||
onChange={() => setCadence('daily')}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-200">Daily</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-4 text-amber-300/80">
|
|
||||||
Research run: roughly 5× the replay work; prefer the offline snapshot runner.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
|
||||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Only surfaced for non-default choices — zero noise on the common path,
|
||||||
|
but a non-production selection still announces itself, which is what
|
||||||
|
the old always-amber cards were really for. */}
|
||||||
|
{(cadence === 'daily' || targetModel === 'structural_sr') && (
|
||||||
|
<div className="space-y-1 text-[11px] text-amber-300/80">
|
||||||
|
{cadence === 'daily' && (
|
||||||
|
<p>Daily replays ~5× the work — prefer the offline snapshot runner.</p>
|
||||||
|
)}
|
||||||
|
{targetModel === 'structural_sr' && (
|
||||||
|
<p>Comparison arm — not the live scanner's target path.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||||||
|
|
||||||
@@ -306,131 +179,21 @@ export function BacktestPanel() {
|
|||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<>
|
<>
|
||||||
<p className="text-[11px] text-gray-500">
|
<PortfolioMonitorPanel
|
||||||
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
monitor={monitor}
|
||||||
({report.qualified} qualified) · {report.params.entry_cadence ?? 'weekly'} cadence,
|
monitorRun={monitorRun}
|
||||||
{' '}{report.params.horizon_days}-day horizon
|
activeStrategy={activeStrategy}
|
||||||
{report.params.cost_per_side_pct != null && (
|
activeLookback={activeLookback}
|
||||||
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
onStrategyChange={setSelectedStrategy}
|
||||||
)}
|
onLookbackChange={setSelectedLookback}
|
||||||
{' '}· target model:{' '}
|
basisLookback={basisLookback}
|
||||||
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
|
basisLookbackLabel={report.recommendation?.basis_lookback_label ?? null}
|
||||||
{report.params.target_model_label ?? 'Unknown (legacy report)'}
|
productionStrategy={monitor?.production_strategy ?? null}
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{monitor && monitorRun ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="section-index">Portfolio monitor</p>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
|
||||||
<label htmlFor="monitor-strategy">Strategy</label>
|
|
||||||
<Dropdown
|
|
||||||
id="monitor-strategy"
|
|
||||||
className="w-64 normal-case tracking-normal"
|
|
||||||
value={activeStrategy}
|
|
||||||
onChange={setSelectedStrategy}
|
|
||||||
options={monitor.strategies.map((s) => ({
|
|
||||||
value: s.strategy,
|
|
||||||
label: `${s.is_production ? 'Production: ' : ''}${s.label}`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
|
||||||
<label htmlFor="monitor-lookback">Lookback</label>
|
|
||||||
<Dropdown
|
|
||||||
id="monitor-lookback"
|
|
||||||
className="w-36 normal-case tracking-normal"
|
|
||||||
value={activeLookback}
|
|
||||||
onChange={setSelectedLookback}
|
|
||||||
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
{report.recommendation && (
|
||||||
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
<BacktestRecommendationCard recommendation={report.recommendation} />
|
||||||
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
|
||||||
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
|
||||||
<Stat
|
|
||||||
label="Total Return"
|
|
||||||
value={fmtSignedPct(monitorRun.total_return_pct)}
|
|
||||||
valueClass={rColor(monitorRun.total_return_pct)}
|
|
||||||
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
|
||||||
/>
|
|
||||||
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EquityCurveChart run={monitorRun} />
|
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-500">
|
|
||||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
|
||||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
|
||||||
{monitorRun.reentry_policy === 'gate_reset' ? (
|
|
||||||
<> · Re-entry after gate failure and fresh qualification</>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
|
||||||
<div className="glass overflow-x-auto p-4">
|
|
||||||
<p className="section-index mb-2">Per-year returns</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{monitorRun.yearly_returns.map((y) => (
|
|
||||||
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
|
||||||
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
|
||||||
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
|
||||||
{fmtSignedPct(y.return_pct)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Callout variant="empty">
|
|
||||||
This report predates the portfolio monitor — re-run the backtest to populate it.
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.recommendation && report.recommendation.items.length > 0 && (
|
|
||||||
<div className="glass border border-blue-400/20 p-4">
|
|
||||||
<p className="section-index">What this backtest recommends</p>
|
|
||||||
{report.recommendation.headline && (
|
|
||||||
<p className="mt-1.5 text-sm font-semibold text-gray-100">
|
|
||||||
{report.recommendation.headline}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{report.recommendation.items.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.topic + item.text}
|
|
||||||
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{report.recommendation.note && (
|
|
||||||
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-600">
|
|
||||||
Strategy research — gate tuning, exit sweeps, factor rank-IC — now runs locally against a
|
|
||||||
database snapshot (see README). This page keeps only what says whether the promoted strategy
|
|
||||||
is worth trading; your realized results up top show what it is actually delivering.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
|
import type { BacktestRecommendation } from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verdict, ahead of the tuning detail.
|
||||||
|
*
|
||||||
|
* Two problems this solves. All eight findings used to render as equal-weight
|
||||||
|
* bullets, so "does this strategy work" sat in the same register as "which
|
||||||
|
* cutoff scored best". And the headline — which is a *description of the
|
||||||
|
* config*, not a verdict — was the loudest thing on the card while every actual
|
||||||
|
* finding was small grey text.
|
||||||
|
*
|
||||||
|
* So: findings first, each split into a label and its detail; the config
|
||||||
|
* description demoted to a footer where it belongs.
|
||||||
|
*/
|
||||||
|
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors how the backend phrases a bad result — `_build_recommendation` emits
|
||||||
|
* "Robustness WARNING: …" and "Book vs SPY: LAGS …". There is deliberately no
|
||||||
|
* `severity` field on the payload; if that changes, this is the one place to fix.
|
||||||
|
*/
|
||||||
|
function isWarning(text: string): boolean {
|
||||||
|
return text.includes('WARNING') || text.includes('LAGS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every backend string self-prefixes ("Gate: keep the R:R floor…"), so the
|
||||||
|
* prefix IS the label — no need for a chip that would just repeat it, and no
|
||||||
|
* need to reword anything server-side. Split on the first colon; if a string
|
||||||
|
* ever stops carrying one, it renders whole as detail.
|
||||||
|
*/
|
||||||
|
function splitLabel(text: string): { label: string | null; detail: string } {
|
||||||
|
const at = text.indexOf(': ');
|
||||||
|
if (at === -1 || at > 48) return { label: null, detail: text };
|
||||||
|
return { label: text.slice(0, at), detail: text.slice(at + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Finding({ text, primary }: { text: string; primary: boolean }) {
|
||||||
|
const warn = isWarning(text);
|
||||||
|
const { label, detail } = splitLabel(text);
|
||||||
|
return (
|
||||||
|
<li className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
className={`shrink-0 text-[11px] font-semibold uppercase tracking-wider sm:w-44 sm:pt-0.5 ${
|
||||||
|
warn ? 'text-amber-400' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`${primary ? 'text-sm' : 'text-xs'} ${
|
||||||
|
warn ? 'text-amber-300' : primary ? 'text-gray-200' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacktestRecommendationCard({
|
||||||
|
recommendation,
|
||||||
|
}: {
|
||||||
|
recommendation: BacktestRecommendation;
|
||||||
|
}) {
|
||||||
|
const items = recommendation.items;
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
// A warning is always visible, whatever its topic — burying "the edge
|
||||||
|
// disappears without the top 5% of winners" behind a disclosure would defeat
|
||||||
|
// the point of surfacing it at all.
|
||||||
|
const primary = items.filter((i) => PRIMARY_TOPICS.has(i.topic) || isWarning(i.text));
|
||||||
|
const secondary = items.filter((i) => !PRIMARY_TOPICS.has(i.topic) && !isWarning(i.text));
|
||||||
|
const warningCount = items.filter((i) => isWarning(i.text)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="glass border border-blue-400/20 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="section-index">What this backtest recommends</p>
|
||||||
|
{/* No headline means the backend found no production monitor row, so
|
||||||
|
nothing here describes the production book. Zero keyword warnings
|
||||||
|
is then absence of data, not a clean bill of health — a green chip
|
||||||
|
beside "this report predates the portfolio monitor" would be a
|
||||||
|
success badge for missing data. */}
|
||||||
|
{!recommendation.headline ? (
|
||||||
|
<span className="rounded-full border border-white/15 bg-white/[0.05] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
baseline unavailable
|
||||||
|
</span>
|
||||||
|
) : warningCount > 0 ? (
|
||||||
|
<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300">
|
||||||
|
⚠ {warningCount} warning{warningCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||||
|
no warnings
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{primary.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-2.5">
|
||||||
|
{primary.map((item) => (
|
||||||
|
<Finding key={item.topic + item.text} text={item.text} primary />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The config description, demoted: it says what the strategy IS, which
|
||||||
|
is context for the findings above rather than a finding itself. */}
|
||||||
|
{recommendation.headline && (
|
||||||
|
<div className="mt-3 border-t border-white/[0.06] pt-3">
|
||||||
|
<p className="section-index">Configuration under test</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">{recommendation.headline}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recommendation.note && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outside the card body on purpose: Disclosure renders its own glass-sm
|
||||||
|
panel, so nesting it inside the bordered card double-frames it. */}
|
||||||
|
{secondary.length > 0 && (
|
||||||
|
<Disclosure summary={`Gate and cutoff detail (${secondary.length})`}>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{secondary.map((item) => (
|
||||||
|
<Finding key={item.topic + item.text} text={item.text} primary={false} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { fmtSignedPct } from '../../lib/format';
|
||||||
|
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio return vs S&P 500 for one monitor run.
|
||||||
|
*
|
||||||
|
* Hand-rolled SVG on purpose: two polylines and two axis rules do not justify a
|
||||||
|
* charting dependency, and the shape is fixed. Lives in `signals/` rather than
|
||||||
|
* `ui/` because it is typed to the backtest payload — generalising it for a
|
||||||
|
* single caller would be the wrong trade.
|
||||||
|
*/
|
||||||
|
function curvePath(
|
||||||
|
points: BacktestCurvePoint[],
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
pad: number,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
): string {
|
||||||
|
if (points.length < 2) return '';
|
||||||
|
const span = Math.max(max - min, 1);
|
||||||
|
const timeSpan = Math.max(endMs - startMs, 1);
|
||||||
|
return points
|
||||||
|
.map((p, i) => {
|
||||||
|
const t = new Date(p.date).getTime();
|
||||||
|
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
|
||||||
|
const value = p.return_pct ?? 0;
|
||||||
|
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
||||||
|
const portfolio = run.equity_curve ?? [];
|
||||||
|
const benchmark = run.benchmark_curve ?? [];
|
||||||
|
const values = [...portfolio, ...benchmark]
|
||||||
|
.map((p) => p.return_pct)
|
||||||
|
.filter((v): v is number => v !== null && v !== undefined);
|
||||||
|
if (portfolio.length < 2 || values.length === 0) {
|
||||||
|
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(0, ...values);
|
||||||
|
const max = Math.max(0, ...values);
|
||||||
|
const times = [...portfolio, ...benchmark]
|
||||||
|
.map((p) => new Date(p.date).getTime())
|
||||||
|
.filter((v) => Number.isFinite(v));
|
||||||
|
if (times.length === 0) {
|
||||||
|
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
|
||||||
|
}
|
||||||
|
const startMs = Math.min(...times);
|
||||||
|
const endMs = Math.max(...times);
|
||||||
|
const w = 720;
|
||||||
|
const h = 240;
|
||||||
|
const pad = 28;
|
||||||
|
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
|
||||||
|
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
|
||||||
|
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
|
||||||
|
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass overflow-hidden">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
|
||||||
|
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
|
||||||
|
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
|
||||||
|
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||||
|
{benchmarkPath && (
|
||||||
|
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
|
||||||
|
)}
|
||||||
|
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
|
||||||
|
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
|
||||||
|
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-24
@@ -5,8 +5,7 @@ import { triggerJob, resetTrackRecord } from '../../api/admin';
|
|||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { BacktestPanel } from './BacktestPanel';
|
import { fmtR, rColor } from '../../lib/format';
|
||||||
import { MyTradesPanel } from './MyTradesPanel';
|
|
||||||
|
|
||||||
// Need at least this many matured setups before the pipeline check means anything;
|
// Need at least this many matured setups before the pipeline check means anything;
|
||||||
// below it the live sample is too noisy to compare.
|
// below it the live sample is too noisy to compare.
|
||||||
@@ -16,18 +15,6 @@ const DRIFT_TOLERANCE_R = 0.2;
|
|||||||
|
|
||||||
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
|
||||||
if (value === null) return '—';
|
|
||||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rColor(value: number | null): string {
|
|
||||||
if (value === null) return 'text-gray-400';
|
|
||||||
if (value > 0) return 'text-emerald-400';
|
|
||||||
if (value < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusChip({ status }: { status: PipelineStatus }) {
|
function StatusChip({ status }: { status: PipelineStatus }) {
|
||||||
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
|
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
|
||||||
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
|
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
|
||||||
@@ -39,7 +26,7 @@ function StatusChip({ status }: { status: PipelineStatus }) {
|
|||||||
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackRecordPanel() {
|
export function EvaluationPanel() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -101,19 +88,14 @@ export function TrackRecordPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Your real, realized results come first; the strategy simulation follows. */}
|
<Disclosure summary="Setup-grading diagnostic & maintenance">
|
||||||
<MyTradesPanel />
|
|
||||||
<div className="border-t border-white/[0.06]" />
|
|
||||||
<BacktestPanel />
|
|
||||||
|
|
||||||
<Disclosure summary="Track-record maintenance">
|
|
||||||
<div className="space-y-4 pt-1">
|
<div className="space-y-4 pt-1">
|
||||||
<p className="max-w-2xl text-xs text-gray-500">
|
<p className="max-w-2xl text-xs text-gray-500">
|
||||||
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
||||||
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
|
||||||
initial stop / ATR trail / max hold — see paper trades and the portfolio monitor above.
|
initial stop / ATR trail / max hold — see the Paper Trades tab and the portfolio monitor
|
||||||
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
|
above. Target before stop = win, stop first = loss (same-bar both = loss), neither in 30
|
||||||
days = expired at 0R. Only matured windows count. Scores{' '}
|
trading days = expired at 0R. Only matured windows count. Scores{' '}
|
||||||
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -2,22 +2,10 @@ import { useMemo } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { usePaperTrades } from '../../hooks/usePaperTrades';
|
import { usePaperTrades } from '../../hooks/usePaperTrades';
|
||||||
import { tradePnl } from '../../lib/paperTrade';
|
import { tradePnl } from '../../lib/paperTrade';
|
||||||
import { formatPrice } from '../../lib/format';
|
import { formatPrice, fmtR, fmtSignedMoney, rColor } from '../../lib/format';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { StatTile } from '../ui/StatTile';
|
||||||
function money(v: number): string {
|
|
||||||
return `${v >= 0 ? '+' : '−'}$${Math.abs(v).toFixed(2)}`;
|
|
||||||
}
|
|
||||||
function fmtR(v: number | null): string {
|
|
||||||
return v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
function color(v: number | null): string {
|
|
||||||
if (v === null) return 'text-gray-400';
|
|
||||||
if (v > 0) return 'text-emerald-400';
|
|
||||||
if (v < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
// How the trade was closed — useful context on real trades at almost no cost.
|
// How the trade was closed — useful context on real trades at almost no cost.
|
||||||
function reasonMeta(reason: string | null): { label: string; cls: string } {
|
function reasonMeta(reason: string | null): { label: string; cls: string } {
|
||||||
@@ -31,18 +19,6 @@ function reasonMeta(reason: string | null): { label: string; cls: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
|
||||||
label: string; value: string; valueClass?: string; sub?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="glass p-4">
|
|
||||||
<p className="section-index">{label}</p>
|
|
||||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
|
||||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MyTradesPanel() {
|
export function MyTradesPanel() {
|
||||||
const { data: closed, isLoading } = usePaperTrades('closed');
|
const { data: closed, isLoading } = usePaperTrades('closed');
|
||||||
|
|
||||||
@@ -70,7 +46,10 @@ export function MyTradesPanel() {
|
|||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="My Trades" hint="your realized paper-trading results">
|
<Section
|
||||||
|
title="Closed Trades"
|
||||||
|
hint="realized paper-trading results — open positions are on the Dashboard"
|
||||||
|
>
|
||||||
{stats.total === 0 ? (
|
{stats.total === 0 ? (
|
||||||
<Callout variant="empty">
|
<Callout variant="empty">
|
||||||
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
||||||
@@ -79,11 +58,11 @@ export function MyTradesPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
<StatTile label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
<StatTile label="Expectancy" value={fmtR(stats.avgR)} valueClass={rColor(stats.avgR)} sub="avg R per closed trade" />
|
||||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
<StatTile label="Total R" value={fmtR(stats.totalR)} valueClass={rColor(stats.totalR)} sub={`${stats.total} closed`} />
|
||||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" />
|
<StatTile label="Total P&L" value={fmtSignedMoney(stats.totalPnl)} valueClass={rColor(stats.totalPnl)} sub="realized, all closed" />
|
||||||
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
<StatTile label="Alpha vs S&P 500" value={stats.totalAlpha != null ? fmtSignedMoney(stats.totalAlpha) : '—'} valueClass={rColor(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass overflow-x-auto">
|
<div className="glass overflow-x-auto">
|
||||||
@@ -112,9 +91,9 @@ export function MyTradesPanel() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? rColor(p.pnl) : 'text-gray-500'}`}>{p ? fmtSignedMoney(p.pnl) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? rColor(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
|
||||||
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? rColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
|
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
|
||||||
{reasonMeta(t.close_reason).label}
|
{reasonMeta(t.close_reason).label}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { Callout } from '../ui/Callout';
|
||||||
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
|
import { StatTile } from '../ui/StatTile';
|
||||||
|
import { EquityCurveChart } from './EquityCurveChart';
|
||||||
|
import {
|
||||||
|
fmtDays,
|
||||||
|
fmtDrawdown,
|
||||||
|
fmtPct,
|
||||||
|
fmtR,
|
||||||
|
fmtRatio,
|
||||||
|
fmtSignedMoney,
|
||||||
|
fmtSignedPct,
|
||||||
|
rColor,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import type {
|
||||||
|
BacktestPortfolioMonitor,
|
||||||
|
BacktestPortfolioMonitorRun,
|
||||||
|
} from '../../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The simulated book for one strategy/lookback selection, against the S&P 500.
|
||||||
|
*
|
||||||
|
* Selection state deliberately stays in BacktestPanel — it also resolves which
|
||||||
|
* run this panel receives, so splitting it here would mean resolving twice.
|
||||||
|
*/
|
||||||
|
export function PortfolioMonitorPanel({
|
||||||
|
monitor,
|
||||||
|
monitorRun,
|
||||||
|
activeStrategy,
|
||||||
|
activeLookback,
|
||||||
|
onStrategyChange,
|
||||||
|
onLookbackChange,
|
||||||
|
basisLookback = null,
|
||||||
|
basisLookbackLabel = null,
|
||||||
|
productionStrategy = null,
|
||||||
|
}: {
|
||||||
|
monitor: BacktestPortfolioMonitor | null | undefined;
|
||||||
|
monitorRun: BacktestPortfolioMonitorRun | null | undefined;
|
||||||
|
activeStrategy: string;
|
||||||
|
activeLookback: string;
|
||||||
|
onStrategyChange: (v: string) => void;
|
||||||
|
onLookbackChange: (v: string) => void;
|
||||||
|
/** The window the recommendation below was computed on. */
|
||||||
|
basisLookback?: string | null;
|
||||||
|
basisLookbackLabel?: string | null;
|
||||||
|
productionStrategy?: string | null;
|
||||||
|
}) {
|
||||||
|
if (!monitor || !monitorRun) {
|
||||||
|
return (
|
||||||
|
<Callout variant="empty">
|
||||||
|
This report predates the portfolio monitor — re-run the backtest to populate it.
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key ABSENT (not null) means the cached report predates these metrics.
|
||||||
|
// Gated on sortino specifically: calmar and avg_trade_pnl have always been
|
||||||
|
// emitted, so testing those would half-populate the row with dashes.
|
||||||
|
const isLegacyRun = monitorRun.sortino === undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="section-index">Portfolio monitor</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="monitor-strategy">Strategy</label>
|
||||||
|
<Dropdown
|
||||||
|
id="monitor-strategy"
|
||||||
|
className="w-64 normal-case tracking-normal"
|
||||||
|
value={activeStrategy}
|
||||||
|
onChange={onStrategyChange}
|
||||||
|
options={monitor.strategies.map((s) => ({
|
||||||
|
value: s.strategy,
|
||||||
|
// "Production: " prefix dropped — a bullet costs one character
|
||||||
|
// instead of twelve, and the full config is spelled out under
|
||||||
|
// the chart anyway.
|
||||||
|
label: `${s.is_production ? '● ' : ''}${s.label}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
<label htmlFor="monitor-lookback">Lookback</label>
|
||||||
|
<Dropdown
|
||||||
|
id="monitor-lookback"
|
||||||
|
className="w-36 normal-case tracking-normal"
|
||||||
|
value={activeLookback}
|
||||||
|
onChange={onLookbackChange}
|
||||||
|
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The recommendation below is baked into the report and cannot follow a
|
||||||
|
dropdown. On load the two agree by construction; say so plainly the
|
||||||
|
moment a selection moves off that basis. */}
|
||||||
|
{((basisLookback && activeLookback !== basisLookback) ||
|
||||||
|
(productionStrategy && activeStrategy !== productionStrategy)) && (
|
||||||
|
<p className="text-[11px] text-amber-300/80">
|
||||||
|
Showing{' '}
|
||||||
|
{productionStrategy && activeStrategy !== productionStrategy
|
||||||
|
? 'a comparison strategy'
|
||||||
|
: 'a different window'}
|
||||||
|
. The recommendation below is computed on the production strategy over{' '}
|
||||||
|
{basisLookbackLabel ?? basisLookback} — these tiles will not match it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tier 1 — what the book returned. */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatTile
|
||||||
|
label="Total Return"
|
||||||
|
value={fmtSignedPct(monitorRun.total_return_pct)}
|
||||||
|
valueClass={rColor(monitorRun.total_return_pct)}
|
||||||
|
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
||||||
|
/>
|
||||||
|
<StatTile label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
||||||
|
<StatTile label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
||||||
|
<StatTile
|
||||||
|
label="EV / trade"
|
||||||
|
value={fmtSignedMoney(monitorRun.avg_trade_pnl)}
|
||||||
|
valueClass={rColor(monitorRun.avg_trade_pnl)}
|
||||||
|
title="Average realized P&L per closed trade. Scales with position size, so it carries no quality band."
|
||||||
|
/>
|
||||||
|
<StatTile label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier 2 — how good that return was. Smaller and labelled on purpose:
|
||||||
|
ten equal tiles would read as ten equally important facts. */}
|
||||||
|
{isLegacyRun ? (
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
Risk-adjusted quality metrics appear after the next backtest run.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<p className="section-index">Risk-adjusted quality</p>
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
Bands are set stricter than textbook ranges — this universe is today's
|
||||||
|
survivors replayed backward, which flatters every ratio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatTile
|
||||||
|
label="Sharpe"
|
||||||
|
value={fmtRatio(monitorRun.sharpe)}
|
||||||
|
metric="sharpe"
|
||||||
|
raw={monitorRun.sharpe}
|
||||||
|
title="Return per unit of total volatility (annualized). Penalizes upside swings as well as downside."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Sortino"
|
||||||
|
value={fmtRatio(monitorRun.sortino)}
|
||||||
|
metric="sortino"
|
||||||
|
raw={monitorRun.sortino}
|
||||||
|
title="Return per unit of downside deviation (annualized). Punishes losing days only, unlike Sharpe."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Calmar (MAR)"
|
||||||
|
value={fmtRatio(monitorRun.calmar)}
|
||||||
|
metric="calmar"
|
||||||
|
raw={monitorRun.calmar}
|
||||||
|
title="CAGR divided by maximum drawdown — return earned per unit of worst-case pain."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Gain / Pain"
|
||||||
|
value={fmtRatio(monitorRun.gain_to_pain)}
|
||||||
|
metric="gain_to_pain"
|
||||||
|
raw={monitorRun.gain_to_pain}
|
||||||
|
title="Sum of monthly returns divided by the absolute sum of the negative ones (Schwager)."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Profit Factor ($)"
|
||||||
|
value={fmtRatio(monitorRun.profit_factor)}
|
||||||
|
metric="profit_factor"
|
||||||
|
raw={monitorRun.profit_factor}
|
||||||
|
title="Gross winning dollars divided by gross losing dollars, across closed trades."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EquityCurveChart run={monitorRun} />
|
||||||
|
|
||||||
|
{/* avg_trade_pnl is a tile now (EV / trade) — not repeated here. */}
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||||
|
{fmtR(monitorRun.worst_trade_r)}
|
||||||
|
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||||
|
<> · Re-entry after gate failure and fresh qualification</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||||
|
<div className="glass overflow-x-auto p-4">
|
||||||
|
<p className="section-index mb-2">Per-year returns</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{monitorRun.yearly_returns.map((y) => (
|
||||||
|
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
||||||
|
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
||||||
|
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
||||||
|
{fmtSignedPct(y.return_pct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ interface DisclosureProps {
|
|||||||
export function Disclosure({ summary, children }: DisclosureProps) {
|
export function Disclosure({ summary, children }: DisclosureProps) {
|
||||||
return (
|
return (
|
||||||
<details className="glass-sm group">
|
<details className="glass-sm group">
|
||||||
<summary className="flex cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
|
<summary className="flex min-h-11 cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
|
||||||
<span className="inline-block transition-transform duration-200 group-open:rotate-90">▸</span>
|
<span className="inline-block transition-transform duration-200 group-open:rotate-90">▸</span>
|
||||||
{summary}
|
{summary}
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ export function Dropdown({
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
|
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
|
||||||
>
|
>
|
||||||
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
|
{/* truncate, not wrap: a long option name used to push the trigger to
|
||||||
|
three lines and shove the whole control row out of alignment. */}
|
||||||
|
<span
|
||||||
|
className={`truncate ${selected ? 'text-gray-200' : 'text-gray-500'}`}
|
||||||
|
title={selected ? selected.label : undefined}
|
||||||
|
>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
BAND_STYLE,
|
||||||
|
bandTicks,
|
||||||
|
classifyMetric,
|
||||||
|
meterFraction,
|
||||||
|
} from '../../lib/metricBands';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One labelled metric.
|
||||||
|
*
|
||||||
|
* Optionally carries a quality meter: pass `metric` (a key in METRIC_BANDS) and
|
||||||
|
* the numeric `raw` value. The meter is the answer to "2.72 — is that good?" —
|
||||||
|
* a track showing where the value sits, ticks at the band edges, and the band
|
||||||
|
* word. Colour never travels alone; the word is always rendered beside it.
|
||||||
|
*
|
||||||
|
* Every tile is the same size. Hierarchy comes from grouping and section
|
||||||
|
* labels, not from shrinking one row — two sizes read as inconsistent rather
|
||||||
|
* than as a deliberate ranking.
|
||||||
|
*/
|
||||||
|
export function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueClass = 'text-gray-100',
|
||||||
|
sub,
|
||||||
|
title,
|
||||||
|
metric,
|
||||||
|
raw,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
valueClass?: string;
|
||||||
|
sub?: string;
|
||||||
|
/** Native tooltip — how the metric is defined. */
|
||||||
|
title?: string;
|
||||||
|
/** Key into METRIC_BANDS; enables the quality meter. */
|
||||||
|
metric?: string;
|
||||||
|
/** Numeric value the meter reads (the formatted `value` is display-only). */
|
||||||
|
raw?: number | null;
|
||||||
|
}) {
|
||||||
|
const band = metric ? classifyMetric(metric, raw) : null;
|
||||||
|
const style = band ? BAND_STYLE[band] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass flex flex-col p-4" title={title}>
|
||||||
|
<p className="section-index">{label}</p>
|
||||||
|
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||||
|
|
||||||
|
{style && metric && (
|
||||||
|
<div className="mt-2.5">
|
||||||
|
<div className="relative h-1.5 overflow-hidden rounded-full bg-white/[0.07]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${style.fill}`}
|
||||||
|
style={{ width: `${meterFraction(metric, raw) * 100}%` }}
|
||||||
|
/>
|
||||||
|
{/* Band edges — where "fair" becomes "good", and so on. */}
|
||||||
|
{bandTicks(metric).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="absolute top-0 h-full w-px bg-black/50"
|
||||||
|
style={{ left: `${t * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`mt-1.5 text-[11px] font-medium ${style.text}`}>{style.label}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,3 +72,58 @@ export function formatDateTime(d: string): string {
|
|||||||
hour12: true,
|
hour12: true,
|
||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Metric display helpers ─────────────────────────────────────────────────
|
||||||
|
// Shared by the Signals backtest/paper-trade panels. Dashboard and
|
||||||
|
// OpenTradesPanel deliberately still carry their own copies — migrating them is
|
||||||
|
// a separate change, not drive-by scope.
|
||||||
|
|
||||||
|
/** R-multiple with an explicit sign. e.g. 1.2 → "+1.20R", null → "—" */
|
||||||
|
export function fmtR(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 12.34 → "12.3%" */
|
||||||
|
export function fmtPct(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 12.34 → "+12.3%" */
|
||||||
|
export function fmtSignedPct(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always rendered negative, whatever sign the source uses. 17.3 → "-17.3%" */
|
||||||
|
export function fmtDrawdown(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** e.g. 15.3 → "15.3d" */
|
||||||
|
export function fmtDays(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unitless ratios — Sharpe, Sortino, Calmar, Gain/Pain, profit factor. */
|
||||||
|
export function fmtRatio(v: number | null | undefined): string {
|
||||||
|
return v === null || v === undefined ? '—' : v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed currency, using U+2212 for negatives. e.g. -12.3 → "−$12.30"
|
||||||
|
* Use wherever a value can go negative and the unit is money.
|
||||||
|
* (For a bare unsigned amount there is already `formatPrice` above.)
|
||||||
|
*/
|
||||||
|
export function fmtSignedMoney(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
return `${v >= 0 ? '+' : '−'}$${Math.abs(v).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green above zero, red below, neutral at zero or null. */
|
||||||
|
export function rColor(v: number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined) return 'text-gray-400';
|
||||||
|
if (v > 0) return 'text-emerald-400';
|
||||||
|
if (v < 0) return 'text-red-400';
|
||||||
|
return 'text-gray-300';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Quality bands for the risk-adjusted metrics.
|
||||||
|
*
|
||||||
|
* A tile reading "Sortino 2.72" answers nothing on its own. These bands turn
|
||||||
|
* each ratio into weak / fair / good / strong so the tile says whether the
|
||||||
|
* number is any good.
|
||||||
|
*
|
||||||
|
* The bands are deliberately STRICTER than the textbook ranges. This backtest
|
||||||
|
* replays today's ~512 tracked tickers backward, so every name that failed or
|
||||||
|
* was acquired inside the window is missing and every ratio here is flattered.
|
||||||
|
* Standard thresholds would print "strong" on numbers survivorship inflated.
|
||||||
|
* Treat a band as a claim about this book relative to itself, not a claim that
|
||||||
|
* the live strategy will reproduce it.
|
||||||
|
*
|
||||||
|
* Edges are lower-inclusive: a value exactly on an edge takes the higher band.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BandName = 'weak' | 'fair' | 'good' | 'strong';
|
||||||
|
|
||||||
|
export interface MetricBand {
|
||||||
|
/** Lower edges for fair / good / strong. Below the first edge is weak. */
|
||||||
|
edges: [number, number, number];
|
||||||
|
/** Where the meter track ends. Values above clamp to full. */
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const METRIC_BANDS: Record<string, MetricBand> = {
|
||||||
|
sharpe: { edges: [0.8, 1.5, 2.5], max: 3.5 },
|
||||||
|
sortino: { edges: [1.2, 2.0, 3.0], max: 4.0 },
|
||||||
|
calmar: { edges: [0.5, 1.0, 2.5], max: 3.5 },
|
||||||
|
gain_to_pain: { edges: [1.0, 1.5, 2.5], max: 3.5 },
|
||||||
|
profit_factor: { edges: [1.3, 1.8, 2.5], max: 3.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAND_ORDER: BandName[] = ['weak', 'fair', 'good', 'strong'];
|
||||||
|
|
||||||
|
export function classifyMetric(
|
||||||
|
key: keyof typeof METRIC_BANDS | string,
|
||||||
|
value: number | null | undefined,
|
||||||
|
): BandName | null {
|
||||||
|
const band = METRIC_BANDS[key];
|
||||||
|
if (!band || value === null || value === undefined || !Number.isFinite(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const passed = band.edges.filter((edge) => value >= edge).length;
|
||||||
|
return BAND_ORDER[passed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fraction of the meter track a value fills, clamped to 0..1. */
|
||||||
|
export function meterFraction(
|
||||||
|
key: keyof typeof METRIC_BANDS | string,
|
||||||
|
value: number | null | undefined,
|
||||||
|
): number {
|
||||||
|
const band = METRIC_BANDS[key];
|
||||||
|
if (!band || value === null || value === undefined || !Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(1, value / band.max));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band edges as track fractions, for drawing the tick marks. */
|
||||||
|
export function bandTicks(key: keyof typeof METRIC_BANDS | string): number[] {
|
||||||
|
const band = METRIC_BANDS[key];
|
||||||
|
if (!band) return [];
|
||||||
|
return band.edges.map((edge) => edge / band.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status colours, not the categorical palette — these encode state, so they are
|
||||||
|
* reserved and always paired with the band word rather than standing alone.
|
||||||
|
*/
|
||||||
|
export const BAND_STYLE: Record<BandName, { fill: string; text: string; label: string }> = {
|
||||||
|
weak: { fill: 'bg-red-400/70', text: 'text-red-400', label: 'weak' },
|
||||||
|
fair: { fill: 'bg-amber-400/70', text: 'text-amber-400', label: 'fair' },
|
||||||
|
good: { fill: 'bg-emerald-400/70', text: 'text-emerald-400', label: 'good' },
|
||||||
|
strong: { fill: 'bg-emerald-300/80', text: 'text-emerald-300', label: 'strong' },
|
||||||
|
};
|
||||||
@@ -1,4 +1,68 @@
|
|||||||
import type { MarketRegime } from './types';
|
import type { FundamentalState, MarketRegime } from './types';
|
||||||
|
|
||||||
|
/** One visual vocabulary for the three-channel regime monitor. Keep chart SVG
|
||||||
|
* literals and DOM text in sync rather than letting Tailwind aliases and
|
||||||
|
* hard-coded colours describe the same state differently.
|
||||||
|
*
|
||||||
|
* **Hue identifies the channel, and only the channel.** Two collisions made
|
||||||
|
* that false and both are fixed here:
|
||||||
|
*
|
||||||
|
* - `supportive` was literally `state`, so teal meant "the State score" in the
|
||||||
|
* Time view and "fundamentals supportive" in the Path view of the same card.
|
||||||
|
* - `adverse` sat 19 degrees from `warning`, which is inside deuteranope
|
||||||
|
* confusion range for two channels that appear on adjacent tooltip lines.
|
||||||
|
*
|
||||||
|
* The market pair now sits at 27/190 degrees and the fundamental pair at
|
||||||
|
* 0/158, so every *cross-channel* pair is at least 27 degrees apart. All six
|
||||||
|
* clear 4.5:1 against `--surface`. Fundamentals additionally carry a glyph, so
|
||||||
|
* colour is never the sole encoding for the categorical channel.
|
||||||
|
*
|
||||||
|
* `neutral` and `unknown` are deliberately the same hue: they are two states of
|
||||||
|
* one channel, both meaning "no directional signal", separated by lightness
|
||||||
|
* (7.1:1 vs 5.4:1) and by glyph (filled circle vs ring). Do not "fix" their
|
||||||
|
* proximity by giving `unknown` a hue — that would make an absence of evidence
|
||||||
|
* look like a reading.
|
||||||
|
*
|
||||||
|
* These deliberately do *not* reuse `--up-text`/`--down-text`: those are the
|
||||||
|
* app's directional tokens, and `--up-text` is already this chart's State
|
||||||
|
* colour, which is how the first collision happened.
|
||||||
|
*/
|
||||||
|
export const REGIME_VISUAL = {
|
||||||
|
// Market channels — continuous scores, drawn as lines and positions.
|
||||||
|
state: '#6ec9db',
|
||||||
|
warning: '#fb923c',
|
||||||
|
// Fundamental channel — categorical, drawn as glyphs.
|
||||||
|
supportive: '#34d399',
|
||||||
|
neutral: '#9aa0b0',
|
||||||
|
adverse: '#f87171',
|
||||||
|
unknown: '#848a9c',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Market quadrant severity as an opacity ramp on one neutral — never a hue.
|
||||||
|
*
|
||||||
|
* The quadrants are a State x Warning construct, so colouring them borrowed
|
||||||
|
* hues that already meant something else: "Healthy" was painted in the
|
||||||
|
* fundamental supportive colour and "Stabilizing" in the adverse one, which put
|
||||||
|
* an adverse glyph on an adverse-coloured background while meaning roughly the
|
||||||
|
* opposite (damage receding). Opacity carries how many axes are elevated, the
|
||||||
|
* position and labels carry which, and hue stays free to mean channel.
|
||||||
|
*/
|
||||||
|
export const QUADRANT_WASH = {
|
||||||
|
healthy: 0.015,
|
||||||
|
early_warning: 0.05,
|
||||||
|
stabilizing: 0.05,
|
||||||
|
active_stress: 0.085,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const FUNDAMENTAL_VISUAL: Record<
|
||||||
|
FundamentalState,
|
||||||
|
{ label: string; color: string; glyph: 'up' | 'circle' | 'diamond' | 'ring' }
|
||||||
|
> = {
|
||||||
|
supportive: { label: 'Supportive', color: REGIME_VISUAL.supportive, glyph: 'up' },
|
||||||
|
neutral: { label: 'Neutral', color: REGIME_VISUAL.neutral, glyph: 'circle' },
|
||||||
|
adverse: { label: 'Adverse', color: REGIME_VISUAL.adverse, glyph: 'diamond' },
|
||||||
|
unknown: { label: 'Unknown', color: REGIME_VISUAL.unknown, glyph: 'ring' },
|
||||||
|
};
|
||||||
|
|
||||||
export function regimeDot(label: MarketRegime['label']): string {
|
export function regimeDot(label: MarketRegime['label']): string {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
|
|||||||
+130
-23
@@ -295,6 +295,20 @@ export interface BacktestPortfolioPolicy {
|
|||||||
cagr_pct: number | null;
|
cagr_pct: number | null;
|
||||||
max_drawdown_pct: number;
|
max_drawdown_pct: number;
|
||||||
sharpe: number | null;
|
sharpe: number | null;
|
||||||
|
sharpe_se?: number | null;
|
||||||
|
psr?: number | null;
|
||||||
|
/** CAGR / max drawdown — the same number commonly called MAR. */
|
||||||
|
calmar?: number | null;
|
||||||
|
/**
|
||||||
|
* Optional because reports cached before these landed lack the keys entirely.
|
||||||
|
* An ABSENT `sortino` is how the UI detects such a report — distinct from
|
||||||
|
* `null`, which means "computed, undefined for this run".
|
||||||
|
*/
|
||||||
|
sortino?: number | null;
|
||||||
|
/** Schwager, on monthly returns. */
|
||||||
|
gain_to_pain?: number | null;
|
||||||
|
/** DOLLAR-based. Not the R-based profit_factor on BacktestBucket. */
|
||||||
|
profit_factor?: number | null;
|
||||||
trades: number;
|
trades: number;
|
||||||
win_rate: number | null;
|
win_rate: number | null;
|
||||||
avg_trade_pnl: number | null;
|
avg_trade_pnl: number | null;
|
||||||
@@ -322,6 +336,13 @@ export interface BacktestCurvePoint {
|
|||||||
export interface BacktestRecommendation {
|
export interface BacktestRecommendation {
|
||||||
headline: string | null;
|
headline: string | null;
|
||||||
items: { topic: string; text: string }[];
|
items: { topic: string; text: string }[];
|
||||||
|
/**
|
||||||
|
* The monitor lookback every production/benchmark figure was read from. The
|
||||||
|
* page defaults its selector to this so the tiles and the recommendation
|
||||||
|
* cannot open on different windows. Absent on reports predating the field.
|
||||||
|
*/
|
||||||
|
basis_lookback?: string | null;
|
||||||
|
basis_lookback_label?: string | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,9 +509,24 @@ export interface RegimeReading {
|
|||||||
trend?: { delta_7: number | null; delta_30: number | null };
|
trend?: { delta_7: number | null; delta_30: number | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Qualitative capex / earnings-reaction context. Not part of either score. */
|
export type FundamentalState = 'supportive' | 'neutral' | 'adverse' | 'unknown';
|
||||||
export interface RegimeFundamentalOverlay {
|
export type EvidenceQuality = 'complete' | 'partial' | 'stale' | 'manual' | 'unavailable';
|
||||||
|
|
||||||
|
/** The third channel: capex / earnings-reaction context, read alongside State
|
||||||
|
* and Warning by confluence. Deliberately never a term in either score — see
|
||||||
|
* the methodology doc on why no fusion weight is measurable yet. */
|
||||||
|
export interface RegimeFundamentalContext {
|
||||||
|
/** Derived from the stored facts by fixed rules, not by an LLM's judgement. */
|
||||||
|
state: FundamentalState;
|
||||||
|
evidence_quality: EvidenceQuality;
|
||||||
|
capex_signal: FundamentalState;
|
||||||
|
reaction_signal: FundamentalState;
|
||||||
|
/** Timing only: there is an effective, non-stale record to display. */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
/** Content too: it is available *and* actually determined something. A
|
||||||
|
* collected observation whose extraction failed is available but not usable,
|
||||||
|
* and only `usable` may confirm anything or count as study exposure. */
|
||||||
|
usable: boolean;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
effective_date: string | null;
|
effective_date: string | null;
|
||||||
@@ -503,7 +539,7 @@ export interface RegimeFundamentalOverlay {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
fetched_at: string | null;
|
fetched_at: string | null;
|
||||||
/** Whether anything was actually collected. Live reading only; the snapshot's
|
/** Whether anything was actually collected. Live reading only; the snapshot's
|
||||||
* point-in-time overlay omits it. */
|
* point-in-time record omits it. */
|
||||||
observed?: boolean;
|
observed?: boolean;
|
||||||
observed_in_snapshot?: boolean;
|
observed_in_snapshot?: boolean;
|
||||||
}
|
}
|
||||||
@@ -512,6 +548,11 @@ export interface RegimeHistoryPoint {
|
|||||||
date: string;
|
date: string;
|
||||||
state: number | null;
|
state: number | null;
|
||||||
warning: number | null;
|
warning: number | null;
|
||||||
|
/** The fundamental channel as recorded that day — drives the Path dot colour.
|
||||||
|
* Rows written before the channel existed read as "unknown", which is correct:
|
||||||
|
* nothing was observed then either. */
|
||||||
|
fundamental_state: FundamentalState;
|
||||||
|
evidence_quality: EvidenceQuality;
|
||||||
state_coverage: number | null;
|
state_coverage: number | null;
|
||||||
warning_coverage: number | null;
|
warning_coverage: number | null;
|
||||||
basket_hash: string | null;
|
basket_hash: string | null;
|
||||||
@@ -524,10 +565,12 @@ export interface RegimeMonitor {
|
|||||||
date?: string;
|
date?: string;
|
||||||
state?: RegimeReading;
|
state?: RegimeReading;
|
||||||
warning?: RegimeReading;
|
warning?: RegimeReading;
|
||||||
/** Point-in-time overlay recorded in the snapshot. */
|
/** The channel as recorded in the snapshot — point-in-time, effective-date gated. */
|
||||||
fundamental_overlay?: RegimeFundamentalOverlay;
|
fundamental_context?: RegimeFundamentalContext;
|
||||||
/** Current observation, even when it is not effective until the next session. */
|
/** What we know right now, even when it is not effective until the next
|
||||||
fundamental_context?: RegimeFundamentalOverlay;
|
* session. Separate from the above so a just-collected observation cannot
|
||||||
|
* look as though it had been backdated into the record. */
|
||||||
|
fundamental_live?: RegimeFundamentalContext;
|
||||||
inputs?: {
|
inputs?: {
|
||||||
vix: number | null;
|
vix: number | null;
|
||||||
vix_date: string | null;
|
vix_date: string | null;
|
||||||
@@ -575,7 +618,7 @@ export interface RegimeFundamentals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
|
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
|
||||||
export type GoodNewsReaction = 'yes' | 'no' | 'mixed';
|
export type GoodNewsReaction = 'yes' | 'no' | 'mixed' | 'unknown';
|
||||||
|
|
||||||
export interface RegimeFundamentalsUpdate {
|
export interface RegimeFundamentalsUpdate {
|
||||||
capex?: Record<string, CapexState>;
|
capex?: Record<string, CapexState>;
|
||||||
@@ -590,9 +633,22 @@ export interface RegimeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event study — measured lead time of early-warning indicators vs. drawdowns
|
// Event study — measured lead time of early-warning indicators vs. drawdowns
|
||||||
|
export interface EventStudyMetrics {
|
||||||
|
events: number;
|
||||||
|
events_warned: number;
|
||||||
|
events_missed: number;
|
||||||
|
alarm_episodes: number;
|
||||||
|
false_alarms: number;
|
||||||
|
/** null when the rule had no eligible sessions — undefined, not zero. */
|
||||||
|
false_alarms_per_year: number | null;
|
||||||
|
median_lead_days: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventStudyReport {
|
export interface EventStudyReport {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
/** Report shape, independent of methodology. Mismatched reports are discarded. */
|
||||||
|
schema?: number;
|
||||||
methodology?: string;
|
methodology?: string;
|
||||||
generated_at?: string;
|
generated_at?: string;
|
||||||
evaluation?: 'exploratory' | 'holdout';
|
evaluation?: 'exploratory' | 'holdout';
|
||||||
@@ -603,14 +659,11 @@ export interface EventStudyReport {
|
|||||||
event_threshold_pct: number;
|
event_threshold_pct: number;
|
||||||
event_cooldown_days: number;
|
event_cooldown_days: number;
|
||||||
horizon_days: number;
|
horizon_days: number;
|
||||||
train_fraction: number;
|
|
||||||
warn_percentile: number;
|
|
||||||
warn_threshold: number;
|
|
||||||
basket_hash: string;
|
basket_hash: string;
|
||||||
basket_asof: string;
|
basket_asof: string;
|
||||||
credit_sensor_from?: string | null;
|
credit_sensor_from?: string | null;
|
||||||
};
|
};
|
||||||
/** How far the headline metrics can be trusted. See _reliability(). */
|
/** How far the *fitted* variant's metrics can be trusted. See _reliability(). */
|
||||||
reliability?: {
|
reliability?: {
|
||||||
events_detected: number;
|
events_detected: number;
|
||||||
events_in_holdout: number;
|
events_in_holdout: number;
|
||||||
@@ -624,21 +677,75 @@ export interface EventStudyReport {
|
|||||||
sample?: {
|
sample?: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
train_end: string;
|
/** Where the quadrant baseline seeds — not a holdout boundary. */
|
||||||
test_start: string;
|
evaluable_from: string;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
holdout_sessions: number;
|
evaluable_sessions: number;
|
||||||
|
events_detected: number;
|
||||||
|
events_evaluable: number;
|
||||||
};
|
};
|
||||||
metrics?: {
|
/** The quadrant-change rule that actually reaches Telegram. The headline. */
|
||||||
|
shipped?: {
|
||||||
|
rule: {
|
||||||
|
state_divider: number;
|
||||||
|
warning_divider: number;
|
||||||
|
margin: number;
|
||||||
|
confirm_sessions: number;
|
||||||
|
cooldown_days: number;
|
||||||
|
entry: string;
|
||||||
|
};
|
||||||
|
metrics: EventStudyMetrics;
|
||||||
|
events: { date: string; warned: boolean; lead_days: number | null }[];
|
||||||
|
quadrant_changes: number;
|
||||||
|
/** Debugging payload: every change the replay would have alerted on. Not rendered. */
|
||||||
|
fires: { index: number; date: string; from: string; to: string; state: number; warning: number }[];
|
||||||
|
/** Credit history starts partway through, so Warning is W1+W2 before it. */
|
||||||
|
by_era?: {
|
||||||
|
credit_from: string;
|
||||||
|
pre_credit: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
|
||||||
|
full_coverage: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
/** The fundamental channel's actual exposure — its rows are scored on this
|
||||||
|
* window, not on the market rows' full sample. */
|
||||||
|
fundamental_coverage?: {
|
||||||
|
observations: number;
|
||||||
|
/** Sessions with usable (observed, effective, non-stale) context. */
|
||||||
|
sessions_eligible: number;
|
||||||
|
evaluable_sessions: number;
|
||||||
|
/** Corrections whose warning horizon had usable context. */
|
||||||
|
events_covered: number;
|
||||||
|
events_evaluable: number;
|
||||||
|
minimum_events: number;
|
||||||
|
/** False until enough corrections are covered: the fundamental rows are
|
||||||
|
* untested, not failed, and must not render as a 0/N result. */
|
||||||
|
measurable: boolean;
|
||||||
|
};
|
||||||
|
/** Ablations, external baselines, and the fundamental channel — all on fixed
|
||||||
|
* (unfitted) rules, so every row is scored on the same events. */
|
||||||
|
comparison?: (EventStudyMetrics & {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'ablation' | 'baseline' | 'fundamental';
|
||||||
|
note: string;
|
||||||
|
measurable: boolean;
|
||||||
|
})[];
|
||||||
|
null_model?: {
|
||||||
|
draws: number;
|
||||||
|
alarms_per_draw: number;
|
||||||
events: number;
|
events: number;
|
||||||
events_warned: number;
|
mean_warned: number;
|
||||||
events_missed: number;
|
sd_warned: number;
|
||||||
alarm_episodes: number;
|
observed_warned: number;
|
||||||
false_alarms: number;
|
p_at_least_observed: number;
|
||||||
false_alarms_per_year: number;
|
} | null;
|
||||||
median_lead_days: number | null;
|
/** The original 70/30 fitted-threshold study, kept for continuity. */
|
||||||
|
fitted?: {
|
||||||
|
params: { train_fraction: number; warn_percentile: number; warn_threshold: number };
|
||||||
|
sample: { train_end: string; test_start: string; holdout_sessions: number };
|
||||||
|
metrics: EventStudyMetrics;
|
||||||
|
events: { date: string; warned: boolean; lead_days: number | null }[];
|
||||||
};
|
};
|
||||||
events?: { date: string; warned: boolean; lead_days: number | null }[];
|
|
||||||
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
|
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+437
-107
@@ -6,6 +6,7 @@ import { Disclosure } from '../components/ui/Disclosure';
|
|||||||
import { Badge } from '../components/ui/Badge';
|
import { Badge } from '../components/ui/Badge';
|
||||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import { FUNDAMENTAL_VISUAL } from '../lib/regime';
|
||||||
import {
|
import {
|
||||||
getEventStudy,
|
getEventStudy,
|
||||||
getRegimeConfig,
|
getRegimeConfig,
|
||||||
@@ -17,11 +18,13 @@ import {
|
|||||||
} from '../api/regime';
|
} from '../api/regime';
|
||||||
import type {
|
import type {
|
||||||
CapexState,
|
CapexState,
|
||||||
|
FundamentalState,
|
||||||
|
EventStudyMetrics,
|
||||||
EventStudyReport,
|
EventStudyReport,
|
||||||
GoodNewsReaction,
|
GoodNewsReaction,
|
||||||
RegimeBand,
|
RegimeBand,
|
||||||
RegimeConfig,
|
RegimeConfig,
|
||||||
RegimeFundamentalOverlay,
|
RegimeFundamentalContext,
|
||||||
RegimeFundamentals,
|
RegimeFundamentals,
|
||||||
RegimeFundamentalsUpdate,
|
RegimeFundamentalsUpdate,
|
||||||
RegimeMonitor,
|
RegimeMonitor,
|
||||||
@@ -39,7 +42,7 @@ const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string;
|
|||||||
|
|
||||||
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
|
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
|
||||||
if (delta == null) {
|
if (delta == null) {
|
||||||
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
|
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">{label}: n/a</span>;
|
||||||
}
|
}
|
||||||
const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
|
const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
|
||||||
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
|
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
|
||||||
@@ -68,21 +71,21 @@ function ScoreGauge({
|
|||||||
// shared set would mislabel one of them. Render none rather than wrong ones.
|
// shared set would mislabel one of them. Render none rather than wrong ones.
|
||||||
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
||||||
return (
|
return (
|
||||||
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
<div className={`glass h-full border p-5 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
|
<div className="text-xs uppercase tracking-wider text-gray-400">{label}</div>
|
||||||
<div className="mt-1 flex items-baseline gap-2">
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
<span className={`font-display text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
|
<span className={`font-display text-5xl font-bold ${style?.text ?? 'text-gray-500'}`}>
|
||||||
{score == null ? '—' : Math.round(score)}
|
{score == null ? '—' : Math.round(score)}
|
||||||
</span>
|
</span>
|
||||||
{score != null && <span className="text-sm text-gray-500">/ 100</span>}
|
{score != null && <span className="text-sm text-gray-400">/ 100</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
|
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
|
||||||
{style?.label ?? 'Incomplete'}
|
{style?.label ?? 'Incomplete'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-600">coverage {Math.round(reading?.coverage ?? 0)}%</span>
|
<span className="text-xs text-gray-400">coverage {Math.round(reading?.coverage ?? 0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -102,7 +105,7 @@ function ScoreGauge({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Thresholds come from the reading: the two axes no longer share them. */}
|
{/* Thresholds come from the reading: the two axes no longer share them. */}
|
||||||
<div className="relative mt-1.5 h-4 text-[10px] uppercase tracking-wider text-gray-600">
|
<div className="relative mt-1.5 h-4 text-xs uppercase tracking-wider text-gray-400">
|
||||||
<span className="absolute left-0">0</span>
|
<span className="absolute left-0">0</span>
|
||||||
{ticks.map((tick) => (
|
{ticks.map((tick) => (
|
||||||
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
|
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
|
||||||
@@ -113,86 +116,153 @@ function ScoreGauge({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="mt-4 text-xs text-gray-500">{footnote}</p>
|
<p className="mt-4 text-xs leading-relaxed text-gray-400">{footnote}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAPEX_TONE: Record<CapexState, string> = {
|
/** Mirrors `_capex_signal`: holding is the neutral case, so it takes the neutral
|
||||||
raising: 'text-emerald-400',
|
* colour rather than an amber that reads as a third severity and sits close to
|
||||||
holding: 'text-amber-400',
|
* the Warning channel's orange. */
|
||||||
cutting: 'text-red-400',
|
const CAPEX_COLOR: Record<CapexState, string> = {
|
||||||
unknown: 'text-gray-500',
|
raising: FUNDAMENTAL_VISUAL.supportive.color,
|
||||||
|
holding: FUNDAMENTAL_VISUAL.neutral.color,
|
||||||
|
cutting: FUNDAMENTAL_VISUAL.adverse.color,
|
||||||
|
unknown: FUNDAMENTAL_VISUAL.unknown.color,
|
||||||
};
|
};
|
||||||
|
|
||||||
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
|
function sentenceCase(value: string): string {
|
||||||
|
const text = value.replace(/_/g, ' ');
|
||||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
const capex = overlay.capex ?? {};
|
|
||||||
const reaction = overlay.good_news_stock_down;
|
|
||||||
|
|
||||||
// Nothing collected: the stored default is "unknown" for every hyperscaler
|
|
||||||
// and "mixed" for the reaction, which are placeholders, not a reading.
|
|
||||||
if (overlay.observed === false) {
|
|
||||||
return (
|
|
||||||
<div className="glass border border-white/[0.06] p-5">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
|
||||||
<p className="mt-3 text-xs text-gray-500">
|
|
||||||
No observation collected yet. An admin can collect one under Admin · Monitor settings. It is
|
|
||||||
context only — it never enters State or Warning.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reactionReading(reaction: GoodNewsReaction | null): { label: string; color: string } {
|
||||||
|
switch (reaction) {
|
||||||
|
case 'yes':
|
||||||
|
return { label: 'Yes · good news sold', color: FUNDAMENTAL_VISUAL.adverse.color };
|
||||||
|
case 'no':
|
||||||
|
return { label: 'No · ordinary reactions', color: FUNDAMENTAL_VISUAL.supportive.color };
|
||||||
|
case 'mixed':
|
||||||
|
return { label: 'Mixed · no clear pattern', color: FUNDAMENTAL_VISUAL.neutral.color };
|
||||||
|
default:
|
||||||
|
return { label: 'Unknown · not observed', color: FUNDAMENTAL_VISUAL.unknown.color };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FundamentalSummaryCard({ overlay }: { overlay: RegimeFundamentalContext }) {
|
||||||
|
const tone = FUNDAMENTAL_VISUAL[overlay.state] ?? FUNDAMENTAL_VISUAL.unknown;
|
||||||
|
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
|
||||||
|
const status = !observed
|
||||||
|
? 'No usable observation. This channel remains Unknown.'
|
||||||
|
: overlay.pending
|
||||||
|
? `Collected now; enters the point-in-time record ${overlay.effective_date ?? 'next session'}.`
|
||||||
|
: overlay.stale
|
||||||
|
? 'The last state is retained for context, but stale evidence cannot confirm alerts.'
|
||||||
|
: !overlay.usable
|
||||||
|
? 'An observation was collected, but no signal could be determined.'
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass border border-white/[0.06] p-5">
|
<div className="glass h-full border p-5" style={{ borderColor: `${tone.color}33` }}>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
<div className="text-[11px] uppercase tracking-wider text-gray-400">Fundamentals · context</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{overlay.source && <span>{overlay.source}</span>}
|
|
||||||
{/* When pending, the line below is the single carrier of this date. */}
|
|
||||||
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
|
|
||||||
{overlay.pending && <Badge label="pending" variant="manual" />}
|
{overlay.pending && <Badge label="pending" variant="manual" />}
|
||||||
{overlay.stale && <Badge label="stale" variant="manual" />}
|
{overlay.stale && <Badge label="stale" variant="manual" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* A pending observation is still shown — it is the freshest read we
|
<div className="mt-2 flex flex-wrap items-baseline gap-3">
|
||||||
have, and nothing here is scored. The date says when the stored
|
<span className="font-display text-4xl font-bold" style={{ color: tone.color }}>{tone.label}</span>
|
||||||
point-in-time record picks it up. */}
|
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-300">
|
||||||
{overlay.pending && (
|
{sentenceCase(overlay.evidence_quality)} evidence
|
||||||
<p className="mt-3 text-xs text-amber-400/90">
|
</span>
|
||||||
Shown as collected. The point-in-time record picks it up{' '}
|
</div>
|
||||||
{overlay.effective_date ?? 'next session'} — observations are never backdated.
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
|
||||||
|
<div className="text-gray-400">Capex</div>
|
||||||
|
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.capex_signal].color }}>
|
||||||
|
{sentenceCase(overlay.capex_signal)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
|
||||||
|
<div className="text-gray-400">Reaction</div>
|
||||||
|
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.reaction_signal].color }}>
|
||||||
|
{sentenceCase(overlay.reaction_signal)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <p className="mt-3 text-xs leading-relaxed text-gray-400">{status}</p>}
|
||||||
|
{(overlay.source || overlay.effective_date) && (
|
||||||
|
<p className="mt-3 text-[11px] text-gray-400">
|
||||||
|
{overlay.source ?? 'stored observation'}
|
||||||
|
{overlay.effective_date && ` · effective ${overlay.effective_date}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
|
||||||
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
|
|
||||||
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FundamentalEvidence({ overlay }: { overlay: RegimeFundamentalContext }) {
|
||||||
|
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
|
||||||
|
if (!observed) return null;
|
||||||
|
const capex = overlay.capex ?? {};
|
||||||
|
const reaction = reactionReading(overlay.good_news_stock_down);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Disclosure summary="Fundamental evidence · capex and earnings reaction">
|
||||||
|
<div className="grid gap-5 pt-1 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-medium text-gray-200">Hyperscaler capex guidance</div>
|
||||||
|
{Object.keys(capex).length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">No company-level observation.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
{Object.entries(capex).map(([symbol, state]) => (
|
{Object.entries(capex).map(([symbol, state]) => (
|
||||||
<div key={symbol} className="flex items-center justify-between text-xs">
|
<div key={symbol} className="flex items-center justify-between text-xs">
|
||||||
<span className="font-mono text-gray-400">{symbol}</span>
|
<span className="font-mono text-gray-300">{symbol}</span>
|
||||||
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
|
<span className="font-medium" style={{ color: CAPEX_COLOR[state] }}>{sentenceCase(state)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
<div className="mb-2 text-xs font-medium text-gray-200">Good news, stock down</div>
|
||||||
<span className="font-medium text-gray-300">Good news, stock down</span>
|
<div className="text-sm font-medium" style={{ color: reaction.color }}>{reaction.label}</div>
|
||||||
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
</div>
|
Derived context: capex {overlay.capex_signal} · reaction {overlay.reaction_signal}.
|
||||||
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
|
</p>
|
||||||
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{overlay.reasoning && (
|
||||||
|
<details className="mt-4 border-t border-white/[0.06] pt-3">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium text-gray-400 hover:text-gray-200">
|
||||||
|
Source reasoning
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-gray-300">{overlay.reasoning}</p>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfluenceStrip({ warning, context }: { warning: RegimeReading; context?: RegimeFundamentalContext }) {
|
||||||
|
const warningElevated = warning.band === 'elevated' || warning.band === 'breaking';
|
||||||
|
if (!warningElevated || !context?.usable || context.state !== 'adverse') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-sm relative overflow-hidden px-4 py-3" role="status">
|
||||||
|
<span className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-orange-400 to-red-400" aria-hidden="true" />
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pl-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-orange-300">Confluence active</span>
|
||||||
|
<span className="text-sm text-gray-200">
|
||||||
|
Warning is {warning.band}; point-in-time fundamentals are adverse with {context.evidence_quality} evidence.
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">Condition only · never a combined score</span>
|
||||||
</div>
|
</div>
|
||||||
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +279,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
|||||||
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-400">
|
||||||
<th className="px-4 py-3 font-medium">Pillar / sensor</th>
|
<th className="px-4 py-3 font-medium">Pillar / sensor</th>
|
||||||
<th className="px-4 py-3 text-right font-medium">Score</th>
|
<th className="px-4 py-3 text-right font-medium">Score</th>
|
||||||
<th className="px-4 py-3 text-right font-medium">Weight</th>
|
<th className="px-4 py-3 text-right font-medium">Weight</th>
|
||||||
@@ -221,7 +291,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
|||||||
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
||||||
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
||||||
{title}
|
{title}
|
||||||
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
<span className="ml-2 normal-case tracking-normal text-gray-400">
|
||||||
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -232,8 +302,8 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
|||||||
<div className="font-medium text-gray-200">{pillar.label}</div>
|
<div className="font-medium text-gray-200">{pillar.label}</div>
|
||||||
<div className="mt-1 space-y-0.5">
|
<div className="mt-1 space-y-0.5">
|
||||||
{pillar.sensors.map((sensor) => (
|
{pillar.sensors.map((sensor) => (
|
||||||
<div key={sensor.id} className="text-xs text-gray-500">
|
<div key={sensor.id} className="text-xs text-gray-400">
|
||||||
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
|
<span className="font-mono text-gray-400">{sensor.id}</span> {sensor.label}:{' '}
|
||||||
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -256,7 +326,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
|||||||
|
|
||||||
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
|
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400" title={title}>
|
||||||
{label} <span className="num text-gray-400">{value}</span>
|
{label} <span className="num text-gray-400">{value}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -288,56 +358,273 @@ function MetaStrip({ data }: { data: RegimeMonitor }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
function StatTiles({ metrics }: { metrics: EventStudyMetrics }) {
|
||||||
const metrics = report.metrics;
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
|
|
||||||
{report.generated_at && <span className="text-xs text-gray-500">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
|
|
||||||
{report.sample && <span className="text-xs text-gray-500">test {report.sample.test_start} → {report.sample.end}</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
|
|
||||||
{metrics && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
['Warned', `${metrics.events_warned}/${metrics.events}`],
|
['Warned', `${metrics.events_warned}/${metrics.events}`],
|
||||||
['Missed', metrics.events_missed],
|
['Missed', metrics.events_missed],
|
||||||
['False alarms/year', metrics.false_alarms_per_year.toFixed(1)],
|
['False alarms/year', metrics.false_alarms_per_year?.toFixed(1) ?? '—'],
|
||||||
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
|
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<div className="text-[11px] text-gray-500">{label}</div>
|
<div className="text-xs text-gray-400">{label}</div>
|
||||||
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
|
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
{report.events && report.events.length > 0 && (
|
}
|
||||||
|
|
||||||
|
function EventTable({ events }: { events: { date: string; warned: boolean; lead_days: number | null }[] }) {
|
||||||
|
return (
|
||||||
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead><tr className="border-b border-white/[0.06] text-left text-gray-500">
|
<thead><tr className="border-b border-white/[0.06] text-left text-gray-400">
|
||||||
<th className="px-3 py-2 font-medium">Correction</th>
|
<th className="px-3 py-2 font-medium">Correction</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Warned</th>
|
<th className="px-3 py-2 text-right font-medium">Warned</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Lead</th>
|
<th className="px-3 py-2 text-right font-medium">Lead</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{report.events.map((event) => (
|
<tbody>{events.map((event) => (
|
||||||
<tr key={event.date} className="border-b border-white/[0.03] last:border-0">
|
<tr key={event.date} className="border-b border-white/[0.03] last:border-0">
|
||||||
<td className="px-3 py-2 num text-gray-300">{event.date}</td>
|
<td className="px-3 py-2 num text-gray-300">{event.date}</td>
|
||||||
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-500'}`}>{event.warned ? 'yes' : 'no'}</td>
|
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-400'}`}>{event.warned ? 'yes' : 'no'}</td>
|
||||||
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
|
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shipped rule against ablations, external baselines, and chance.
|
||||||
|
*
|
||||||
|
* The two kinds answer different questions and must not be read as one list:
|
||||||
|
* an ablation asks whether the quadrant machinery earns its place, a baseline
|
||||||
|
* asks whether the score earns its complexity.
|
||||||
|
*/
|
||||||
|
function ComparisonTable({ report }: { report: EventStudyReport }) {
|
||||||
|
const shipped = report.shipped;
|
||||||
|
if (!shipped || !report.comparison?.length) return null;
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'shipped',
|
||||||
|
label: 'Quadrant alert (shipped)',
|
||||||
|
kind: 'shipped' as const,
|
||||||
|
note: shipped.rule.entry,
|
||||||
|
measurable: true,
|
||||||
|
...shipped.metrics,
|
||||||
|
},
|
||||||
|
...report.comparison,
|
||||||
|
];
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
shipped: 'shipped',
|
||||||
|
ablation: 'ablation',
|
||||||
|
baseline: 'baseline',
|
||||||
|
fundamental: 'fundamental',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead><tr className="border-b border-white/[0.06] text-left text-gray-400">
|
||||||
|
<th className="px-3 py-2 font-medium">Rule</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Warned</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">FA/yr</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Median lead</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={`border-b border-white/[0.03] last:border-0 ${row.kind === 'shipped' ? 'bg-white/[0.03]' : ''}`}
|
||||||
|
title={row.note}
|
||||||
|
>
|
||||||
|
<td className={`px-3 py-2 ${row.kind === 'shipped' ? 'font-medium text-gray-200' : 'text-gray-400'}`}>
|
||||||
|
{row.label}
|
||||||
|
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">{KIND_LABEL[row.kind]}</span>
|
||||||
|
</td>
|
||||||
|
{/* A rule whose input does not exist yet scores 0/N, and printing
|
||||||
|
that would read as tested-and-failed. Say "not measurable". */}
|
||||||
|
{row.measurable === false ? (
|
||||||
|
<td className="px-3 py-2 text-right text-xs italic text-gray-400" colSpan={3}>
|
||||||
|
{/* Not "no observations yet": once some exist but fewer than
|
||||||
|
the minimum are covered, that is simply false. Matches the
|
||||||
|
callout below. */}
|
||||||
|
insufficient exposure — not measurable
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="px-3 py-2 text-right num text-gray-300">{row.events_warned}/{row.events}</td>
|
||||||
|
<td className="px-3 py-2 text-right num text-gray-300">{row.false_alarms_per_year?.toFixed(1) ?? '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-right num text-gray-300">{row.median_lead_days == null ? '—' : `${row.median_lead_days}d`}</td>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{report.null_model && (
|
||||||
|
<tr className="border-t border-white/[0.06] text-gray-400">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
Random alarms, same firing rate
|
||||||
|
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">null</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right num">
|
||||||
|
{report.null_model.mean_warned.toFixed(1)} ± {report.null_model.sd_warned.toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right num">—</td>
|
||||||
|
<td className="px-3 py-2 text-right num">—</td>
|
||||||
|
</tr>
|
||||||
|
)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudyVerdict({ report }: { report: EventStudyReport }) {
|
||||||
|
const model = report.null_model;
|
||||||
|
if (!model) return null;
|
||||||
|
const chancePct = (model.p_at_least_observed * 100).toFixed(0);
|
||||||
|
const indistinguishable = model.p_at_least_observed >= 0.1;
|
||||||
|
// The number carries the claim, not the adjective. At ~10 corrections a p of
|
||||||
|
// 0.09 is not evidence of anything, so "beats the null" would over-state a
|
||||||
|
// result this panel is otherwise careful never to over-state.
|
||||||
|
return (
|
||||||
|
<Callout variant={indistinguishable ? 'warning' : 'info'}>
|
||||||
|
<strong>
|
||||||
|
{indistinguishable
|
||||||
|
? `Not distinguishable from chance (p = ${model.p_at_least_observed.toFixed(2)}).`
|
||||||
|
: `Above the firing-rate null (p = ${model.p_at_least_observed.toFixed(2)}).`}
|
||||||
|
</strong>{' '}
|
||||||
|
Random alarms match or beat {model.observed_warned}/{model.events} warned corrections in {chancePct}% of{' '}
|
||||||
|
{model.draws} draws placing {model.alarms_per_draw} alarms over the same sessions. Corrections cluster and random
|
||||||
|
placement does not, so this is the floor, not the bar.
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The credit sensor starts partway through, so Warning is a different
|
||||||
|
* construct either side of it. The share is derived, never asserted: if one era
|
||||||
|
* carries no corrections there is no comparison to draw and the per-era ratios
|
||||||
|
* would be noise dressed up as a finding. */
|
||||||
|
function EraDisclosure({
|
||||||
|
eras,
|
||||||
|
divider,
|
||||||
|
}: {
|
||||||
|
eras: NonNullable<NonNullable<EventStudyReport['shipped']>['by_era']>;
|
||||||
|
divider: number | undefined;
|
||||||
|
}) {
|
||||||
|
const { pre_credit: pre, full_coverage: full } = eras;
|
||||||
|
const total = pre.sessions + full.sessions;
|
||||||
|
const share = total > 0 ? Math.round((pre.sessions / total) * 100) : 0;
|
||||||
|
// An era holding one or two corrections has a recall of 0/1 or 1/2, which is
|
||||||
|
// not a rate. Below this the eras get their false-alarm rates compared and
|
||||||
|
// nothing else.
|
||||||
|
const comparable = pre.events >= 3 && full.events >= 3;
|
||||||
|
return (
|
||||||
|
<Disclosure summary={`Sensor-era caveat · ${share}% of sessions predate credit`}>
|
||||||
|
<p className="text-xs leading-relaxed text-gray-400">
|
||||||
|
<strong>{share}% of the evaluated sessions predate the credit sensor.</strong> W3 begins {eras.credit_from}, so
|
||||||
|
before that Warning renormalises to W1+W2 and the fixed {divider} divider is applied to a different construct
|
||||||
|
than it was reasoned about. Dropping the training split makes every correction evaluable; it does not make the
|
||||||
|
coverage gap go away, it moves it from the threshold to the score.
|
||||||
|
{comparable ? (
|
||||||
|
<>
|
||||||
|
{' '}Two sensors:{' '}
|
||||||
|
<strong className="text-gray-300">{pre.events_warned}/{pre.events}</strong> at{' '}
|
||||||
|
{pre.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr. All three:{' '}
|
||||||
|
<strong className="text-gray-300">{full.events_warned}/{full.events}</strong> at{' '}
|
||||||
|
{full.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}The corrections do not straddle that boundary ({pre.events} before, {full.events} after), so the two
|
||||||
|
eras cannot be compared on recall — only the false-alarm rates are meaningful ({pre.false_alarms_per_year?.toFixed(1) ?? '—'}{' '}
|
||||||
|
vs {full.false_alarms_per_year?.toFixed(1) ?? '—'} per year).
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Disclosure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||||
|
const shipped = report.shipped;
|
||||||
|
const eras = shipped?.by_era;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
|
||||||
|
{report.generated_at && <span className="text-xs text-gray-400">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
|
||||||
|
{report.sample && <span className="text-xs text-gray-400">{report.sample.evaluable_from} → {report.sample.end}</span>}
|
||||||
|
</div>
|
||||||
|
<StudyVerdict report={report} />
|
||||||
|
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
|
||||||
|
{shipped && <StatTiles metrics={shipped.metrics} />}
|
||||||
|
<ComparisonTable report={report} />
|
||||||
|
|
||||||
|
{shipped && shipped.events.length > 0 && (
|
||||||
|
<Disclosure summary={`Correction details · ${shipped.events.length} events`}>
|
||||||
|
<EventTable events={shipped.events} />
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.null_model && (
|
||||||
|
<Disclosure summary="Null-model interpretation">
|
||||||
|
<p className="text-xs leading-relaxed text-gray-400">
|
||||||
|
The null places {report.null_model.alarms_per_draw} alarms at random over the same sessions and at the
|
||||||
|
shipped rule's firing rate. Corrections cluster while random placement does not, so this is a floor rather
|
||||||
|
than a demanding benchmark: a clustering rule could beat it without genuine foresight.
|
||||||
|
</p>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.fundamental_coverage && !report.fundamental_coverage.measurable && (
|
||||||
|
<Disclosure
|
||||||
|
summary={`Fundamental exposure · ${report.fundamental_coverage.events_covered}/${report.fundamental_coverage.events_evaluable} corrections covered`}
|
||||||
|
>
|
||||||
|
<p className="text-xs leading-relaxed text-gray-400">
|
||||||
|
<strong>Insufficient exposure — the fundamental rows are untested, not failed.</strong>{' '}
|
||||||
|
The channel had usable context on{' '}
|
||||||
|
<strong className="text-gray-300">
|
||||||
|
{report.fundamental_coverage.sessions_eligible} of{' '}
|
||||||
|
{report.fundamental_coverage.evaluable_sessions}
|
||||||
|
</strong>{' '}
|
||||||
|
evaluated sessions, covering{' '}
|
||||||
|
<strong className="text-gray-300">
|
||||||
|
{report.fundamental_coverage.events_covered} of{' '}
|
||||||
|
{report.fundamental_coverage.events_evaluable}
|
||||||
|
</strong>{' '}
|
||||||
|
corrections ({report.fundamental_coverage.minimum_events} needed;{' '}
|
||||||
|
{report.fundamental_coverage.observations} observation
|
||||||
|
{report.fundamental_coverage.observations === 1 ? '' : 's'} recorded). Those rows are
|
||||||
|
scored only on that window, never on the market rows' full sample — otherwise a
|
||||||
|
fortnight of data would render as a 0/10 and read as a failed test. Read the market rows
|
||||||
|
as a verdict on the technical sensors and the alert machinery only.
|
||||||
|
</p>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eras && <EraDisclosure eras={eras} divider={shipped?.rule.warning_divider} />}
|
||||||
|
|
||||||
|
{report.fitted && (
|
||||||
|
<Disclosure summary={`Fitted-threshold variant · ${report.fitted.metrics.events_warned}/${report.fitted.metrics.events} on the 30% holdout`}>
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<p className="text-xs leading-relaxed text-gray-400">
|
||||||
|
The original study, kept because it is what the methodology document reports: an{' '}
|
||||||
|
{report.fitted.params.warn_percentile}th-percentile Warning threshold (
|
||||||
|
{report.fitted.params.warn_threshold}) frozen on the first{' '}
|
||||||
|
{(report.fitted.params.train_fraction * 100).toFixed(0)}% of sessions and measured on the rest. Nothing
|
||||||
|
consumes this rule — the shipped alert uses fixed dividers with hysteresis, confirmation and a cooldown.
|
||||||
|
</p>
|
||||||
|
<StatTiles metrics={report.fitted.metrics} />
|
||||||
|
{report.fitted.events.length > 0 && <EventTable events={report.fitted.events} />}
|
||||||
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
|
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
|
||||||
<Callout variant="warning">
|
<Callout variant="warning">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{report.reliability.underpowered && (
|
{report.reliability.underpowered && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
||||||
{report.reliability.events_detected} detected corrections fall in the test period (
|
{report.reliability.events_detected} detected corrections fall in the holdout (
|
||||||
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -355,6 +642,9 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
|||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,15 +684,26 @@ function FundamentalsEditor({
|
|||||||
}) {
|
}) {
|
||||||
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
|
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
|
||||||
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
||||||
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
|
const values = Object.values(capex);
|
||||||
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
|
const counts = {
|
||||||
// the deceleration case and used to score identically to raising.
|
cutting: values.filter((s) => s === 'cutting').length,
|
||||||
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
|
holding: values.filter((s) => s === 'holding').length,
|
||||||
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
|
raising: values.filter((s) => s === 'raising').length,
|
||||||
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
|
unknown: values.filter((s) => s === 'unknown').length,
|
||||||
|
};
|
||||||
|
// Mirrors _capex_signal: any cut is adverse on partial evidence, any hold is
|
||||||
|
// neutral, all-known-raising is supportive, nothing known is unknown. No
|
||||||
|
// average — an average would let cuts and unknowns land on "neutral".
|
||||||
|
const capexSignal: FundamentalState =
|
||||||
|
counts.cutting > 0 ? 'adverse'
|
||||||
|
: counts.holding > 0 ? 'neutral'
|
||||||
|
: counts.raising > 0 ? 'supportive'
|
||||||
|
: 'unknown';
|
||||||
|
const reactionSignal: FundamentalState =
|
||||||
|
reaction === 'yes' ? 'adverse' : reaction === 'no' ? 'supportive' : reaction === 'mixed' ? 'neutral' : 'unknown';
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
||||||
<span>Source: {data.source}</span>
|
<span>Source: {data.source}</span>
|
||||||
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
|
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
|
||||||
{data.effective_date && <span>· effective {data.effective_date}</span>}
|
{data.effective_date && <span>· effective {data.effective_date}</span>}
|
||||||
@@ -411,8 +712,8 @@ function FundamentalsEditor({
|
|||||||
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
|
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
||||||
<span className="font-medium text-gray-300">F1 · Capex guidance by hyperscaler</span>
|
<span className="font-medium text-gray-300">Capex guidance by hyperscaler</span>
|
||||||
<span className="num text-gray-500">score {derivedF1 ?? 'n/a'}</span>
|
<span style={{ color: FUNDAMENTAL_VISUAL[capexSignal].color }}>{capexSignal}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(capex).map(([symbol, state]) => (
|
{Object.entries(capex).map(([symbol, state]) => (
|
||||||
@@ -428,17 +729,24 @@ function FundamentalsEditor({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
|
<p className="mt-1.5 text-xs text-gray-400">
|
||||||
|
{counts.cutting} cutting · {counts.holding} holding · {counts.raising} raising · {counts.unknown} unknown.
|
||||||
|
Any cut reads adverse on partial evidence; supportive needs every known name raising.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-gray-300">F3 · Good news, stock down</span>
|
<span className="font-medium text-gray-300">Good news, stock down</span>
|
||||||
<span className="ml-2 num text-gray-600">score {derivedF3 ?? 'n/a'}</span>
|
<span className="ml-2" style={{ color: FUNDAMENTAL_VISUAL[reactionSignal].color }}>{reactionSignal}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{/* "Mixed" is an observed mixed reaction; "unknown" is nobody looked or
|
||||||
|
the extraction failed. Collapsing them made a parse error read as
|
||||||
|
neutral evidence. */}
|
||||||
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
|
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
|
||||||
<option value="yes">Yes · stress</option>
|
<option value="yes">Yes · good news sold</option>
|
||||||
<option value="no">No · ordinary</option>
|
<option value="no">No · reacting normally</option>
|
||||||
<option value="mixed">Mixed · unavailable</option>
|
<option value="mixed">Mixed · observed, no clear pattern</option>
|
||||||
|
<option value="unknown">Unknown · not observed</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -465,7 +773,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u
|
|||||||
<input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
|
<input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
|
||||||
<span>days</span>
|
<span>days</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
|
<p className="text-xs text-gray-400">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
|
||||||
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save basket & freshness</button>
|
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save basket & freshness</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -483,13 +791,13 @@ function AdminControls() {
|
|||||||
<Disclosure summary="Admin · Monitor settings">
|
<Disclosure summary="Admin · Monitor settings">
|
||||||
<div className="grid gap-5 xl:grid-cols-2 xl:gap-6">
|
<div className="grid gap-5 xl:grid-cols-2 xl:gap-6">
|
||||||
<section className="border-b border-white/[0.06] pb-5 xl:border-b-0 xl:border-r xl:pb-0 xl:pr-6">
|
<section className="border-b border-white/[0.06] pb-5 xl:border-b-0 xl:border-r xl:pb-0 xl:pr-6">
|
||||||
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fundamental observations</div>
|
<div className="mb-3 text-xs uppercase tracking-wider text-gray-400">Fundamental observations</div>
|
||||||
{fundamentals.isLoading && <SkeletonCard className="h-36" />}
|
{fundamentals.isLoading && <SkeletonCard className="h-36" />}
|
||||||
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
|
||||||
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fixed basket & freshness</div>
|
<div className="mb-3 text-xs uppercase tracking-wider text-gray-400">Fixed basket & freshness</div>
|
||||||
{config.isLoading && <SkeletonCard className="h-36" />}
|
{config.isLoading && <SkeletonCard className="h-36" />}
|
||||||
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
|
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
|
||||||
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
|
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
|
||||||
@@ -508,7 +816,19 @@ export default function RegimePage() {
|
|||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="AI/Tech Risk Monitor"
|
title="AI/Tech Risk Monitor"
|
||||||
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
|
subtitle="Market stress, early warning, and fundamental context"
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Badge label="observational only" variant="default" />
|
||||||
|
{data?.date && <span className="num text-xs text-gray-400">as of {data.date}</span>}
|
||||||
|
{data?.available && (
|
||||||
|
<Badge
|
||||||
|
label={data.data_quality?.is_fresh ? 'fresh' : 'check data'}
|
||||||
|
variant={data.data_quality?.is_fresh ? 'auto' : 'manual'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
|
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
|
||||||
@@ -524,7 +844,7 @@ export default function RegimePage() {
|
|||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="State · stress right now"
|
label="State · stress right now"
|
||||||
reading={data.state}
|
reading={data.state}
|
||||||
@@ -542,17 +862,27 @@ export default function RegimePage() {
|
|||||||
<ScoreGauge
|
<ScoreGauge
|
||||||
label="Warning · deterioration & divergence"
|
label="Warning · deterioration & divergence"
|
||||||
reading={data.warning}
|
reading={data.warning}
|
||||||
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
|
footnote={
|
||||||
|
<>
|
||||||
|
Breadth divergence · SMH/SPY rollover · HY credit impulse.
|
||||||
|
{data.warning.coverage < 100 && ' Missing sensors are omitted rather than filled.'}
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
{data.fundamental_live && <FundamentalSummaryCard overlay={data.fundamental_live} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfluenceStrip warning={data.warning} context={data.fundamental_context} />
|
||||||
|
|
||||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
||||||
|
|
||||||
|
{data.fundamental_live && <FundamentalEvidence overlay={data.fundamental_live} />}
|
||||||
|
|
||||||
<PillarTable state={data.state} warning={data.warning} />
|
<PillarTable state={data.state} warning={data.warning} />
|
||||||
|
|
||||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
<Disclosure summary="Data provenance · coverage and history">
|
||||||
|
|
||||||
<MetaStrip data={data} />
|
<MetaStrip data={data} />
|
||||||
|
</Disclosure>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { Tabs } from '../components/ui/Tabs';
|
import { Tabs } from '../components/ui/Tabs';
|
||||||
import { SetupsPanel } from '../components/signals/SetupsPanel';
|
import { SetupsPanel } from '../components/signals/SetupsPanel';
|
||||||
import { TrackRecordPanel } from '../components/signals/TrackRecordPanel';
|
import { MyTradesPanel } from '../components/signals/MyTradesPanel';
|
||||||
|
import { BacktestPanel } from '../components/signals/BacktestPanel';
|
||||||
|
import { EvaluationPanel } from '../components/signals/EvaluationPanel';
|
||||||
|
|
||||||
const tabs = ['Setups', 'Track Record'] as const;
|
const tabs = ['Setups', 'Paper Trades', 'Backtest'] as const;
|
||||||
type Tab = (typeof tabs)[number];
|
type Tab = (typeof tabs)[number];
|
||||||
|
|
||||||
|
// `track` stays the Paper Trades slug: App.tsx redirects the legacy /performance
|
||||||
|
// route to ?tab=track, and that is where realized results live.
|
||||||
|
const SLUG_TO_TAB: Record<string, Tab> = {
|
||||||
|
track: 'Paper Trades',
|
||||||
|
backtest: 'Backtest',
|
||||||
|
};
|
||||||
|
const TAB_TO_SLUG: Record<Tab, string> = {
|
||||||
|
Setups: '',
|
||||||
|
'Paper Trades': 'track',
|
||||||
|
Backtest: 'backtest',
|
||||||
|
};
|
||||||
|
const SUBTITLE: Record<Tab, string> = {
|
||||||
|
Setups: 'Detected trade setups from the latest scan',
|
||||||
|
'Paper Trades': 'What the strategy actually delivered on trades you took',
|
||||||
|
Backtest: 'Whether the promoted strategy is worth trading, replayed over history',
|
||||||
|
};
|
||||||
|
|
||||||
export default function SignalsPage() {
|
export default function SignalsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const activeTab: Tab = searchParams.get('tab') === 'track' ? 'Track Record' : 'Setups';
|
const activeTab: Tab = SLUG_TO_TAB[searchParams.get('tab') ?? ''] ?? 'Setups';
|
||||||
|
|
||||||
const setTab = (tab: Tab) => {
|
const setTab = (tab: Tab) => {
|
||||||
setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true });
|
const slug = TAB_TO_SLUG[tab];
|
||||||
|
setSearchParams(slug ? { tab: slug } : {}, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: Record<Tab, ReactNode> = {
|
||||||
|
Setups: <SetupsPanel />,
|
||||||
|
'Paper Trades': <MyTradesPanel />,
|
||||||
|
// The backtest and the diagnostic that checks it against live outcomes.
|
||||||
|
Backtest: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BacktestPanel />
|
||||||
|
<EvaluationPanel />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
<PageHeader
|
<PageHeader title="Signals" subtitle={SUBTITLE[activeTab]} />
|
||||||
title="Signals"
|
|
||||||
subtitle="Detected trade setups and how past signals actually performed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||||
|
|
||||||
<div className="animate-fade-in" key={activeTab}>
|
<div className="animate-fade-in" key={activeTab}>
|
||||||
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />}
|
{body[activeTab]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --ink, not --up-text: a focus ring must not carry a semantic colour. The
|
||||||
|
directional token reads as "up/positive" and lands at poor contrast on the
|
||||||
|
controls that are already that colour. */
|
||||||
|
:where(button, a, input, select, textarea, summary, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--ink);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
|
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
|
||||||
#root {
|
#root {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -83,6 +91,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
||||||
.app-horizon {
|
.app-horizon {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# SEC fundamentals alerts, 2026-08-21
|
||||||
|
|
||||||
|
Two `sec_facts` warnings, investigated against live SEC data. Both originate in SEC's
|
||||||
|
own published data — a stale per-company Company-Facts file (1) and a stale
|
||||||
|
ticker→CIK mapping (2) — and neither is a parser defect: no stored fundamental value
|
||||||
|
is wrong. Every SEC-side probe below reproduces offline from public endpoints; the
|
||||||
|
four database facts used are quoted where they appear.
|
||||||
|
|
||||||
|
## 1. `filing_gap_aged` — 43 gaps, all `not_in_companyfacts`
|
||||||
|
|
||||||
|
**Root cause: SEC's per-company Company-Facts files are stale for these issuers,
|
||||||
|
while the same filings are present in SEC's own `frames` aggregation.**
|
||||||
|
|
||||||
|
All ten named filings are real 10-Qs filed 2026-07-28/29, present in the issuer's
|
||||||
|
`submissions` with `isXBRL=1`, with complete R-files and XBRL in the EDGAR archive
|
||||||
|
— and absent from `companyfacts/CIK*.json`:
|
||||||
|
|
||||||
|
| CIK | issuer | accession | filed | in `companyfacts` | newest fact in file |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 0000001800 | Abbott | 0001628280-26-050134 | 2026-07-28 | no | 2026-04-29 |
|
||||||
|
| 0000021344 | Coca-Cola | 0001628280-26-050503 | 2026-07-29 | no | 2026-04-30 |
|
||||||
|
| 0000024741 | Corning | 0000024741-26-000255 | 2026-07-29 | no | 2026-05-01 |
|
||||||
|
| 0000029989 | Omnicom | 0000029989-26-000019 | 2026-07-29 | no | 2026-04-29 |
|
||||||
|
| 0000037996 | Ford | 0000037996-26-000156 | 2026-07-29 | no | 2026-04-30 |
|
||||||
|
| 0000040533 | General Dynamics | 0000040533-26-000032 | 2026-07-29 | no | 2026-07-01 |
|
||||||
|
| 0000048898 | Hubbell | 0001628280-26-050405 | 2026-07-29 | no | 2026-06-04 |
|
||||||
|
| 0000049071 | Humana | 0000049071-26-000050 | 2026-07-29 | no | 2026-04-29 |
|
||||||
|
| 0000049196 | Huntington Bancshares | 0000049196-26-000066 | 2026-07-28 | no | 2026-04-30 |
|
||||||
|
| 0000062996 | Masco | 0000062996-26-000027 | 2026-07-29 | no | 2026-04-22 |
|
||||||
|
|
||||||
|
Ruled out, with evidence:
|
||||||
|
|
||||||
|
- **Not a global SEC outage.** Company Facts is current for other issuers filing the
|
||||||
|
same days — MSFT `0001193125-26-323660` @2026-07-29, AAPL @2026-07-31, P&G
|
||||||
|
@2026-08-04, Chevron @2026-08-06, JPMorgan @2026-08-20.
|
||||||
|
- **Not a CDN/cache artifact.** A cache-busted request with `Cache-Control: no-cache`
|
||||||
|
returns the identical stale 3.39 MB payload; the response carries no cache headers.
|
||||||
|
- **Not our filter.** The scan covers every taxonomy/concept/unit in the payload.
|
||||||
|
- **Not a metadata discriminator.** Gap and non-gap filings are identical on
|
||||||
|
`isXBRL`, `isInlineXBRL`, `reportDate`, `primaryDocDescription`.
|
||||||
|
- **SEC does have the facts.** `frames/us-gaap/Assets/USD/CY2026Q2I.json` lists
|
||||||
|
Abbott at exactly the missing accession `0001628280-26-050134`, and Coca-Cola and
|
||||||
|
Ford at theirs. The per-company endpoints are the degraded ones:
|
||||||
|
`companyconcept/CIK0000001800/us-gaap/Assets.json` returns `"units":{"USD":{}}`.
|
||||||
|
|
||||||
|
**Consequence, and why the gate changed.** Retrying `companyfacts` cannot recover
|
||||||
|
these — Abbott's file has been stale since April. And because `active_gaps`
|
||||||
|
supersedes a gap only on a *successfully ingested later* filing, a stale file also
|
||||||
|
swallows Q3: the pause was open-ended, not seasonal, on 43 large caps.
|
||||||
|
|
||||||
|
**Fix** (`app/services/fundamentals_quality_service.py`): once `filing_gap_aged` has
|
||||||
|
escalated a gap (`escalated_at`), it stops pausing setups **if** the issuer's own
|
||||||
|
newest stored 10-K/10-Q is under `GAP_GATE_RECENT_FILING_DAYS` (180) old. Pause hands
|
||||||
|
off to the alert; an issuer with nothing that recent stays paused. `active_gaps` is
|
||||||
|
deliberately untouched, so `_retry_backlog` keeps retrying and a recovered filing
|
||||||
|
still resolves normally. The bound is applied to the queue path *and* the
|
||||||
|
`validation_json` summary path, which mirrors the same filings — bounding only one
|
||||||
|
leaves the behaviour unchanged in production.
|
||||||
|
|
||||||
|
**This is a bounded reprieve, not a removal — know the two ways it ends.** Abbott's
|
||||||
|
newest ingested filing is `0001628280-26-028357`, filed 2026-04-29, so its recency
|
||||||
|
window closes around **2026-10-26**; most of the 43 sit on late-April filings and
|
||||||
|
turn back to paused within days of each other. That crossing is **silent**: the
|
||||||
|
importer escalates only gaps with `escalated_at IS NULL`, so `filing_gap_aged` does
|
||||||
|
not re-fire for a gap it has already reported. Separately, a Q3 10-Q that also fails
|
||||||
|
to ingest creates a *new* un-escalated gap on the same CIK, which re-pauses it at
|
||||||
|
once (that one does raise its own `filing_gap_aged` 14 days later). Whether the
|
||||||
|
silent re-block deserves a re-escalation signal is an open call, deliberately not
|
||||||
|
made here — "one actionable escalation rather than a daily warning" is the existing
|
||||||
|
design intent.
|
||||||
|
|
||||||
|
**Not done, with reasons.** A `frames`-backed recovery source was considered and
|
||||||
|
rejected: frames are calendar-aligned with a tolerance (off-fiscal filers drop out)
|
||||||
|
and carry one fact per issuer per period, so amendment/restatement semantics differ
|
||||||
|
from Company Facts — lossy as a snapshot source, not merely expensive. Parsing the
|
||||||
|
filing's own inline-XBRL instance is the authoritative alternative but is a new
|
||||||
|
subsystem (contexts, dimensions, unit refs) duplicating the parser's fact model.
|
||||||
|
|
||||||
|
## 2. `snapshot_discrepancy` — 0000906107-15-000012 / -000016
|
||||||
|
|
||||||
|
**Root cause: two tracked tickers claim the same filing, because SEC's
|
||||||
|
`company_tickers.json` still points the old symbol at a non-traded co-registrant.
|
||||||
|
No stored value is wrong and no reparse is warranted.**
|
||||||
|
|
||||||
|
CIK 0000906107 is **Vivmark Residential** (VMRK, formerly Equity Residential). Both
|
||||||
|
alerted accessions are **combined EQR + ERP Operating LP 10-Qs** — one accession, two
|
||||||
|
registrants (0000906107 and 0000931182) — the pattern behind the existing
|
||||||
|
co-registrant recovery path.
|
||||||
|
|
||||||
|
The stored rows are **byte-identical** to what the current parser reconstructs from
|
||||||
|
EQR's own Company Facts — every column, verified: `cik` (`0000906107`), `form`,
|
||||||
|
`filed_date`, `accepted_at`, both period dates, `fiscal_year`/`fiscal_period`,
|
||||||
|
`revenue`, `net_income`, `operating_income`, `diluted_eps`, `cfo`, the two nulls,
|
||||||
|
`cash_and_st_investments`, `total_debt` (340,900,000 / null),
|
||||||
|
`shares_outstanding`, `shares_outstanding_date`, `weighted_avg_diluted_shares`. Both
|
||||||
|
carry `import_run_id = 6`, and CIK 0000906107 holds all 69 of its filings across runs
|
||||||
|
6–30, so the issuer's own history is complete.
|
||||||
|
|
||||||
|
Run 63 (2026-08-19) recorded
|
||||||
|
`fields: ["cik"]` for both accessions, and the universe explains it:
|
||||||
|
|
||||||
|
```
|
||||||
|
tickers: VMRK -> 0000906107 (Vivmark Residential, ex-Equity Residential)
|
||||||
|
EQR -> 0000931182 (ERP Operating Ltd Partnership)
|
||||||
|
```
|
||||||
|
|
||||||
|
SEC's own `company_tickers.json` carries `{"cik_str": 931182, "ticker": "EQR",
|
||||||
|
"title": "ERP OPERATING LTD PARTNERSHIP"}` — after the rename, the old symbol stayed
|
||||||
|
attached to the **non-traded operating partnership**, the co-registrant on those
|
||||||
|
combined 10-Qs. `resolve_ciks` reads `active_only` tickers and follows SEC, so
|
||||||
|
0000931182 is tracked. Its Company Facts holds 7 accessions, exactly 2 of them
|
||||||
|
EQR-prefixed, so its backfill reconstructs exactly those two rows, stamps them
|
||||||
|
`cik=0000931182`, and collides with the rows already stored under 0000906107 —
|
||||||
|
identical in every fact, differing only in attribution.
|
||||||
|
|
||||||
|
It cannot self-heal. The collision loser never stores a row (the insert is skipped as
|
||||||
|
immutable), so `_ciks_with_snapshots` never sees 0000931182, and it is full-history
|
||||||
|
backfilled — refetching every submissions shard and its companyfacts — **on every
|
||||||
|
run**, re-raising the warning each time. `fundamental_snapshots` for 0000906107 holds
|
||||||
|
all 69 filings across runs 6–30, so the issuer's own history is complete and correct.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
**Code** (`sec_fundamentals_importer.py`): a `cik`-only difference is no longer
|
||||||
|
reported as a reconstruction discrepancy. It raises `accession_cik_collision`, naming
|
||||||
|
both CIKs and pointing at `sec_cik_overrides`, because the fix is the universe, not
|
||||||
|
the parser. The reparse path also excludes these from its rewrite set — rewriting a
|
||||||
|
cik-only difference would re-stamp the filing onto the co-registrant and take it from
|
||||||
|
the issuer that filed it. (A reparse run while both CIKs are tracked fails validation
|
||||||
|
on `duplicate accession in staged snapshots` instead, which is a safe stop.)
|
||||||
|
|
||||||
|
**Data — needs an operator, and the alert repeats daily until then.** `EQR` is a stale
|
||||||
|
symbol: the security now trades as `VMRK`, which is already tracked at the correct
|
||||||
|
CIK. Retiring the `EQR` ticker ends the loop. A `sec_cik_overrides` pin of
|
||||||
|
`EQR -> 906107` would silence the collision but leave two tickers on one security,
|
||||||
|
double-counting the issuer in scans — retirement is the right action.
|
||||||
|
|
||||||
|
**Not fixed, deliberately:** the permanent-backfill loop itself. A tracked CIK whose
|
||||||
|
only parseable filings belong to another CIK is re-backfilled every run; ending that
|
||||||
|
in code means teaching `_ciks_with_snapshots` about foreign-owned accessions, which is
|
||||||
|
more state for a condition that is now loudly and specifically reported.
|
||||||
|
|
||||||
|
### Separate observation: `total_debt` on this issuer looks wrong
|
||||||
|
|
||||||
|
Independent of the alert, and unchanged by any fix here: the parser reconstructs
|
||||||
|
`total_debt = 340,900,000` for EQR's 2015 Q1 and `null` for Q2, while the REIT carried
|
||||||
|
roughly $10bn of debt. `_compose_debt` returns the short-term component alone when
|
||||||
|
every `_LONG_TERM_DEBT_AGG` concept **and** the `LongTermDebtNoncurrent`/`Current`
|
||||||
|
pair miss — which is what happened here, and Q2 matched neither. Worth checking
|
||||||
|
against a current REIT filer before trusting `total_debt` for that sector.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Follow-ups from the two alerts above
|
||||||
|
|
||||||
|
### 3a. The reprieve in (1) ended silently — now it doesn't
|
||||||
|
|
||||||
|
The hand-off in section 1 is a **bounded** reprieve. It ends two ways, and neither
|
||||||
|
said anything: the issuer's stored filings age past `GAP_GATE_RECENT_FILING_DAYS`
|
||||||
|
(for the 43, their last good filings are late April, so ~2026-10-26), or a newer
|
||||||
|
filing gap arrives and the all-escalated condition fails. `filing_gap_aged` cannot
|
||||||
|
report either, because it only escalates gaps whose `escalated_at` is NULL and so
|
||||||
|
never fires twice for the same gap.
|
||||||
|
|
||||||
|
`sec_filing_gaps.exempted_at` (migration `034`) makes the transition observable: set
|
||||||
|
quietly while the issuer is exempt, cleared when the exemption lapses, and the clear
|
||||||
|
is what raises `filing_gap_repaused`. Once per lapse, re-arming if the issuer's data
|
||||||
|
recovers and ages out again. A gap that was never exempt has no transition and stays
|
||||||
|
silent — it is simply still paused, which `filing_gap_aged` already said.
|
||||||
|
|
||||||
|
The exemption rule itself is not duplicated: `fundamentals_quality_service.gap_exempt_ciks`
|
||||||
|
is now public and the importer alerts on membership changes in exactly the set the
|
||||||
|
gate reads.
|
||||||
|
|
||||||
|
### 3b. `total_debt` was materially wrong for a third of large caps
|
||||||
|
|
||||||
|
The EQR observation in section 2 was not a REIT edge case. Measured over 19 large
|
||||||
|
caps, the old composition — `LongTermDebt`, else `LongTermDebtNoncurrent`/`Current`,
|
||||||
|
plus one of `ShortTermBorrowings`/`CommercialPaper` — missed two whole tagging styles:
|
||||||
|
|
||||||
|
| issuer | before | after | what was missed |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| T | None | 143.95b | `LongTermDebtAndCapitalLeaseObligations` |
|
||||||
|
| XOM | None | 47.66b | same |
|
||||||
|
| VZ | 21.78b | 165.23b | same (read only the current maturities) |
|
||||||
|
| KO | 0.25b | 39.31b | same (read only commercial paper) |
|
||||||
|
| HD | 3.50b | 48.33b | same |
|
||||||
|
| O | 1.40b | 26.53b | REIT parts (`NotesPayable` + `SecuredDebt`) |
|
||||||
|
| VMRK | 1.50b | 9.09b | same |
|
||||||
|
| CVX | 0.40b | **None** | partial suppressed — see below |
|
||||||
|
| PFE | 63.10b | 63.19b | `DebtCurrent` is the completer current side |
|
||||||
|
| 10 others | — | unchanged | already composed correctly |
|
||||||
|
|
||||||
|
`total_debt` feeds `net_debt` → `net_debt_to_ebitda` → the peer percentile and the
|
||||||
|
categorical leverage read, so Coca-Cola at 0.25bn of debt was not a missing value —
|
||||||
|
it was a confident *"conservative leverage"* on an issuer carrying ~39bn.
|
||||||
|
|
||||||
|
The composition now spans four mutually exclusive styles, with each concept's span
|
||||||
|
respected: `LongTermDebt` already includes current maturities (Apple tags all three
|
||||||
|
and 71.34 + 11.01 = 82.30 confirms it), `LongTermDebtAndCapitalLeaseObligations` is
|
||||||
|
noncurrent and needs a current complement, and `DebtCurrent` *is* that whole
|
||||||
|
complement rather than an addition to it.
|
||||||
|
|
||||||
|
**A short-term component alone is no longer reported as a total.** Chevron tags full
|
||||||
|
debt only in its 10-K, so its 10-Q carries 0.40bn of short-term borrowing and nothing
|
||||||
|
else. `_net_debt` needs both sides and yields nothing when either is missing, so None
|
||||||
|
costs a leverage read where the partial value produced a confidently wrong one.
|
||||||
|
|
||||||
|
The REIT branch needed disambiguating, because `NotesPayable` does not mean the same
|
||||||
|
thing across issuers (measured over 14 REITs): MAA tags `NotesPayable` 5.66bn =
|
||||||
|
`UnsecuredDebt` 5.30bn + `SecuredDebt` 0.36bn **exactly**, so there it is the total and
|
||||||
|
adding the secured side double-counts — while EQR tags it alongside a *larger*
|
||||||
|
`SecuredDebt` (5.38bn vs 6.38bn in 2013), where it is only the unsecured component.
|
||||||
|
`UnsecuredDebt`'s presence separates the two: where tagged it is the unambiguous
|
||||||
|
unsecured side and `NotesPayable` is ignored; where absent, `NotesPayable` is that
|
||||||
|
side. Both sides are required, which is also what stops the branch inventing a total
|
||||||
|
from a fragment.
|
||||||
|
|
||||||
|
| REIT | before | after | |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| MAA | None | 5.66b | matches its own `NotesPayable` total exactly |
|
||||||
|
| KIM | None | 8.74b | |
|
||||||
|
| O / VMRK | 1.40b / 1.50b | 26.53b / 9.09b | |
|
||||||
|
| BXP | 0.75b | **None** | tagged only `SecuredDebt` + paper against ~15bn real debt |
|
||||||
|
| VTR | 0.27b | **None** | same shape |
|
||||||
|
| 8 others | — | unchanged | already composed correctly |
|
||||||
|
|
||||||
|
Known limit: where EQR tags both the parts and the aggregate, the parts sum 2.6–12.2%
|
||||||
|
*below* it, so this branch approximates. It is last in line — any issuer tagging an
|
||||||
|
aggregate never reaches it — and the alternative there is no value at all.
|
||||||
|
|
||||||
|
### Sequencing the history fix
|
||||||
|
|
||||||
|
Snapshots are immutable, so **3b corrects new filings only**; every stored quarter
|
||||||
|
keeps its old `total_debt`. `scripts/reparse_fundamentals.py` exists for exactly this
|
||||||
|
("after a parser fix, keeping the stored row is preserving a stale cache").
|
||||||
|
|
||||||
|
**Retire the `EQR` ticker before reparsing.** A reparse backfills every tracked CIK,
|
||||||
|
so while both 0000906107 and 0000931182 are tracked, both stage the same two 2015
|
||||||
|
accessions and the run fails validation on `duplicate accession in staged snapshots`.
|
||||||
|
That is a safe stop — nothing is written — but the reparse will not complete until the
|
||||||
|
collision is gone.
|
||||||
@@ -18,6 +18,15 @@ The script refuses to emit a band recommendation unless every hard gate passes.
|
|||||||
That is deliberate: it must be structurally impossible to read a calibration
|
That is deliberate: it must be structurally impossible to read a calibration
|
||||||
result out of a run whose pipeline did not validate.
|
result out of a run whose pipeline did not validate.
|
||||||
|
|
||||||
|
**Fundamental channel note.** The sourced capex / earnings read is a separate
|
||||||
|
categorical channel and is never a term in State or Warning, so every variant and
|
||||||
|
gate below is unaffected by it. This harness passes no observation, which means
|
||||||
|
the ``fundamental_context`` on each replayed row reads ``unknown`` -- correct, and
|
||||||
|
the same thing production reports for a session nobody observed. Calibrating
|
||||||
|
anything *about* that channel needs an observation series passed through
|
||||||
|
``_compute_index(..., observations=...)``, and enough history to be worth
|
||||||
|
calibrating against.
|
||||||
|
|
||||||
Research branch only. Example:
|
Research branch only. Example:
|
||||||
|
|
||||||
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
|
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -1272,32 +1273,63 @@ def test_build_recommendation_reads_the_report():
|
|||||||
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
|
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
|
||||||
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
|
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
|
||||||
],
|
],
|
||||||
|
# Legacy policy book. Its numbers are deliberately DIFFERENT from the
|
||||||
|
# production monitor's below, so sourcing the benchmark line from here
|
||||||
|
# again would fail the assertion rather than pass unnoticed.
|
||||||
"portfolio_sim": {"policies": [
|
"portfolio_sim": {"policies": [
|
||||||
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
|
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
|
||||||
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
|
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
|
||||||
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
|
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
|
||||||
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
|
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
|
||||||
]},
|
]},
|
||||||
|
"portfolio_monitor": {
|
||||||
|
"production_strategy": "prod",
|
||||||
|
"runs": [{
|
||||||
|
"strategy": "prod", "lookback": "all", "lookback_label": "All history",
|
||||||
|
"cagr_pct": 40.0, "sharpe": 1.72, "max_drawdown_pct": 17.7,
|
||||||
|
"total_return_pct": 297.8, "spy_return_pct": 101.9,
|
||||||
|
}, {
|
||||||
|
# A second window with DIFFERENT numbers. Without it the "all"
|
||||||
|
# preference is untested and a lookback mix-up cannot fail.
|
||||||
|
"strategy": "prod", "lookback": "3y", "lookback_label": "3y",
|
||||||
|
"cagr_pct": 47.9, "sharpe": 1.96, "max_drawdown_pct": 17.3,
|
||||||
|
"total_return_pct": 220.9, "spy_return_pct": 71.5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
rec = bt._build_recommendation(report)
|
rec = bt._build_recommendation(report)
|
||||||
by_topic: dict[str, list[str]] = {}
|
by_topic: dict[str, list[str]] = {}
|
||||||
for item in rec["items"]:
|
for item in rec["items"]:
|
||||||
by_topic.setdefault(item["topic"], []).append(item["text"])
|
by_topic.setdefault(item["topic"], []).append(item["text"])
|
||||||
|
|
||||||
assert rec["headline"] is not None and "hold 30" in rec["headline"]
|
assert rec["headline"] is not None and "Production baseline" in rec["headline"]
|
||||||
assert any("hold 30 trading days" in t for t in by_topic["exit"])
|
# The hold-vs-target comparison is gone: both are exits the production book
|
||||||
|
# replaced, so a recommendation between them cannot lead to an action.
|
||||||
|
assert "exit" not in by_topic
|
||||||
|
# Benchmark must quote the SAME row the page's tiles show, not the policy sim.
|
||||||
|
assert "+297.8%" in by_topic["benchmark"][0]
|
||||||
|
assert "203.6" not in by_topic["benchmark"][0]
|
||||||
gate_texts = " | ".join(by_topic["gate"])
|
gate_texts = " | ".join(by_topic["gate"])
|
||||||
assert "confidence floor adds nothing" in gate_texts
|
assert "confidence floor adds nothing" in gate_texts
|
||||||
assert "keep the R:R floor" in gate_texts
|
assert "keep the R:R floor" in gate_texts
|
||||||
assert "keep the NEUTRAL exclusion" in gate_texts
|
assert "keep the NEUTRAL exclusion" in gate_texts
|
||||||
assert "80" in by_topic["cutoff"][0]
|
assert "80" in by_topic["cutoff"][0]
|
||||||
assert "beats" in by_topic["benchmark"][0]
|
assert "beats" in by_topic["benchmark"][0]
|
||||||
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
|
|
||||||
# target model the recommendation advises abandoning
|
# Every production figure comes from ONE window, and the report says which,
|
||||||
assert any(
|
# so the page can default its selector to the same one.
|
||||||
"not a handful of outliers" in t and "under the recommended 30d hold" in t
|
assert rec["basis_lookback"] == "all"
|
||||||
for t in by_topic["robustness"]
|
assert rec["basis_lookback_label"] == "All history"
|
||||||
)
|
assert "+40.0%" in by_topic["production"][0]
|
||||||
|
assert "47.9" not in by_topic["production"][0] # the 3y row must not leak in
|
||||||
|
assert "220.9" not in by_topic["benchmark"][0]
|
||||||
|
|
||||||
|
# Robustness names its real basis. It used to claim "under the recommended
|
||||||
|
# 30d hold" — nothing recommends that exit; production is the ATR trail.
|
||||||
|
robustness = by_topic["robustness"][0]
|
||||||
|
assert "not a handful of outliers" in robustness
|
||||||
|
assert "gate-level grading" in robustness
|
||||||
|
assert "recommended" not in robustness
|
||||||
|
|
||||||
|
|
||||||
def test_build_recommendation_flags_outlier_dependence():
|
def test_build_recommendation_flags_outlier_dependence():
|
||||||
@@ -1688,3 +1720,201 @@ async def test_run_backtest_rolls_back_a_failed_portfolio_sim_load(session, monk
|
|||||||
assert called, "the portfolio-sim block never ran; test proves nothing"
|
assert called, "the portfolio-sim block never ran; test proves nothing"
|
||||||
assert rolled_back, "a failed portfolio-sim load left the session un-rolled-back"
|
assert rolled_back, "a failed portfolio-sim load left the session un-rolled-back"
|
||||||
assert report["tickers"] == 1
|
assert report["tickers"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortfolioQualityMetrics:
|
||||||
|
"""Sortino / Gain-to-Pain / dollar profit factor.
|
||||||
|
|
||||||
|
Each derives its expectation from the returned ``equity_curve`` rather than
|
||||||
|
hand-tracing position sizing, and each also asserts the *wrong* variant is
|
||||||
|
NOT what came back — the denominator and the numerator are exactly where
|
||||||
|
these ratios are usually got wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORD = date(2025, 1, 6).toordinal()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _daily_returns(sim: dict) -> list[float]:
|
||||||
|
eq = [row["equity"] for row in sim["equity_curve"]]
|
||||||
|
return [b / a - 1.0 for a, b in zip(eq, eq[1:]) if a > 0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _monthly_returns(sim: dict) -> list[float]:
|
||||||
|
monthly: list[float] = []
|
||||||
|
rows = sim["equity_curve"]
|
||||||
|
start = last = rows[0]["equity"]
|
||||||
|
cur = date.fromisoformat(rows[0]["date"]).replace(day=1)
|
||||||
|
for row in rows:
|
||||||
|
m = date.fromisoformat(row["date"]).replace(day=1)
|
||||||
|
if m != cur:
|
||||||
|
monthly.append(last / start - 1.0)
|
||||||
|
cur, start = m, last
|
||||||
|
last = row["equity"]
|
||||||
|
monthly.append(last / start - 1.0)
|
||||||
|
return monthly
|
||||||
|
|
||||||
|
def _wobbly_sim(self) -> dict:
|
||||||
|
"""~70 sessions crossing four month boundaries with a real mid drawdown,
|
||||||
|
so monthly returns include both signs (a short fixture yields one month
|
||||||
|
and zero pain, which reads as a broken formula)."""
|
||||||
|
closes = (
|
||||||
|
[100.0 + i for i in range(20)] # climb
|
||||||
|
+ [120.0 - 1.5 * i for i in range(20)] # drawdown
|
||||||
|
+ [90.0 + 1.2 * i for i in range(30)] # recovery
|
||||||
|
)
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=80.0, target=400.0)
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[cand], prices, None, "hold", 65, include_curve=True
|
||||||
|
)
|
||||||
|
assert sim is not None
|
||||||
|
return sim
|
||||||
|
|
||||||
|
def test_sortino_denominator_is_full_sample_not_downside_count(self):
|
||||||
|
sim = self._wobbly_sim()
|
||||||
|
rets = self._daily_returns(sim)
|
||||||
|
downside = [r for r in rets if r < 0.0]
|
||||||
|
assert downside, "fixture must produce down days or the test proves nothing"
|
||||||
|
|
||||||
|
mean_ret = sum(rets) / len(rets)
|
||||||
|
correct = mean_ret / math.sqrt(
|
||||||
|
sum(r * r for r in downside) / len(rets)
|
||||||
|
) * math.sqrt(252.0)
|
||||||
|
# The classic error: dividing by the count of down days shrinks the
|
||||||
|
# denominator and inflates the ratio.
|
||||||
|
inflated = mean_ret / math.sqrt(
|
||||||
|
sum(r * r for r in downside) / len(downside)
|
||||||
|
) * math.sqrt(252.0)
|
||||||
|
|
||||||
|
assert sim["sortino"] == pytest.approx(round(correct, 2), abs=0.01)
|
||||||
|
assert sim["sortino"] != pytest.approx(round(inflated, 2), abs=0.01)
|
||||||
|
|
||||||
|
def test_gain_to_pain_is_schwager_on_monthly_returns(self):
|
||||||
|
sim = self._wobbly_sim()
|
||||||
|
monthly = self._monthly_returns(sim)
|
||||||
|
assert len(monthly) >= 3, "fixture must span several months"
|
||||||
|
pain = -sum(r for r in monthly if r < 0.0)
|
||||||
|
assert pain > 0, "fixture must have a losing month or pain is zero"
|
||||||
|
|
||||||
|
schwager = sum(monthly) / pain
|
||||||
|
# sum(all) = sum(pos) - |sum(neg)|, so the profit-factor-shaped variant
|
||||||
|
# sits exactly 1.0 higher for every input.
|
||||||
|
profit_factor_shaped = sum(r for r in monthly if r > 0.0) / pain
|
||||||
|
assert profit_factor_shaped == pytest.approx(schwager + 1.0, abs=1e-9)
|
||||||
|
|
||||||
|
assert sim["gain_to_pain"] == pytest.approx(round(schwager, 2), abs=0.01)
|
||||||
|
assert sim["gain_to_pain"] != pytest.approx(
|
||||||
|
round(profit_factor_shaped, 2), abs=0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_profit_factor_is_dollar_based(self):
|
||||||
|
"""One winner, one loser, on separate symbols so both fill."""
|
||||||
|
up = [100.0 + 2.0 * i for i in range(8)]
|
||||||
|
down = [100.0 - 2.0 * i for i in range(8)]
|
||||||
|
prices = {
|
||||||
|
"WIN": _sim_prices(self.ORD, up),
|
||||||
|
"LOSE": _sim_prices(self.ORD, down),
|
||||||
|
}
|
||||||
|
cands = [
|
||||||
|
_sim_cand("WIN", self.ORD, entry=100.0, stop=90.0, target=400.0, mp=95.0),
|
||||||
|
_sim_cand("LOSE", self.ORD, entry=100.0, stop=80.0, target=400.0, mp=94.0),
|
||||||
|
]
|
||||||
|
sim = bt._simulate_portfolio([cands[0], cands[1]], prices, None, "hold", 5)
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 2
|
||||||
|
# With exactly two trades the reported best/worst ARE the win and the loss.
|
||||||
|
gross_win = sim["best_trade_pnl"]
|
||||||
|
gross_loss = -sim["worst_trade_pnl"]
|
||||||
|
assert gross_win > 0 and gross_loss > 0, "fixture must produce one of each"
|
||||||
|
assert sim["profit_factor"] == pytest.approx(
|
||||||
|
round(gross_win / gross_loss, 2), abs=0.01
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_keys_always_present_and_no_downside_is_none(self):
|
||||||
|
"""Monotonic rise: no down days. Sortino must be None, never inf — and
|
||||||
|
all three keys must still be emitted, because the UI reads an ABSENT key
|
||||||
|
as 'report predates these metrics'."""
|
||||||
|
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||||
|
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
||||||
|
assert sim is not None
|
||||||
|
for key in ("sortino", "gain_to_pain", "profit_factor"):
|
||||||
|
assert key in sim
|
||||||
|
assert sim["sortino"] is None
|
||||||
|
|
||||||
|
def test_build_recommendation_states_no_baseline_without_a_production_row():
|
||||||
|
"""A report with no portfolio monitor cannot describe the production book.
|
||||||
|
It used to fall back to recommending the fixed-hold exit — advice for a model
|
||||||
|
the production book had already replaced."""
|
||||||
|
report = {
|
||||||
|
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
|
||||||
|
"time_exit_sweep": [{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21}],
|
||||||
|
"portfolio_sim": {"policies": [
|
||||||
|
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
|
||||||
|
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
|
||||||
|
]},
|
||||||
|
}
|
||||||
|
rec = bt._build_recommendation(report)
|
||||||
|
topics = {item["topic"] for item in rec["items"]}
|
||||||
|
|
||||||
|
assert rec["headline"] is None
|
||||||
|
# Nothing may be sourced from the legacy policy book.
|
||||||
|
assert "benchmark" not in topics
|
||||||
|
assert "exit" not in topics
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cached_report_recommendation_is_rebuilt_on_read(session):
|
||||||
|
"""A report cached by an older build carries that build's recommendation.
|
||||||
|
|
||||||
|
Served verbatim, the page would show the legacy wording and no
|
||||||
|
basis_lookback — which let the lookback selector default elsewhere, putting
|
||||||
|
3y tiles beside an all-history recommendation with no warning. This is the
|
||||||
|
shape of the report sitting in production right now.
|
||||||
|
"""
|
||||||
|
from app.services.admin_service import update_setting
|
||||||
|
|
||||||
|
stale = {
|
||||||
|
"generated_at": "2026-08-12T05:00:00+00:00",
|
||||||
|
"tickers": 512, "candidates": 100, "qualified": 10,
|
||||||
|
"params": {"horizon_days": 30},
|
||||||
|
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.20},
|
||||||
|
"portfolio_sim": {"policies": [
|
||||||
|
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 175.0,
|
||||||
|
"spy_return_pct": 101.9, "max_drawdown_pct": 23.7},
|
||||||
|
]},
|
||||||
|
"portfolio_monitor": {
|
||||||
|
"production_strategy": "prod",
|
||||||
|
"runs": [{
|
||||||
|
"strategy": "prod", "lookback": "all", "lookback_label": "All history",
|
||||||
|
"cagr_pct": 40.0, "sharpe": 1.72, "max_drawdown_pct": 17.7,
|
||||||
|
"total_return_pct": 297.8, "spy_return_pct": 101.9,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
# What the old build stored: sourced from the policy book, and naming an
|
||||||
|
# exit the production book replaced.
|
||||||
|
"recommendation": {
|
||||||
|
"headline": "Trade the qualified list long-only; hold 30 trading days.",
|
||||||
|
"items": [
|
||||||
|
{"topic": "benchmark", "text": "Book vs SPY: beats buy-and-hold by "
|
||||||
|
"+73.1 points (+175.0% vs +101.9%)."},
|
||||||
|
{"topic": "robustness", "text": "Robustness: expectancy survives removing "
|
||||||
|
"the top 5% of winners (+0.20R net/trade "
|
||||||
|
"under the recommended 30d hold)."},
|
||||||
|
],
|
||||||
|
"note": "stale",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await update_setting(session, bt.KEY_REPORT, json.dumps(stale))
|
||||||
|
|
||||||
|
report = await bt.get_backtest_report(session)
|
||||||
|
assert report is not None
|
||||||
|
rec = report["recommendation"]
|
||||||
|
|
||||||
|
# Rebuilt: the basis is published, so the page cannot default elsewhere.
|
||||||
|
assert rec["basis_lookback"] == "all"
|
||||||
|
texts = " | ".join(i["text"] for i in rec["items"])
|
||||||
|
# ...and it quotes the production book, not the policy sim it used to.
|
||||||
|
assert "+297.8%" in texts and "175.0" not in texts
|
||||||
|
assert "recommended 30d hold" not in texts
|
||||||
|
assert "Production baseline" in (rec["headline"] or "")
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
"""Tests for v3 correction events, warning alarm episodes, and report caveats."""
|
"""Tests for correction events, alarm episodes, the shipped-rule replay, and caveats."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
||||||
from app.services.event_study_service import (
|
from app.services.event_study_service import (
|
||||||
MIN_EVENTS_FOR_CONFIDENCE,
|
MIN_EVENTS_FOR_CONFIDENCE,
|
||||||
|
STRESS_QUADRANT,
|
||||||
|
WARNING_QUADRANTS,
|
||||||
|
_era_split,
|
||||||
|
_null_model,
|
||||||
_percentile,
|
_percentile,
|
||||||
_reliability,
|
_reliability,
|
||||||
alarm_episodes,
|
alarm_episodes,
|
||||||
|
below_average_series,
|
||||||
detect_events,
|
detect_events,
|
||||||
|
entry_alarms,
|
||||||
evaluate_alarms,
|
evaluate_alarms,
|
||||||
|
replay_quadrant_changes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +27,33 @@ def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
|
|||||||
return [start + timedelta(days=index) for index in range(count)]
|
return [start + timedelta(days=index) for index in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def _row(
|
||||||
|
warning: float,
|
||||||
|
state: float = 0.0,
|
||||||
|
*,
|
||||||
|
warning_coverage: float = 100.0,
|
||||||
|
state_coverage: float = 100.0,
|
||||||
|
fresh: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"warning": warning,
|
||||||
|
"state_coverage": state_coverage,
|
||||||
|
"warning_coverage": warning_coverage,
|
||||||
|
"inputs_fresh": fresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(
|
||||||
|
dates: list[date], warnings: list[float], patch: dict[int, dict] | None = None
|
||||||
|
) -> dict[date, dict]:
|
||||||
|
"""One publishable row per date, with per-position replacements."""
|
||||||
|
built = {day: _row(value) for day, value in zip(dates, warnings)}
|
||||||
|
for index, replacement in (patch or {}).items():
|
||||||
|
built[dates[index]] = replacement
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
def test_detect_events_uses_rising_edge_and_cooldown():
|
def test_detect_events_uses_rising_edge_and_cooldown():
|
||||||
closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
|
closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
|
||||||
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0, cooldown=40)
|
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0, cooldown=40)
|
||||||
@@ -88,6 +123,366 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days():
|
|||||||
assert result["median_lead_days"] == 17.5
|
assert result["median_lead_days"] == 17.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The shipped quadrant rule, replayed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_replay_seeds_silently_and_needs_two_sessions():
|
||||||
|
"""A one-session spike is not an alert; the second session confirms it.
|
||||||
|
|
||||||
|
The alarm is therefore dated at the confirmation rather than at the first
|
||||||
|
crossing, which costs one session of lead. That is what ships.
|
||||||
|
"""
|
||||||
|
dates = _days(10)
|
||||||
|
spike = _rows(dates, [30] * 5 + [70] + [30] * 4)
|
||||||
|
assert replay_quadrant_changes(spike, dates) == []
|
||||||
|
|
||||||
|
held = _rows(dates, [30] * 5 + [70, 70] + [30] * 3)
|
||||||
|
fires = replay_quadrant_changes(held, dates)
|
||||||
|
# The rule alerts on quadrant changes in both directions, so the return to
|
||||||
|
# calm fires too. Only the entry is a warning about anything.
|
||||||
|
assert [(f["index"], f["from"], f["to"]) for f in fires] == [
|
||||||
|
(6, "3", "1"),
|
||||||
|
(9, "1", "3"),
|
||||||
|
]
|
||||||
|
assert entry_alarms(fires, WARNING_QUADRANTS) == [6]
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmation_classifies_the_prior_session_against_the_baseline():
|
||||||
|
"""Not against its own predecessor -- the distinction changes the answer.
|
||||||
|
|
||||||
|
Warning 42 sits inside the hysteresis deadband. Measured from the standing
|
||||||
|
"3" baseline it is still "3", so it cannot confirm a move to "1". A chain
|
||||||
|
that classified each session against the one before it would read 42 as "1"
|
||||||
|
(having just seen 70) and fire a day later, which production does not do.
|
||||||
|
"""
|
||||||
|
dates = _days(10)
|
||||||
|
rows = _rows(dates, [30, 30, 30, 30, 70, 42, 70, 30, 30, 30])
|
||||||
|
assert replay_quadrant_changes(rows, dates) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cooldown_suppresses_and_the_baseline_only_advances_on_a_fire():
|
||||||
|
dates = _days(10)
|
||||||
|
rows = _rows(dates, [30, 30, 30, 30, 70, 70, 30, 30, 30, 30])
|
||||||
|
fires = replay_quadrant_changes(rows, dates)
|
||||||
|
|
||||||
|
# Entry confirmed on day 5. The exit confirms on day 7 but lands inside the
|
||||||
|
# 3-day cooldown, so it is re-evaluated and fires on day 8 instead.
|
||||||
|
assert [(f["index"], f["from"], f["to"]) for f in fires] == [
|
||||||
|
(5, "3", "1"),
|
||||||
|
(8, "1", "3"),
|
||||||
|
]
|
||||||
|
assert entry_alarms(fires, WARNING_QUADRANTS) == [5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_coverage_sessions_cannot_confirm():
|
||||||
|
"""The confirmation source has to be a session that published a band."""
|
||||||
|
dates = _days(10)
|
||||||
|
warnings = [30, 30, 30, 30, 30, 70, 70, 30, 30, 30]
|
||||||
|
visible = replay_quadrant_changes(_rows(dates, warnings), dates)
|
||||||
|
assert entry_alarms(visible, WARNING_QUADRANTS) == [6]
|
||||||
|
|
||||||
|
# Day 5 is the only session that could confirm the entry on day 6; below
|
||||||
|
# MIN_COVERAGE it never published a band, so day 4 is the prior instead.
|
||||||
|
hidden = _rows(dates, warnings, {5: _row(70, warning_coverage=70.0)})
|
||||||
|
assert replay_quadrant_changes(hidden, dates) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_inputs_block_todays_alert_but_not_tomorrows_confirmation():
|
||||||
|
"""is_fresh gates the live reading only; the prior session comes from history."""
|
||||||
|
dates = _days(10)
|
||||||
|
rows = _rows(dates, [30] * 4 + [70, 70, 70] + [30] * 3, {5: _row(70, fresh=False)})
|
||||||
|
fires = replay_quadrant_changes(rows, dates)
|
||||||
|
assert entry_alarms(fires, WARNING_QUADRANTS) == [6]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_alarms_ignore_movement_inside_the_set():
|
||||||
|
fires = [
|
||||||
|
{"index": 3, "from": "3", "to": "1"},
|
||||||
|
{"index": 9, "from": "1", "to": "2"},
|
||||||
|
{"index": 20, "from": "2", "to": "4"},
|
||||||
|
]
|
||||||
|
assert entry_alarms(fires, WARNING_QUADRANTS) == [3]
|
||||||
|
assert entry_alarms(fires, STRESS_QUADRANT) == [9]
|
||||||
|
|
||||||
|
|
||||||
|
def test_below_average_series_needs_a_full_window():
|
||||||
|
series = list(zip(_days(6), [10.0, 10.0, 10.0, 10.0, 4.0, 20.0]))
|
||||||
|
indicator = below_average_series(series, window=3)
|
||||||
|
assert _days(6)[1] not in indicator # warm-up
|
||||||
|
assert indicator[_days(6)[4]] == 100.0 # 4 is under the 3-day mean of 8
|
||||||
|
assert indicator[_days(6)[5]] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_model_is_seeded_and_drawn_from_evaluable_sessions_only():
|
||||||
|
dates = _days(300)
|
||||||
|
events = [100, 180, 260]
|
||||||
|
first = _null_model(6, events, dates, horizon=20, start_index=50, observed_warned=2, draws=200)
|
||||||
|
second = _null_model(6, events, dates, horizon=20, start_index=50, observed_warned=2, draws=200)
|
||||||
|
assert first == second # a re-run must not move the report
|
||||||
|
assert 0.0 <= first["p_at_least_observed"] <= 1.0
|
||||||
|
assert first["alarms_per_draw"] == 6
|
||||||
|
assert first["mean_warned"] <= len(events)
|
||||||
|
|
||||||
|
# More alarms than there are sessions to place them on is not a null.
|
||||||
|
assert _null_model(500, events, dates, 20, 50, 2, draws=10) is None
|
||||||
|
assert _null_model(6, [], dates, 20, 50, 0, draws=10) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_era_split_reports_the_two_sensor_eras_separately():
|
||||||
|
"""The fuller sample is mostly pre-credit, where Warning is W1+W2 only."""
|
||||||
|
dates = _days(400)
|
||||||
|
eras = _era_split(
|
||||||
|
alarms=[80, 300],
|
||||||
|
event_indices=[90, 310],
|
||||||
|
dates=dates,
|
||||||
|
horizon=20,
|
||||||
|
start_index=10,
|
||||||
|
credit_from=dates[200],
|
||||||
|
)
|
||||||
|
assert eras["pre_credit"]["events"] == 1
|
||||||
|
assert eras["pre_credit"]["events_warned"] == 1
|
||||||
|
assert eras["full_coverage"]["events"] == 1
|
||||||
|
assert eras["full_coverage"]["events_warned"] == 1
|
||||||
|
assert eras["credit_from"] == dates[200].isoformat()
|
||||||
|
|
||||||
|
# No credit series at all means there is no boundary to split on.
|
||||||
|
assert _era_split([80], [90], dates, 20, 10, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _business_days(count: int, end: date = date(2026, 8, 7)) -> list[date]:
|
||||||
|
out: list[date] = []
|
||||||
|
cursor = end
|
||||||
|
while len(out) < count:
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
out.append(cursor)
|
||||||
|
cursor -= timedelta(days=1)
|
||||||
|
return list(reversed(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_path(sessions: int) -> list[float]:
|
||||||
|
"""A rising leader with two deep drawdowns, so corrections exist to detect."""
|
||||||
|
closes: list[float] = []
|
||||||
|
for index in range(sessions):
|
||||||
|
if index < 350:
|
||||||
|
closes.append(100.0 + index * 0.25)
|
||||||
|
elif index < 400:
|
||||||
|
closes.append(187.5 - (index - 350) * 0.9)
|
||||||
|
elif index < 650:
|
||||||
|
closes.append(142.5 + (index - 400) * 0.4)
|
||||||
|
elif index < 700:
|
||||||
|
closes.append(242.5 - (index - 650) * 1.1)
|
||||||
|
else:
|
||||||
|
closes.append(187.5 + (index - 700) * 0.3)
|
||||||
|
return closes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_report_assembles_every_rule_from_synthetic_inputs(monkeypatch):
|
||||||
|
"""End-to-end: the shipped replay, ablations, baselines and null all score.
|
||||||
|
|
||||||
|
Synthetic rather than recorded because the point is the wiring -- that every
|
||||||
|
rule is measured on the same events over the same sessions and the report
|
||||||
|
carries what the panel reads. The numbers are meaningless by construction.
|
||||||
|
"""
|
||||||
|
import app.services.event_study_service as ess
|
||||||
|
|
||||||
|
sessions = 900
|
||||||
|
dates = _business_days(sessions)
|
||||||
|
closes = _synthetic_path(sessions)
|
||||||
|
leader = list(zip(dates, closes))
|
||||||
|
# SPY grinds up throughout, so the leader's relative strength rolls over
|
||||||
|
# exactly when it falls.
|
||||||
|
market = list(zip(dates, [100.0 + index * 0.12 for index in range(sessions)]))
|
||||||
|
# Breadth deteriorates ~15 sessions ahead of each decline, which is the
|
||||||
|
# divergence W1 exists to catch.
|
||||||
|
breadth = {}
|
||||||
|
for index, day in enumerate(dates):
|
||||||
|
weak = 335 <= index < 400 or 635 <= index < 700
|
||||||
|
breadth[day] = 30.0 if weak else 70.0
|
||||||
|
vix = [(day, 32.0 if (350 <= i < 400 or 650 <= i < 700) else 15.0) for i, day in enumerate(dates)]
|
||||||
|
# Credit starts late, exactly as ICE's 3-year cap makes it in production.
|
||||||
|
oas = [(day, 4.2 if (650 <= i < 700) else 3.0) for i, day in enumerate(dates) if i >= 500]
|
||||||
|
|
||||||
|
async def fake_config(_db):
|
||||||
|
return deepcopy(ess.rms.DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
async def fake_prices(_config, _start, _end):
|
||||||
|
return {"SMH": leader, "QQQ": leader, "SPY": market}
|
||||||
|
|
||||||
|
async def fake_fred(series_id, _start, _end):
|
||||||
|
return {"VIXCLS": vix, "BAMLH0A0HYM2": oas}.get(series_id)
|
||||||
|
|
||||||
|
async def fake_breadth(_db, _symbols, window=200, min_tickers=20):
|
||||||
|
return breadth, {day: 30 for day in dates}
|
||||||
|
|
||||||
|
async def fake_observations(_db):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(ess.rms, "get_regime_config", fake_config)
|
||||||
|
monkeypatch.setattr(ess.rms, "_fetch_prices", fake_prices)
|
||||||
|
monkeypatch.setattr(ess.rms, "_fetch_fred_series", fake_fred)
|
||||||
|
monkeypatch.setattr(ess.rms, "get_fundamental_observations", fake_observations)
|
||||||
|
monkeypatch.setattr(ess.breadth_service, "compute_breadth_details", fake_breadth)
|
||||||
|
monkeypatch.setattr(ess, "NULL_DRAWS", 100)
|
||||||
|
|
||||||
|
report = await ess.run_event_study(None)
|
||||||
|
|
||||||
|
assert report["available"] is True
|
||||||
|
assert report["schema"] == ess.STUDY_SCHEMA
|
||||||
|
|
||||||
|
# The shipped rule is measured on the whole sample, not a 30% holdout.
|
||||||
|
shipped = report["shipped"]
|
||||||
|
assert shipped["metrics"]["events"] == report["sample"]["events_evaluable"]
|
||||||
|
assert report["sample"]["events_evaluable"] >= 2
|
||||||
|
assert shipped["metrics"]["events"] >= report["fitted"]["metrics"]["events"]
|
||||||
|
assert len(shipped["events"]) == shipped["metrics"]["events"]
|
||||||
|
|
||||||
|
assert {row["kind"] for row in report["comparison"]} == {
|
||||||
|
"ablation", "baseline", "fundamental",
|
||||||
|
}
|
||||||
|
# Market rows share the headline's events, or the table lies. Fundamental
|
||||||
|
# rows deliberately do not: they are coverage-matched to the sessions the
|
||||||
|
# channel actually existed on, which is a different (here empty) window.
|
||||||
|
for row in report["comparison"]:
|
||||||
|
if row["kind"] != "fundamental":
|
||||||
|
assert row["events"] == shipped["metrics"]["events"]
|
||||||
|
assert row["false_alarms_per_year"] >= 0
|
||||||
|
else:
|
||||||
|
# No eligible sessions means the rate is undefined, not zero. A
|
||||||
|
# tiny-divisor fallback here printed 5e9 alarms/year.
|
||||||
|
assert row["false_alarms_per_year"] is None
|
||||||
|
|
||||||
|
# The credit sensor starts mid-sample, so the era split must be populated.
|
||||||
|
eras = shipped["by_era"]
|
||||||
|
assert eras["credit_from"] == dates[500].isoformat()
|
||||||
|
assert eras["pre_credit"]["events"] + eras["full_coverage"]["events"] == shipped["metrics"]["events"]
|
||||||
|
|
||||||
|
if report["null_model"] is not None:
|
||||||
|
assert 0.0 <= report["null_model"]["p_at_least_observed"] <= 1.0
|
||||||
|
assert report["null_model"]["observed_warned"] == shipped["metrics"]["events_warned"]
|
||||||
|
|
||||||
|
# With an empty observation series the fundamental rows are *untested*, not
|
||||||
|
# failed, and the report has to carry that distinction or a 0/10 in the table
|
||||||
|
# reads as a measured result.
|
||||||
|
coverage = report["fundamental_coverage"]
|
||||||
|
assert coverage["observations"] == 0
|
||||||
|
assert coverage["sessions_eligible"] == 0
|
||||||
|
assert coverage["events_covered"] == 0
|
||||||
|
assert coverage["measurable"] is False
|
||||||
|
fundamental_rows = [r for r in report["comparison"] if r["kind"] == "fundamental"]
|
||||||
|
assert {r["id"] for r in fundamental_rows} == {
|
||||||
|
"fundamental_adverse", "confluence", "market_over_covered",
|
||||||
|
}
|
||||||
|
assert all(row["measurable"] is False for row in fundamental_rows)
|
||||||
|
# Coverage-matched denominators: with no exposure these rows must not claim
|
||||||
|
# to have been scored against the market rows' 10 corrections.
|
||||||
|
assert all(row["events"] == 0 for row in fundamental_rows)
|
||||||
|
# Market rows are unaffected: their inputs exist for the whole window.
|
||||||
|
assert all(
|
||||||
|
row["measurable"] is True
|
||||||
|
for row in report["comparison"]
|
||||||
|
if row["kind"] != "fundamental"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fundamental_rows_are_scored_only_on_their_own_exposure():
|
||||||
|
"""One day of coverage must not render as 0/10.
|
||||||
|
|
||||||
|
A fundamental rule scores zero whether it is wrong or merely absent, so
|
||||||
|
scoring it against corrections it could never have seen manufactures a
|
||||||
|
failed result out of a thin one — the same mistake the `measurable` flag
|
||||||
|
prevents for an empty table, arriving one observation later.
|
||||||
|
"""
|
||||||
|
import app.services.event_study_service as ess
|
||||||
|
|
||||||
|
dates = _days(300)
|
||||||
|
events = [50, 120, 200, 280]
|
||||||
|
# Context exists for a single stretch, covering only the 120 event's horizon.
|
||||||
|
rows = {
|
||||||
|
day: {
|
||||||
|
"fundamental_state": "adverse",
|
||||||
|
"fundamental_usable": 105 <= index <= 115,
|
||||||
|
}
|
||||||
|
for index, day in enumerate(dates)
|
||||||
|
}
|
||||||
|
|
||||||
|
covered = ess.covered_events(events, rows, dates, horizon=20)
|
||||||
|
assert covered == [120]
|
||||||
|
assert ess.eligible_sessions(rows, dates, start_index=0) == 11
|
||||||
|
|
||||||
|
# A stale stretch counts for nothing, however adverse it reads.
|
||||||
|
stale = {
|
||||||
|
day: {"fundamental_state": "adverse", "fundamental_usable": False}
|
||||||
|
for day in dates
|
||||||
|
}
|
||||||
|
assert ess.covered_events(events, stale, dates, horizon=20) == []
|
||||||
|
assert ess.eligible_sessions(stale, dates, start_index=0) == 0
|
||||||
|
assert ess.adverse_episodes(stale, dates, 0) == []
|
||||||
|
assert ess.confluence_episodes([120], stale, dates) == []
|
||||||
|
|
||||||
|
# And neither does a *fresh* observation that determined nothing. Repeated
|
||||||
|
# extraction failures would otherwise accumulate exposure until the rows
|
||||||
|
# flipped to a measurable 0/8 for a channel that never knew anything —
|
||||||
|
# the same tested-versus-unavailable confusion, arriving by a slower route.
|
||||||
|
empty = {
|
||||||
|
day: {"fundamental_state": "unknown", "fundamental_usable": False}
|
||||||
|
for day in dates
|
||||||
|
}
|
||||||
|
assert ess.covered_events(events, empty, dates, horizon=20) == []
|
||||||
|
assert ess.eligible_sessions(empty, dates, start_index=0) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_fundamental_channel_never_moves_the_warning_score():
|
||||||
|
"""The channel is compared, never fused. Warning must be identical either way.
|
||||||
|
|
||||||
|
A weighted modifier was built and reverted: with ~10 correction events and
|
||||||
|
almost no fundamental history any fusion weight is a policy preference
|
||||||
|
presented as a measurement.
|
||||||
|
"""
|
||||||
|
import app.services.event_study_service as ess
|
||||||
|
|
||||||
|
end = date(2026, 6, 26)
|
||||||
|
dates = _business_days(400, end)
|
||||||
|
rising = [(day, 100.0 + index * 0.2) for index, day in enumerate(dates)]
|
||||||
|
prices = {"SMH": rising, "QQQ": rising, "SPY": rising}
|
||||||
|
args = (prices, [(end, 20.0)], [(day, 4.0) for day in dates])
|
||||||
|
config = deepcopy(ess.rms.DEFAULT_CONFIG)
|
||||||
|
names = config["tickers"]["hyperscalers"]
|
||||||
|
tail = (rising, [(day, 20.0) for day in dates], dates, config)
|
||||||
|
|
||||||
|
def adverse(effective: date) -> list[dict]:
|
||||||
|
return [{
|
||||||
|
"effective_date": effective,
|
||||||
|
"f1_score": 100.0,
|
||||||
|
"f3_score": 100.0,
|
||||||
|
"capex": dict.fromkeys(names, "cutting"),
|
||||||
|
"good_news_stock_down": "yes",
|
||||||
|
"fetched_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
}]
|
||||||
|
|
||||||
|
bare = ess._axis_rows(*args, *tail, None)
|
||||||
|
observed = ess._axis_rows(*args, *tail, adverse(dates[-20]))
|
||||||
|
|
||||||
|
latest, early = dates[-1], dates[-90]
|
||||||
|
assert observed[latest]["warning"] == bare[latest]["warning"]
|
||||||
|
assert observed[latest]["fundamental_state"] == "adverse"
|
||||||
|
assert bare[latest]["fundamental_state"] == "unknown"
|
||||||
|
|
||||||
|
# Sessions before the effective date stay unknown, so a rebuild cannot stamp
|
||||||
|
# today's reading onto history.
|
||||||
|
assert observed[early]["fundamental_state"] == "unknown"
|
||||||
|
|
||||||
|
# The confluence rule keeps only crossings the channel agrees with, and the
|
||||||
|
# fundamental rule fires on the transition into adverse -- both rising-edge,
|
||||||
|
# so both stay comparable with the market rows.
|
||||||
|
adverse_alarms = ess.adverse_episodes(observed, dates, 0)
|
||||||
|
assert [dates[i] for i in adverse_alarms] == [dates[-20]]
|
||||||
|
assert ess.adverse_episodes(bare, dates, 0) == []
|
||||||
|
assert ess.confluence_episodes([dates.index(early), dates.index(latest)], observed, dates) == [
|
||||||
|
dates.index(latest)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_breadth_from_fixed_closes_and_tapered_divergence():
|
def test_breadth_from_fixed_closes_and_tapered_divergence():
|
||||||
dates = _days(10)
|
dates = _days(10)
|
||||||
closes_by_symbol = {
|
closes_by_symbol = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.models.data_import_run import DataImportRun
|
from app.models.data_import_run import DataImportRun
|
||||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||||
@@ -139,3 +139,143 @@ async def test_ticker_quality_explains_no_xbrl_block(db_session):
|
|||||||
assert await fundamentals_quality_service.ticker_is_eligible(
|
assert await fundamentals_quality_service.ticker_is_eligible(
|
||||||
db_session, ticker.id
|
db_session, ticker.id
|
||||||
) is False
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _escalated_gap(cik: str, *, escalated: bool = True) -> SecFilingGap:
|
||||||
|
first_seen = datetime.now(timezone.utc) - timedelta(days=24)
|
||||||
|
return SecFilingGap(
|
||||||
|
cik=cik,
|
||||||
|
accession=f"{cik}-STALE-Q",
|
||||||
|
form="10-Q",
|
||||||
|
index_date=(first_seen.date()),
|
||||||
|
reason="not_in_companyfacts",
|
||||||
|
first_seen_at=first_seen,
|
||||||
|
last_attempted_at=datetime.now(timezone.utc),
|
||||||
|
escalated_at=(
|
||||||
|
datetime.now(timezone.utc) - timedelta(days=10) if escalated else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prior_quarter(cik: str, *, age_days: int) -> FundamentalSnapshot:
|
||||||
|
"""The issuer's last successfully ingested filing, older than the gap so it
|
||||||
|
cannot supersede it — exactly the production shape of a stale companyfacts
|
||||||
|
file: Q1 stored, Q2 missing."""
|
||||||
|
filed = date.today() - timedelta(days=age_days)
|
||||||
|
return FundamentalSnapshot(
|
||||||
|
cik=cik,
|
||||||
|
accession=f"{cik}-PRIOR-Q",
|
||||||
|
form="10-Q",
|
||||||
|
filed_date=filed,
|
||||||
|
accepted_at=datetime.now(timezone.utc) - timedelta(days=age_days),
|
||||||
|
period_end=filed,
|
||||||
|
fiscal_year=filed.year,
|
||||||
|
fiscal_period="Q1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_escalated_gap_stops_blocking_when_fundamentals_are_recent(db_session):
|
||||||
|
ticker = Ticker(symbol="STALEFACTS", cik="0000000046")
|
||||||
|
db_session.add(ticker)
|
||||||
|
db_session.add(_escalated_gap(ticker.cik))
|
||||||
|
await db_session.flush()
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
||||||
|
ticker.id
|
||||||
|
}
|
||||||
|
|
||||||
|
# The alert has run and the issuer still has last quarter to score on.
|
||||||
|
db_session.add(_prior_quarter(ticker.cik, age_days=120))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
||||||
|
# ...but the filing is still queued, so the importer keeps retrying it.
|
||||||
|
assert len(await fundamentals_quality_service.active_gaps(db_session)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_escalated_gap_keeps_blocking_when_fundamentals_are_stale(db_session):
|
||||||
|
ticker = Ticker(symbol="NOTHINGFRESH", cik="0000000047")
|
||||||
|
db_session.add_all([
|
||||||
|
ticker,
|
||||||
|
_escalated_gap(ticker.cik),
|
||||||
|
_prior_quarter(
|
||||||
|
ticker.cik,
|
||||||
|
age_days=fundamentals_quality_service.GAP_GATE_RECENT_FILING_DAYS + 30,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
||||||
|
ticker.id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unescalated_gap_still_blocks_alongside_an_escalated_one(db_session):
|
||||||
|
ticker = Ticker(symbol="TWOGAPS", cik="0000000048")
|
||||||
|
fresh = datetime.now(timezone.utc)
|
||||||
|
db_session.add_all([
|
||||||
|
ticker,
|
||||||
|
_escalated_gap(ticker.cik),
|
||||||
|
SecFilingGap(
|
||||||
|
cik=ticker.cik,
|
||||||
|
accession="TWOGAPS-FRESH-Q",
|
||||||
|
form="10-Q",
|
||||||
|
index_date=date.today(),
|
||||||
|
reason="not_in_companyfacts",
|
||||||
|
first_seen_at=fresh,
|
||||||
|
last_attempted_at=fresh,
|
||||||
|
),
|
||||||
|
_prior_quarter(ticker.cik, age_days=120),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
||||||
|
ticker.id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_summary_path_does_not_reblock_an_exempt_cik(db_session):
|
||||||
|
"""The run summary mirrors the same filings as the queue — it must honour the
|
||||||
|
same hand-off, or the bound is inert in production."""
|
||||||
|
ticker = Ticker(symbol="MIRRORED", cik="0000000049")
|
||||||
|
db_session.add(ticker)
|
||||||
|
db_session.add(_escalated_gap(ticker.cik))
|
||||||
|
db_session.add(_prior_quarter(ticker.cik, age_days=120))
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.add(
|
||||||
|
DataImportRun(
|
||||||
|
source="sec_facts",
|
||||||
|
status="promoted",
|
||||||
|
validation_json=json.dumps({
|
||||||
|
"setup_blocked_ciks": [ticker.cik],
|
||||||
|
"missing_xbrl": [
|
||||||
|
{"cik": ticker.cik, "accession": f"{ticker.cik}-STALE-Q"}
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_newer_gap_ends_the_exemption(db_session):
|
||||||
|
"""Production's second exit path: Q3 also fails to ingest, so an un-escalated
|
||||||
|
gap joins the escalated one and the issuer pauses again immediately."""
|
||||||
|
ticker = Ticker(symbol="NEWGAP", cik="0000000050")
|
||||||
|
db_session.add_all([
|
||||||
|
ticker, _escalated_gap(ticker.cik), _prior_quarter(ticker.cik, age_days=120)
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == set()
|
||||||
|
|
||||||
|
fresh = datetime.now(timezone.utc)
|
||||||
|
db_session.add(SecFilingGap(
|
||||||
|
cik=ticker.cik, accession="NEWGAP-Q3", form="10-Q", index_date=date.today(),
|
||||||
|
reason="not_in_companyfacts", first_seen_at=fresh, last_attempted_at=fresh,
|
||||||
|
))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assert await fundamentals_quality_service.blocked_ticker_ids(db_session) == {
|
||||||
|
ticker.id
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from app.services.regime_monitor_service import (
|
|||||||
drawdown_pct,
|
drawdown_pct,
|
||||||
f2_credit_spreads,
|
f2_credit_spreads,
|
||||||
current_observation,
|
current_observation,
|
||||||
fundamental_overlay,
|
fundamental_context,
|
||||||
p1_trend_break,
|
p1_trend_break,
|
||||||
p2_death_cross,
|
p2_death_cross,
|
||||||
p3_drawdown,
|
p3_drawdown,
|
||||||
@@ -40,6 +40,24 @@ from app.services.regime_monitor_service import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _no_observations(_db):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _skip_recording(_db, _observation):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _CommitOnlyDB:
|
||||||
|
"""Enough session for writers that own their own transaction boundary."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
|
||||||
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
|
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
|
||||||
return [
|
return [
|
||||||
(end - timedelta(days=len(values) - 1 - index), value)
|
(end - timedelta(days=len(values) - 1 - index), value)
|
||||||
@@ -215,7 +233,7 @@ def test_score_pillars_gates_band_below_75_percent_coverage():
|
|||||||
assert result["band"] is None
|
assert result["band"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
|
def test_fundamental_context_never_replays_before_effective_date_and_expires():
|
||||||
overrides = {
|
overrides = {
|
||||||
"f1_score": 0.0,
|
"f1_score": 0.0,
|
||||||
"f3_score": 100.0,
|
"f3_score": 100.0,
|
||||||
@@ -226,19 +244,19 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
|
|||||||
}
|
}
|
||||||
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
||||||
|
|
||||||
pending = fundamental_overlay(overrides, config, date(2026, 6, 1))
|
pending = fundamental_context(overrides, config, date(2026, 6, 1))
|
||||||
assert pending["pending"] is True
|
assert pending["pending"] is True
|
||||||
assert pending["available"] is False
|
assert pending["available"] is False
|
||||||
assert pending["capex"] is None
|
assert pending["capex"] is None
|
||||||
# The effective date is still reported so a pending refresh is visible.
|
# The effective date is still reported so a pending refresh is visible.
|
||||||
assert pending["effective_date"] == "2026-06-02"
|
assert pending["effective_date"] == "2026-06-02"
|
||||||
|
|
||||||
live = fundamental_overlay(overrides, config, date(2026, 6, 2))
|
live = fundamental_context(overrides, config, date(2026, 6, 2))
|
||||||
assert live["available"] is True
|
assert live["available"] is True
|
||||||
assert live["good_news_stock_down"] == "yes"
|
assert live["good_news_stock_down"] == "yes"
|
||||||
assert live["earnings_stress"] == 100.0
|
assert live["earnings_stress"] == 100.0
|
||||||
|
|
||||||
expired = fundamental_overlay(overrides, config, date(2026, 8, 22))
|
expired = fundamental_context(overrides, config, date(2026, 8, 22))
|
||||||
assert expired["stale"] is True
|
assert expired["stale"] is True
|
||||||
assert expired["available"] is False
|
assert expired["available"] is False
|
||||||
|
|
||||||
@@ -263,7 +281,7 @@ def test_live_observation_is_visible_before_its_effective_date():
|
|||||||
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
|
||||||
|
|
||||||
before = date(2026, 6, 1)
|
before = date(2026, 6, 1)
|
||||||
record = fundamental_overlay(overrides, config, before)
|
record = fundamental_context(overrides, config, before)
|
||||||
now = current_observation(overrides, config, before)
|
now = current_observation(overrides, config, before)
|
||||||
|
|
||||||
# Same day, same observation: the record hides it, the live reading shows it.
|
# Same day, same observation: the record hides it, the live reading shows it.
|
||||||
@@ -314,35 +332,256 @@ def test_an_uncollected_observation_is_not_reported_as_collected():
|
|||||||
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
|
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_fundamentals_do_not_move_the_warning_score():
|
def test_fundamental_state_never_averages_unknown_into_neutral():
|
||||||
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
|
"""Missing evidence must not present as evidence of normality.
|
||||||
|
|
||||||
It no longer feeds Warning at all, so Warning is identical either way and
|
This is the trap that mattered when the channel replaced the weighted
|
||||||
the observation is reported beside the score instead of buried in it.
|
modifier: treating ``unknown`` as a middle value would let two ``cutting``
|
||||||
|
reads and two ``unknown`` ones land on "neutral". A single adverse read
|
||||||
|
carries on partial evidence; ``unknown`` survives only when *nothing* was
|
||||||
|
observed.
|
||||||
|
"""
|
||||||
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
|
||||||
|
assert rms._capex_signal(dict.fromkeys(names, "unknown"), names) == "unknown"
|
||||||
|
assert rms._capex_signal(dict.fromkeys(names, "raising"), names) == "supportive"
|
||||||
|
assert rms._capex_signal(dict.fromkeys(names, "holding"), names) == "neutral"
|
||||||
|
|
||||||
|
half_cut = {names[0]: "cutting", names[1]: "cutting", **dict.fromkeys(names[2:], "unknown")}
|
||||||
|
assert rms._capex_signal(half_cut, names) == "adverse"
|
||||||
|
|
||||||
|
assert rms._reaction_signal("yes") == "adverse"
|
||||||
|
assert rms._reaction_signal("no") == "supportive"
|
||||||
|
assert rms._reaction_signal("mixed") == "neutral"
|
||||||
|
assert rms._reaction_signal(None) == "unknown"
|
||||||
|
|
||||||
|
combine = rms.combine_fundamental_signals
|
||||||
|
assert combine("unknown", "unknown") == "unknown"
|
||||||
|
assert combine("adverse", "supportive") == "adverse" # one adverse read carries
|
||||||
|
assert combine("supportive", "unknown") == "supportive"
|
||||||
|
assert combine("neutral", "unknown") == "neutral"
|
||||||
|
assert combine("supportive", "neutral") == "neutral"
|
||||||
|
# Nothing combines *into* unknown -- that would be inventing missing evidence.
|
||||||
|
assert "unknown" not in {
|
||||||
|
combine(a, b)
|
||||||
|
for a in rms.FUNDAMENTAL_STATES
|
||||||
|
for b in rms.FUNDAMENTAL_STATES
|
||||||
|
if not (a == "unknown" and b == "unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fundamental_context_is_a_channel_not_a_term_in_warning():
|
||||||
|
"""The read is reported beside the scores and never added into them.
|
||||||
|
|
||||||
|
A weighted modifier was built and reverted: with ~10 correction events and
|
||||||
|
almost no fundamental history, any fusion weight is a policy preference
|
||||||
|
presented as a measurement, and adding a slow categorical judgement to a fast
|
||||||
|
continuous score manufactures precision by summing unlike things.
|
||||||
"""
|
"""
|
||||||
end = date(2026, 6, 26)
|
end = date(2026, 6, 26)
|
||||||
rising = [100.0 + index * 0.2 for index in range(700)]
|
rising = [100.0 + index * 0.2 for index in range(700)]
|
||||||
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
|
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
|
||||||
args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))])
|
args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))])
|
||||||
tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25})
|
tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25})
|
||||||
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
|
||||||
quiet = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
|
def observed(capex_state: str, reaction: str) -> dict:
|
||||||
screaming = _compute_index(
|
return {
|
||||||
*args,
|
"capex": dict.fromkeys(names, capex_state),
|
||||||
{
|
"good_news_stock_down": reaction,
|
||||||
"f1_score": 100.0,
|
|
||||||
"f3_score": 100.0,
|
|
||||||
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
|
|
||||||
"good_news_stock_down": "yes",
|
|
||||||
"effective_date": "2026-06-01",
|
"effective_date": "2026-06-01",
|
||||||
|
"fetched_at": "2026-06-01T00:00:00+00:00",
|
||||||
|
"source": "openai",
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserved = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
|
||||||
|
supportive = _compute_index(*args, observed("raising", "no"), *tail)
|
||||||
|
adverse = _compute_index(*args, observed("cutting", "yes"), *tail)
|
||||||
|
|
||||||
|
# Every Warning is identical: the channel is not a term in the score.
|
||||||
|
scores = {
|
||||||
|
snapshot["warning"]["score"]
|
||||||
|
for snapshot in (unobserved, supportive, adverse)
|
||||||
|
}
|
||||||
|
assert len(scores) == 1
|
||||||
|
assert {p["id"] for p in unobserved["warning"]["pillars"]} == set(WARNING_WEIGHTS)
|
||||||
|
# And it never touches coverage, so a missing observation cannot suppress a
|
||||||
|
# band or silently redistribute weight onto the technical sensors.
|
||||||
|
assert len({s["warning"]["coverage"] for s in (unobserved, supportive, adverse)}) == 1
|
||||||
|
|
||||||
|
assert unobserved["fundamental_context"]["state"] == "unknown"
|
||||||
|
assert unobserved["fundamental_context"]["evidence_quality"] == "unavailable"
|
||||||
|
assert supportive["fundamental_context"]["state"] == "supportive"
|
||||||
|
assert adverse["fundamental_context"]["state"] == "adverse"
|
||||||
|
assert adverse["fundamental_context"]["evidence_quality"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_but_empty_observation_is_available_to_show_and_not_usable():
|
||||||
|
"""Collected-but-determined-nothing must not count as evidence.
|
||||||
|
|
||||||
|
`available` is about timing (there is an effective, non-stale record to
|
||||||
|
display); `usable` is about content. An LLM run that failed to extract
|
||||||
|
anything produces a perfectly fresh observation that knows nothing — and if
|
||||||
|
that counted, repeated extraction failures would slowly accumulate study
|
||||||
|
exposure until the fundamental rows reported a measurable 0/8 for a channel
|
||||||
|
that had never seen a thing.
|
||||||
|
"""
|
||||||
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
names = config["tickers"]["hyperscalers"]
|
||||||
|
as_of = date(2026, 6, 26)
|
||||||
|
base = {
|
||||||
|
"effective_date": "2026-06-01",
|
||||||
|
"fetched_at": "2026-06-01T00:00:00+00:00",
|
||||||
|
"source": "openai",
|
||||||
|
}
|
||||||
|
|
||||||
|
empty = fundamental_context(
|
||||||
|
{**base, "capex": dict.fromkeys(names, "unknown"), "good_news_stock_down": "unknown"},
|
||||||
|
config, as_of,
|
||||||
|
)
|
||||||
|
assert empty["state"] == "unknown"
|
||||||
|
assert empty["available"] is True # there is a record, and it has a date
|
||||||
|
assert empty["usable"] is False # but it says nothing
|
||||||
|
|
||||||
|
# One real signal is enough to be usable, on partial evidence.
|
||||||
|
partial = fundamental_context(
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"capex": {names[0]: "cutting", **dict.fromkeys(names[1:], "unknown")},
|
||||||
|
"good_news_stock_down": "unknown",
|
||||||
},
|
},
|
||||||
*tail,
|
config, as_of,
|
||||||
|
)
|
||||||
|
assert partial["state"] == "adverse"
|
||||||
|
assert partial["usable"] is True
|
||||||
|
assert partial["evidence_quality"] == "partial"
|
||||||
|
|
||||||
|
# Stale is neither available nor usable — `available` means effective *and*
|
||||||
|
# non-stale. What survives is `state`, which the card renders on its own
|
||||||
|
# (with the stale badge) so the last thing observed stays visible.
|
||||||
|
stale = fundamental_context(
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"effective_date": "2026-01-01",
|
||||||
|
"capex": dict.fromkeys(names, "cutting"),
|
||||||
|
"good_news_stock_down": "yes",
|
||||||
|
},
|
||||||
|
config, as_of,
|
||||||
|
)
|
||||||
|
assert stale["state"] == "adverse"
|
||||||
|
assert stale["stale"] is True
|
||||||
|
assert stale["available"] is False
|
||||||
|
assert stale["usable"] is False
|
||||||
|
|
||||||
|
# Nothing collected at all: neither.
|
||||||
|
absent = fundamental_context({}, config, as_of)
|
||||||
|
assert (absent["available"], absent["usable"]) == (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_live_reading_publishes_the_same_fields_as_the_record():
|
||||||
|
""""Same shape" has to mean the same fields, not the same ones it needs.
|
||||||
|
|
||||||
|
The frontend types both payloads as one interface, so a field present on the
|
||||||
|
record and missing from the live reading is an undefined at runtime that
|
||||||
|
TypeScript cannot catch across a trusted server boundary.
|
||||||
|
"""
|
||||||
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
names = config["tickers"]["hyperscalers"]
|
||||||
|
as_of = date(2026, 6, 26)
|
||||||
|
observation = {
|
||||||
|
"effective_date": "2026-06-01",
|
||||||
|
"fetched_at": "2026-06-01T00:00:00+00:00",
|
||||||
|
"source": "openai",
|
||||||
|
"capex": dict.fromkeys(names, "cutting"),
|
||||||
|
"good_news_stock_down": "yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
record = fundamental_context(observation, config, as_of)
|
||||||
|
live = current_observation(observation, config, as_of)
|
||||||
|
assert set(record) <= set(live)
|
||||||
|
assert (live["state"], live["usable"]) == ("adverse", True)
|
||||||
|
|
||||||
|
# A just-collected observation is shown but is not yet in force, so it is
|
||||||
|
# available to read and not yet usable as evidence.
|
||||||
|
pending = current_observation(
|
||||||
|
{**observation, "effective_date": "2026-07-01"}, config, as_of
|
||||||
|
)
|
||||||
|
assert (pending["pending"], pending["available"], pending["usable"]) == (True, True, False)
|
||||||
|
|
||||||
|
# And an extraction that determined nothing is never usable, however fresh.
|
||||||
|
empty = current_observation(
|
||||||
|
{**observation, "capex": dict.fromkeys(names, "unknown"), "good_news_stock_down": "unknown"},
|
||||||
|
config, as_of,
|
||||||
|
)
|
||||||
|
assert (empty["state"], empty["usable"]) == ("unknown", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_rename_snapshots_keep_their_recorded_fundamental_evidence():
|
||||||
|
"""The rename shipped without a methodology bump, so those rows were never reseeded.
|
||||||
|
|
||||||
|
Reading only the new key would turn real observations into `unknown` and
|
||||||
|
silently drop historical Path colours and legitimate study exposure.
|
||||||
|
"""
|
||||||
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
legacy = {
|
||||||
|
"methodology": rms.METHODOLOGY,
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"state": {"score": 10.0, "band": "stable"},
|
||||||
|
"warning": {"score": 20.0, "band": "stable"},
|
||||||
|
"fundamental_overlay": {
|
||||||
|
"available": True,
|
||||||
|
"pending": False,
|
||||||
|
"stale": False,
|
||||||
|
"effective_date": "2026-06-20",
|
||||||
|
"capex": {names[0]: "cutting", **dict.fromkeys(names[1:], "raising")},
|
||||||
|
"good_news_stock_down": "yes",
|
||||||
|
"source": "openai",
|
||||||
|
"fetched_at": "2026-06-19T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed = rms._parse_snapshot(json.dumps(legacy))
|
||||||
|
context = parsed["fundamental_context"]
|
||||||
|
assert context["state"] == "adverse"
|
||||||
|
assert context["evidence_quality"] == "complete"
|
||||||
|
assert context["usable"] is True
|
||||||
|
assert context["effective_date"] == "2026-06-20"
|
||||||
|
|
||||||
|
# A pending legacy overlay carried no facts, so it stays unknown rather than
|
||||||
|
# inventing an observation for a session nobody had looked at.
|
||||||
|
blank = json.loads(json.dumps(legacy))
|
||||||
|
blank["fundamental_overlay"] = {"pending": True, "stale": False, "capex": None}
|
||||||
|
blank_context = rms._parse_snapshot(json.dumps(blank))["fundamental_context"]
|
||||||
|
assert blank_context["state"] == "unknown"
|
||||||
|
assert blank_context["evidence_quality"] == "unavailable"
|
||||||
|
assert blank_context["usable"] is False
|
||||||
|
|
||||||
|
# A row already carrying the new key is left exactly as written.
|
||||||
|
modern = json.loads(json.dumps(legacy))
|
||||||
|
modern["fundamental_context"] = {"state": "supportive", "usable": True}
|
||||||
|
assert rms._parse_snapshot(json.dumps(modern))["fundamental_context"]["state"] == "supportive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_quality_ranks_what_an_operator_needs_first():
|
||||||
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
full = dict.fromkeys(names, "raising")
|
||||||
|
partial = {names[0]: "raising", **dict.fromkeys(names[1:], "unknown")}
|
||||||
|
|
||||||
|
def quality(capex, reaction, *, observed=True, stale=False, source="openai"):
|
||||||
|
return rms._evidence_quality(
|
||||||
|
capex, reaction, names, observed=observed, stale=stale, source=source
|
||||||
)
|
)
|
||||||
|
|
||||||
assert quiet["warning"]["score"] == screaming["warning"]["score"]
|
assert quality(full, "no") == "complete"
|
||||||
assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS)
|
assert quality(partial, "no") == "partial"
|
||||||
assert screaming["fundamental_overlay"]["available"] is True
|
assert quality(full, None) == "partial" # reaction unknown
|
||||||
assert screaming["fundamental_overlay"]["capex_stress"] == 100.0
|
assert quality(full, "no", source="manual") == "manual"
|
||||||
|
assert quality(full, "no", stale=True) == "stale"
|
||||||
|
# Nothing collected outranks every other grade.
|
||||||
|
assert quality(full, "no", observed=False, stale=True, source="manual") == "unavailable"
|
||||||
|
assert set(rms.EVIDENCE_QUALITY) >= {quality(full, "no"), quality(partial, "no")}
|
||||||
|
assert config["tickers"]["hyperscalers"] == names
|
||||||
|
|
||||||
|
|
||||||
def test_capex_score_separates_holding_from_raising():
|
def test_capex_score_separates_holding_from_raising():
|
||||||
@@ -375,10 +614,12 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v4(monkeypatch):
|
|||||||
|
|
||||||
result = await rms.get_fundamental_overrides(object())
|
result = await rms.get_fundamental_overrides(object())
|
||||||
|
|
||||||
assert result["methodology"] == "v4"
|
assert result["methodology"] == rms.METHODOLOGY
|
||||||
assert result["f1_score"] is None
|
assert result["f1_score"] is None
|
||||||
assert result["f3_score"] is None
|
assert result["f3_score"] is None
|
||||||
assert result["good_news_stock_down"] == "mixed"
|
# Not "mixed": an unreadable blob is an absence of an observation, and
|
||||||
|
# "mixed" is a genuinely observed mixed reaction.
|
||||||
|
assert result["good_news_stock_down"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -442,11 +683,12 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
|
|||||||
|
|
||||||
async def fake_update(_db, _key, value):
|
async def fake_update(_db, _key, value):
|
||||||
saved.update(json.loads(value))
|
saved.update(json.loads(value))
|
||||||
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
||||||
monkeypatch.setattr(rms, "update_setting", fake_update)
|
monkeypatch.setattr(rms.settings_store, "upsert_setting", fake_update)
|
||||||
|
|
||||||
result = await rms.set_fundamental_overrides(object(), locked=False)
|
result = await rms.set_fundamental_overrides(_CommitOnlyDB(), locked=False)
|
||||||
|
|
||||||
assert result["locked"] is False
|
assert result["locked"] is False
|
||||||
assert result["fetched_at"] == stored["fetched_at"]
|
assert result["fetched_at"] == stored["fetched_at"]
|
||||||
@@ -476,14 +718,22 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
|
|||||||
|
|
||||||
async def fake_update(_db, _key, value):
|
async def fake_update(_db, _key, value):
|
||||||
saved.update(json.loads(value))
|
saved.update(json.loads(value))
|
||||||
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
|
||||||
monkeypatch.setattr(rms, "update_setting", fake_update)
|
monkeypatch.setattr(rms.settings_store, "upsert_setting", fake_update)
|
||||||
|
# A manual save now also appends to the point-in-time series.
|
||||||
|
monkeypatch.setattr(rms, "record_fundamental_observation", _skip_recording)
|
||||||
capex = {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}
|
capex = {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}
|
||||||
|
|
||||||
|
db = _CommitOnlyDB()
|
||||||
result = await rms.set_fundamental_overrides(
|
result = await rms.set_fundamental_overrides(
|
||||||
object(), capex=capex, good_news_stock_down="mixed"
|
db, capex=capex, good_news_stock_down="mixed"
|
||||||
)
|
)
|
||||||
|
# The series row is a second write after update_setting's own commit, so the
|
||||||
|
# writer has to take one -- record_fundamental_observation deliberately does
|
||||||
|
# not, or it would steal update_regime_monitor's transaction boundary.
|
||||||
|
assert db.commits == 1
|
||||||
|
|
||||||
assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50)
|
assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50)
|
||||||
assert result["f3_score"] is None
|
assert result["f3_score"] is None
|
||||||
@@ -498,7 +748,9 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
|
|||||||
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
||||||
snapshot_date = date(2026, 6, 26)
|
snapshot_date = date(2026, 6, 26)
|
||||||
first = {
|
first = {
|
||||||
"methodology": "v4",
|
# Must be the *current* methodology: a foreign row does not parse, so it
|
||||||
|
# reads as absent and the rewrite guard never comes into play.
|
||||||
|
"methodology": rms.METHODOLOGY,
|
||||||
"date": snapshot_date.isoformat(),
|
"date": snapshot_date.isoformat(),
|
||||||
"state": {"score": 10.0, "band": "stable"},
|
"state": {"score": 10.0, "band": "stable"},
|
||||||
"warning": {"score": 20.0, "band": "stable"},
|
"warning": {"score": 20.0, "band": "stable"},
|
||||||
@@ -571,6 +823,8 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
|||||||
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
||||||
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
|
monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
|
||||||
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
|
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
|
||||||
|
monkeypatch.setattr(rms, "get_fundamental_observations", _no_observations)
|
||||||
|
monkeypatch.setattr(rms, "record_fundamental_observation", _skip_recording)
|
||||||
|
|
||||||
result = await rms.update_regime_monitor(FakeDB())
|
result = await rms.update_regime_monitor(FakeDB())
|
||||||
|
|
||||||
@@ -636,6 +890,8 @@ async def test_a_stale_sensor_revision_reseeds_stored_history(
|
|||||||
("_fetch_fred_series", fake_fred),
|
("_fetch_fred_series", fake_fred),
|
||||||
("_latest_snapshot_row", fake_latest),
|
("_latest_snapshot_row", fake_latest),
|
||||||
("_upsert_snapshot", fake_upsert),
|
("_upsert_snapshot", fake_upsert),
|
||||||
|
("get_fundamental_observations", _no_observations),
|
||||||
|
("record_fundamental_observation", _skip_recording),
|
||||||
):
|
):
|
||||||
monkeypatch.setattr(rms, name, value)
|
monkeypatch.setattr(rms, name, value)
|
||||||
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
|
||||||
@@ -722,7 +978,7 @@ def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
|
|||||||
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
|
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
|
||||||
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
||||||
assert price["score"] == max(sensor_scores)
|
assert price["score"] == max(sensor_scores)
|
||||||
assert result["methodology"] == "v4"
|
assert result["methodology"] == rms.METHODOLOGY
|
||||||
assert "combined" not in result
|
assert "combined" not in result
|
||||||
assert result["basket"]["members_available"] == 25
|
assert result["basket"]["members_available"] == 25
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ different realized ranges -- Warning never exceeded 64.9 in the 408 calibration
|
|||||||
sessions, so a shared 60 left the whole upper half of that axis unreachable.
|
sessions, so a shared 60 left the whole upper half of that axis unreachable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import alert_service
|
||||||
from app.services.alert_service import (
|
from app.services.alert_service import (
|
||||||
|
CONFLUENCE_TYPE,
|
||||||
|
FUND_TYPE,
|
||||||
QUAD_X_DIV,
|
QUAD_X_DIV,
|
||||||
QUAD_Y_DIV,
|
QUAD_Y_DIV,
|
||||||
_classify_quadrant,
|
_classify_quadrant,
|
||||||
|
_collect_regime_fundamental,
|
||||||
_parse_quadrant_log_key,
|
_parse_quadrant_log_key,
|
||||||
_quadrant_log_key,
|
_quadrant_log_key,
|
||||||
)
|
)
|
||||||
@@ -48,3 +54,140 @@ def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
|
|||||||
assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
|
assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
|
||||||
assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
|
assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
|
||||||
assert _parse_quadrant_log_key("3") == (None, "3", None, None)
|
assert _parse_quadrant_log_key("3") == (None, "3", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fundamental-context and confluence alerts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _monitor(
|
||||||
|
warning_score: float, state: str, *, coverage: float = 100.0, usable: bool = True
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"warning": {"score": warning_score, "coverage": coverage},
|
||||||
|
"fundamental_context": {
|
||||||
|
"state": state,
|
||||||
|
"evidence_quality": "complete" if usable else "stale",
|
||||||
|
# The state survives going stale so the card can still show it, and
|
||||||
|
# a failed extraction is fresh but knows nothing; `usable` is what
|
||||||
|
# says whether it may still confirm anything.
|
||||||
|
"available": usable,
|
||||||
|
"usable": usable,
|
||||||
|
},
|
||||||
|
"data_quality": {"is_fresh": True},
|
||||||
|
"quadrant_config": {"warning_divider": QUAD_Y_DIV},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _LogSpyDB:
|
||||||
|
"""Records what would be logged; returns a canned "last logged key"."""
|
||||||
|
|
||||||
|
def __init__(self, last: dict[str, str | None]) -> None:
|
||||||
|
self.last = last
|
||||||
|
self.logged: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched(monkeypatch):
|
||||||
|
def apply(data: dict, last: dict[str, str | None]):
|
||||||
|
db = _LogSpyDB(last)
|
||||||
|
|
||||||
|
async def fake_monitor(_db):
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def fake_last(_db, alert_type):
|
||||||
|
return db.last.get(alert_type)
|
||||||
|
|
||||||
|
def fake_log(_db, alert_type, key, value=None):
|
||||||
|
db.logged.append((alert_type, key))
|
||||||
|
|
||||||
|
import app.services.regime_monitor_service as rms
|
||||||
|
|
||||||
|
monkeypatch.setattr(rms, "get_regime_monitor", fake_monitor)
|
||||||
|
monkeypatch.setattr(alert_service, "_last_logged_key", fake_last)
|
||||||
|
monkeypatch.setattr(alert_service, "_log_alert", fake_log)
|
||||||
|
return db
|
||||||
|
|
||||||
|
return apply
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_run_seeds_both_channels_without_alerting(patched):
|
||||||
|
db = patched(_monitor(60.0, "adverse"), {FUND_TYPE: None, CONFLUENCE_TYPE: None})
|
||||||
|
assert await _collect_regime_fundamental(db) == []
|
||||||
|
assert dict(db.logged) == {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fundamental_change_and_confluence_are_separate_messages(patched):
|
||||||
|
db = patched(_monitor(60.0, "adverse"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
out = await _collect_regime_fundamental(db)
|
||||||
|
|
||||||
|
assert [alert_type for alert_type, _, _ in out] == [FUND_TYPE, CONFLUENCE_TYPE]
|
||||||
|
assert "neutral → adverse" in out[0][2]
|
||||||
|
assert "Confluence" in out[1][2]
|
||||||
|
# Neither message reports a fused score; they name which channel moved.
|
||||||
|
assert "not a score" in out[0][2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_never_alerts(patched):
|
||||||
|
"""Absence of evidence is not a change in the evidence."""
|
||||||
|
db = patched(_monitor(60.0, "unknown"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
assert await _collect_regime_fundamental(db) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adverse_alone_is_not_confluence(patched):
|
||||||
|
"""A calm tape with adverse fundamentals is a context change, not confluence."""
|
||||||
|
db = patched(_monitor(10.0, "adverse"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
out = await _collect_regime_fundamental(db)
|
||||||
|
assert [alert_type for alert_type, _, _ in out] == [FUND_TYPE]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_leaving_confluence_rebaselines_quietly(patched):
|
||||||
|
db = patched(_monitor(10.0, "neutral"), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "yes"})
|
||||||
|
assert await _collect_regime_fundamental(db) == []
|
||||||
|
assert (CONFLUENCE_TYPE, "no") in db.logged
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_low_coverage_or_stale_inputs_stay_quiet(patched):
|
||||||
|
thin = _monitor(60.0, "adverse", coverage=50.0)
|
||||||
|
assert await _collect_regime_fundamental(
|
||||||
|
patched(thin, {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
) == []
|
||||||
|
|
||||||
|
stale = _monitor(60.0, "adverse")
|
||||||
|
stale["data_quality"]["is_fresh"] = False
|
||||||
|
assert await _collect_regime_fundamental(
|
||||||
|
patched(stale, {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stale_observation_cannot_confirm_a_new_crossing(patched):
|
||||||
|
"""The state is kept for display, but it stops being evidence.
|
||||||
|
|
||||||
|
Without this, one adverse read corroborates every Warning crossing for the
|
||||||
|
rest of time — the strongest claim the channel makes, from the data with the
|
||||||
|
least right to make it.
|
||||||
|
"""
|
||||||
|
stale = _monitor(60.0, "adverse", usable=False)
|
||||||
|
db = patched(stale, {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "no"})
|
||||||
|
assert await _collect_regime_fundamental(db) == []
|
||||||
|
# It also rebaselines to "no", so recollecting the observation re-arms it.
|
||||||
|
assert (CONFLUENCE_TYPE, "no") not in db.logged # already "no"; nothing to log
|
||||||
|
|
||||||
|
fresh = _monitor(60.0, "adverse", usable=True)
|
||||||
|
db2 = patched(fresh, {FUND_TYPE: "adverse", CONFLUENCE_TYPE: "no"})
|
||||||
|
out = await _collect_regime_fundamental(db2)
|
||||||
|
assert [alert_type for alert_type, _, _ in out] == [CONFLUENCE_TYPE]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_stale_state_change_does_not_alert(patched):
|
||||||
|
db = patched(_monitor(10.0, "adverse", usable=False), {FUND_TYPE: "neutral", CONFLUENCE_TYPE: "no"})
|
||||||
|
assert await _collect_regime_fundamental(db) == []
|
||||||
|
|||||||
@@ -538,3 +538,88 @@ def test_a_wrong_declared_year_end_no_longer_collides_two_periods():
|
|||||||
keys = {(r.fiscal_year, r.fiscal_period) for r in res.rows}
|
keys = {(r.fiscal_year, r.fiscal_period) for r in res.rows}
|
||||||
assert len(keys) == 2, f"periods collided on one key: {keys}"
|
assert len(keys) == 2, f"periods collided on one key: {keys}"
|
||||||
assert keys == {(2026, "Q1"), (2026, "Q2")}
|
assert keys == {(2026, "Q1"), (2026, "Q2")}
|
||||||
|
|
||||||
|
|
||||||
|
# --- debt composition across the tagging styles large filers actually use ----
|
||||||
|
# Values are the real shapes measured 2026-08; before this composition, seven of
|
||||||
|
# nineteen sampled large caps carried a materially wrong or absent total_debt.
|
||||||
|
|
||||||
|
_RD = date(2026, 3, 28)
|
||||||
|
|
||||||
|
|
||||||
|
def _f(concept, val):
|
||||||
|
return Fact("us-gaap", concept, "USD", None, _RD, val, 2026, "Q2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_from_a_noncurrent_lease_aggregate_adds_its_current_side():
|
||||||
|
"""KO/HD/T/XOM/CVX tag LongTermDebtAndCapitalLeaseObligations, which nothing
|
||||||
|
read before — AT&T reported no debt at all against 134bn tagged."""
|
||||||
|
facts = [_f("LongTermDebtAndCapitalLeaseObligations", 134_630), _f("DebtCurrent", 9_320)]
|
||||||
|
assert _compose_debt(facts, _RD) == 143_950
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_current_is_the_whole_current_side_not_an_addition():
|
||||||
|
"""DebtCurrent already spans short-term borrowing AND current maturities, so
|
||||||
|
adding commercial paper on top would count it twice."""
|
||||||
|
facts = [
|
||||||
|
_f("LongTermDebtNoncurrent", 22_840),
|
||||||
|
_f("DebtCurrent", 11_300),
|
||||||
|
_f("LongTermDebtCurrent", 6_460),
|
||||||
|
_f("CommercialPaper", 4_840),
|
||||||
|
]
|
||||||
|
assert _compose_debt(facts, _RD) == 34_140
|
||||||
|
|
||||||
|
|
||||||
|
def test_debt_falls_back_to_the_split_current_parts():
|
||||||
|
facts = [
|
||||||
|
_f("LongTermDebtNoncurrent", 36_890),
|
||||||
|
_f("LongTermDebtCurrent", 3_900),
|
||||||
|
_f("ShortTermBorrowings", 10_670),
|
||||||
|
]
|
||||||
|
assert _compose_debt(facts, _RD) == 51_460
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_payable_is_the_unsecured_side_when_nothing_names_it():
|
||||||
|
"""Realty Income and VMRK tag a secured and an unsecured side, no aggregate."""
|
||||||
|
facts = [_f("NotesPayable", 25_090), _f("SecuredDebt", 40), _f("CommercialPaper", 1_400)]
|
||||||
|
assert _compose_debt(facts, _RD) == 26_530
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_explicit_unsecured_side_wins_over_notes_payable():
|
||||||
|
"""MAA tags NotesPayable 5.66bn = UnsecuredDebt 5.30bn + SecuredDebt 0.36bn, so
|
||||||
|
NotesPayable is the total there and adding SecuredDebt to it double-counts.
|
||||||
|
Preferring the explicit unsecured side reproduces the total either way."""
|
||||||
|
facts = [_f("NotesPayable", 5_660), _f("UnsecuredDebt", 5_300), _f("SecuredDebt", 360)]
|
||||||
|
assert _compose_debt(facts, _RD) == 5_660
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_side_of_a_reits_debt_is_not_a_total():
|
||||||
|
"""Boston Properties tags SecuredDebt 4.28bn and commercial paper against ~15bn
|
||||||
|
of real debt; Ventas the same shape. Composing from one side invents a total."""
|
||||||
|
assert _compose_debt([_f("SecuredDebt", 4_280), _f("CommercialPaper", 750)], _RD) is None
|
||||||
|
assert _compose_debt([_f("UnsecuredDebt", 5_300)], _RD) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_maturities_alone_are_not_a_total():
|
||||||
|
"""LongTermDebtCurrent used to stand in for the whole long-term side, which
|
||||||
|
reports the slice due within a year as if it were the debt."""
|
||||||
|
assert _compose_debt([_f("LongTermDebtCurrent", 6_460)], _RD) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_aggregate_beats_the_reit_parts():
|
||||||
|
"""AvalonBay tags all three; summing the parts would understate the total."""
|
||||||
|
facts = [_f("LongTermDebt", 9_020), _f("SecuredDebt", 700), _f("UnsecuredDebt", 7_410),
|
||||||
|
_f("CommercialPaper", 920)]
|
||||||
|
assert _compose_debt(facts, _RD) == 9_940
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_term_only_filing_reports_no_total_at_all():
|
||||||
|
"""Chevron tags its full debt only in the 10-K, so a 10-Q carries 0.40bn of
|
||||||
|
short-term borrowing alone — reporting that as *total* debt reads as a
|
||||||
|
near-unlevered issuer carrying 50bn. None costs a leverage read; the partial
|
||||||
|
value produces a confidently wrong one."""
|
||||||
|
assert _compose_debt([_f("ShortTermBorrowings", 401)], _RD) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_debt_facts_at_all_is_still_none():
|
||||||
|
assert _compose_debt([_f("CashAndCashEquivalentsAtCarryingValue", 100)], _RD) is None
|
||||||
|
|||||||
@@ -950,6 +950,9 @@ async def test_discrepancy_in_shares_is_detected_and_reported(engine):
|
|||||||
assert k.shares_outstanding == 999.0 and k.import_run_id == 1 # immutable — not overwritten
|
assert k.shares_outstanding == 999.0 and k.import_run_id == 1 # immutable — not overwritten
|
||||||
events = (await s.execute(select(SystemEvent).where(SystemEvent.code == "snapshot_discrepancy"))).scalars().all()
|
events = (await s.execute(select(SystemEvent).where(SystemEvent.code == "snapshot_discrepancy"))).scalars().all()
|
||||||
assert len(events) == 1 and events[0].severity == "warning"
|
assert len(events) == 1 and events[0].severity == "warning"
|
||||||
|
# The alert has to say WHICH column moved: a differing cik is a co-registrant
|
||||||
|
# attribution, a differing revenue is our numbers changing.
|
||||||
|
assert "K (shares_outstanding, shares_outstanding_date)" in events[0].message
|
||||||
|
|
||||||
|
|
||||||
# --- reparse: rewriting rows a fixed parser reconstructs differently --------
|
# --- reparse: rewriting rows a fixed parser reconstructs differently --------
|
||||||
@@ -1283,3 +1286,181 @@ async def test_ceiling_promotes_queues_and_alerts_end_to_end(engine, monkeypatch
|
|||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0].severity == "warning"
|
assert events[0].severity == "warning"
|
||||||
assert "7 days" in events[0].message
|
assert "7 days" in events[0].message
|
||||||
|
|
||||||
|
|
||||||
|
# --- attribution collisions: two tracked CIKs claiming one filing ----------
|
||||||
|
|
||||||
|
# A REIT and its operating partnership co-file one 10-K, and SEC's
|
||||||
|
# company_tickers.json points the old symbol at the partnership (EQR ->
|
||||||
|
# ERP Operating LP) while the issuer itself trades under a new one (VMRK).
|
||||||
|
_COMBINED = [_filing("COMBINED-K", "10-K", "2025-12-31", "2026-02-13",
|
||||||
|
"2026-02-13T21:00:00.000Z")]
|
||||||
|
_CF_COMBINED = _rev("2025-01-01", "2025-12-31", 2900000, 2025, "FY", "COMBINED-K")
|
||||||
|
_SH_COMBINED = _shares("2026-02-01", 380000, "COMBINED-K", 2025, "FY")
|
||||||
|
|
||||||
|
|
||||||
|
def _reit_submissions(cik, tickers):
|
||||||
|
return {"cik": cik, "sic": "6798", "sic_description": "REIT",
|
||||||
|
"fiscal_year_end": "1231", "tickers": tickers, "filings": _COMBINED}
|
||||||
|
|
||||||
|
|
||||||
|
def _reit_client(tickers):
|
||||||
|
return FakeSecClient(
|
||||||
|
tickers=tickers,
|
||||||
|
companyfacts={
|
||||||
|
cik: _companyfacts([_CF_COMBINED], [_SH_COMBINED], cik=cik)
|
||||||
|
for cik in tickers.values()
|
||||||
|
},
|
||||||
|
submissions={
|
||||||
|
cik: _reit_submissions(cik, [sym]) for sym, cik in tickers.items()
|
||||||
|
},
|
||||||
|
latest_index=date(2026, 3, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cik_collision_is_reported_as_attribution_not_discrepancy(engine):
|
||||||
|
"""Only `cik` differs, so nothing was re-parsed differently — the universe
|
||||||
|
resolves a co-registrant it should not track, and the alert must say that."""
|
||||||
|
factory = _factory(engine)
|
||||||
|
await _seed(factory, ["VMRK"])
|
||||||
|
run = await run_import(
|
||||||
|
_importer(_reit_client({"VMRK": 906107}), today=date(2026, 3, 2)), engine=engine
|
||||||
|
)
|
||||||
|
assert run.status == STATUS_PROMOTED
|
||||||
|
|
||||||
|
# The stale symbol is added, resolving to the partnership's CIK.
|
||||||
|
await _seed(factory, ["EQR"])
|
||||||
|
run = await run_import(
|
||||||
|
_importer(_reit_client({"VMRK": 906107, "EQR": 931182}), today=date(2026, 3, 2)),
|
||||||
|
engine=engine,
|
||||||
|
)
|
||||||
|
assert run.status == STATUS_PROMOTED
|
||||||
|
# Production's shape: a run-level incremental in which the untracked-until-now
|
||||||
|
# CIK is individually backfilled (run 63 recorded exactly this).
|
||||||
|
assert '"backfill": false' in (run.validation_json or "")
|
||||||
|
|
||||||
|
async with factory() as s:
|
||||||
|
rows = (await s.execute(select(FundamentalSnapshot))).scalars().all()
|
||||||
|
events = (await s.execute(select(SystemEvent))).scalars().all()
|
||||||
|
# The filing stays with the issuer that filed it, stored once.
|
||||||
|
assert [(r.accession, r.cik) for r in rows] == [("COMBINED-K", "0000906107")]
|
||||||
|
|
||||||
|
codes = {e.code for e in events}
|
||||||
|
assert "accession_cik_collision" in codes
|
||||||
|
assert "snapshot_discrepancy" not in codes # not a reconstruction change
|
||||||
|
collision = next(e for e in events if e.code == "accession_cik_collision")
|
||||||
|
assert "stored 0000906107, parsed 0000931182" in collision.message
|
||||||
|
assert "sec_cik_overrides" in collision.message # names the actual fix
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reparse_never_restamps_a_collision_onto_the_co_registrant(engine):
|
||||||
|
"""A reparse rewrites rows a fixed parser reconstructs differently. A cik-only
|
||||||
|
difference is not that: rewriting would hand the filing to the co-registrant."""
|
||||||
|
from app.services.sec_facts_parser import SnapshotRow
|
||||||
|
|
||||||
|
factory = _factory(engine)
|
||||||
|
await _seed(factory, ["VMRK"])
|
||||||
|
assert (await run_import(
|
||||||
|
_importer(_reit_client({"VMRK": 906107}), today=date(2026, 3, 2)), engine=engine
|
||||||
|
)).status == STATUS_PROMOTED
|
||||||
|
|
||||||
|
importer = _importer(_reit_client({"VMRK": 906107}), today=date(2026, 3, 2))
|
||||||
|
importer.reparse = True
|
||||||
|
staged = StagedFundamentals(
|
||||||
|
resolved=ResolvedUniverse(),
|
||||||
|
rows=[SnapshotRow(
|
||||||
|
cik="0000931182", accession="COMBINED-K", form="10-K",
|
||||||
|
filed_date=date(2026, 2, 13),
|
||||||
|
accepted_at=datetime(2026, 2, 13, 21, tzinfo=timezone.utc),
|
||||||
|
period_end=date(2025, 12, 31), fiscal_year=2025, fiscal_period="FY",
|
||||||
|
)],
|
||||||
|
existing_accessions={"COMBINED-K"},
|
||||||
|
discrepancies=[{
|
||||||
|
"accession": "COMBINED-K", "fields": ["cik"],
|
||||||
|
"cik": "0000931182", "stored_cik": "0000906107",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
async with _factory(engine)() as db:
|
||||||
|
counts = await importer.promote(db, staged, run_id=999)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
assert counts["updated"] == 0
|
||||||
|
async with factory() as s:
|
||||||
|
row = (await s.execute(select(FundamentalSnapshot))).scalar_one()
|
||||||
|
assert row.cik == "0000906107" # still the issuer that filed it
|
||||||
|
|
||||||
|
|
||||||
|
# --- the reprieve ending: an exemption that lapses must not do so silently ---
|
||||||
|
|
||||||
|
def _stale_gap(cik, *, exempted: bool):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return SecFilingGap(
|
||||||
|
cik=cik, accession=f"{cik}-AGED-Q", form="10-Q",
|
||||||
|
index_date=(now - timedelta(days=30)).date(), reason="not_in_companyfacts",
|
||||||
|
first_seen_at=now - timedelta(days=30), last_attempted_at=now,
|
||||||
|
escalated_at=now - timedelta(days=16),
|
||||||
|
exempted_at=(now - timedelta(days=16)) if exempted else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(cik, *, age_days):
|
||||||
|
filed = date.today() - timedelta(days=age_days)
|
||||||
|
return FundamentalSnapshot(
|
||||||
|
cik=cik, accession=f"{cik}-PRIOR", form="10-Q", filed_date=filed,
|
||||||
|
accepted_at=datetime.now(timezone.utc) - timedelta(days=age_days),
|
||||||
|
period_end=filed, fiscal_year=filed.year, fiscal_period="Q1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _promote_only(engine, seed):
|
||||||
|
"""Run promote() alone against seeded gap/snapshot state."""
|
||||||
|
factory = _factory(engine)
|
||||||
|
async with factory() as s:
|
||||||
|
for obj in seed:
|
||||||
|
s.add(obj)
|
||||||
|
await s.commit()
|
||||||
|
importer = _importer(FakeSecClient(
|
||||||
|
tickers={}, companyfacts={}, submissions={}, latest_index=date(2026, 3, 1)
|
||||||
|
))
|
||||||
|
async with factory() as db:
|
||||||
|
await importer.promote(db, StagedFundamentals(resolved=ResolvedUniverse()), run_id=77)
|
||||||
|
await db.commit()
|
||||||
|
async with factory() as s:
|
||||||
|
gaps = (await s.execute(select(SecFilingGap))).scalars().all()
|
||||||
|
events = (await s.execute(select(SystemEvent))).scalars().all()
|
||||||
|
return gaps, events
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_lapsed_exemption_raises_its_own_alert(engine):
|
||||||
|
"""filing_gap_aged fires once and never again, so nothing else would say the
|
||||||
|
pause came back when the issuer's own fundamentals aged out."""
|
||||||
|
cik = "0000000060"
|
||||||
|
gaps, events = await _promote_only(
|
||||||
|
engine, [_stale_gap(cik, exempted=True), _snapshot(cik, age_days=400)]
|
||||||
|
)
|
||||||
|
repaused = [e for e in events if e.code == "filing_gap_repaused"]
|
||||||
|
assert len(repaused) == 1
|
||||||
|
assert f"{cik}/{cik}-AGED-Q" in repaused[0].message
|
||||||
|
# Cleared, so a later recovery can re-arm and lapse again.
|
||||||
|
assert gaps[0].exempted_at is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_exemption_taking_effect_is_stamped_silently(engine):
|
||||||
|
"""Setups resuming is what filing_gap_aged already described — stamping the
|
||||||
|
state must not raise a second alert for it."""
|
||||||
|
cik = "0000000061"
|
||||||
|
gaps, events = await _promote_only(
|
||||||
|
engine, [_stale_gap(cik, exempted=False), _snapshot(cik, age_days=120)]
|
||||||
|
)
|
||||||
|
assert [e.code for e in events if e.code.startswith("filing_gap")] == []
|
||||||
|
assert gaps[0].exempted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_still_paused_gap_is_not_reported_as_lapsing(engine):
|
||||||
|
"""It never became exempt, so there is no transition to report."""
|
||||||
|
cik = "0000000062"
|
||||||
|
gaps, events = await _promote_only(
|
||||||
|
engine, [_stale_gap(cik, exempted=False), _snapshot(cik, age_days=400)]
|
||||||
|
)
|
||||||
|
assert [e for e in events if e.code == "filing_gap_repaused"] == []
|
||||||
|
assert gaps[0].exempted_at is None
|
||||||
|
|||||||
Reference in New Issue
Block a user