Compare commits

..
5 Commits
Author SHA1 Message Date
dennisthiessenandClaude Opus 5 98b41629e7 ci: lint the whole repo, and pin the rule set so it stays deterministic
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m14s
Deploy / deploy (push) Successful in 39s
Widens the lint step from `ruff check app/` to `ruff check .`, since tests/ and
scripts/ had drifted to 11 findings while unchecked (fixed in 1c6ccce).

Widening alone would have been unsafe, and the check turned up something worse
than the drift: CI installs ruff unpinned, the repo had no [tool.ruff] config,
and ruff's default rule set is not stable across releases. Local 0.15.4 reports
0 findings in app/; 0.16.2 -- what `pip install ruff` resolves to today --
reports 376. 168 of those are B008 flagging FastAPI's `Depends()` in a signature
default, which is the framework's documented idiom, not a defect. So the lint
job would have failed the deploy pipeline on untouched code at the next push,
independent of this change.

Pinning the ruff version would freeze the bug in place. Pinning the *rule set*
is the actual fix: select = ["E4", "E7", "E9", "F"] in pyproject.toml, the set
the tree was already clean under, now enforced repo-wide. The ruff version can
float freely without changing what CI enforces.

Verified both scopes against both versions with caches disabled: `ruff check .`
passes under 0.15.4 and 0.16.2. Confirmed the pin actually binds rather than
passing by luck -- a probe file using `Depends()` in a default is clean under
the committed config and reports B008 + I001 under `--isolated` 0.16.2 defaults.
Full suite still 852 passed, 1 skipped.

Adding rules is welcome; do it in pyproject.toml with the fixes in the same
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:28:26 +02:00
dennisthiessenandClaude Opus 5 1c6ccceb12 chore: make the whole tree ruff-clean, not just app/
CI only lints app/, so 11 findings had accumulated in tests/ and scripts/.

Mechanical and behaviour-neutral, but two were not auto-fixable and needed a
judgement call rather than `ruff --fix`:

- E741 in run_fip_breadth_diagnostics: `l` is the OHLCV low and is genuinely
  used, so this was a naming fix (`l` -> `lo`), not a deletion.
- F841 in the same file: `vol_ix`/`momr_ix` are assigned from a pure local
  `_index()` and never read, so removing them cannot change any output. Their
  upstream `vol_weeks`/`momr_weeks` maps *are* used further down and stay; the
  comment above `_index` was corrected to say so.

The rest are unused imports and f-strings without placeholders (literal
markdown table headers, so identical output).

