Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b41629e7 | ||
|
|
1c6ccceb12 | ||
|
|
3483797e75 | ||
|
|
7dc804be2b | ||
|
|
46ace501a2 |
@@ -38,7 +38,10 @@ jobs:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
- 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:
|
||||
needs: lint
|
||||
|
||||
@@ -52,7 +52,14 @@ METHODOLOGY = "v3"
|
||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||
# collected by hand/LLM and carried across it when the format is compatible.
|
||||
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
|
||||
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
|
||||
# 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.
|
||||
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_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
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
||||
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)
|
||||
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)))
|
||||
effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
|
||||
return {
|
||||
"available": not pending and not stale,
|
||||
"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:
|
||||
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||
@@ -630,6 +703,8 @@ def _compute_index(
|
||||
|
||||
return {
|
||||
"methodology": METHODOLOGY,
|
||||
# Not part of the history filter -- only the reseed trigger.
|
||||
"sensor_revision": SENSOR_REVISION,
|
||||
"date": as_of.isoformat(),
|
||||
"state": state,
|
||||
"warning": warning,
|
||||
@@ -889,7 +964,7 @@ async def _upsert_snapshot(
|
||||
db: AsyncSession,
|
||||
result: dict,
|
||||
*,
|
||||
rewrite_existing_v2: bool,
|
||||
rewrite_existing: bool,
|
||||
) -> tuple[bool, dict]:
|
||||
snapshot_date = date.fromisoformat(result["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),
|
||||
))
|
||||
else:
|
||||
existing_v2 = _parse_snapshot(row.breakdown_json)
|
||||
if existing_v2 is not None and not rewrite_existing_v2:
|
||||
return False, existing_v2
|
||||
existing_parsed = _parse_snapshot(row.breakdown_json)
|
||||
if existing_parsed is not None and not rewrite_existing:
|
||||
return False, existing_parsed
|
||||
row.total_score = float(state_score or 0.0)
|
||||
row.band = state_band or "unavailable"
|
||||
row.breakdown_json = payload
|
||||
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:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
@@ -934,7 +1017,9 @@ async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict]
|
||||
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)
|
||||
overrides = await get_fundamental_overrides(db)
|
||||
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)
|
||||
breadth, breadth_counts, divergence = {}, {}, {}
|
||||
|
||||
latest_v2 = await _latest_snapshot_row(db)
|
||||
rebuilding = latest_v2 is None and bool(leader_series)
|
||||
latest_snapshot = await _latest_snapshot_row(db)
|
||||
# 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:
|
||||
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:
|
||||
# Routine PIT rule: only the latest trading date may be inserted/updated.
|
||||
dates = [latest_date]
|
||||
@@ -995,7 +1088,9 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
||||
written, latest_result = await _upsert_snapshot(
|
||||
db,
|
||||
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)
|
||||
await db.commit()
|
||||
@@ -1042,7 +1137,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
|
||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
latest = await _latest_snapshot_row(db)
|
||||
if latest is None:
|
||||
return {"available": False, "reason": "v2 not computed yet"}
|
||||
return {"available": False, "reason": "not computed yet"}
|
||||
row, result = latest
|
||||
basket_hash = (result.get("basket") or {}).get("hash")
|
||||
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.
|
||||
config = await get_regime_config(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"))
|
||||
result["fundamental_context"] = live
|
||||
result["available"] = True
|
||||
|
||||
@@ -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
|
||||
observation onto historical snapshots. Because the observation is stored in a
|
||||
single slot, a refresh replaces the previously effective record: the snapshot
|
||||
therefore reports the overlay as `pending` until the new effective date, and the
|
||||
live reading additionally carries `fundamental_context` so a just-collected
|
||||
observation is visible immediately rather than appearing to have done nothing.
|
||||
therefore reports the overlay as `pending` until the new effective date.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
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
|
||||
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 1–3 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
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -500,6 +500,9 @@ export interface RegimeFundamentalOverlay {
|
||||
reasoning: string | null;
|
||||
source: 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;
|
||||
}
|
||||
|
||||
@@ -549,6 +552,9 @@ export interface RegimeMonitor {
|
||||
inputs_fresh: boolean;
|
||||
snapshot_age_days?: number;
|
||||
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 };
|
||||
}
|
||||
|
||||
+163
-110
@@ -24,11 +24,11 @@ import type {
|
||||
RegimeFundamentalOverlay,
|
||||
RegimeFundamentals,
|
||||
RegimeFundamentalsUpdate,
|
||||
RegimeMonitor,
|
||||
RegimeReading,
|
||||
} from '../lib/types';
|
||||
|
||||
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
|
||||
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
|
||||
const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
|
||||
|
||||
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' },
|
||||
@@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
||||
function ScoreGauge({
|
||||
label,
|
||||
reading,
|
||||
divider,
|
||||
footnote,
|
||||
}: {
|
||||
label: string;
|
||||
reading: RegimeReading | undefined;
|
||||
divider?: number;
|
||||
footnote: ReactNode;
|
||||
}) {
|
||||
const score = reading?.score;
|
||||
@@ -66,7 +64,9 @@ function ScoreGauge({
|
||||
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
|
||||
const position = Math.min(100, Math.max(0, score ?? 0));
|
||||
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 (
|
||||
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -92,10 +92,10 @@ function ScoreGauge({
|
||||
</div>
|
||||
{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">
|
||||
{divider != null && (
|
||||
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
|
||||
)}
|
||||
<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'}`}
|
||||
style={{ left: `${position}%` }}
|
||||
@@ -113,7 +113,7 @@ function ScoreGauge({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -125,73 +125,87 @@ const CAPEX_TONE: Record<CapexState, string> = {
|
||||
unknown: 'text-gray-500',
|
||||
};
|
||||
|
||||
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
|
||||
|
||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass border border-white/[0.06] p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Fundamental overlay · context, not scored
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||
{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.stale && <Badge label="stale" variant="manual" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overlay.pending ? (
|
||||
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
|
||||
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
|
||||
Observations are never backdated, so the reading below appears from that session onward.
|
||||
{/* A pending observation is still shown — it is the freshest read we
|
||||
have, and nothing here is scored. The date says when the stored
|
||||
point-in-time record picks it up. */}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
|
||||
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
|
||||
Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
|
||||
when both were pegged — so they are reported here rather than diluted into a daily score.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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]">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reading.pillars.map((pillar) => (
|
||||
<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>
|
||||
{groups.map(({ title, reading }) => (
|
||||
<tbody key={title}>
|
||||
<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">
|
||||
{title}
|
||||
<span className="ml-2 normal-case tracking-normal text-gray-600">
|
||||
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||
</span>
|
||||
</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>
|
||||
{reading.pillars.map((pillar) => (
|
||||
<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>
|
||||
</div>
|
||||
</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 }) {
|
||||
const metrics = report.metrics;
|
||||
return (
|
||||
@@ -278,9 +338,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
<p>
|
||||
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
||||
{report.reliability.events_detected} detected corrections fall in the test period (
|
||||
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially
|
||||
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.
|
||||
{report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
|
||||
</p>
|
||||
)}
|
||||
{report.reliability.sensor_coverage_mismatch && (
|
||||
@@ -290,17 +348,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
{report.reliability.sensors_expected} Warning sensors versus{' '}
|
||||
{report.reliability.holdout_full_sensor_share}% of test sessions
|
||||
{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
|
||||
different construct than it is measured against.
|
||||
. The threshold was frozen on a partly different construct than it is measured against.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -375,7 +428,7 @@ function FundamentalsEditor({
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||
<span>
|
||||
@@ -450,14 +503,17 @@ export default function RegimePage() {
|
||||
const isAdmin = useAuthStore((state) => state.role) === 'admin';
|
||||
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
|
||||
const data = monitor.data;
|
||||
const inputs = data?.inputs;
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
|
||||
<Callout variant="info"><strong>Risk thermometer — not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
|
||||
<PageHeader
|
||||
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.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 && (
|
||||
<>
|
||||
@@ -467,39 +523,36 @@ export default function RegimePage() {
|
||||
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ScoreGauge
|
||||
label="State · current structural stress"
|
||||
label="State · stress right now"
|
||||
reading={data.state}
|
||||
divider={data.quadrant_config?.state_divider}
|
||||
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 ?? '—'}.</>}
|
||||
footnote={
|
||||
<>
|
||||
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
|
||||
label="Warning · deterioration & divergence"
|
||||
reading={data.warning}
|
||||
divider={data.quadrant_config?.warning_divider}
|
||||
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.</>}
|
||||
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
|
||||
/>
|
||||
</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-72" />}><ScoreHistoryChart /></Suspense>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<PillarBreakdown title="State" reading={data.state} />
|
||||
<PillarBreakdown title="Warning" reading={data.warning} />
|
||||
</div>
|
||||
{data.basket && (
|
||||
<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>
|
||||
)}
|
||||
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
||||
|
||||
<PillarTable state={data.state} warning={data.warning} />
|
||||
|
||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||
|
||||
<MetaStrip data={data} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,3 +40,21 @@ include = ["app*"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
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"]
|
||||
|
||||
@@ -31,7 +31,6 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
POLICY_NAMES = (
|
||||
|
||||
@@ -57,7 +57,6 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
# Must match Phase A cache when reusing research-cands.pkl
|
||||
|
||||
@@ -162,13 +162,13 @@ def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
|
||||
if len(rows) < 90:
|
||||
return None
|
||||
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):
|
||||
d = date.fromisoformat(d[:10])
|
||||
ords.append(d.toordinal())
|
||||
opens.append(float(o))
|
||||
highs.append(float(h))
|
||||
lows.append(float(l))
|
||||
lows.append(float(lo))
|
||||
closes.append(float(c))
|
||||
vols.append(float(v or 0))
|
||||
return (symbol, ords, opens, highs, lows, closes, vols, spy)
|
||||
@@ -275,7 +275,8 @@ def main() -> None:
|
||||
vol_weeks = collected.get("vol_6m") 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]:
|
||||
out: dict[tuple, dict] = {}
|
||||
for wk, recs in weeks_map.items():
|
||||
@@ -290,8 +291,6 @@ def main() -> None:
|
||||
return out
|
||||
|
||||
mom_ix = _index(mom_weeks)
|
||||
vol_ix = _index(vol_weeks)
|
||||
momr_ix = _index(momr_weeks)
|
||||
|
||||
# Per-week membership + extended checks via shared rich filter
|
||||
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)",
|
||||
"",
|
||||
f"| metric | value |",
|
||||
f"|---|---|",
|
||||
"| metric | value |",
|
||||
"|---|---|",
|
||||
f"| mean_ic | {h.get('mean_ic')} |",
|
||||
f"| ic_t_stat | {h.get('ic_t_stat')} |",
|
||||
f"| weeks | {h.get('weeks')} |",
|
||||
|
||||
@@ -162,8 +162,8 @@ def _write_md(path: Path, payload: dict) -> None:
|
||||
row = br.get("row") or br
|
||||
if row:
|
||||
lines.extend([
|
||||
f"| metric | value |",
|
||||
f"|---|---|",
|
||||
"| metric | value |",
|
||||
"|---|---|",
|
||||
f"| mean_ic | {row.get('mean_ic')} |",
|
||||
f"| ic_t_stat | {row.get('ic_t_stat')} |",
|
||||
f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
|
||||
|
||||
@@ -26,7 +26,6 @@ import os
|
||||
import pickle
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -70,7 +70,6 @@ if str(ROOT) not in sys.path:
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
# forward-horizon gate (>= 21d) is satisfied on the fixed clone.
|
||||
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(
|
||||
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)
|
||||
|
||||
assert run.status == STATUS_PROMOTED
|
||||
assert run.status == STATUS_PROMOTED, run.error_details
|
||||
events = await _events(factory)
|
||||
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"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
|
||||
breadth_level_score,
|
||||
drawdown_pct,
|
||||
f2_credit_spreads,
|
||||
current_observation,
|
||||
fundamental_overlay,
|
||||
p1_trend_break,
|
||||
p2_death_cross,
|
||||
@@ -238,6 +239,77 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
|
||||
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():
|
||||
"""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"}
|
||||
|
||||
written, _ = await rms._upsert_snapshot(
|
||||
db_session, first, rewrite_existing_v2=True
|
||||
db_session, first, rewrite_existing=True
|
||||
)
|
||||
await db_session.flush()
|
||||
rewritten, persisted = await rms._upsert_snapshot(
|
||||
db_session, changed, rewrite_existing_v2=False
|
||||
db_session, changed, rewrite_existing=False
|
||||
)
|
||||
row = (
|
||||
await db_session.execute(
|
||||
@@ -468,10 +540,10 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
return {}, {}
|
||||
|
||||
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):
|
||||
rewrites.append(rewrite_existing_v2)
|
||||
async def fake_upsert(_db, result, *, rewrite_existing):
|
||||
rewrites.append(rewrite_existing)
|
||||
return True, result
|
||||
|
||||
class FakeDB:
|
||||
@@ -492,6 +564,91 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
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
|
||||
async def test_manual_llm_refresh_recomputes_latest_regime_snapshot(monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ httpx transport (no network)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
|
||||
Reference in New Issue
Block a user