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>
This commit is contained in:
@@ -81,7 +81,16 @@ 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
|
||||||
W3_OAS_LOOKBACK = 20
|
W3_OAS_LOOKBACK = 20
|
||||||
W3_OAS_FULL_SCALE_PCT = 35.0
|
W3_OAS_FULL_SCALE_PCT = 35.0
|
||||||
|
|
||||||
@@ -477,17 +486,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 +525,35 @@ 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)
|
||||||
|
return {
|
||||||
|
# Live availability is about usefulness, not effectiveness: a pending
|
||||||
|
# observation is the freshest thing we have.
|
||||||
|
"available": not stale,
|
||||||
|
"pending": pending,
|
||||||
|
"stale": stale,
|
||||||
|
"effective_date": effective.isoformat() if effective else None,
|
||||||
|
"age_days": age,
|
||||||
|
"capex": overrides.get("capex"),
|
||||||
|
"good_news_stock_down": overrides.get("good_news_stock_down"),
|
||||||
|
"capex_stress": overrides.get("f1_score"),
|
||||||
|
"earnings_stress": overrides.get("f3_score"),
|
||||||
|
"reasoning": overrides.get("reasoning"),
|
||||||
|
"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]
|
||||||
@@ -1042,7 +1092,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 +1121,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
|
||||||
|
|||||||
@@ -140,13 +140,34 @@ 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").
|
||||||
|
|
||||||
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 +215,72 @@ 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.
|
||||||
|
|
||||||
|
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
|
## 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -549,6 +549,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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,19 +136,22 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
|
|||||||
</div>
|
</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 className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-baseline justify-between text-xs">
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
||||||
@@ -174,24 +177,20 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{overlay.reasoning && (
|
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
||||||
<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>
|
</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,7 +201,16 @@ 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 }) => (
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
{reading.pillars.map((pillar) => (
|
{reading.pillars.map((pillar) => (
|
||||||
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -218,16 +226,53 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
|
|||||||
</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-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-400">{pillar.weight}</td>
|
||||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td>
|
<td className="px-4 py-3 text-right num text-gray-300">
|
||||||
|
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 +323,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 +333,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 +413,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 +488,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 +508,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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,46 @@ 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_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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user