Verified beyond the linter, since py_compile does not catch a removed-but-used
import: every removed symbol has zero remaining references, all scripts compile,
and the full unit suite passes (852 passed, 1 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:43:46 +02:00
dennisthiessenandClaude Opus 5 3483797e75 fix(regime): reseed stored history on a sensor change, and stop faking an observation
Two review findings on 46ace50.

[P1] Raising HY_OAS_WINDOW_DAYS to 700 only reached newly computed rows. A
routine run recomputes the latest trading date alone, and `rebuilding` was keyed
on "no v3 snapshot exists at all", which is false once the cutover has run --
so every row already written kept the credit gap the wider window exists to
close, indefinitely.

Adds SENSOR_REVISION: stamped into each snapshot, absent on pre-marker rows
(read as 1), and a stored revision below the current one triggers exactly one
reseed. Deliberately not METHODOLOGY, which would partition the history API and
discard the cached event study -- neither warranted, since the study recomputes
its Warning series from source rather than reading snapshots and so cannot be
staled by a reseed.

The reseed is bounded by REBUILD_LOOKBACK_DAYS in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed
row needs W3's 20-business-day lookback inside HY_OAS_WINDOW_DAYS. Replaying by
session count would have left the oldest stored rows unrepaired -- the exact
rows the fix targets. At 672 days the replay covers ~464 sessions, W3's oldest
requirement lands on the first fetched OAS day, and the ~400-session series the
cutover wrote is fully covered. A test asserts that relationship so the two
constants cannot drift back into recreating the gap.

[P2] With nothing ever collected, current_observation returned available=true
and the default placeholders -- "unknown" for every hyperscaler, "mixed" for the
reaction -- so the card announced a reading that never happened. Those are the
absence of an observation, not an observation of absence. Gated on `observed`
(non-null fetched_at, the one field every path writing real content stamps),
which blanks the content and drives a proper empty state naming where an admin
collects one. This was a regression from 46ace50; fundamental_overlay never had
it, since no observation means no effective date means pending.

Also renames the leftover v2 identifiers in the touched paths
(rewrite_existing_v2, latest_v2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:22:25 +02:00
dennisthiessenandClaude Opus 5 7dc804be2b test(dolt): anchor the real-clone smoke test to the clone, not the wall clock
The test built the importer with today=date.today() while running against a
fixed local clone with do_pull=False, so its forward horizon shrank by a day
per real day. It has now decayed past the initial-load gate -- 19d against the
21d MIN_FORWARD_HORIZON_DAYS floor -- and would have kept failing, worse each
day.

Anchors today to the clone's own calendar (max reporting date across the seeded
dot-free symbols, minus 35 days, mirroring the ~35d horizon the importer's own
comment cites) and uses that date in the forward-calendar assertion. Also
surfaces run.error_details on failure, which is how the cause was found.

Test-only. MIN_FORWARD_HORIZON_DAYS and the importer are untouched: production
pulls fresh data on every run and was never affected by this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:22:11 +02:00
dennisthiessenandClaude Opus 5 46ace501a2 refactor(regime): collapse the monitor page, fix the OAS rebuild window
The page had twelve stacked blocks, several of them different views of the
same numbers. The quadrant plot and the score-history chart drew the same two
series from the same query key, which read as two datasets; they are now one
card with a Time | Path toggle. The two pillar disclosures become one grouped
table, and three prose blocks (data quality, basket, coverage) become one
provenance chip strip. Page text is now limited to what changes how the reader
interprets today's number; the rest moved to the methodology doc.

Removes three stale-threshold bugs of one class. The quadrant fell back to v2's
60/60 dividers when quadrant_config was absent -- the real values are 50/40 and
they feed alert_service, so the chart could disagree with what actually fires.
The gauge fell back to v2's 30/60/80 band ticks, and drew a divider line that
always landed on its own "elevated" tick. The time series' reference lines were
at 30/60/80, which correspond to nothing in v3; they are now per-axis dashed
lines read from the same quadrant_config. Rendering also surfaced a live
clipping bug inherited from the old chart: margin.left -18 against YAxis
width 28 left ~10px for a 3-digit label, so every Y tick was cut off.

HY_OAS_WINDOW_DAYS was 400 *calendar* days while a rebuild replays
REBUILD_SESSIONS = 400 *trading* sessions (~579 calendar days), so the oldest
~180 days of any rebuild got no OAS at all and both credit sensors returned
None. State then lands at 80% coverage and Warning at exactly MIN_COVERAGE, so
both still publish bands -- a series that looks homogeneous while its oldest
rows were scored without credit. Widened to 700. This needs no methodology
bump: C1 reads [-1] and W3 reads [-21], both from the end, so widening only
prepends and every live score is bit-identical. Sequenced deliberately, since
acting on the open findings below bumps METHODOLOGY and fires the rebuild.

A just-collected fundamental observation was hidden until its effective date --
one day, three over a weekend -- because the live reading called the
point-in-time function, so refreshing appeared to do nothing. That was the
opposite of what the doc claimed. fundamental_overlay stays the gated record
(it runs for every replayed date during a rebuild); current_observation is the
live reading and reports the effective date instead of blanking the content.
Nothing in the overlay is scored, so showing it early cannot reach a published
number.

Documents four calculation findings. Three are not implemented, since each
changes a published score and so requires a v4 cut: State's top band is a
credit-event band (credit returns 0.0 rather than None below the 3.5 anchor, so
it is pinned at zero at weight 20 -- with everything else pegged State computes
to exactly 80.0, the breaking threshold); V1 saturates at VIX 30; and the
deliberate max(P1,P2,P3) defeats P3's anchoring because P1 is binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:53:45 +02:00
20 changed files with 923 additions and 474 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ jobs:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- run: pip install ruff - run: pip install ruff
- run: ruff check app/ # Whole repo, not just app/: tests/ and scripts/ drifted to 11 findings
# while unchecked. Rules are pinned in pyproject.toml, so the unpinned
# ruff above cannot change what this enforces.
- run: ruff check .
test: test:
needs: lint needs: lint
+114 -17
View File
@@ -52,7 +52,14 @@ METHODOLOGY = "v3"
# Snapshots are reseeded on a methodology bump, but fundamental observations are # Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible. # collected by hand/LLM and carried across it when the format is compatible.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"}) CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
REBUILD_SESSIONS = 400
# Bumped when a fix changes what historical rows *should* contain without
# changing the live formula, so stored history needs one reseed. Deliberately
# not METHODOLOGY: that partitions the history API and discards the cached event
# study, neither of which is warranted here -- the study recomputes its Warning
# series from source rather than reading snapshots, so a reseed cannot stale it.
# Snapshots written before this marker existed carry no key and read as 1.
SENSOR_REVISION = 2
MIN_COVERAGE = 75.0 MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7 SOURCE_MAX_LAG_DAYS = 7
@@ -81,7 +88,24 @@ HY_OAS_STRESSED = 7.0
# of stress at 3.5 -- the level these anchors call "mild". The anchors already # of stress at 3.5 -- the level these anchors call "mild". The anchors already
# encode the long-run distribution, so the credit *level* is now purely anchored # encode the long-run distribution, so the credit *level* is now purely anchored
# and credit *dynamics* live in W3 on the Warning axis where they belong. # and credit *dynamics* live in W3 on the Warning axis where they belong.
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now # Calendar days, and it must cover the oldest date a rebuild replays -- not just
# W3's lookback. REBUILD_SESSIONS is 400 *trading* sessions (~579 calendar
# days), so a 400-calendar-day fetch left the oldest ~180 days of a rebuild with
# no OAS at all: C1 and W3 both returned None, State landed at 80% coverage and
# Warning at exactly MIN_COVERAGE, and *both still published bands* -- a series
# that looks homogeneous while its oldest rows were scored without credit.
# Widening only prepends older observations; C1 reads [-1] and W3 reads [-21], so
# live scores are unchanged and this needs no methodology bump. Stays under
# ICE's ~3-year cap so FRED still honours the request.
HY_OAS_WINDOW_DAYS = 700
# A rebuild replays every session inside this window. Bounded by calendar days
# rather than a session count because the binding constraint is the OAS fetch:
# each replayed row needs W3's 20-business-day lookback (~28 calendar days)
# inside HY_OAS_WINDOW_DAYS, so replaying further back would recreate the exact
# credit gap a reseed exists to close. 672 days is ~464 trading sessions, which
# comfortably covers the 400-session series the v3 cutover wrote.
REBUILD_LOOKBACK_DAYS = HY_OAS_WINDOW_DAYS - 28
W3_OAS_LOOKBACK = 20 W3_OAS_LOOKBACK = 20
W3_OAS_FULL_SCALE_PCT = 35.0 W3_OAS_FULL_SCALE_PCT = 35.0
@@ -477,17 +501,29 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
return _next_weekday(fetched) if fetched else None return _next_weekday(fetched) if fetched else None
def _overlay_timing(
overrides: dict, config: dict, as_of: date
) -> tuple[date | None, bool, int | None, bool]:
"""Shared effective-date arithmetic: (effective, pending, age_days, stale)."""
effective = _fundamental_effective_date(overrides)
pending = effective is None or as_of < effective
age = None if pending else (as_of - effective).days
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
return effective, pending, age, stale
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict: def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3. """Point-in-time qualitative overlay. Never feeds State or Warning in v3.
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 400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record. onto 2024 snapshots would be plain lookahead in the stored record.
This is the *record*. For "what do we know right now", use
``current_observation`` -- do not add a bypass flag here, because this runs
for every replayed date during a rebuild.
""" """
effective = _fundamental_effective_date(overrides) effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
pending = effective is None or as_of < effective
age = None if pending else (as_of - effective).days
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
return { return {
"available": not pending and not stale, "available": not pending and not stale,
"pending": pending, "pending": pending,
@@ -504,6 +540,43 @@ def fundamental_overlay(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.
Same shape as ``fundamental_overlay``, but the effective date is *reported*
rather than used to blank the content. A refresh stamps
``_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
nothing. Nothing here is scored, so showing it early cannot leak into a
published number; the stored snapshot keeps the gate.
"""
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
# The default override carries "unknown"/"mixed" placeholders for every
# hyperscaler. Those are the absence of an observation, not an observation
# 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
# produces real content (LLM refresh and manual save both stamp it).
observed = bool(overrides.get("fetched_at"))
return {
"observed": observed,
# Live availability is about usefulness, not effectiveness: a pending
# observation is the freshest thing we have -- but nothing collected is
# never available.
"available": observed and not stale,
"pending": pending,
"stale": stale,
"effective_date": effective.isoformat() if effective else None,
"age_days": age,
"capex": overrides.get("capex") if observed else None,
"good_news_stock_down": overrides.get("good_news_stock_down") if observed else None,
"capex_stress": overrides.get("f1_score") if observed else None,
"earnings_stress": overrides.get("f3_score") if observed else None,
"reasoning": overrides.get("reasoning") if observed else None,
"source": overrides.get("source"),
"fetched_at": overrides.get("fetched_at"),
}
def _basket_hash(symbols: list[str]) -> str: def _basket_hash(symbols: list[str]) -> str:
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()})) canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
@@ -630,6 +703,8 @@ def _compute_index(
return { return {
"methodology": METHODOLOGY, "methodology": METHODOLOGY,
# Not part of the history filter -- only the reseed trigger.
"sensor_revision": SENSOR_REVISION,
"date": as_of.isoformat(), "date": as_of.isoformat(),
"state": state, "state": state,
"warning": warning, "warning": warning,
@@ -889,7 +964,7 @@ async def _upsert_snapshot(
db: AsyncSession, db: AsyncSession,
result: dict, result: dict,
*, *,
rewrite_existing_v2: bool, rewrite_existing: bool,
) -> tuple[bool, dict]: ) -> tuple[bool, dict]:
snapshot_date = date.fromisoformat(result["date"]) snapshot_date = date.fromisoformat(result["date"])
existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date)) existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date))
@@ -906,15 +981,23 @@ async def _upsert_snapshot(
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
)) ))
else: else:
existing_v2 = _parse_snapshot(row.breakdown_json) existing_parsed = _parse_snapshot(row.breakdown_json)
if existing_v2 is not None and not rewrite_existing_v2: if existing_parsed is not None and not rewrite_existing:
return False, existing_v2 return False, existing_parsed
row.total_score = float(state_score or 0.0) row.total_score = float(state_score or 0.0)
row.band = state_band or "unavailable" row.band = state_band or "unavailable"
row.breakdown_json = payload row.breakdown_json = payload
return True, result return True, result
def _snapshot_revision(snapshot: dict) -> int:
"""Sensor revision of a stored snapshot; pre-marker rows read as 1."""
try:
return int(snapshot.get("sensor_revision") or 1)
except (TypeError, ValueError):
return 1
def _parse_snapshot(raw: str) -> dict | None: def _parse_snapshot(raw: str) -> dict | None:
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
@@ -934,7 +1017,9 @@ async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict]
return None return None
async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUILD_SESSIONS) -> dict: async def update_regime_monitor(
db: AsyncSession, rebuild_lookback_days: int = REBUILD_LOOKBACK_DAYS
) -> 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)
if _fundamentals_stale(overrides, config) and not overrides.get("locked"): if _fundamentals_stale(overrides, config) and not overrides.get("locked"):
@@ -968,10 +1053,18 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc) logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
breadth, breadth_counts, divergence = {}, {}, {} breadth, breadth_counts, divergence = {}, {}, {}
latest_v2 = await _latest_snapshot_row(db) latest_snapshot = await _latest_snapshot_row(db)
rebuilding = latest_v2 is None and bool(leader_series) # A stored series written under an older sensor revision is reseeded once.
# Without this, raising HY_OAS_WINDOW_DAYS would only ever reach newly
# computed rows: routine runs touch the latest date alone, so every older row
# would keep the credit gap indefinitely.
rebuilding = bool(leader_series) and (
latest_snapshot is None
or _snapshot_revision(latest_snapshot[1]) < SENSOR_REVISION
)
if rebuilding: if rebuilding:
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]] floor = end - timedelta(days=rebuild_lookback_days)
dates = [d for d, _ in leader_series if d >= floor] or [latest_date]
else: else:
# Routine PIT rule: only the latest trading date may be inserted/updated. # Routine PIT rule: only the latest trading date may be inserted/updated.
dates = [latest_date] dates = [latest_date]
@@ -995,7 +1088,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
written, latest_result = await _upsert_snapshot( written, latest_result = await _upsert_snapshot(
db, db,
computed, computed,
rewrite_existing_v2=rebuilding or snapshot_date == latest_date, # True for *every* replayed date on a reseed, or it would write one
# row and leave the rest at the old revision.
rewrite_existing=rebuilding or snapshot_date == latest_date,
) )
snapshots_written += int(written) snapshots_written += int(written)
await db.commit() await db.commit()
@@ -1042,7 +1137,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict: async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_snapshot_row(db) latest = await _latest_snapshot_row(db)
if latest is None: if latest is None:
return {"available": False, "reason": "v2 not computed yet"} return {"available": False, "reason": "not computed yet"}
row, result = latest row, result = latest
basket_hash = (result.get("basket") or {}).get("hash") basket_hash = (result.get("basket") or {}).get("hash")
previous_7 = await _result_at_or_before( previous_7 = await _result_at_or_before(
@@ -1071,7 +1166,9 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
# session, because otherwise refreshing it looks like it did nothing. # session, because otherwise 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 = fundamental_overlay(overrides, config, date.today()) live = current_observation(overrides, config, date.today())
# Deliberately reads the *snapshot's* overlay, not the live one: this is how
# 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.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live result["fundamental_context"] = live
result["available"] = True result["available"] = True
+119 -3
View File
@@ -140,13 +140,42 @@ The fundamental overlay keeps its effective date (normally the next session afte
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. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date, and the therefore reports the overlay as `pending` until the new effective date.
live reading additionally carries `fundamental_context` so a just-collected
observation is visible immediately rather than appearing to have done nothing. Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
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
`fundamental_context`, and *reports* the effective date instead of blanking the
content.
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 —
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
nothing in the overlay is scored (see "Fundamentals left the score").
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
every path writing real content stamps). Without it, the default override —
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
live observation with `available: true`, so the card presented placeholders as a
collected reading. Those are the absence of an observation, not an observation of
absence. `fundamental_overlay` never had this problem: no observation means no
effective date, which means `pending`, which already blanks the content.
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.
## Presentation
The page is deliberately thin: two gauges, one chart card, one pillar table, the
overlay, and a provenance strip. Time and Path are two projections of the same
snapshot series and share one card and one query key — they were previously two
panels, which read as two datasets. Methodology rationale lives in this document,
not on the page; page text is limited to what changes how the reader interprets
today's number. The quadrant dividers rendered in Path view come from
`quadrant_config` and are the same constants the alert path consumes
(`alert_service`), so the chart cannot drift from what actually fires.
## 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. The first
@@ -194,6 +223,93 @@ coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have. states its limits rather than pretending to a precision it does not have.
## Open calibration questions
Raised 2026-08-07 during the page refactor. **None are implemented.** Each one
changes a published score, so acting on any of them means cutting `METHODOLOGY`
to v4 — which reseeds 400 sessions and discards the cached event study. They are
recorded here rather than hand-patched into v3.
**1. State's top band is a credit-event band.** `f2_credit_spreads` returns
`0.0` — not `None` — for any OAS below the 3.5 mild anchor, so credit stays
*available* at weight 20 and is not renormalized out. It is simply pinned at
zero. Verified: with price, breadth and volatility all pegged at 100 and OAS at
the cutover's 2.77, State computes to exactly **80.0** at 100% coverage — the
"breaking" threshold to the decimal. So the top State band requires either a
credit event or all three remaining pillars simultaneously at maximum. A pure
AI/Tech drawdown with calm credit — the scenario this monitor exists to
measure — cannot print it with anything to spare. Anchors-only credit was
nonzero on 27 of 408 calibration sessions, so that 20-point weight sits at zero
roughly 93% of the time. This is structurally the same defect v3 corrected on
the Warning axis ("the upper half of the Warning axis was unreachable"), and it
means the State bands were fit against a v2 credit distribution that v3 no
longer produces.
**2. V1 saturates at VIX 30.** `(vix - 15) / 15 * 100` reaches 100 at VIX 30 and
has no resolution above it: VIX 30, 50 and 82 all score identically. That is the
same failure mode, at a similar percentile, as the `dd_pct * 5` formula this
version replaced for pegging at a 20% drawdown. If addressed, it should get an
anchor table in the P3 style rather than a rescaled slope.
**3. `max(P1, P2, P3)` defeats P3's anchoring.** The `max` is deliberate ("one
capped vote for correlated reads"), but `_under_200` is binary, so P1 prints 100
whenever SMH and QQQ are both below their 200-DMA. P3's anchor ladder therefore
only resolves anything while price is *above* the 200-DMA — that is, before the
drawdown it measures is underway. Note also that "P3's realized share of State
falls from 65% to 40%" is argmax-share accounting, which is a slippery statistic
under `max()`.
## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
homogeneous while its oldest rows had been scored without credit, the tell being
a null `data_quality.credit_history_days` on exactly those rows.
The window is now 700 days: it must cover the oldest replayed date (~579) plus
W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
honours the request. This required **no methodology bump** — C1 reads
`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
widening only prepends older observations and every live score is bit-identical.
Confirmed by evaluating both windows against a varying synthetic series: today's
C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
values.
Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
the widened request, not new upstream history — and it makes the chip a better
truncation canary, since a 700-day request returning ~1095 days' worth is now
the visible ceiling.
**Widening the window alone does not repair stored history.** Routine runs
recompute only the latest trading date, and `rebuilding` was keyed on "no v3
snapshot exists at all" — which is false once the cutover has run — so every row
already written would have kept its credit gap indefinitely. `SENSOR_REVISION`
fixes that: it is stamped into each snapshot, snapshots predating it read as 1,
and a stored revision below the current one triggers exactly one reseed.
It is deliberately not `METHODOLOGY`. That constant partitions the history API
and discards the cached event study; neither is warranted here, because the study
recomputes its Warning series from source (`_warning_series` calls
`warning_sensor_scores` against freshly fetched prices and OAS) rather than
reading snapshots, so a reseed cannot stale it.
The reseed is bounded by `REBUILD_LOOKBACK_DAYS` in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed row
needs W3's 20-business-day lookback inside `HY_OAS_WINDOW_DAYS`. At 672 days the
replay reaches ~464 sessions, W3's oldest requirement lands exactly on the first
fetched OAS day, and the ~400-session series the v3 cutover wrote is fully
covered. A test asserts that relationship so the two constants cannot drift into
recreating the gap.
The fix was sequenced deliberately: acting on items 13 above bumps
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
rows into the fresh series. Fixing the window afterwards would mean reseeding
twice.
## Operator rule ## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they Quadrant alerts default off for new/reset configurations. When enabled they
@@ -0,0 +1,308 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
CartesianGrid,
Cell,
Line,
LineChart,
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
ZAxis,
} from 'recharts';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// 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
// query rather than sitting in two panels that look like different data.
const VIEWS = ['Time', 'Path'] as const;
type View = (typeof VIEWS)[number];
const RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: Number.POSITIVE_INFINITY },
] as const;
type RangeKey = (typeof RANGES)[number]['key'];
/** Sessions drawn in Path view. The full series is unreadable as a path. */
const PATH_TRAIL = 60;
const STATE_COLOR = '#60a5fa';
const WARNING_COLOR = '#fb923c';
// Fall back to the v3 constants, not v2's shared 60/60, so a missing
// quadrant_config cannot draw dividers that disagree with the alert path.
const DEFAULT_STATE_DIVIDER = 50;
const DEFAULT_WARNING_DIVIDER = 40;
interface PathPoint {
x: number;
y: number;
date: string;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
let sx = 0;
let sy = 0;
let c = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
sx += points[j].x;
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
}
function SegmentedControl<T extends string>({
options,
value,
onChange,
label,
}: {
options: readonly T[];
value: T;
onChange: (next: T) => void;
label: string;
}) {
return (
<div className="flex gap-1" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={value === option}
onClick={() => onChange(option)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{option}
</button>
))}
</div>
);
}
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{formatDate(p.date)}</div>
<div className="text-gray-400">
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeChart() {
const [view, setView] = useState<View>('Time');
const [range, setRange] = useState<RangeKey>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const xDiv = monitor.data?.quadrant_config?.state_divider ?? DEFAULT_STATE_DIVIDER;
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? DEFAULT_WARNING_DIVIDER;
const basketAsOf = monitor.data?.basket?.basket_asof;
const series = useMemo(() => {
const data = history.data ?? [];
if (view === 'Path') {
return data
.filter((p) => p.state != null && p.warning != null)
.slice(-PATH_TRAIL);
}
const days = RANGES.find((r) => r.key === range)!.days;
if (!Number.isFinite(days)) return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, view, range]);
const pathPoints = useMemo<PathPoint[]>(
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
[series],
);
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
const latest = view === 'Path' && pathPoints.length ? pathPoints[pathPoints.length - 1] : null;
// Only warn about pre-freeze history when the drawn window actually reaches
// back past the freeze date.
const crossesFreeze = Boolean(basketAsOf && series.length && series[0].date < basketAsOf);
const enoughData = view === 'Path' ? pathPoints.length > 0 : series.length >= 2;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
</span>
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
</div>
{view === 'Time' ? (
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
) : (
latest && (
<span className="text-[11px] text-gray-500">
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
</span>
)
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !enoughData ? (
<Callout variant="empty">Not enough coverage-qualified history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-72">
<ResponsiveContainer width="100%" height="100%">
{view === 'Time' ? (
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
{/* width must clear a 3-digit label: the old chart paired
width 28 with margin.left -18 and clipped every tick. */}
<YAxis
domain={[0, 100]}
ticks={[0, 25, 50, 75, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={34}
tickLine={false}
axisLine={false}
/>
{/* The two axes have different thresholds, so each divider is
drawn in its series' colour rather than as shared gridlines. */}
<ReferenceLine y={xDiv} stroke={STATE_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<ReferenceLine y={yDiv} stroke={WARNING_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
<Line type="monotone" dataKey="state" name="State" stroke={STATE_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
<Line type="monotone" dataKey="warning" name="Warning" stroke={WARNING_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
</LineChart>
) : (
<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" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<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}>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
)}
/>
)}
</ScatterChart>
)}
</ResponsiveContainer>
</div>
{view === 'Time' ? (
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
State
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
Warning
</span>
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
</div>
) : (
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> — calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> — damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> — calm, broadly supported</span>
<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>
</div>
)}
{crossesFreeze && (
<p className="mt-2 text-[11px] text-gray-600">
History before {basketAsOf} is reconstructed against today's basket retrospective, not a live record.
</p>
)}
</>
)}
</div>
);
}
@@ -1,184 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ScatterChart,
Scatter,
Cell,
XAxis,
YAxis,
ZAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant boundaries come from the backend v2 methodology response.
const TRAIL = 60; // sessions shown
interface QPoint {
x: number;
y: number;
date: string;
}
/** Centered moving average to de-noise the path; today (last) kept exact. */
function smoothTrail(points: QPoint[], half = 2): QPoint[] {
const n = points.length;
return points.map((p, i) => {
if (i === n - 1) return { ...p };
let sx = 0;
let sy = 0;
let c = 0;
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
sx += points[j].x;
sy += points[j].y;
c += 1;
}
return { x: sx / c, y: sy / c, date: p.date };
});
}
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
function recencyColor(t: number): string {
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
const r = lerp(71, 96);
const g = lerp(85, 165);
const b = lerp(105, 250);
const alpha = (0.3 + 0.7 * t).toFixed(2);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const xDiv = monitor.data?.quadrant_config?.state_divider ?? 60;
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60;
const points = useMemo<QPoint[]>(() => {
const data = history.data ?? [];
return data
.filter((p) => p.state != null && p.warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
}, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]);
const latest = points.length ? points[points.length - 1] : null;
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough coverage-qualified v2 history yet.
</Callout>
) : (
<>
<div className="mt-3 h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
{/* Smoothed trail with a recency gradient (old → new) */}
<Scatter
data={trail}
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
isAnimationActive={false}
>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{/* Today */}
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
or sizing signal.
</p>
</>
)}
</div>
);
}
@@ -1,133 +0,0 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
const HISTORY_RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: 100000 },
] as const;
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'state', label: 'State', color: '#60a5fa' },
{ key: 'warning', label: 'Warning', color: '#fb923c' },
] as const;
export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const filtered = useMemo(() => {
const data = history.data ?? [];
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
if (range === 'All') return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, range]);
return (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
<div className="flex gap-1">
{HISTORY_RANGES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRange(r.key)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{r.key}
</button>
))}
</div>
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-56" />
) : filtered.length < 2 ? (
<Callout variant="empty">Not enough history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
<YAxis
domain={[0, 100]}
ticks={[0, 30, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={28}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
{HISTORY_SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color}
dot={false}
strokeWidth={1.5}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap gap-4">
{HISTORY_SERIES.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
</>
)}
</div>
);
}
+6
View File
@@ -500,6 +500,9 @@ export interface RegimeFundamentalOverlay {
reasoning: string | null; reasoning: string | null;
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
* point-in-time overlay omits it. */
observed?: boolean;
observed_in_snapshot?: boolean; observed_in_snapshot?: boolean;
} }
@@ -549,6 +552,9 @@ export interface RegimeMonitor {
inputs_fresh: boolean; inputs_fresh: boolean;
snapshot_age_days?: number; snapshot_age_days?: number;
is_fresh?: boolean; is_fresh?: boolean;
/** Upstream history spans, so a silently truncated series is visible. */
credit_history_days?: number | null;
vix_history_days?: number | null;
}; };
quadrant_config?: { state_divider: number; warning_divider: number; margin: number }; quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
} }
+163 -110
View File
@@ -24,11 +24,11 @@ import type {
RegimeFundamentalOverlay, RegimeFundamentalOverlay,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate, RegimeFundamentalsUpdate,
RegimeMonitor,
RegimeReading, RegimeReading,
} from '../lib/types'; } from '../lib/types';
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart')); const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = { const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' }, stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' },
@@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
function ScoreGauge({ function ScoreGauge({
label, label,
reading, reading,
divider,
footnote, footnote,
}: { }: {
label: string; label: string;
reading: RegimeReading | undefined; reading: RegimeReading | undefined;
divider?: number;
footnote: ReactNode; footnote: ReactNode;
}) { }) {
const score = reading?.score; const score = reading?.score;
@@ -66,7 +64,9 @@ function ScoreGauge({
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0)); const position = Math.min(100, Math.max(0, score ?? 0));
const bands = reading?.bands; const bands = reading?.bands;
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80]; // No fallback ticks: the two axes have different thresholds, so guessing a
// shared set would mislabel one of them. Render none rather than wrong ones.
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 border p-6 ${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">
@@ -92,10 +92,10 @@ function ScoreGauge({
</div> </div>
{score != null && ( {score != null && (
<> <>
{/* The quadrant divider is each axis's watch/elevated boundary, so it
is already the middle tick below — drawing it again was two marks
for one threshold. */}
<div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40"> <div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{divider != null && (
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
)}
<div <div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`} className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
style={{ left: `${position}%` }} style={{ left: `${position}%` }}
@@ -113,7 +113,7 @@ function ScoreGauge({
</div> </div>
</> </>
)} )}
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p> <p className="mt-4 text-xs text-gray-500">{footnote}</p>
</div> </div>
); );
} }
@@ -125,73 +125,87 @@ const CAPEX_TONE: Record<CapexState, string> = {
unknown: 'text-gray-500', unknown: 'text-gray-500',
}; };
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) { function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
const capex = overlay.capex ?? {}; const capex = overlay.capex ?? {};
const reaction = overlay.good_news_stock_down; 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>
);
}
return ( return (
<div className="glass border border-white/[0.06] p-5"> <div className="glass border border-white/[0.06] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500"> <div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
Fundamental overlay · context, not scored
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500"> <div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>} {overlay.source && <span>{overlay.source}</span>}
{overlay.effective_date && <span>· effective {overlay.effective_date}</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>
{overlay.pending ? ( {/* A pending observation is still shown — it is the freshest read we
<p className="mt-3 text-xs leading-relaxed text-amber-400/90"> have, and nothing here is scored. The date says when the stored
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}. point-in-time record picks it up. */}
Observations are never backdated, so the reading below appears from that session onward. {overlay.pending && (
<p className="mt-3 text-xs text-amber-400/90">
Shown as collected. The point-in-time record picks it up{' '}
{overlay.effective_date ?? 'next session'} observations are never backdated.
</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 className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && (
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
)}
</>
)} )}
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<p className="mt-4 text-[11px] leading-relaxed text-gray-600"> <div>
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and <div className="mb-2 flex items-baseline justify-between text-xs">
Warning. In v2 they carried 20 of 100 Warning points not enough to cross the study's alarm threshold even <span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
when both were pegged — so they are reported here rather than diluted into a daily score. <span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</p> </div>
<div className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
</div> </div>
); );
} }
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { /** One table for both axes — they share a shape, and two panels invited
* comparing numbers that are not on the same scale. */
function PillarTable({ state, warning }: { state: RegimeReading; warning: RegimeReading }) {
const groups: { title: string; reading: RegimeReading }[] = [
{ title: 'State', reading: state },
{ title: 'Warning', reading: warning },
];
return ( return (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}> <Disclosure summary="Pillars & sensors · what drives each score">
<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>
@@ -202,32 +216,78 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
<th className="px-4 py-3 text-right font-medium">Contribution</th> <th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr> </tr>
</thead> </thead>
<tbody> {groups.map(({ title, reading }) => (
{reading.pillars.map((pillar) => ( <tbody key={title}>
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0"> <tr className="border-b border-white/[0.06] bg-white/[0.02]">
<td className="px-4 py-3"> <td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
<div className="font-medium text-gray-200">{pillar.label}</div> {title}
<div className="mt-1 space-y-0.5"> <span className="ml-2 normal-case tracking-normal text-gray-600">
{pillar.sensors.map((sensor) => ( {reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
<div key={sensor.id} className="text-xs text-gray-500"> </span>
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
</div>
))}
</div>
</td> </td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? ''}</td>
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : ''}</td>
</tr> </tr>
))} {reading.pillars.map((pillar) => (
</tbody> <tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-gray-200">{pillar.label}</div>
<div className="mt-1 space-y-0.5">
{pillar.sensors.map((sensor) => (
<div key={sensor.id} className="text-xs text-gray-500">
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
</div>
))}
</div>
</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
</td>
</tr>
))}
</tbody>
))}
</table> </table>
</div> </div>
</Disclosure> </Disclosure>
); );
} }
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
return (
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
{label} <span className="num text-gray-400">{value}</span>
</span>
);
}
/** Provenance strip — replaces three separate prose blocks. */
function MetaStrip({ data }: { data: RegimeMonitor }) {
const quality = data.data_quality;
const basket = data.basket;
const days = (value: number | null | undefined) => (value == null ? '—' : `${value}d`);
return (
<div className="flex flex-wrap items-center gap-2">
<MetaChip label="as of" value={data.date ?? '—'} />
<MetaChip label="oldest input" value={days(quality?.oldest_market_input_age_days)} />
{basket && (
<MetaChip
label="basket"
value={`${basket.members_available ?? '—'}/${basket.members_expected} · frozen ${basket.basket_asof}`}
title={`hash ${basket.hash}`}
/>
)}
<MetaChip
label="credit history"
value={days(quality?.credit_history_days)}
title="Upstream span actually available. ICE caps the HY OAS series at 3 rolling years."
/>
<MetaChip label="VIX history" value={days(quality?.vix_history_days)} />
</div>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) { function EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics; const metrics = report.metrics;
return ( return (
@@ -278,9 +338,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
<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 test period (
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially {report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
different headline, and which events flip is usually decided by where the frozen threshold
lands rather than by what the score saw. Read the direction, not the ratio.
</p> </p>
)} )}
{report.reliability.sensor_coverage_mismatch && ( {report.reliability.sensor_coverage_mismatch && (
@@ -290,17 +348,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
{report.reliability.sensors_expected} Warning sensors versus{' '} {report.reliability.sensors_expected} Warning sensors versus{' '}
{report.reliability.holdout_full_sensor_share}% of test sessions {report.reliability.holdout_full_sensor_share}% of test sessions
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`} {report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
. The score renormalises over what is available, so the threshold was frozen on a partly . The threshold was frozen on a partly different construct than it is measured against.
different construct than it is measured against.
</p> </p>
)} )}
</div> </div>
</Callout> </Callout>
)} )}
<p className="text-[11px] leading-relaxed text-gray-600">
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
pre-freeze basket history remains exploratory.
</p>
</div> </div>
); );
} }
@@ -375,7 +428,7 @@ 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. Display only — this does not enter Warning.</p> <p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</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>
@@ -450,14 +503,17 @@ export default function RegimePage() {
const isAdmin = useAuthStore((state) => state.role) === 'admin'; const isAdmin = useAuthStore((state) => state.role) === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data; const data = monitor.data;
const inputs = data?.inputs;
return ( return (
<div className="space-y-6 animate-slide-up"> <div className="space-y-6 animate-slide-up">
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" /> <PageHeader
<Callout variant="info"><strong>Risk thermometer — not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout> title="Regime Monitor"
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
/>
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>} {monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
{monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>} {monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>}
{data && !data.available && <Callout variant="empty">V2 is not computed yet — run “Regime Monitor” from Admin → Jobs or wait for the daily pipeline.</Callout>} {data && !data.available && <Callout variant="empty">Not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
{data?.available && data.state && data.warning && ( {data?.available && data.state && data.warning && (
<> <>
@@ -467,39 +523,36 @@ export default function RegimePage() {
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}. {data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
</Callout> </Callout>
)} )}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge <ScoreGauge
label="State · current structural stress" label="State · stress right now"
reading={data.state} reading={data.state}
divider={data.quadrant_config?.state_divider} footnote={
footnote={<>One capped price vote plus fixed-basket breadth, HY credit, and volatility. As of {data.date}. VIX {data.inputs?.vix ?? ''} · HY OAS {data.inputs?.hy_oas ?? ''}.</>} <>
Price, breadth, credit and volatility levels · VIX{' '}
<span className="num text-gray-400">{inputs?.vix ?? '—'}</span> · HY OAS{' '}
<span className="num text-gray-400">{inputs?.hy_oas ?? '—'}</span> · breadth{' '}
<span className="num text-gray-400">
{inputs?.breadth_pct_above_200 == null ? '—' : `${inputs.breadth_pct_above_200}%`}
</span>
</>
}
/> />
<ScoreGauge <ScoreGauge
label="Warning · deterioration & divergence" label="Warning · deterioration & divergence"
reading={data.warning} reading={data.warning}
divider={data.quadrant_config?.warning_divider} 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, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
/> />
</div> </div>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600">
Data quality · oldest market input:{' '}
{data.data_quality?.oldest_market_input_age_days == null
? 'unavailable'
: `${data.data_quality.oldest_market_input_age_days}d`}
</p>
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense> <Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
<div className="grid gap-3 lg:grid-cols-2"> <PillarTable state={data.state} warning={data.warning} />
<PillarBreakdown title="State" reading={data.state} />
<PillarBreakdown title="Warning" reading={data.warning} /> {data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
</div>
{data.basket && ( <MetaStrip data={data} />
<p className="text-xs leading-relaxed text-gray-600">
Fixed basket {data.basket.members_available ?? ''}/{data.basket.members_expected} available · hash {data.basket.hash} · frozen {data.basket.basket_asof}. History reconstructed before the freeze date is retrospective/exploratory; readings after it form the trustworthy forward series.
</p>
)}
</> </>
)} )}
+18
View File
@@ -40,3 +40,21 @@ include = ["app*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
# Pinned explicitly rather than inherited. CI installs ruff unpinned, and the
# default rule set is not stable across releases: 0.16 broadened it so far that
# `ruff check app/` went from 0 findings to 376 -- 168 of them B008 flagging
# FastAPI's `Depends()` in a signature default, which is the framework's
# documented idiom and not a defect. An unpinned linter with drifting defaults
# fails the deploy pipeline on code nobody touched, so the rule set is the thing
# to pin; the ruff version can then float freely.
#
# E4 imports, E7 statements, E9 syntax/IO errors, F pyflakes. This is the set the
# tree was already clean under, now applied repo-wide instead of to app/ alone.
# Adding rules is welcome -- do it here, deliberately, with the fixes in the same
# commit.
select = ["E4", "E7", "E9", "F"]
-1
View File
@@ -31,7 +31,6 @@ if str(ROOT) not in sys.path:
from scripts.research_rankings import ( # noqa: E402 from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map, _live_universe_rank_map,
_period_percentiles,
) )
POLICY_NAMES = ( POLICY_NAMES = (
-1
View File
@@ -57,7 +57,6 @@ if str(ROOT) not in sys.path:
from scripts.research_rankings import ( # noqa: E402 from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map, _live_universe_rank_map,
_period_percentiles,
) )
# Must match Phase A cache when reusing research-cands.pkl # Must match Phase A cache when reusing research-cands.pkl
+6 -7
View File
@@ -162,13 +162,13 @@ def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
if len(rows) < 90: if len(rows) < 90:
return None return None
ords, opens, highs, lows, closes, vols = [], [], [], [], [], [] ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
for d, o, h, l, c, v in rows: for d, o, h, lo, c, v in rows:
if isinstance(d, str): if isinstance(d, str):
d = date.fromisoformat(d[:10]) d = date.fromisoformat(d[:10])
ords.append(d.toordinal()) ords.append(d.toordinal())
opens.append(float(o)) opens.append(float(o))
highs.append(float(h)) highs.append(float(h))
lows.append(float(l)) lows.append(float(lo))
closes.append(float(c)) closes.append(float(c))
vols.append(float(v or 0)) vols.append(float(v or 0))
return (symbol, ords, opens, highs, lows, closes, vols, spy) return (symbol, ords, opens, highs, lows, closes, vols, spy)
@@ -275,7 +275,8 @@ def main() -> None:
vol_weeks = collected.get("vol_6m") or {} vol_weeks = collected.get("vol_6m") or {}
momr_weeks = collected.get("mom_12_1_resid") or {} momr_weeks = collected.get("mom_12_1_resid") or {}
# Index mom/vol by (week, symbol) for joins # Index mom by (week, symbol) for joins. vol/momr are consumed as week maps
# directly further down, so they need no index.
def _index(weeks_map: dict) -> dict[tuple, dict]: def _index(weeks_map: dict) -> dict[tuple, dict]:
out: dict[tuple, dict] = {} out: dict[tuple, dict] = {}
for wk, recs in weeks_map.items(): for wk, recs in weeks_map.items():
@@ -290,8 +291,6 @@ def main() -> None:
return out return out
mom_ix = _index(mom_weeks) mom_ix = _index(mom_weeks)
vol_ix = _index(vol_weeks)
momr_ix = _index(momr_weeks)
# Per-week membership + extended checks via shared rich filter # Per-week membership + extended checks via shared rich filter
same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list) same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
@@ -606,8 +605,8 @@ def _update_md(path: Path, results: dict, artifact: Path) -> None:
"", "",
"### Authoritative unconditional fip (liquid top-N, post-mask)", "### Authoritative unconditional fip (liquid top-N, post-mask)",
"", "",
f"| metric | value |", "| metric | value |",
f"|---|---|", "|---|---|",
f"| mean_ic | {h.get('mean_ic')} |", f"| mean_ic | {h.get('mean_ic')} |",
f"| ic_t_stat | {h.get('ic_t_stat')} |", f"| ic_t_stat | {h.get('ic_t_stat')} |",
f"| weeks | {h.get('weeks')} |", f"| weeks | {h.get('weeks')} |",
+2 -2
View File
@@ -162,8 +162,8 @@ def _write_md(path: Path, payload: dict) -> None:
row = br.get("row") or br row = br.get("row") or br
if row: if row:
lines.extend([ lines.extend([
f"| metric | value |", "| metric | value |",
f"|---|---|", "|---|---|",
f"| mean_ic | {row.get('mean_ic')} |", f"| mean_ic | {row.get('mean_ic')} |",
f"| ic_t_stat | {row.get('ic_t_stat')} |", f"| ic_t_stat | {row.get('ic_t_stat')} |",
f"| ic_positive_pct | {row.get('ic_positive_pct')} |", f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
-1
View File
@@ -26,7 +26,6 @@ import os
import pickle import pickle
import sys import sys
import time import time
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
-1
View File
@@ -70,7 +70,6 @@ if str(ROOT) not in sys.path:
from scripts.research_rankings import ( # noqa: E402 from scripts.research_rankings import ( # noqa: E402
_live_universe_rank_map, _live_universe_rank_map,
_period_percentiles,
) )
CACHE_VERSION = "research-matrix-v1-daily-prod" CACHE_VERSION = "research-matrix-v1-daily-prod"
+20 -4
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import tempfile import tempfile
from datetime import date from datetime import date, timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -252,13 +252,29 @@ async def test_real_clone_smoke(engine):
# A few tickers spanning near + further-out reporters so the initial-load # A few tickers spanning near + further-out reporters so the initial-load
# forward-horizon gate (>= 21d) is satisfied on the fixed clone. # forward-horizon gate (>= 21d) is satisfied on the fixed clone.
await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"]) await _seed_tickers(factory, ["AAPL", "MSFT", "NVDA", "JPM", "BRK.B"])
# "today" is anchored to the clone, NOT the wall clock. The clone is fixed
# and do_pull=False, so a wall-clock today makes this test decay: the
# forward horizon shrinks a day per real day and eventually trips the
# >= 21d gate (it did, at 19d). Anchoring keeps it time-stable. Production
# pulls fresh data and is unaffected. Dot-free symbols only, so the query
# needs no symbol normalisation.
rows = await dolt_client.query_csv(
_CLONE_DIR,
"SELECT MAX(`date`) AS max_date FROM earnings_calendar "
"WHERE act_symbol IN ('AAPL', 'MSFT', 'NVDA', 'JPM')",
binary=_DOLT_BIN,
)
max_date = date.fromisoformat(rows[0]["max_date"])
today = max_date - timedelta(days=35) # ~35d horizon, per the importer's note
imp = DoltEarningsImporter( imp = DoltEarningsImporter(
repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=date.today(), do_pull=False, dolt=dolt_client repo_dir=_CLONE_DIR, binary=_DOLT_BIN, today=today, do_pull=False, dolt=dolt_client
) )
run = await run_import(imp, engine=engine) run = await run_import(imp, engine=engine)
assert run.status == STATUS_PROMOTED assert run.status == STATUS_PROMOTED, run.error_details
events = await _events(factory) events = await _events(factory)
assert events, "no earnings parsed from the real clone" assert events, "no earnings parsed from the real clone"
assert any(e.announce_date > date.today() for e in events), "no forward calendar" assert any(e.announce_date > today for e in events), "no forward calendar"
assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing" assert any(e.eps_actual is not None for e in events), "no calendar<->history pairing"
+1 -2
View File
@@ -2,9 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
+162 -5
View File
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
breadth_level_score, breadth_level_score,
drawdown_pct, drawdown_pct,
f2_credit_spreads, f2_credit_spreads,
current_observation,
fundamental_overlay, fundamental_overlay,
p1_trend_break, p1_trend_break,
p2_death_cross, p2_death_cross,
@@ -238,6 +239,77 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
assert expired["available"] is False assert expired["available"] is False
def test_live_observation_is_visible_before_its_effective_date():
"""Refreshing must not look like it did nothing.
The stored snapshot keeps the effective-date gate so a rebuild cannot
backdate an observation, but the live card reports that date instead of
blanking the content -- otherwise a Friday refresh stays invisible until
Monday.
"""
overrides = {
"f1_score": 50.0,
"f3_score": 100.0,
"capex": {"GOOGL": "holding"},
"good_news_stock_down": "yes",
"reasoning": "fresh read",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
before = date(2026, 6, 1)
record = fundamental_overlay(overrides, config, before)
now = current_observation(overrides, config, before)
# Same day, same observation: the record hides it, the live reading shows it.
assert record["capex"] is None and record["reasoning"] is None
assert now["capex"] == {"GOOGL": "holding"}
assert now["reasoning"] == "fresh read"
assert now["capex_stress"] == 50.0
assert now["earnings_stress"] == 100.0
# ...while still reporting when the stored record picks it up.
assert now["pending"] is True
assert now["effective_date"] == "2026-06-02"
assert now["available"] is True
# Staleness still expires the live reading.
assert current_observation(overrides, config, date(2026, 8, 22))["stale"] is True
assert current_observation(overrides, config, date(2026, 8, 22))["available"] is False
def test_an_uncollected_observation_is_not_reported_as_collected():
"""The default override is placeholders, not a reading.
``capex`` defaults to "unknown" for every hyperscaler and the reaction to
"mixed". Surfacing those as an observation made the card claim a read that
never happened.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
nothing_collected = {
"f1_score": None,
"f3_score": None,
"capex": {name: "unknown" for name in names},
"good_news_stock_down": "mixed",
"reasoning": None,
"fetched_at": None,
"effective_date": None,
"source": "default",
}
blank = current_observation(nothing_collected, DEFAULT_CONFIG, date(2026, 8, 7))
assert blank["observed"] is False
assert blank["available"] is False
assert blank["capex"] is None
assert blank["good_news_stock_down"] is None
assert blank["reasoning"] is None
# One real observation flips it, placeholders and all.
collected = {**nothing_collected, "fetched_at": "2026-08-07T10:00:00+00:00", "source": "gemini"}
assert current_observation(collected, DEFAULT_CONFIG, date(2026, 8, 7))["observed"] is True
def test_fundamentals_do_not_move_the_warning_score(): def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing. """The v3 complaint: a maxed-out LLM read must not silently do nothing.
@@ -421,11 +493,11 @@ async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
changed["state"] = {"score": 90.0, "band": "breaking"} changed["state"] = {"score": 90.0, "band": "breaking"}
written, _ = await rms._upsert_snapshot( written, _ = await rms._upsert_snapshot(
db_session, first, rewrite_existing_v2=True db_session, first, rewrite_existing=True
) )
await db_session.flush() await db_session.flush()
rewritten, persisted = await rms._upsert_snapshot( rewritten, persisted = await rms._upsert_snapshot(
db_session, changed, rewrite_existing_v2=False db_session, changed, rewrite_existing=False
) )
row = ( row = (
await db_session.execute( await db_session.execute(
@@ -468,10 +540,10 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
return {}, {} return {}, {}
async def fake_latest(_db): async def fake_latest(_db):
return object(), {"methodology": "v3"} return object(), {"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}
async def fake_upsert(_db, result, *, rewrite_existing_v2): async def fake_upsert(_db, result, *, rewrite_existing):
rewrites.append(rewrite_existing_v2) rewrites.append(rewrite_existing)
return True, result return True, result
class FakeDB: class FakeDB:
@@ -492,6 +564,91 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
assert rewrites == [True] assert rewrites == [True]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stored", "expect_reseed"),
[
({"methodology": "v3"}, True), # written before the marker existed
({"methodology": "v3", "sensor_revision": 1}, True),
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False),
],
)
async def test_a_stale_sensor_revision_reseeds_stored_history(
monkeypatch, stored, expect_reseed
):
"""Widening the OAS window has to reach rows that are already stored.
Routine runs recompute only the latest date, so without this trigger every
older row would keep the credit gap the wider window exists to close.
"""
sessions = [date.today() - timedelta(days=offset) for offset in reversed(range(10))]
prices = {symbol: [(day, 100.0) for day in sessions] for symbol in ("SMH", "QQQ", "SPY")}
written: list[date] = []
revisions: list[int] = []
async def fake_config(_db):
return copy.deepcopy(DEFAULT_CONFIG)
async def fake_overrides(_db):
return {"locked": True, "fetched_at": None, "effective_date": None}
async def fake_prices(_config, _start, _end):
return prices
async def fake_fred(_series_id, _start, _end):
return None
async def fake_breadth(_db, _symbols, window, min_tickers):
return {}, {}
async def fake_latest(_db):
return object(), stored
async def fake_upsert(_db, result, *, rewrite_existing):
written.append(date.fromisoformat(result["date"]))
revisions.append(result["sensor_revision"])
# Every replayed row must be rewritable, or a reseed writes one row.
assert rewrite_existing is True
return True, result
class FakeDB:
async def commit(self):
return None
for name, value in (
("get_regime_config", fake_config),
("get_fundamental_overrides", fake_overrides),
("_fetch_prices", fake_prices),
("_fetch_fred_series", fake_fred),
("_latest_snapshot_row", fake_latest),
("_upsert_snapshot", fake_upsert),
):
monkeypatch.setattr(rms, name, value)
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
await rms.update_regime_monitor(FakeDB())
if expect_reseed:
assert written == sessions, "a reseed must replay the whole stored span"
else:
assert written == [sessions[-1]], "a current revision must not reseed"
assert set(revisions) == {rms.SENSOR_REVISION}
def test_the_rebuild_span_stays_inside_the_oas_window():
"""The reseed must not replay rows it cannot compute credit for.
Each replayed row needs W3's lookback inside the fetched OAS window; if the
replay reached further back than the fetch, the reseed would recreate the
very gap it exists to close.
"""
replay_calendar_days = rms.REBUILD_LOOKBACK_DAYS
w3_lookback_calendar = rms.W3_OAS_LOOKBACK * 7 / 5 # business days -> calendar
assert replay_calendar_days + w3_lookback_calendar <= rms.HY_OAS_WINDOW_DAYS
# ...and still covers the 400-session series the v3 cutover wrote.
assert replay_calendar_days >= 400 * 365 / 252
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch): async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
calls: list[str] = [] calls: list[str] = []
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
from pathlib import Path from pathlib import Path
-1
View File
@@ -3,7 +3,6 @@ httpx transport (no network)."""
from __future__ import annotations from __future__ import annotations
import json
from datetime import date from datetime import date
import httpx import httpx