feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+2 -2
View File
@@ -124,7 +124,7 @@ Once a day (default 07:00). Steps run **in dependency order**, each consuming th
3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 121 momentum activation percentile plus the promoted 80/20 production rank. 3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 121 momentum activation percentile plus the promoted 80/20 production rank.
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: 3x ATR trail with a 30-trading-day max hold). 4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: 3x ATR trail with a 30-trading-day max hold).
5. **Market Regime** — recompute the regime index (breadth/trend). 5. **Market Regime** — recompute the regime index (breadth/trend).
6. **Regime Monitor**observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else. 6. **Regime Monitor**separate v2 State/Warning risk thermometer with fixed-basket breadth, VIX, credit, and point-in-time fundamentals; feeds no trades.
A failing step is logged; the pipeline continues with the next. A failing step is logged; the pipeline continues with the next.
@@ -279,7 +279,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge) - Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge)
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability - Recommendation layer — directional confidence, conflict detection, per-target reach-probability
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation - Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study - Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit, PIT fundamentals) with a manual chronological correction study
- Telegram alerts (e.g. regime-quadrant changes) - Telegram alerts (e.g. regime-quadrant changes)
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary - User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
- JWT auth with admin role, configurable registration, user access control - JWT auth with admin role, configurable registration, user access control
+4 -4
View File
@@ -8,12 +8,12 @@ from app.database import Base
class RegimeSnapshot(Base): class RegimeSnapshot(Base):
"""Daily snapshot of the AI/Tech regime-change index. """Daily point-in-time snapshot of the AI/Tech Regime Monitor.
One row per calendar date (unique). ``breakdown_json`` holds the full One row per calendar date (unique). ``breakdown_json`` holds the full
per-signal breakdown plus the raw inputs, so reads need no recomputation and ``breakdown_json`` is authoritative for v2 State, Warning, source dates,
the 7/30-day trend is just a query over ``total_score``. Decoupled from the coverage, and fixed-basket metadata. ``total_score``/``band`` retain the v2
rest of the platform: nothing reads this to gate or score trades. State reading for schema compatibility. Nothing reads this to gate trades.
""" """
__tablename__ = "regime_snapshots" __tablename__ = "regime_snapshots"
+18 -12
View File
@@ -1,7 +1,7 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor).""" """Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin from app.dependencies import get_db, require_access, require_admin
@@ -40,12 +40,18 @@ async def backtest_report(
class RegimeConfigUpdate(BaseModel): class RegimeConfigUpdate(BaseModel):
weights: dict[str, float] | None = None breadth_basket: list[str] | None = Field(default=None, min_length=20, max_length=100)
alert_threshold: float | None = None fundamental_staleness_days: int | None = Field(default=None, ge=30, le=180)
tickers: dict | None = None
leader_weight: float | None = None @field_validator("breadth_basket")
rs_lookback: int | None = None @classmethod
fundamental_staleness_days: int | None = None def normalise_basket(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
cleaned = [symbol.strip().upper().replace(".", "-") for symbol in value if symbol.strip()]
if len(cleaned) != len(set(cleaned)):
raise ValueError("breadth basket symbols must be unique")
return cleaned
class RegimeFundamentalsUpdate(BaseModel): class RegimeFundamentalsUpdate(BaseModel):
@@ -59,7 +65,7 @@ async def regime_monitor(
_user: User = Depends(require_access), _user: User = Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
"""Latest AI/Tech regime-change index (0-100) + per-signal breakdown + trend.""" """Latest v2 State and Warning risk-thermometer readings."""
data = await regime_monitor_service.get_regime_monitor(db) data = await regime_monitor_service.get_regime_monitor(db)
return APIEnvelope(status="success", data=data) return APIEnvelope(status="success", data=data)
@@ -69,7 +75,7 @@ async def regime_config(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
"""Editable weights / thresholds / ticker lists for the regime monitor.""" """Editable fixed breadth basket and fundamental freshness window."""
data = await regime_monitor_service.get_regime_config(db) data = await regime_monitor_service.get_regime_config(db)
return APIEnvelope(status="success", data=data) return APIEnvelope(status="success", data=data)
@@ -80,7 +86,7 @@ async def update_regime_config(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
"""Merge the supplied fields into the stored regime-monitor config.""" """Update the deliberately small v2 operator configuration."""
updates = body.model_dump(exclude_none=True) updates = body.model_dump(exclude_none=True)
data = await regime_monitor_service.update_regime_config(db, updates) data = await regime_monitor_service.update_regime_config(db, updates)
return APIEnvelope(status="success", data=data) return APIEnvelope(status="success", data=data)
@@ -133,10 +139,10 @@ async def regime_event_study(
@router.get("/regime/history", response_model=APIEnvelope) @router.get("/regime/history", response_model=APIEnvelope)
async def regime_history( async def regime_history(
days: int = Query(default=400, ge=7, le=2000), days: int = Query(default=800, ge=7, le=2000),
_user: User = Depends(require_access), _user: User = Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> APIEnvelope: ) -> APIEnvelope:
"""Daily history of the index / early-warning / combined scores (for the chart).""" """Point-in-time v2 State/Warning history. Legacy rows are excluded."""
data = await regime_monitor_service.get_regime_history(db, days=days) data = await regime_monitor_service.get_regime_history(db, days=days)
return APIEnvelope(status="success", data=data) return APIEnvelope(status="success", data=data)
+15 -3
View File
@@ -1001,12 +1001,20 @@ async def compute_regime_monitor() -> None:
result = await update_regime_monitor(db) result = await update_regime_monitor(db)
state = result.get("state") or {}
warning = result.get("warning") or {}
_runtime_progress(job_name, processed=1, total=1) _runtime_progress(job_name, processed=1, total=1)
_runtime_finish( _runtime_finish(
job_name, "completed", processed=1, total=1, job_name, "completed", processed=1, total=1,
message=f"Index: {result.get('total_score')} ({result.get('band')})", message=f"State: {state.get('score')} · Warning: {warning.get('score')}",
)
_log_event(
logging.INFO,
"job_complete",
job=job_name,
state=state.get("score"),
warning=warning.get("score"),
) )
_log_event(logging.INFO, "job_complete", job=job_name, score=result.get("total_score"))
except Exception as exc: except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc)) _runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc)) _log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
@@ -1086,7 +1094,11 @@ async def run_event_study_job() -> None:
_runtime_progress(job_name, processed=1, total=1) _runtime_progress(job_name, processed=1, total=1)
if report.get("available"): if report.get("available"):
msg = f"{len(report.get('events', []))} events, lead Δ {report.get('lead_delta_days')}d" metrics = report.get("metrics") or {}
msg = (
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
)
else: else:
msg = report.get("reason", "no data") msg = report.get("reason", "no data")
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg) _runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
+99 -40
View File
@@ -58,7 +58,9 @@ _BOOL_DEFAULTS = {
KEY_SR: True, KEY_SR: True,
KEY_SCORE_DROP: True, KEY_SCORE_DROP: True,
KEY_DIGEST: True, KEY_DIGEST: True,
KEY_REGIME_QUADRANT: True, # Experimental human-facing thermometer: opt in explicitly. Existing stored
# true values remain true; only missing/reset configurations default off.
KEY_REGIME_QUADRANT: False,
KEY_TRADE_CLOSED: True, KEY_TRADE_CLOSED: True,
} }
@@ -90,19 +92,19 @@ SIGNAL_BUNDLE_SECTIONS = (
) )
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
# Regime quadrant-change alert: (regime index x early-warning) quadrant. # Regime quadrant-change alert: (State x Warning) quadrant.
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary # Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert. # from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant" QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant) QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # early-warning divider QUAD_Y_DIV = 60.0 # v2 Warning divider
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = { QUAD_LABELS = {
"1": "① Hot & brittle", "1": "Early warning",
"2": "② Transition", "2": "Active stress",
"3": "③ Healthy & broad", "3": "Healthy",
"4": "④ Real downturn", "4": "Stressed / stabilizing",
} }
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
@@ -693,49 +695,65 @@ def _closed_trade_bundle(
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str: def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
if y_high: if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle return "2" if x_high else "1" # Active stress / Early warning
return "4" if x_high else "3" # ④ Real downturn / Healthy & broad return "4" if x_high else "3" # Stressed/stabilizing / Healthy
def _quadrant_to_bools(q: str) -> tuple[bool, bool]: def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q] return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str: def _classify_quadrant(
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis. x: float,
y: float,
prev: str | None,
margin: float = QUAD_MARGIN,
x_div: float = QUAD_X_DIV,
y_div: float = QUAD_Y_DIV,
) -> str:
"""Quadrant of (State x, Warning y), with per-axis hysteresis.
Each axis only flips once the value crosses its divider by ``margin`` in the Each axis only flips once the value crosses its divider by ``margin`` in the
new direction, so a point parked on a divider keeps its current quadrant new direction, so a point parked on a divider keeps its current quadrant
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify. instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
""" """
if prev is None: if prev is None:
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV) return _bools_to_quadrant(x >= x_div, y >= y_div)
px, py = _quadrant_to_bools(prev) px, py = _quadrant_to_bools(prev)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin) x_high = (x >= x_div - margin) if px else (x >= x_div + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin) y_high = (y >= y_div - margin) if py else (y >= y_div + margin)
return _bools_to_quadrant(x_high, y_high) return _bools_to_quadrant(x_high, y_high)
def _quadrant_log_key(q: str, x: float, y: float) -> str: def _quadrant_log_key(q: str, x: float, y: float, basket_hash: str | None = None) -> str:
return f"{q}:{x:.1f}:{y:.1f}" return f"{basket_hash or 'legacy'}:{q}:{x:.1f}:{y:.1f}"
def _parse_quadrant_log_key(key: str | None) -> tuple[str | None, float | None, float | None]: def _parse_quadrant_log_key(
key: str | None,
) -> tuple[str | None, str | None, float | None, float | None]:
if not key: if not key:
return None, None, None return None, None, None, None
parts = key.split(":") parts = key.split(":")
q = parts[0] if parts[0] in QUAD_LABELS:
basket_hash, q, values = None, parts[0], parts[1:]
elif len(parts) >= 2:
basket_hash, q, values = parts[0], parts[1], parts[2:]
else:
return None, None, None, None
if q not in QUAD_LABELS: if q not in QUAD_LABELS:
return None, None, None return None, None, None, None
if len(parts) >= 3: if len(values) >= 2:
try: try:
return q, float(parts[1]), float(parts[2]) return basket_hash, q, float(values[0]), float(values[1])
except ValueError: except ValueError:
pass pass
return q, None, None return basket_hash, q, None, None
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, float | None, datetime | None]: async def _last_quadrant(
db: AsyncSession,
) -> tuple[str | None, str | None, float | None, float | None, datetime | None]:
"""Most recently logged quadrant (and when), our baseline for change + cooldown.""" """Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute( result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at) select(AlertLog.dedup_key, AlertLog.created_at)
@@ -745,9 +763,9 @@ async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, fl
) )
row = result.first() row = result.first()
if not row: if not row:
return None, None, None, None return None, None, None, None, None
prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0]) basket_hash, prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return prev_q, prev_x, prev_y, row[1] return basket_hash, prev_q, prev_x, prev_y, row[1]
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]: async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
@@ -758,25 +776,64 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
becomes the next baseline and resets the cooldown clock. becomes the next baseline and resets the cooldown clock.
""" """
from app.services.regime_monitor_service import get_regime_monitor from app.services.regime_monitor_service import get_regime_history, get_regime_monitor
data = await get_regime_monitor(db) data = await get_regime_monitor(db)
if not data.get("available"): if not data.get("available"):
return [] return []
x = data.get("total_score") state = data.get("state") or {}
y = (data.get("early_warning") or {}).get("score") warning = data.get("warning") or {}
x = state.get("score")
y = warning.get("score")
if x is None or y is None: if x is None or y is None:
return [] return []
prev, prev_x, prev_y, prev_time = await _last_quadrant(db) quality = data.get("data_quality") or {}
if prev is None: if (
_log_alert(db, QUAD_TYPE, _quadrant_log_key(_classify_quadrant(x, y, None), x, y)) # seed, no alert float(state.get("coverage") or 0) < 75
or float(warning.get("coverage") or 0) < 75
or not quality.get("is_fresh")
):
return [] return []
new_q = _classify_quadrant(x, y, prev) quadrant_cfg = data.get("quadrant_config") or {}
x_div = float(quadrant_cfg.get("state_divider", QUAD_X_DIV))
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
margin = float(quadrant_cfg.get("margin", QUAD_MARGIN))
basket_hash = str((data.get("basket") or {}).get("hash") or "unknown")
prev_hash, prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None or prev_hash != basket_hash:
seed = _classify_quadrant(x, y, None, margin, x_div, y_div)
_log_alert(db, QUAD_TYPE, _quadrant_log_key(seed, x, y, basket_hash))
return []
new_q = _classify_quadrant(x, y, prev, margin, x_div, y_div)
if new_q == prev: if new_q == prev:
return [] return []
history = await get_regime_history(db, days=14)
valid = [
point for point in history
if point.get("state") is not None
and point.get("warning") is not None
and float(point.get("state_coverage") or 0) >= 75
and float(point.get("warning_coverage") or 0) >= 75
]
if len(valid) < 2:
return []
prior = valid[-2]
prior_q = _classify_quadrant(
float(prior["state"]),
float(prior["warning"]),
prev,
margin,
x_div,
y_div,
)
if prior_q != new_q:
return []
if prev_time is not None: if prev_time is not None:
if prev_time.tzinfo is None: if prev_time.tzinfo is None:
prev_time = prev_time.replace(tzinfo=timezone.utc) prev_time = prev_time.replace(tzinfo=timezone.utc)
@@ -785,17 +842,19 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
if prev_x is not None and prev_y is not None: if prev_x is not None and prev_y is not None:
metrics = ( metrics = (
f"regime {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · " f"State {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"early-warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})" f"Warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
) )
else: else:
metrics = f"regime {x:.0f} · early-warning {y:.0f}" metrics = f"State {x:.0f} · Warning {y:.0f}"
text = ( text = (
f"🧭 <b>Regime quadrant change</b>\n" f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n" f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}" f"{metrics}\n"
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
f"<i>Risk thermometer - not a trade signal.</i>"
) )
return [(_quadrant_log_key(new_q, x, y), text)] return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+50 -21
View File
@@ -1,18 +1,19 @@
"""Market-breadth early-warning indicator (from the stored universe OHLCV). """Market-breadth state and early-warning indicators.
Breadth is a genuinely *leading* construct: a few mega-caps can keep an index Breadth is a genuinely *leading* construct: a few mega-caps can keep an index
rising while participation narrows underneath — the classic pre-top divergence. rising while participation narrows underneath — the classic pre-top divergence.
We measure it from the OHLCV we already store for the whole universe, so it costs V2 measures an explicit, frozen basket rather than every ticker currently stored
no new data source. in the database. That keeps the live series reproducible when the wider product
universe changes.
Two layers: Two layers:
- breadth = % of the universe trading above its own 200-DMA (0-100). - breadth = % of the universe trading above its own 200-DMA (0-100).
- divergence = an early-warning score (0-100, high = fragile): the benchmark - divergence = an early-warning score (0-100, high = fragile): the benchmark
price rising *while* breadth falls, plus a nudge for already-low breadth. price holding/rising *while* breadth falls. Absolute low breadth stays in the
State index so it is not counted twice.
This module only *computes* the indicator. It is deliberately NOT wired into the The live monitor uses the breadth level in State and the pure divergence in
live regime index yet — the event study measures whether it actually leads before Warning. The event study evaluates the latter chronologically.
it earns any weight.
""" """
from __future__ import annotations from __future__ import annotations
@@ -31,9 +32,9 @@ logger = logging.getLogger(__name__)
Series = list[tuple[date, float]] Series = list[tuple[date, float]]
def _breadth_from_closes( def _breadth_with_counts(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20 closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]: ) -> tuple[dict[date, float], dict[date, int]]:
"""Pure core: % of symbols above their own rolling SMA(window), per date. """Pure core: % of symbols above their own rolling SMA(window), per date.
Each symbol's SMA is computed once with a sliding sum (O(bars)); dates with Each symbol's SMA is computed once with a sliding sum (O(bars)); dates with
@@ -55,11 +56,20 @@ def _breadth_from_closes(
entry[1] += 1 entry[1] += 1
if closes[i] > sma: if closes[i] > sma:
entry[0] += 1 entry[0] += 1
return { values = {
d: round(above / total * 100.0, 2) d: round(above / total * 100.0, 2)
for d, (above, total) in counts.items() for d, (above, total) in counts.items()
if total >= min_tickers if total >= min_tickers
} }
eligible = {d: total for d, (_, total) in counts.items() if total >= min_tickers}
return values, eligible
def _breadth_from_closes(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]:
"""Compatibility wrapper returning only the breadth percentage series."""
return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0]
def compute_divergence_series( def compute_divergence_series(
@@ -67,10 +77,10 @@ def compute_divergence_series(
) -> dict[date, float]: ) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date. """Early-warning score (0-100, high = fragile) per date.
Fragility rises when the benchmark price climbs over ``lookback`` days while This is deliberately a pure divergence: it is positive only when benchmark
breadth deteriorates over the same window, and is nudged up when the absolute price holds/rises while breadth falls. Absolute low breadth belongs in the
breadth level is already low. It is the *divergence* (not the level) that State score, so it is not counted again here. A 20 percentage-point breadth
makes this leading. deterioration maps to 100.
""" """
bench = {d: c for d, c in benchmark_closes} bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth) common = sorted(d for d in bench if d in breadth)
@@ -82,14 +92,19 @@ def compute_divergence_series(
continue continue
price_ret = (bench[d] / price_past - 1.0) * 100.0 # % price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points breadth_chg = breadth[d] - breadth[d0] # percentage points
raw = price_ret - breadth_chg # price up & breadth down -> large deterioration = max(0.0, -breadth_chg)
score = 50.0 + raw * 2.0 + (50.0 - breadth[d]) * 0.4 score = deterioration * 5.0 if price_ret >= 0 else 0.0
out[d] = max(0.0, min(100.0, round(score, 2))) out[d] = max(0.0, min(100.0, round(score, 2)))
return out return out
async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]: async def _load_universe_closes(
result = await db.execute(select(Ticker).order_by(Ticker.symbol)) db: AsyncSession, symbols: list[str] | None = None
) -> dict[str, Series]:
stmt = select(Ticker).order_by(Ticker.symbol)
if symbols is not None:
stmt = stmt.where(Ticker.symbol.in_(symbols))
result = await db.execute(stmt)
closes_by_symbol: dict[str, Series] = {} closes_by_symbol: dict[str, Series] = {}
for ticker in result.scalars().all(): for ticker in result.scalars().all():
try: try:
@@ -103,13 +118,27 @@ async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
async def compute_breadth_series( async def compute_breadth_series(
db: AsyncSession, window: int = 200, min_tickers: int = 20 db: AsyncSession,
window: int = 200,
min_tickers: int = 20,
symbols: list[str] | None = None,
) -> dict[date, float]: ) -> dict[date, float]:
"""Historical breadth series across the stored universe (for the event study).""" """Historical breadth series across an explicit basket (or all stored names)."""
closes_by_symbol = await _load_universe_closes(db) closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_from_closes(closes_by_symbol, window, min_tickers) return _breadth_from_closes(closes_by_symbol, window, min_tickers)
async def compute_breadth_details(
db: AsyncSession,
symbols: list[str],
window: int = 200,
min_tickers: int = 20,
) -> tuple[dict[date, float], dict[date, int]]:
"""Breadth values plus the qualifying-member count for snapshot metadata."""
closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_with_counts(closes_by_symbol, window, min_tickers)
async def compute_breadth_today(db: AsyncSession) -> float | None: async def compute_breadth_today(db: AsyncSession) -> float | None:
"""Latest breadth reading (thin wrapper, for future live use).""" """Latest breadth reading (thin wrapper, for future live use)."""
series = await compute_breadth_series(db) series = await compute_breadth_series(db)
+191 -239
View File
@@ -1,21 +1,9 @@
"""Event study: does a candidate indicator actually *lead* regime breaks? """Compact chronological validation for the Regime Monitor warning score.
This is a backtest-style measurement, but the unit of analysis is **events** The study calls its outcome a 10% correction, uses the first 70% of sessions to
(historical drawdowns), not trades. For each candidate indicator it answers: freeze an 80th-percentile warning threshold, and reports alarm episodes only on
- how many days of warning did it give before the break (event-centered)? the final 30%. It is still labelled exploratory while the fixed breadth basket
- at what false-alarm cost (signal-centered precision/recall vs. the base rate)? is reconstructed before its freeze date.
It compares the breadth-divergence early-warning candidate against a deterministic
**coincident** price composite (the existing regime price sub-scores), so you can
see whether the candidate crosses *earlier*. Everything is price/breadth only —
no LLM/FRED — so the result is reproducible.
Honest caveat: with only a handful of real drawdowns in ~5y, the sample is tiny
and the numbers are noisy. Read the median lead time as an order of magnitude, and
do NOT overfit thresholds to this history.
Report is cached in a SystemSetting (mirrors ``backtest_service``); a manual job
(Admin → Jobs) drives it.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,304 +22,268 @@ logger = logging.getLogger(__name__)
KEY_REPORT = "regime_event_study" KEY_REPORT = "regime_event_study"
# Defaults. The 15% threshold gave only 2 events in 5y (statistically useless), EVENT_THRESHOLD_PCT = 10.0
# so the default is lower with a cooldown-based dedup to surface more, cleaner EVENT_COOLDOWN_DAYS = 40
# events. Each indicator "warns" at its OWN 80th percentile rather than a shared DRAWDOWN_LOOKBACK = 252
# absolute level, so the leading vs. coincident comparison is fair across scales. HORIZON_DAYS = 20
EVENT_THRESHOLD_PCT = 10.0 # drawdown from the 52w high that counts as a "break" WARN_PERCENTILE = 80.0
COOLDOWN_DAYS = 40 # min trading days between event onsets (dedup) TRAIN_FRACTION = 0.70
DRAWDOWN_LOOKBACK = 252 # 52-week trailing high
HORIZON_DAYS = 20 # signal-centered prediction horizon
WARN_PERCENTILE = 80.0 # each indicator warns at its own Nth percentile
PRE, POST = 60, 20 # event-centered window (trading days)
def _median(values: list[float]) -> float | None: def _median(values: list[float]) -> float | None:
if not values: if not values:
return None return None
s = sorted(values) ordered = sorted(values)
n = len(s) middle = len(ordered) // 2
mid = n // 2 return (
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0 float(ordered[middle])
if len(ordered) % 2
else (ordered[middle - 1] + ordered[middle]) / 2.0
)
def _percentile(values: list[float], pct: float) -> float | None: def _percentile(values: list[float], pct: float) -> float | None:
"""Linear-interpolated percentile of the non-None values.""" ordered = sorted(v for v in values if v is not None)
vals = sorted(v for v in values if v is not None) if not ordered:
if not vals:
return None return None
k = (len(vals) - 1) * (pct / 100.0) position = (len(ordered) - 1) * pct / 100.0
lo = int(k) lower = int(position)
hi = min(lo + 1, len(vals) - 1) upper = min(lower + 1, len(ordered) - 1)
return vals[lo] + (vals[hi] - vals[lo]) * (k - lo) return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
def detect_events( def detect_events(
closes: list[float], closes: list[float],
dates: list[date], dates: list[date],
threshold_pct: float = EVENT_THRESHOLD_PCT, threshold_pct: float = EVENT_THRESHOLD_PCT,
lookback: int = DRAWDOWN_LOOKBACK, lookback: int = DRAWDOWN_LOOKBACK,
cooldown: int = COOLDOWN_DAYS, cooldown: int = EVENT_COOLDOWN_DAYS,
) -> list[dict]: ) -> list[dict]:
"""Drawdown events: ``t0`` = a day the drawdown from the trailing 52w high """Rising-edge corrections from the trailing 52-week high."""
crosses up through ``threshold_pct`` (rising edge). De-duplicated by a
``cooldown`` of trading days, so a continuous decline counts once but distinct
drawdowns separated by a recovery each register."""
events: list[dict] = [] events: list[dict] = []
prev_dd = 0.0 previous_drawdown = 0.0
last_event = -10**9 last_event = -10**9
for i in range(len(closes)): for index, close in enumerate(closes):
window = closes[max(0, i - lookback + 1): i + 1] high = max(closes[max(0, index - lookback + 1): index + 1])
hi = max(window) drawdown = (high - close) / high * 100.0 if high > 0 else 0.0
dd = (hi - closes[i]) / hi * 100.0 if hi > 0 else 0.0 if (
if dd >= threshold_pct and prev_dd < threshold_pct and (i - last_event) >= cooldown: drawdown >= threshold_pct
events.append({"date": dates[i].isoformat(), "index": i, "depth_pct": round(dd, 1)}) and previous_drawdown < threshold_pct
last_event = i and index - last_event >= cooldown
prev_dd = dd ):
events.append({
"date": dates[index].isoformat(),
"index": index,
"depth_pct": round(drawdown, 1),
})
last_event = index
previous_drawdown = drawdown
return events return events
# --------------------------------------------------------------------------- def alarm_episodes(
# Event-centered: lead time + mean path
# ---------------------------------------------------------------------------
def _lead(indicator: dict[date, float], t0: int, dates: list[date], pre: int, threshold: float) -> int | None:
"""Earliest day within ``[t0-pre, t0]`` at which the indicator crosses
``threshold`` — i.e. how many days of warning before the event, or None."""
lead: int | None = None
for k in range(0, pre + 1):
idx = t0 - k
if idx < 0:
break
v = indicator.get(dates[idx])
if v is not None and v >= threshold:
lead = k # keep going: the largest k = earliest warning in the window
return lead
def event_centered(
indicator: dict[date, float], indicator: dict[date, float],
events_idx: list[int],
dates: list[date], dates: list[date],
pre: int = PRE, threshold: float,
post: int = POST, start_index: int = 1,
threshold: float = 60.0, ) -> list[int]:
) -> dict: """Indices where the warning crosses upward; it must reset below first."""
"""Align the indicator at each event's ``t0`` and measure how early it warned. alarms: list[int] = []
was_high = False
if start_index > 0:
previous = indicator.get(dates[start_index - 1])
was_high = previous is not None and previous >= threshold
for index in range(start_index, len(dates)):
value = indicator.get(dates[index])
if value is None:
continue
high = value >= threshold
if high and not was_high:
alarms.append(index)
was_high = high
return alarms
Lead time is measured against ``threshold`` (each indicator gets its own,
derived from its distribution). Also returns the cross-event mean path. def evaluate_alarms(
""" alarm_indices: list[int],
event_indices: list[int],
dates: list[date],
horizon: int = HORIZON_DAYS,
) -> dict:
"""Event recall, episode false alarms, and lead time for one holdout."""
leads: list[float] = [] leads: list[float] = []
sums: dict[int, float] = {} per_event: list[dict] = []
counts: dict[int, int] = {} warned = 0
for t0 in events_idx: for event_index in event_indices:
lead = _lead(indicator, t0, dates, pre, threshold) matching = [
alarm for alarm in alarm_indices if 0 < event_index - alarm <= horizon
]
lead = max((event_index - alarm for alarm in matching), default=None)
if lead is not None: if lead is not None:
leads.append(lead) warned += 1
for rel in range(-pre, post + 1): leads.append(float(lead))
idx = t0 + rel per_event.append({
if 0 <= idx < len(dates): "date": dates[event_index].isoformat(),
v = indicator.get(dates[idx]) "warned": lead is not None,
if v is not None: "lead_days": lead,
sums[rel] = sums.get(rel, 0.0) + v })
counts[rel] = counts.get(rel, 0) + 1
mean_path = [ false_alarms = sum(
{"rel_day": rel, "value": round(sums[rel] / counts[rel], 1)} for rel in sorted(sums) 1
] for alarm in alarm_indices
if not any(0 < event - alarm <= horizon for event in event_indices)
)
return { return {
"events": len(event_indices),
"events_warned": warned,
"events_missed": len(event_indices) - warned,
"alarm_episodes": len(alarm_indices),
"false_alarms": false_alarms,
"median_lead_days": _median(leads), "median_lead_days": _median(leads),
"events_with_signal": len(leads), "per_event": per_event,
"events_total": len(events_idx),
"warn_threshold": round(threshold, 1),
"mean_path": mean_path,
} }
# --------------------------------------------------------------------------- def _warning_series(
# Signal-centered: precision / recall vs. base rate prices: dict[str, rms.Series],
# --------------------------------------------------------------------------- breadth_divergence: dict[date, float],
def signal_centered(
indicator: dict[date, float],
events_idx: list[int],
dates: list[date], dates: list[date],
horizon: int = HORIZON_DAYS, config: dict,
thresholds: list[float] | None = None, ) -> dict[date, float]:
) -> dict: """Technical Warning score used historically (fundamentals have no PIT history)."""
"""Treat ``indicator >= threshold`` as predicting a break within ``horizon`` tickers = config["tickers"]
days. Sweep thresholds → precision/recall/alarm count, plus the base rate.""" smh_full = prices.get(tickers["leaders"][0], [])
thresholds = thresholds or [50, 55, 60, 65, 70, 75, 80] spy_full = prices.get(tickers["market"], [])
n = len(dates)
labels = [1 if any(i < e <= i + horizon for e in events_idx) else 0 for i in range(n)]
positives = sum(labels)
base_rate = positives / n if n else 0.0
rows: list[dict] = []
for th in thresholds:
tp = fp = fn = 0
for i in range(n):
v = indicator.get(dates[i])
if v is None:
continue
pred = v >= th
if pred and labels[i]:
tp += 1
elif pred and not labels[i]:
fp += 1
elif not pred and labels[i]:
fn += 1
precision = tp / (tp + fp) if (tp + fp) else None
recall = tp / (tp + fn) if (tp + fn) else None
rows.append({
"threshold": th,
"precision": round(precision, 3) if precision is not None else None,
"recall": round(recall, 3) if recall is not None else None,
"alarms": tp + fp,
})
return {"base_rate": round(base_rate, 3), "horizon_days": horizon, "rows": rows}
# ---------------------------------------------------------------------------
# Coincident baseline (deterministic price composite, reusing the regime sub-scores)
# ---------------------------------------------------------------------------
def _coincident_series(prices: dict[str, list], dates: list[date], config: dict) -> dict[date, float]:
"""Mean of the available price sub-scores (P1-P4) as-of each date — the
coincident baseline the leading candidate must beat on lead time."""
lw = float(config.get("leader_weight", 2.0))
lb = int(config.get("rs_lookback", 60))
t = config["tickers"]
smh_full = prices.get(t["leaders"][0], []) if t["leaders"] else []
qqq_full = prices.get(t["confirm"][0], []) if t["confirm"] else []
spy_full = prices.get(t["market"], [])
out: dict[date, float] = {} out: dict[date, float] = {}
for d in dates: for session in dates:
smh = rms._closes_asof(smh_full, d) divergence = breadth_divergence.get(session)
qqq = rms._closes_asof(qqq_full, d) relative = rms.p4_relative_strength(
spy = rms._closes_asof(spy_full, d) rms._closes_asof(smh_full, session),
subs = [ rms._closes_asof(spy_full, session),
rms.p1_trend_break(smh, qqq, lw), )
rms.p2_death_cross(smh, qqq, lw), values: list[tuple[float, float]] = []
rms.p3_drawdown(smh, qqq), if divergence is not None:
rms.p4_relative_strength(smh, spy, lb), values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"]))
] if relative is not None:
vals = [v for v in subs if v is not None] values.append((relative, rms.WARNING_WEIGHTS["relative_strength"]))
if vals: if values:
out[d] = round(sum(vals) / len(vals), 2) out[session] = round(
sum(value * weight for value, weight in values)
/ sum(weight for _, weight in values),
2,
)
return out return out
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
async def run_event_study( async def run_event_study(
db: AsyncSession, db: AsyncSession,
threshold_pct: float = EVENT_THRESHOLD_PCT, threshold_pct: float = EVENT_THRESHOLD_PCT,
horizon: int = HORIZON_DAYS, horizon: int = HORIZON_DAYS,
cooldown: int = COOLDOWN_DAYS,
warn_percentile: float = WARN_PERCENTILE,
) -> dict: ) -> dict:
"""Run the study: detect events on the benchmark, then measure breadth-divergence
vs. the coincident price composite. Best-effort; returns available=False on no data."""
config = await rms.get_regime_config(db) config = await rms.get_regime_config(db)
end = date.today() end = date.today()
start = end - timedelta(days=5 * 365 + 30) start = end - timedelta(days=5 * 365 + 30)
prices = await rms._fetch_prices(config, start, end) prices = await rms._fetch_prices(config, start, end)
leader = config["tickers"]["leaders"][0] if config["tickers"]["leaders"] else "SMH" leader = config["tickers"]["leaders"][0]
bench = sorted(prices.get(leader, []), key=lambda x: x[0]) benchmark = sorted(prices.get(leader, []), key=lambda item: item[0])
if len(bench) < 260: if len(benchmark) < 500:
return {"available": False, "reason": "insufficient benchmark history"} return {"available": False, "reason": "insufficient benchmark history"}
dates = [d for d, _ in bench] dates = [d for d, _ in benchmark]
closes = [c for _, c in bench] closes = [value for _, value in benchmark]
events = detect_events(closes, dates, threshold_pct, cooldown=cooldown) breadth, _ = await breadth_service.compute_breadth_details(
events_idx = [e["index"] for e in events] db, config["breadth_basket"], window=200, min_tickers=20
)
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
warning = _warning_series(prices, divergence, dates, config)
breadth = await breadth_service.compute_breadth_series(db) split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
divergence = breadth_service.compute_divergence_series(breadth, bench) train_values = [warning[d] for d in dates[:split] if d in warning]
coincident = _coincident_series(prices, dates, config) warn_threshold = _percentile(train_values, WARN_PERCENTILE)
if warn_threshold is None:
return {"available": False, "reason": "insufficient warning history"}
# Each indicator warns at its OWN distribution's percentile, so a leading all_events = detect_events(closes, dates, threshold_pct)
# indicator isn't penalised for living on a different scale than the baseline. holdout_events = [event["index"] for event in all_events if event["index"] >= split]
warn = { alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
"breadth_divergence": _percentile(list(divergence.values()), warn_percentile) or 60.0, metrics = evaluate_alarms(alarms, holdout_events, dates, horizon)
"coincident_price": _percentile(list(coincident.values()), warn_percentile) or 60.0, holdout_sessions = max(1, len(dates) - split)
} metrics["false_alarms_per_year"] = round(
series_by_key = {"breadth_divergence": divergence, "coincident_price": coincident} metrics["false_alarms"] / (holdout_sessions / 252.0), 2
)
def _evaluate(series: dict[date, float], threshold: float) -> dict: basket_asof = date.fromisoformat(config["basket_asof"])
return { retrospective = dates[split] < basket_asof
**event_centered(series, events_idx, dates, threshold=threshold), evaluation = "exploratory" if retrospective else "holdout"
"signal": signal_centered(series, events_idx, dates, horizon), lead_text = (
} f"median lead {metrics['median_lead_days']:.0f} sessions"
if metrics["median_lead_days"] is not None
indicators = {key: _evaluate(series_by_key[key], warn[key]) for key in series_by_key} else "no successful warning lead"
)
# Per-event comparison: which event, and each indicator's lead on THAT event — summary = (
# so a median over a tiny sample can't hide an apples-to-oranges comparison. f"{evaluation.capitalize()} chronological test: warning episodes preceded "
per_event = [ f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
{ f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
"date": e["date"], f"false alarms/year, {lead_text}."
"depth_pct": e["depth_pct"], )
"breadth_lead": _lead(divergence, e["index"], dates, PRE, warn["breadth_divergence"]), per_event = metrics.pop("per_event")
"coincident_lead": _lead(coincident, e["index"], dates, PRE, warn["coincident_price"]),
}
for e in events
]
bd = indicators["breadth_divergence"]["median_lead_days"]
cd = indicators["coincident_price"]["median_lead_days"]
lead_delta = (bd - cd) if (bd is not None and cd is not None) else None
recent_breadth = [
{"date": d.isoformat(), "breadth": breadth[d], "divergence": divergence.get(d)}
for d in dates[-90:]
if d in breadth
]
report = { report = {
"available": True, "available": True,
"methodology": rms.METHODOLOGY,
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
"evaluation": evaluation,
"summary": summary,
"params": { "params": {
"benchmark": leader, "benchmark": leader,
"outcome": "10% correction from trailing 52-week high",
"event_threshold_pct": threshold_pct, "event_threshold_pct": threshold_pct,
"cooldown_days": cooldown, "event_cooldown_days": EVENT_COOLDOWN_DAYS,
"horizon_days": horizon, "horizon_days": horizon,
"warn_percentile": warn_percentile, "train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1),
"basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"],
}, },
"events": events, "sample": {
"indicators": indicators, "start": dates[0].isoformat(),
"per_event": per_event, "end": dates[-1].isoformat(),
"lead_delta_days": lead_delta, "train_end": dates[split - 1].isoformat(),
"recent_breadth": recent_breadth, "test_start": dates[split].isoformat(),
"sessions": len(dates),
"holdout_sessions": holdout_sessions,
},
"metrics": metrics,
"events": per_event,
"recent_breadth": [
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
for d in dates[-90:]
if d in breadth
],
} }
logger.info(json.dumps({ logger.info(json.dumps({
"event": "event_study_complete", "events": len(events), "event": "regime_event_study_complete",
"breadth_lead": bd, "coincident_lead": cd, "evaluation": evaluation,
"events": metrics["events"],
"warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"],
})) }))
return report return report
async def run_and_store(db: AsyncSession) -> dict: async def run_and_store(db: AsyncSession) -> dict:
"""Run the event study and cache the report in a SystemSetting. Job entrypoint."""
report = await run_event_study(db) report = await run_event_study(db)
await update_setting(db, KEY_REPORT, json.dumps(report)) await update_setting(db, KEY_REPORT, json.dumps(report))
return report return report
async def get_event_study_report(db: AsyncSession) -> dict | None: async def get_event_study_report(db: AsyncSession) -> dict | None:
"""Return the last cached event-study report, or None if never run."""
setting = await settings_store.get_setting(db, KEY_REPORT) setting = await settings_store.get_setting(db, KEY_REPORT)
if setting is None: if setting is None:
return None return None
try: try:
return json.loads(setting.value) report = json.loads(setting.value)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return report if report.get("methodology") == rms.METHODOLOGY else None
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
# Regime Monitor v2 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
## Outputs
**State** measures current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, so the correlated 200-DMA, death-cross,
and drawdown readings receive one capped vote.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread, 20%.
- VIX level, 15%.
**Warning** measures deterioration and divergence:
- Fixed-basket breadth divergence while SMH holds/rises, 50%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- Hyperscaler capex cuts, 12%.
- Good-news-stock-down earnings reactions, 8%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v2.
## Scale and missing data
Zero means ordinary/healthy, and only stress contributes positively. Automated
capex `raising`/`holding` and no good-news-stock-down pattern map to zero;
`mixed`, unknown, and stale observations are unavailable rather than neutral 50.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Bands are stable `<30`, watch `<60`, elevated `<80`, and
breaking `>=80`.
Credit uses named HY OAS anchors (3.5 mild, 5.0 elevated, 7.0 stressed) for 70%
of its score and a ten-year upper-tail percentile for 30%.
## Point-in-time record
The first v2 run rebuilds the latest 400 trading sessions with sufficient sensor
warm-up. Routine runs thereafter insert/update only the latest trading date.
Fundamental observations have an effective date (normally the next session after
collection) and are never replayed backward. The history API and main chart show
only snapshots marked `methodology: v2`.
Each snapshot stores the fixed basket symbols, hash, and freeze date. Reconstructed
history before that freeze date is retrospective/exploratory; readings after it
form the forward record.
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. An alarm requires an upward crossing and another alarm
requires a reset below the threshold. The report exposes warned/missed events,
false alarms per year, median lead, sample dates, event count, report date, and
whether the result is exploratory or a true forward holdout. UI claims are
generated from that report; no performance sentence is hard-coded.
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
not a trade signal.**
+1 -1
View File
@@ -11,7 +11,7 @@ export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data); return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
} }
export function getRegimeHistory(days = 400) { export function getRegimeHistory(days = 800) {
return apiClient return apiClient
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } }) .get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
.then((r) => r.data); .then((r) => r.data);
@@ -13,15 +13,13 @@ import {
ReferenceLine, ReferenceLine,
ReferenceArea, ReferenceArea,
} from 'recharts'; } from 'recharts';
import { getRegimeHistory } from '../../api/regime'; import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout'; import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton'; import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk. // Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated. // Quadrant boundaries come from the backend v2 methodology response.
const X_DIV = 40; // regime index
const Y_DIV = 60; // early warning
const TRAIL = 60; // sessions shown const TRAIL = 60; // sessions shown
interface QPoint { interface QPoint {
@@ -64,7 +62,7 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
<div className="glass px-2.5 py-1.5 text-[11px]"> <div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div> <div className="text-gray-300">{p.date}</div>
<div className="text-gray-400"> <div className="text-gray-400">
Regime <span className="text-blue-300">{Math.round(p.x)}</span> · Early warning{' '} State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span> <span className="text-orange-300">{Math.round(p.y)}</span>
</div> </div>
</div> </div>
@@ -72,14 +70,17 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
} }
export default function RegimeQuadrant() { export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) }); 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 points = useMemo<QPoint[]>(() => {
const data = history.data ?? []; const data = history.data ?? [];
return data return data
.filter((p) => p.early_warning != null) .filter((p) => p.state != null && p.warning != null)
.slice(-TRAIL) .slice(-TRAIL)
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date })); .map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
}, [history.data]); }, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]); const trail = useMemo(() => smoothTrail(points), [points]);
@@ -89,11 +90,11 @@ export default function RegimeQuadrant() {
<div className="glass p-5"> <div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500"> <div className="text-[11px] uppercase tracking-wider text-gray-500">
Regime quadrant last {TRAIL} sessions State × Warning quadrant last {TRAIL} sessions
</div> </div>
{latest && ( {latest && (
<div className="text-[11px] text-gray-500"> <div className="text-[11px] text-gray-500">
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '} now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span> <span className="text-orange-300">{Math.round(latest.y)}</span>
</div> </div>
)} )}
@@ -103,7 +104,7 @@ export default function RegimeQuadrant() {
<SkeletonCard className="mt-3 h-72" /> <SkeletonCard className="mt-3 h-72" />
) : !points.length ? ( ) : !points.length ? (
<Callout variant="empty"> <Callout variant="empty">
Not enough history yet the early-warning fills in as the daily job runs. Not enough coverage-qualified v2 history yet.
</Callout> </Callout>
) : ( ) : (
<> <>
@@ -111,13 +112,13 @@ export default function RegimeQuadrant() {
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}> <ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */} {/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={X_DIV} y1={Y_DIV} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" /> <ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={Y_DIV} y2={100} fill="#f97316" 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={X_DIV} y1={0} y2={Y_DIV} fill="#10b981" fillOpacity={0.07} stroke="none" /> <ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={0} y2={Y_DIV} fill="#ef4444" fillOpacity={0.08} 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)" /> <CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={X_DIV} stroke="rgba(255,255,255,0.12)" /> <ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" /> <ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis <XAxis
type="number" type="number"
dataKey="x" dataKey="x"
@@ -126,7 +127,7 @@ export default function RegimeQuadrant() {
tick={{ fill: '#6b7280', fontSize: 10 }} tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false} tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }} axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Regime index →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }} label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/> />
<YAxis <YAxis
type="number" type="number"
@@ -137,7 +138,7 @@ export default function RegimeQuadrant() {
width={30} width={30}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }} label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/> />
<ZAxis range={[13, 13]} /> <ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} /> <Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
@@ -166,15 +167,15 @@ export default function RegimeQuadrant() {
</div> </div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2"> <div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400"> Hot &amp; brittle</span> narrow melt-up, shakeout risk</span> <span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400"> Transition</span> break may be starting</span> <span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400"> Healthy &amp; broad</span> calm uptrend</span> <span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400"> Real downturn</span> regime breaking, broad</span> <span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div> </div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600"> <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}{' '} White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The tell isn&apos;t a single spot but the move (early warning rolling over while sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
the regime index climbs = divergence resolving downward). Observational not wired into trades. or sizing signal.
</p> </p>
</> </>
)} )}
@@ -26,14 +26,13 @@ const HISTORY_RANGES = [
type HistoryRange = (typeof HISTORY_RANGES)[number]['key']; type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [ const HISTORY_SERIES = [
{ key: 'index', label: 'Index', color: '#60a5fa' }, { key: 'state', label: 'State', color: '#60a5fa' },
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' }, { key: 'warning', label: 'Warning', color: '#fb923c' },
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
] as const; ] as const;
export default function ScoreHistoryChart() { export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M'); const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) }); const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const filtered = useMemo(() => { const filtered = useMemo(() => {
const data = history.data ?? []; const data = history.data ?? [];
@@ -113,7 +112,6 @@ export default function ScoreHistoryChart() {
stroke={s.color} stroke={s.color}
dot={false} dot={false}
strokeWidth={1.5} strokeWidth={1.5}
connectNulls
isAnimationActive={false} isAnimationActive={false}
/> />
))} ))}
+81 -53
View File
@@ -439,106 +439,134 @@ export type RegimeBand = 'stable' | 'watch' | 'elevated' | 'breaking';
export interface RegimeSignal { export interface RegimeSignal {
id: string; id: string;
label: string; label: string;
sub_score: number | null; score: number | null;
weight: number;
available: boolean; available: boolean;
contribution: number; details?: Record<string, unknown>;
} }
export interface RegimeSubScore { export interface RegimePillar {
id: string;
label: string;
score: number | null;
weight: number;
contribution: number;
available: boolean;
sensors: RegimeSignal[];
}
export interface RegimeReading {
score: number | null; score: number | null;
band: RegimeBand | null; band: RegimeBand | null;
delta_7?: number | null; coverage: number;
delta_30?: number | null; minimum_coverage: number;
available_pillars: string[];
pillars: RegimePillar[];
trend?: { delta_7: number | null; delta_30: number | null };
} }
export interface RegimeHistoryPoint { export interface RegimeHistoryPoint {
date: string; date: string;
index: number; state: number | null;
early_warning: number | null; warning: number | null;
combined: number | null; state_coverage: number | null;
warning_coverage: number | null;
basket_hash: string | null;
} }
export interface RegimeMonitor { export interface RegimeMonitor {
available: boolean; available: boolean;
reason?: string; reason?: string;
methodology?: string;
date?: string; date?: string;
total_score?: number; state?: RegimeReading;
band?: RegimeBand; warning?: RegimeReading;
alert_threshold?: number;
breakdown?: RegimeSignal[];
inputs?: { inputs?: {
vix: number | null; vix: number | null;
vix_date: string | null;
hy_oas: number | null; hy_oas: number | null;
hy_oas_date: string | null;
breadth_pct_above_200: number | null;
breadth_date: string | null;
fundamentals_fetched_at: string | null; fundamentals_fetched_at: string | null;
fundamentals_effective_date: string | null;
fundamentals_age_days: number | null;
}; };
trend?: { delta_7: number | null; delta_30: number | null }; basket?: {
// Separate, observational early-warning score (breadth divergence) + a small symbols: string[];
// combined blend. Decoupled from the index above. hash: string;
early_warning?: RegimeSubScore; basket_asof: string;
combined?: RegimeSubScore; members_available: number | null;
members_expected: number;
history_kind: 'forward' | 'retrospective';
};
data_quality?: {
minimum_coverage: number;
oldest_market_input_age_days: number | null;
stale_inputs: string[];
inputs_fresh: boolean;
snapshot_age_days?: number;
is_fresh?: boolean;
};
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
} }
export interface RegimeFundamentals { export interface RegimeFundamentals {
f1_score: number; f1_score: number | null;
f3_score: number; f3_score: number | null;
locked: boolean; locked: boolean;
reasoning: string | null; reasoning: string | null;
fetched_at: string | null; fetched_at: string | null;
effective_date: string | null;
source: string; source: string;
capex?: Record<string, string>; capex?: Record<string, string>;
good_news_stock_down?: string | null; good_news_stock_down?: string | null;
} }
export interface RegimeConfig { export interface RegimeConfig {
weights: Record<string, number>; breadth_basket: string[];
alert_threshold: number; basket_asof: string;
tickers: Record<string, unknown>;
leader_weight: number;
rs_lookback: number;
fundamental_staleness_days: number; fundamental_staleness_days: number;
} }
// Event study — measured lead time of early-warning indicators vs. drawdowns // Event study — measured lead time of early-warning indicators vs. drawdowns
export interface EventStudyLeadStats {
median_lead_days: number | null;
events_with_signal: number;
events_total: number;
warn_threshold: number;
mean_path: { rel_day: number; value: number }[];
signal: {
base_rate: number;
horizon_days: number;
rows: { threshold: number; precision: number | null; recall: number | null; alarms: number }[];
};
}
export interface EventStudyPerEvent {
date: string;
depth_pct: number;
breadth_lead: number | null;
coincident_lead: number | null;
}
export interface EventStudyReport { export interface EventStudyReport {
available: boolean; available: boolean;
reason?: string; reason?: string;
methodology?: string;
generated_at?: string; generated_at?: string;
evaluation?: 'exploratory' | 'holdout';
summary?: string;
params?: { params?: {
benchmark: string; benchmark: string;
outcome: string;
event_threshold_pct: number; event_threshold_pct: number;
cooldown_days: number; event_cooldown_days: number;
horizon_days: number; horizon_days: number;
train_fraction: number;
warn_percentile: number; warn_percentile: number;
warn_threshold: number;
basket_hash: string;
basket_asof: string;
}; };
events?: { date: string; index: number; depth_pct: number }[]; sample?: {
indicators?: { start: string;
breadth_divergence: EventStudyLeadStats; end: string;
coincident_price: EventStudyLeadStats; train_end: string;
test_start: string;
sessions: number;
holdout_sessions: number;
}; };
per_event?: EventStudyPerEvent[]; metrics?: {
lead_delta_days?: number | null; events: number;
recent_breadth?: { date: string; breadth: number; divergence: number | null }[]; events_warned: number;
events_missed: number;
alarm_episodes: number;
false_alarms: number;
false_alarms_per_year: number;
median_lead_days: number | null;
};
events?: { date: string; warned: boolean; lead_days: number | null }[];
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
} }
export interface AlertConfig { export interface AlertConfig {
+213 -436
View File
@@ -1,5 +1,5 @@
import { useState, lazy, Suspense, type ReactNode } from 'react'; import { lazy, Suspense, useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { Callout } from '../components/ui/Callout'; import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure'; import { Disclosure } from '../components/ui/Disclosure';
@@ -7,44 +7,38 @@ import { Badge } from '../components/ui/Badge';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { import {
getRegimeMonitor,
getRegimeConfig,
updateRegimeConfig,
getRegimeFundamentals,
updateRegimeFundamentals,
refreshRegimeFundamentals,
getEventStudy, getEventStudy,
getRegimeConfig,
getRegimeFundamentals,
getRegimeMonitor,
refreshRegimeFundamentals,
updateRegimeConfig,
updateRegimeFundamentals,
} from '../api/regime'; } from '../api/regime';
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
import type { import type {
EventStudyReport,
RegimeBand, RegimeBand,
RegimeSignal,
RegimeConfig, RegimeConfig,
RegimeFundamentals, RegimeFundamentals,
EventStudyReport, RegimeReading,
EventStudyLeadStats,
EventStudyPerEvent,
} from '../lib/types'; } from '../lib/types';
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
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' },
watch: { text: 'text-amber-400', bar: 'bg-amber-400', ring: 'border-amber-400/30', label: 'Watch' }, watch: { text: 'text-amber-400', bar: 'bg-amber-400', ring: 'border-amber-400/30', label: 'Watch' },
elevated: { text: 'text-orange-400', bar: 'bg-orange-400', ring: 'border-orange-400/30', label: 'Elevated' }, elevated: { text: 'text-orange-400', bar: 'bg-orange-400', ring: 'border-orange-400/30', label: 'Elevated' },
breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'Breaking' }, breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'High stress' },
}; };
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) { function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
if (delta == null) { if (delta == null) {
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>; return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
} }
const rising = delta > 0; const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
const flat = delta === 0; const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
// Higher index = worse, so a rising score is the warning direction.
const color = flat ? 'text-gray-400' : rising ? 'text-red-400' : 'text-emerald-400';
const arrow = flat ? '→' : rising ? '↑' : '↓';
return ( return (
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400"> <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">
{label}: <span className={`font-medium ${color}`}>{arrow} {delta > 0 ? '+' : ''}{delta}</span> {label}: <span className={`font-medium ${color}`}>{arrow} {delta > 0 ? '+' : ''}{delta}</span>
@@ -54,61 +48,51 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
function ScoreGauge({ function ScoreGauge({
label, label,
score, reading,
band, divider,
trend,
threshold,
footnote, footnote,
size = 'lg',
}: { }: {
label: string; label: string;
score: number | null | undefined; reading: RegimeReading | undefined;
band: RegimeBand | null | undefined; divider?: number;
trend?: { delta_7?: number | null; delta_30?: number | null }; footnote: ReactNode;
threshold?: number;
footnote?: ReactNode;
size?: 'lg' | 'md';
}) { }) {
const naa = score == null; const score = reading?.score;
const style = BAND_STYLES[(band ?? 'stable') as RegimeBand]; const complete = reading?.band != null;
const s = score ?? 0; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const clamp = (v: number) => Math.min(100, Math.max(0, v)); const position = Math.min(100, Math.max(0, score ?? 0));
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
return ( return (
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}> <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">
<div> <div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div> <div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-2"> <div className="mt-1 flex items-baseline gap-2">
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}> <span className={`font-display text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
{naa ? '—' : Math.round(s)} {score == null ? '—' : Math.round(score)}
</span> </span>
{!naa && <span className="text-sm text-gray-500">/ 100</span>} {score != null && <span className="text-sm text-gray-500">/ 100</span>}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
{style?.label ?? 'Incomplete'}
</span>
<span className="text-xs text-gray-600">coverage {Math.round(reading?.coverage ?? 0)}%</span>
</div> </div>
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
</div> </div>
{trend && ( <div className="flex gap-2">
<div className="flex flex-wrap gap-2"> <TrendChip label="7d" delta={reading?.trend?.delta_7} />
<TrendChip label="7d" delta={trend.delta_7} /> <TrendChip label="30d" delta={reading?.trend?.delta_30} />
<TrendChip label="30d" delta={trend.delta_30} /> </div>
</div>
)}
</div> </div>
{score != null && (
{!naa && (
<> <>
{/* Band track with score (+ optional threshold) markers */} <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 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40"> {divider != null && (
{threshold != null && ( <div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
<div
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
style={{ left: `${clamp(threshold)}%` }}
title={`Alert threshold ${threshold}`}
/>
)} )}
<div <div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`} 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: `${clamp(s)}%` }} style={{ left: `${position}%` }}
/> />
</div> </div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600"> <div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
@@ -116,68 +100,110 @@ function ScoreGauge({
</div> </div>
</> </>
)} )}
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>} <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>
</div> </div>
); );
} }
function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) { function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
return ( return (
<div className="glass overflow-hidden"> <Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
<table className="w-full text-sm"> <div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<thead> <table className="w-full text-sm">
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500"> <thead>
<th className="px-4 py-3 font-medium">Signal</th> <tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3 font-medium">Sub-score</th> <th className="px-4 py-3 font-medium">Pillar / sensor</th>
<th className="px-4 py-3 text-right font-medium">Weight</th> <th className="px-4 py-3 text-right font-medium">Score</th>
<th className="px-4 py-3 text-right font-medium">Contribution</th> <th className="px-4 py-3 text-right font-medium">Weight</th>
</tr> <th className="px-4 py-3 text-right font-medium">Contribution</th>
</thead>
<tbody>
{breakdown.map((s) => (
<tr key={s.id} className="border-b border-white/[0.03] last:border-0">
<td className="px-4 py-3">
<span className="font-mono text-[10px] text-gray-600">{s.id}</span>{' '}
<span className="text-gray-300">{s.label}</span>
</td>
<td className="px-4 py-3">
{s.available && s.sub_score != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-blue-400/70" style={{ width: `${s.sub_score}%` }} />
</div>
<span className="num text-gray-300">{s.sub_score}</span>
</div>
) : (
<span className="text-xs text-gray-600">n/a</span>
)}
</td>
<td className="px-4 py-3 text-right num text-gray-400">{s.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">
{s.available ? s.contribution.toFixed(1) : '—'}
</td>
</tr> </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>
</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 EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
{report.generated_at && <span className="text-xs text-gray-500">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
{report.sample && <span className="text-xs text-gray-500">test {report.sample.test_start} {report.sample.end}</span>}
</div>
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
{metrics && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['Warned', `${metrics.events_warned}/${metrics.events}`],
['Missed', metrics.events_missed],
['False alarms/year', metrics.false_alarms_per_year.toFixed(1)],
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
</div>
))} ))}
</tbody> </div>
</table> )}
{report.events && report.events.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead><tr className="border-b border-white/[0.06] text-left text-gray-500">
<th className="px-3 py-2 font-medium">Correction</th>
<th className="px-3 py-2 text-right font-medium">Warned</th>
<th className="px-3 py-2 text-right font-medium">Lead</th>
</tr></thead>
<tbody>{report.events.map((event) => (
<tr key={event.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{event.date}</td>
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-500'}`}>{event.warned ? 'yes' : 'no'}</td>
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
</tr>
))}</tbody>
</table>
</div>
)}
<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>
); );
} }
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { function EventStudyPanel() {
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
return ( return (
<label className="flex items-center gap-3 text-xs text-gray-400"> <Disclosure summary="Warning study · chronological correction alarms">
<span className="w-52 shrink-0">{label}</span> {study.isLoading && <SkeletonCard className="h-24" />}
<input {study.data === null && <Callout variant="empty">Not run yet trigger Event Study in Admin Jobs.</Callout>}
type="range" {study.data && !study.data.available && <Callout variant="warning">{study.data.reason ?? 'No data'}</Callout>}
min={0} {study.data?.available && <EventStudyBody report={study.data} />}
max={100} </Disclosure>
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
/>
<span className="w-8 text-right num text-gray-300">{value}</span>
</label>
); );
} }
@@ -194,379 +220,130 @@ function FundamentalsEditor({
saving: boolean; saving: boolean;
refreshing: boolean; refreshing: boolean;
}) { }) {
const [f1, setF1] = useState(Math.round(data.f1_score)); const [f1, setF1] = useState(data.f1_score ?? 0);
const [f3, setF3] = useState(Math.round(data.f3_score)); const [f3, setF3] = useState(data.f3_score ?? 0);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500"> <div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Source: {data.source}</span> <span>Source: {data.source}</span>
{data.fetched_at && <span>· {new Date(data.fetched_at).toLocaleDateString()}</span>} {data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
{data.effective_date && <span>· effective {data.effective_date}</span>}
{data.locked && <Badge label="locked" variant="manual" />} {data.locked && <Badge label="locked" variant="manual" />}
</div> </div>
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>} {data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
<SliderRow label="F1 · Hyperscaler capex guidance" value={f1} onChange={setF1} /> {[
<SliderRow label="F3 · Good news, stock down" value={f3} onChange={setF3} /> ['F1 · Capex cuts', f1, setF1],
<div className="flex flex-wrap gap-2 pt-1"> ['F3 · Good news, stock down', f3, setF3],
<button ].map(([label, value, setter]) => (
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" <label key={String(label)} className="flex items-center gap-3 text-xs text-gray-400">
disabled={saving} <span className="w-52 shrink-0">{String(label)}</span>
onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })} <input type="range" min={0} max={100} value={Number(value)} onChange={(event) => (setter as (v: number) => void)(Number(event.target.value))} className="h-2 flex-1 accent-blue-500" />
> <span className="w-8 text-right num text-gray-300">{Number(value)}</span>
Save override </label>
</button> ))}
<button <div className="flex flex-wrap gap-2">
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50" <button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving} onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })}>Save override</button>
disabled={refreshing} <button className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] disabled:opacity-50" disabled={refreshing} onClick={onRefresh}>{refreshing ? 'Refreshing…' : 'Refresh via LLM'}</button>
onClick={onRefresh} {data.locked && <button className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04]" onClick={() => onSave({ locked: false })}>Unlock</button>}
>
{refreshing ? 'Refreshing…' : 'Refresh via LLM'}
</button>
{data.locked && (
<button
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ locked: false })}
>
Unlock
</button>
)}
</div> </div>
</div> </div>
); );
} }
function WeightsEditor({ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (updates: Partial<RegimeConfig>) => void; saving: boolean }) {
data, const [basket, setBasket] = useState(data.breadth_basket.join(', '));
onSave, const [staleness, setStaleness] = useState(data.fundamental_staleness_days);
saving, const symbols = basket.split(/[\s,]+/).map((symbol) => symbol.trim().toUpperCase()).filter(Boolean);
}: {
data: RegimeConfig;
onSave: (updates: Partial<RegimeConfig>) => void;
saving: boolean;
}) {
const [weights, setWeights] = useState<Record<string, number>>(() => ({ ...data.weights }));
const [threshold, setThreshold] = useState<number>(data.alert_threshold);
const setWeight = (key: string, value: string) => {
const num = parseFloat(value);
setWeights((prev) => ({ ...prev, [key]: isNaN(num) ? 0 : num }));
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> <label className="block text-xs text-gray-400">
{Object.keys(weights).map((key) => ( <span>Fixed breadth basket · {symbols.length} symbols</span>
<label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-400"> <textarea value={basket} onChange={(event) => setBasket(event.target.value)} rows={5} className="mt-1 w-full rounded-lg border border-white/[0.08] bg-white/[0.03] p-2 font-mono text-xs text-gray-200" />
<span className="font-mono text-gray-500">{key}</span>
<input
type="number"
min={0}
value={weights[key]}
onChange={(e) => setWeight(key, e.target.value)}
className="w-16 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
</label>
))}
</div>
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Alert threshold</span>
<input
type="number"
min={0}
max={100}
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value, 10) || 0)}
className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
</label> </label>
<button <label className="flex items-center gap-2 text-xs text-gray-400">
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" <span>Fundamental staleness</span>
disabled={saving} <input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
onClick={() => onSave({ weights, alert_threshold: threshold })} <span>days</span>
> </label>
Save weights <p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
</button> <button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save monitor settings</button>
</div> </div>
); );
} }
function Sparkline({ values, color = '#60a5fa', height = 28 }: { values: number[]; color?: string; height?: number }) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 120;
const pts = values
.map((v, i) => `${(i / (values.length - 1)) * w},${height - ((v - min) / range) * height}`)
.join(' ');
return (
<svg width={w} height={height}>
<polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} />
</svg>
);
}
function pctLabel(v: number | null): string {
return v == null ? '—' : `${Math.round(v * 100)}%`;
}
function leadLabel(v: number | null): string {
return v == null ? 'missed' : `${v}d`;
}
function bestPr(stats: EventStudyLeadStats) {
const rows = stats.signal.rows.filter((r) => r.precision != null && r.recall != null && r.recall > 0);
if (!rows.length) return null;
return rows.reduce((a, b) => ((b.precision ?? 0) > (a.precision ?? 0) ? b : a));
}
function LeadStat({ label, stats, highlight }: { label: string; stats: EventStudyLeadStats; highlight?: boolean }) {
const pr = bestPr(stats);
return (
<div className={`rounded-lg border px-3 py-2 ${highlight ? 'border-blue-400/30 bg-blue-400/[0.06]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
<div className="text-xs text-gray-500">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-gray-200">
{stats.median_lead_days != null ? `${stats.median_lead_days}d lead` : 'no signal'}
</div>
<div className="text-[11px] text-gray-600">
{stats.events_with_signal}/{stats.events_total} warned
{stats.warn_threshold != null ? ` · warn ≥ ${Math.round(stats.warn_threshold)}` : ''}
</div>
{pr && (
<div className="text-[11px] text-gray-600">
best P {pctLabel(pr.precision)} · R {pctLabel(pr.recall)} @ {pr.threshold}
</div>
)}
</div>
);
}
function PerEventTable({ rows }: { rows: EventStudyPerEvent[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/[0.06] text-left uppercase tracking-wider text-gray-500">
<th className="px-3 py-2 font-medium">Drawdown</th>
<th className="px-3 py-2 text-right font-medium">Depth</th>
<th className="px-3 py-2 text-right font-medium">Breadth lead</th>
<th className="px-3 py-2 text-right font-medium">Coincident lead</th>
</tr>
</thead>
<tbody>
{rows.map((e) => {
const earlier = e.breadth_lead != null && (e.coincident_lead == null || e.breadth_lead > e.coincident_lead);
return (
<tr key={e.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{e.date}</td>
<td className="px-3 py-2 text-right num text-gray-400">{e.depth_pct}%</td>
<td className={`px-3 py-2 text-right num ${earlier ? 'text-emerald-400' : 'text-gray-300'}`}>
{leadLabel(e.breadth_lead)}
</td>
<td className="px-3 py-2 text-right num text-gray-300">{leadLabel(e.coincident_lead)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) {
const bd = report.indicators!.breadth_divergence;
const cd = report.indicators!.coincident_price;
const recent = report.recent_breadth ?? [];
const breadthVals = recent.map((r) => r.breadth);
const divVals = recent.map((r) => r.divergence ?? 0);
const moreCoverage = bd.events_with_signal > cd.events_with_signal;
return (
<div className="space-y-4">
<p className="text-xs text-gray-500">
{report.events?.length ?? 0} drawdown events ({report.params?.event_threshold_pct}%) on{' '}
{report.params?.benchmark} over ~5y. With so few events, coverage (how many it warned before) matters
more than the median lead.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={moreCoverage} />
<LeadStat label="Coincident price composite (baseline)" stats={cd} />
</div>
<p className="text-xs text-gray-400">
Breadth divergence warned before{' '}
<span className="font-medium text-emerald-400">{bd.events_with_signal}/{bd.events_total}</span> drawdowns
{bd.median_lead_days != null ? ` (median ${bd.median_lead_days}d lead)` : ''}; the coincident baseline only{' '}
<span className="font-medium text-gray-300">{cd.events_with_signal}/{cd.events_total}</span>. The median-lead
comparison is unreliable when coverage differs this much see per-drawdown below.
</p>
{report.per_event && report.per_event.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Per drawdown (same events, both indicators)</div>
<PerEventTable rows={report.per_event} />
</div>
)}
{recent.length > 1 && (
<div className="flex flex-wrap items-end gap-6">
<div>
<div className="text-[11px] text-gray-500">Breadth (% &gt; 200d), last 90d</div>
<Sparkline values={breadthVals} color="#34d399" />
<div className="num text-xs text-gray-400">{breadthVals[breadthVals.length - 1]?.toFixed(0)}%</div>
</div>
<div>
<div className="text-[11px] text-gray-500">Divergence (fragility), last 90d</div>
<Sparkline values={divVals} color="#fb923c" />
<div className="num text-xs text-gray-400">{divVals[divVals.length - 1]?.toFixed(0)}</div>
</div>
</div>
)}
<p className="text-[11px] leading-relaxed text-gray-600">
Base rate {Math.round(bd.signal.base_rate * 100)}% · horizon {bd.signal.horizon_days}d. Few events in
5y noisy; treat lead time as an order of magnitude and don&apos;t overfit thresholds. Not yet wired
into the live score.
</p>
</div>
);
}
function EventStudyPanel() {
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
return (
<Disclosure summary="Early-warning study — measured lead time vs. drawdowns">
{study.isLoading && <SkeletonCard className="h-24" />}
{study.data === null && (
<Callout variant="empty">Not run yet trigger the Event Study job in Admin Jobs.</Callout>
)}
{study.data && !study.data.available && (
<Callout variant="warning">{study.data.reason ?? 'No data'}</Callout>
)}
{study.data && study.data.available && <EventStudyBody report={study.data} />}
</Disclosure>
);
}
function AdminControls() { function AdminControls() {
const qc = useQueryClient(); const queryClient = useQueryClient();
const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals }); const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig }); const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['regime'] });
const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] });
const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate }); const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate });
const saveFund = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate }); const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate }); const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Disclosure summary="Admin · Fundamentals (F1 / F3)"> <Disclosure summary="Admin · Fundamental observations">
{fundamentals.isLoading && <SkeletonCard className="h-24" />} {fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
{fundamentals.data && ( {refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
<FundamentalsEditor
key={fundamentals.dataUpdatedAt}
data={fundamentals.data}
onSave={(body) => saveFund.mutate(body)}
onRefresh={() => refresh.mutate()}
saving={saveFund.isPending}
refreshing={refresh.isPending}
/>
)}
{refresh.isError && (
<Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>
)}
</Disclosure> </Disclosure>
<Disclosure summary="Admin · Fixed basket & freshness">
<Disclosure summary="Admin · Weights & threshold"> {config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
{config.isLoading && <SkeletonCard className="h-24" />} {saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
{config.data && (
<WeightsEditor
key={config.dataUpdatedAt}
data={config.data}
onSave={(updates) => saveConfig.mutate(updates)}
saving={saveConfig.isPending}
/>
)}
</Disclosure> </Disclosure>
</div> </div>
); );
} }
export default function RegimePage() { export default function RegimePage() {
const role = useAuthStore((s) => s.role); const isAdmin = useAuthStore((state) => state.role) === 'admin';
const isAdmin = role === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data;
return ( return (
<div className="space-y-6 animate-slide-up"> <div className="space-y-6 animate-slide-up">
<PageHeader <PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
title="Regime Monitor" <Callout variant="info"><strong>Risk thermometer not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
subtitle="AI/Tech regime-change index — observational, feeds no trades"
/>
{monitor.isLoading && ( {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>}
<SkeletonCard className="h-44" /> {data && !data.available && <Callout variant="empty">V2 is not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
<SkeletonTable rows={6} cols={4} />
</> {data?.available && data.state && data.warning && (
)}
{monitor.isError && (
<Callout variant="error" onRetry={() => monitor.refetch()}>
Failed to load: {(monitor.error as Error).message}
</Callout>
)}
{monitor.data && !monitor.data.available && (
<Callout variant="empty">
Not computed yet run the Regime Monitor job from Admin Jobs, or wait for the daily pipeline.
</Callout>
)}
{monitor.data && monitor.data.available && (
<> <>
{(!data.data_quality?.is_fresh || data.state.band == null || data.warning.band == null) && (
<Callout variant="warning">
Reading is incomplete or stale. State coverage {Math.round(data.state.coverage)}%, Warning coverage {Math.round(data.warning.coverage)}%
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
</Callout>
)}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge <ScoreGauge
label="Regime index · coincident" label="State · current structural stress"
score={monitor.data.total_score} reading={data.state}
band={monitor.data.band} divider={data.quadrant_config?.state_divider}
trend={monitor.data.trend} 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 ?? '—'}.</>}
threshold={monitor.data.alert_threshold}
footnote={
<>
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech
bull regime has deteriorated. Mostly coincident it shortens reaction time, it doesn&apos;t predict
the turn.
{monitor.data.date && <> As of {monitor.data.date}.</>}
{monitor.data.inputs && (monitor.data.inputs.vix != null || monitor.data.inputs.hy_oas != null) && (
<span className="ml-1 text-gray-600">
VIX {monitor.data.inputs.vix ?? '—'} · HY OAS {monitor.data.inputs.hy_oas ?? '—'}
</span>
)}
</>
}
/> />
<ScoreGauge <ScoreGauge
label="Early warning · breadth divergence" label="Warning · deterioration & divergence"
score={monitor.data.early_warning?.score} reading={data.warning}
band={monitor.data.early_warning?.band} divider={data.quadrant_config?.warning_divider}
trend={monitor.data.early_warning} footnote={<>Breadth divergence, SMH/SPY rollover, and point-in-time fundamental observations. Unknown or stale fundamentals reduce coverage; they never default to 50.</>}
footnote={
<>
Breadth narrowing while price holds. In the event study it led ~6 weeks on 7/11 past drawdowns, but
it&apos;s noisy (2× base rate) and blind to shocks. Observational separate from the index, not
wired into trades.
</>
}
/> />
</div> </div>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<RegimeQuadrant /> <Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense>
</Suspense> <Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}> <div className="grid gap-3 lg:grid-cols-2">
<ScoreHistoryChart /> <PillarBreakdown title="State" reading={data.state} />
</Suspense> <PillarBreakdown title="Warning" reading={data.warning} />
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />} </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>
)}
</> </>
)} )}
<EventStudyPanel /> <EventStudyPanel />
{isAdmin && <AdminControls />} {isAdmin && <AdminControls />}
</div> </div>
); );
+42 -99
View File
@@ -1,4 +1,4 @@
"""Unit tests for the breadth indicator and the event-study measurement.""" """Tests for v2 correction events and warning alarm episodes."""
from __future__ import annotations from __future__ import annotations
@@ -6,124 +6,67 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import ( from app.services.event_study_service import (
_lead,
_percentile, _percentile,
alarm_episodes,
detect_events, detect_events,
event_centered, evaluate_alarms,
signal_centered,
) )
def _days(n: int, start: date = date(2021, 1, 1)) -> list[date]: def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=i) for i in range(n)] return [start + timedelta(days=index) for index in range(count)]
# --------------------------------------------------------------------------- def test_detect_events_uses_rising_edge_and_cooldown():
# Event detection closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
# --------------------------------------------------------------------------- events = detect_events(closes, _days(len(closes)), threshold_pct=15.0, cooldown=40)
assert [event["index"] for event in events] == [300, 355]
def test_detect_events_single_drawdown():
closes = [100.0] * 300 + [85.0] * 5 # 15% off the trailing high -> one event
dates = _days(len(closes))
events = detect_events(closes, dates, threshold_pct=15.0)
assert len(events) == 1
assert events[0]["index"] == 300
def test_detect_events_dedup_without_recovery(): def test_percentile_is_fixed_from_supplied_values():
closes = [100.0] * 300 + [85.0] * 5 + [80.0] * 5 # deepens but never recovers values = [float(value) for value in range(0, 101, 10)]
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0) assert _percentile(values, 50) == 50.0
assert len(events) == 1 assert _percentile(values, 80) == 80.0
assert _percentile([], 80) is None
def test_detect_events_two_after_recovery(): def test_alarm_requires_upward_crossing_and_reset():
closes = [100.0] * 300 + [85.0] * 10 + [100.0] * 300 + [85.0] * 10 dates = _days(10)
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0) values = [10, 70, 80, 75, 20, 70, 80, 20, 20, 70]
assert len(events) == 2 indicator = dict(zip(dates, values))
assert alarm_episodes(indicator, dates, threshold=60) == [1, 5, 9]
def test_detect_events_cooldown_suppresses_close_recross(): def test_holdout_start_does_not_invent_crossing_when_already_high():
# Dips below threshold then re-crosses only a few bars later. dates = _days(6)
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3 indicator = dict(zip(dates, [10, 70, 80, 80, 20, 70]))
dates = _days(len(closes)) assert alarm_episodes(indicator, dates, threshold=60, start_index=3) == [5]
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=40)) == 1
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=3)) == 2
def test_percentile_interpolation(): def test_evaluate_alarms_counts_episodes_not_alarm_days():
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100 dates = _days(100)
assert _percentile(vals, 50) == 50.0 result = evaluate_alarms([10, 50, 80], [25, 70], dates, horizon=20)
assert _percentile(vals, 80) == 80.0 assert result["events_warned"] == 2
assert _percentile([], 50) is None assert result["events_missed"] == 0
assert result["false_alarms"] == 1
assert result["median_lead_days"] == 17.5
def test_lead_earliest_crossing(): def test_breadth_from_fixed_closes_and_pure_divergence():
dates = _days(200) dates = _days(10)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
assert _lead(indicator, t0, dates, pre=60, threshold=60.0) == 30
assert _lead(indicator, t0, dates, pre=60, threshold=80.0) is None
# ---------------------------------------------------------------------------
# Event-centered lead time
# ---------------------------------------------------------------------------
def test_event_centered_lead_time():
dates = _days(200)
t0 = 120
# Indicator goes hot 30 days before t0 and stays hot through t0.
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
res = event_centered(indicator, [t0], dates, pre=60, post=20, threshold=60.0)
assert res["median_lead_days"] == 30
assert res["events_with_signal"] == 1
def test_breadth_divergence_leads_coincident():
dates = _days(200)
t0 = 120
breadth_ind = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
coincident = {dates[i]: (70.0 if t0 - 2 <= i <= t0 else 10.0) for i in range(len(dates))}
bd = event_centered(breadth_ind, [t0], dates, threshold=60.0)
cd = event_centered(coincident, [t0], dates, threshold=60.0)
assert bd["median_lead_days"] > cd["median_lead_days"]
# ---------------------------------------------------------------------------
# Signal-centered precision / recall
# ---------------------------------------------------------------------------
def test_signal_centered_base_rate_and_recall():
dates = _days(200)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
res = signal_centered(indicator, [t0], dates, horizon=20)
assert 0.0 < res["base_rate"] < 1.0
# An aligned indicator should catch some of the pre-event window at a mid threshold.
row60 = next(r for r in res["rows"] if r["threshold"] == 60)
assert row60["recall"] is not None and row60["recall"] > 0
# ---------------------------------------------------------------------------
# Breadth aggregation + divergence
# ---------------------------------------------------------------------------
def test_breadth_from_closes_fraction_above_sma():
dates = _days(5)
closes_by_symbol = { closes_by_symbol = {
"A": list(zip(dates, [1.0, 2.0, 3.0, 4.0, 5.0])), # rising -> above its SMA "A": list(zip(dates, [1.0 + index for index in range(10)])),
"B": list(zip(dates, [5.0, 4.0, 3.0, 2.0, 1.0])), # falling -> below "B": list(zip(dates, [10.0 - index for index in range(10)])),
"C": list(zip(dates, [3.0, 3.0, 3.0, 3.0, 3.0])), # flat -> not strictly above "C": list(zip(dates, [5.0] * 10)),
} }
breadth = _breadth_from_closes(closes_by_symbol, window=3, min_tickers=2) breadth = _breadth_from_closes(closes_by_symbol, window=3, min_tickers=2)
# At d2: SMA(3) over each -> only A is strictly above -> 1/3.
assert breadth[dates[2]] == round(1 / 3 * 100, 2) assert breadth[dates[2]] == round(1 / 3 * 100, 2)
falling_breadth = {dates[index]: 80.0 - index * 3 for index in range(10)}
rising_benchmark = list(zip(dates, [100.0 + index for index in range(10)]))
divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3)
assert divergence[dates[-1]] > 0
def test_divergence_high_when_price_up_breadth_down(): falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
dates = _days(10) no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
breadth = {dates[i]: 80.0 - i * 3 for i in range(len(dates))} # falling breadth assert no_divergence[dates[-1]] == 0
benchmark = list(zip(dates, [100.0 + i for i in range(len(dates))])) # rising price
div = compute_divergence_series(breadth, benchmark, lookback=3)
last = div[dates[-1]]
assert last > 50.0 # fragile: price up while breadth deteriorates
+155 -121
View File
@@ -1,166 +1,200 @@
"""Unit tests for the regime-monitor pure functions and aggregation.""" """Pure-function tests for the v2 Regime Monitor contract."""
from __future__ import annotations from __future__ import annotations
import copy
import json
from datetime import date, timedelta from datetime import date, timedelta
import pytest
from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot
from app.services import regime_monitor_service as rms
from app.services.regime_monitor_service import ( from app.services.regime_monitor_service import (
DEFAULT_CONFIG, DEFAULT_CONFIG,
_attach_early_warning, HY_OAS_ELEVATED,
HY_OAS_MILD,
HY_OAS_STRESSED,
_compute_index,
_fundamental_scores_asof,
_score_pillars,
band_for, band_for,
compute_regime_score, breadth_level_score,
f2_credit_spreads, f2_credit_spreads,
p1_trend_break, p1_trend_break,
p2_death_cross, p2_death_cross,
p3_drawdown, p3_drawdown,
p4_relative_strength, p4_relative_strength,
p5_volatility, p5_volatility,
p6_canary,
_compute_index,
) )
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]: def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
n = len(values) return [
return [(end - timedelta(days=(n - 1 - i)), v) for i, v in enumerate(values)] (end - timedelta(days=len(values) - 1 - index), value)
for index, value in enumerate(values)
]
# --------------------------------------------------------------------------- def test_band_for_keeps_documented_boundaries():
# Bands
# ---------------------------------------------------------------------------
def test_band_for():
assert band_for(10) == "stable" assert band_for(10) == "stable"
assert band_for(45) == "watch" assert band_for(30) == "watch"
assert band_for(70) == "elevated" assert band_for(60) == "elevated"
assert band_for(90) == "breaking" assert band_for(80) == "breaking"
def test_attach_early_warning_blends(): def test_price_sensors_are_stress_only():
result = {"total_score": 80.0} smh_under = [100.0] * 199 + [50.0]
_attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4}) qqq_above = [100.0] * 200
assert result["early_warning"]["score"] == 40.0 assert round(p1_trend_break(smh_under, qqq_above) or 0, 1) == 66.7
assert result["early_warning"]["band"] == "watch"
# combined = (80*0.6 + 40*0.4) / 1.0 = 64
assert result["combined"]["score"] == 64.0
assert result["combined"]["band"] == "elevated"
bearish = [300.0 - index for index in range(260)]
healthy = [100.0 + index * 0.5 for index in range(260)]
assert (p2_death_cross(bearish, bearish) or 0) > 0
assert p2_death_cross(healthy, healthy) == 0
def test_attach_early_warning_none_falls_back_to_index(): closes = [100.0] * 252 + [80.0]
result = {"total_score": 80.0}
_attach_early_warning(result, None, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] is None
assert result["combined"]["score"] == 80.0 # no early warning -> just the index
def test_divergence_asof_tolerates_small_lag():
from app.services.regime_monitor_service import _divergence_asof
items = [(date(2026, 6, 1), 55.0), (date(2026, 6, 3), 60.0)]
assert _divergence_asof(items, date(2026, 6, 3)) == 60.0 # exact date
assert _divergence_asof(items, date(2026, 6, 4)) == 60.0 # 1-day lag -> newest
assert _divergence_asof(items, date(2026, 6, 20)) is None # too stale
assert _divergence_asof([], date(2026, 6, 3)) is None
# ---------------------------------------------------------------------------
# Price sub-scores
# ---------------------------------------------------------------------------
def test_p1_blends_leader_double():
smh_under = [100.0] * 199 + [50.0] # last below its 200-DMA
qqq_above = [100.0] * 200 # last at/above its 200-DMA -> healthy
score = p1_trend_break(smh_under, qqq_above, leader_weight=2.0)
# leader(100) weighted 2, confirm(0) weighted 1 -> 66.7
assert round(score, 1) == 66.7
def test_p1_none_without_history():
assert p1_trend_break([100.0] * 50, [100.0] * 50, 2.0) is None
def test_p2_death_cross_bearish_vs_healthy():
bearish = [300.0 - i for i in range(260)] # falling: 50 < 200, slope down
healthy = [100.0 + i * 0.5 for i in range(260)] # rising: 50 > 200
assert p2_death_cross(bearish, bearish, 2.0) > 0
assert p2_death_cross(healthy, healthy, 2.0) == 0
def test_p3_drawdown_linear():
closes = [100.0] * 252 + [80.0] # 20% below the 52w high -> 100
assert p3_drawdown(closes, [100.0] * 253) == 100.0 assert p3_drawdown(closes, [100.0] * 253) == 100.0
def test_p4_relative_strength_direction(): def test_relative_strength_flat_or_better_is_zero():
falling = [100.0 - i * 0.5 for i in range(70)] # SMH underperforms flat SPY flat = [100.0] * 70
rising = [100.0 + i * 0.5 for i in range(70)] rising = [100.0 + index for index in range(70)]
spy = [100.0] * 70 falling = [100.0 - index * 0.5 for index in range(70)]
assert p4_relative_strength(falling, spy, 60) > 50 assert p4_relative_strength(flat, flat) == 0.0
assert p4_relative_strength(rising, spy, 60) < 50 assert p4_relative_strength(rising, flat) == 0.0
assert (p4_relative_strength(falling, flat) or 0) > 0
def test_p5_volatility_linear(): def test_volatility_and_breadth_zero_points():
assert p5_volatility(15) == 0 assert p5_volatility(15) == 0
assert p5_volatility(30) == 100 assert p5_volatility(30) == 100
assert p5_volatility(22.5) == 50 assert breadth_level_score(60) == 0
assert p5_volatility(None) is None assert breadth_level_score(20) == 100
assert breadth_level_score(None) is None
def test_f2_credit_percentile(): def test_credit_uses_named_anchors_and_constant_series_is_not_extreme():
rising = [float(i) for i in range(1, 31)] # latest is the max -> ~100th pct assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0
assert f2_credit_spreads(rising) == 100.0 assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
falling = [float(i) for i in range(30, 0, -1)] # latest is the min assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0
assert f2_credit_spreads(falling) < 10 rising = [3.0 + index * 0.01 for index in range(100)]
assert f2_credit_spreads([1.0] * 5) is None # too short assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100)
def test_p6_canary_divergence(): def test_score_pillars_gates_band_below_75_percent_coverage():
nvda_weak = [100.0] * 49 + [80.0] # below its 50-DMA pillars = [
smh_intact = [100.0] * 199 + [120.0] # above its 200-DMA {"id": "price", "label": "Price", "score": 80.0, "sensors": []},
assert p6_canary(nvda_weak, smh_intact) == 100.0 {"id": "breadth", "label": "Breadth", "score": 20.0, "sensors": []},
assert p6_canary([100.0] * 50, smh_intact) == 0.0 {"id": "credit", "label": "Credit", "score": None, "sensors": []},
{"id": "volatility", "label": "Vol", "score": None, "sensors": []},
]
result = _score_pillars(pillars, {"price": 40, "breadth": 25, "credit": 20, "volatility": 15})
assert result["coverage"] == 65.0
assert result["score"] is not None
assert result["band"] is None
# --------------------------------------------------------------------------- def test_fundamentals_never_replay_before_effective_date_and_expire():
# Aggregation overrides = {
# --------------------------------------------------------------------------- "f1_score": 0.0,
"f3_score": 100.0,
def test_compute_regime_score_excludes_na_and_zero_weight(): "fetched_at": "2026-06-01T10:00:00+00:00",
weights = {"P1": 10, "P2": 0, "F2": 5} "effective_date": "2026-06-02",
subs = {"P1": 80.0, "P2": 50.0, "F2": None} }
result = compute_regime_score(subs, weights) config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
# Only P1 counts: P2 weight 0, F2 unavailable. assert _fundamental_scores_asof(overrides, config, date(2026, 6, 1))[:2] == (None, None)
assert result["total_score"] == 80.0 assert _fundamental_scores_asof(overrides, config, date(2026, 6, 2))[:2] == (0.0, 100.0)
ids = {row["id"]: row for row in result["breakdown"]} assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
assert "P2" not in ids # zero-weight signals are hidden
assert ids["F2"]["available"] is False
assert ids["P1"]["contribution"] == 80.0
def test_compute_regime_score_contributions_sum_to_total(): @pytest.mark.asyncio
weights = {"P1": 10, "F2": 10} async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
subs = {"P1": 80.0, "F2": 40.0} stored = {
result = compute_regime_score(subs, weights) "f1_score": 100.0,
assert result["total_score"] == 60.0 "f3_score": 0.0,
total = sum(row["contribution"] for row in result["breakdown"]) "locked": True,
assert round(total, 1) == 60.0 "source": "manual",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
saved: dict = {}
async def fake_get(_db):
return dict(stored)
async def fake_update(_db, _key, value):
saved.update(json.loads(value))
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
monkeypatch.setattr(rms, "update_setting", fake_update)
result = await rms.set_fundamental_overrides(object(), locked=False)
assert result["locked"] is False
assert result["fetched_at"] == stored["fetched_at"]
assert result["effective_date"] == stored["effective_date"]
assert saved == result
# --------------------------------------------------------------------------- @pytest.mark.asyncio
# As-of index replay (backfill mechanics) async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session):
# --------------------------------------------------------------------------- snapshot_date = date(2026, 6, 26)
first = {
"methodology": "v2",
"date": snapshot_date.isoformat(),
"state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"},
}
changed = copy.deepcopy(first)
changed["state"] = {"score": 90.0, "band": "breaking"}
def test_compute_index_as_of_truncates_history(): written, _ = await rms._upsert_snapshot(
rising = [100.0 + i * 0.2 for i in range(260)] db_session, first, rewrite_existing_v2=True
prices = {sym: _dated(rising) for sym in ("SMH", "QQQ", "SPY", "RSP", "NVDA")} )
overrides = {"f1_score": 50.0, "f3_score": 50.0} await db_session.flush()
rewritten, persisted = await rms._upsert_snapshot(
db_session, changed, rewrite_existing_v2=False
)
row = (
await db_session.execute(
select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date)
)
).scalar_one()
full = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26)) assert written is True
by_id = {r["id"]: r for r in full["breakdown"]} assert rewritten is False
assert by_id["P1"]["available"] is True # 200-DMA computable on full history assert persisted["state"]["score"] == 10.0
assert 0 <= full["total_score"] <= 100 assert row.total_score == 10.0
assert full["band"] in {"stable", "watch", "elevated", "breaking"}
# As-of 250 days earlier: only ~10 bars are in scope -> long-lookback signals n/a.
early = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26) - timedelta(days=250)) def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
early_by_id = {r["id"]: r for r in early["breakdown"]} end = date(2026, 6, 26)
assert early_by_id["P1"]["available"] is False rising = [100.0 + index * 0.2 for index in range(700)]
qqq = rising.copy()
smh = rising[:-1] + [rising[-1] * 0.75]
prices = {
"SMH": _dated(smh, end),
"QQQ": _dated(qqq, end),
"SPY": _dated(rising, end),
}
breadth = [(end, 55.0)]
divergence = [(end, 20.0)]
result = _compute_index(
prices,
[(end, 20.0)],
[(end - timedelta(days=index), 4.0) for index in reversed(range(100))],
{"f1_score": None, "f3_score": None},
copy.deepcopy(DEFAULT_CONFIG),
end,
breadth,
divergence,
{end: 25},
)
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
assert price["score"] == max(sensor_scores)
assert result["methodology"] == "v2"
assert "combined" not in result
assert result["basket"]["members_available"] == 25
+28 -40
View File
@@ -1,52 +1,40 @@
"""Tests for the regime quadrant classification + hysteresis (anti-flicker).""" """Tests for v2 State/Warning quadrant hysteresis and basket reseeding keys."""
from __future__ import annotations from app.services.alert_service import (
_classify_quadrant,
from app.services.alert_service import _classify_quadrant, _parse_quadrant_log_key, _quadrant_log_key _parse_quadrant_log_key,
_quadrant_log_key,
)
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition def test_fresh_classification_uses_60_60_boundaries():
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low). assert _classify_quadrant(20, 90, None) == "1"
# Dividers: regime 40, early-warning 60; margin 5. assert _classify_quadrant(70, 90, None) == "2"
assert _classify_quadrant(20, 30, None) == "3"
assert _classify_quadrant(70, 30, None) == "4"
def test_fresh_classification(): def test_warning_axis_hysteresis():
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning assert _classify_quadrant(20, 62, prev="3") == "3"
assert _classify_quadrant(70, 90, None) == "2" # both high assert _classify_quadrant(20, 66, prev="3") == "1"
assert _classify_quadrant(20, 30, None) == "3" # both low assert _classify_quadrant(20, 58, prev="1") == "1"
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning assert _classify_quadrant(20, 54, prev="1") == "3"
def test_hysteresis_holds_inside_deadband(): def test_state_axis_hysteresis():
# From ③ (both low): early-warning nudging just past 60 stays ③ until it assert _classify_quadrant(63, 30, prev="3") == "3"
# clears 60 + margin (65). assert _classify_quadrant(66, 30, prev="3") == "4"
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip assert _classify_quadrant(57, 30, prev="4") == "4"
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ① assert _classify_quadrant(54, 30, prev="4") == "3"
def test_hysteresis_sticky_when_already_high():
# From ① (warning high): a dip below 60 keeps ① until it drops past 60 - margin (55).
assert _classify_quadrant(20, 58, prev="1") == "1" # still high (deadband)
assert _classify_quadrant(20, 54, prev="1") == "3" # drops past 55 → back to ③
def test_hysteresis_on_regime_axis():
# From ③: regime rising past 40 stays ③ until it clears 45.
assert _classify_quadrant(43, 30, prev="3") == "3"
assert _classify_quadrant(46, 30, prev="3") == "4"
# From ④: regime easing keeps ④ until below 35.
assert _classify_quadrant(37, 30, prev="4") == "4"
assert _classify_quadrant(34, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip(): def test_boundary_sitting_does_not_flip():
# A point parked exactly on both dividers keeps whatever quadrant it had. for quadrant in ("1", "2", "3", "4"):
for q in ("1", "2", "3", "4"): assert _classify_quadrant(60, 60, prev=quadrant) == quadrant
assert _classify_quadrant(40, 60, prev=q) == q
def test_quadrant_log_key_keeps_previous_values(): def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
key = _quadrant_log_key("3", 32.4, 54.6) key = _quadrant_log_key("3", 32.4, 54.6, "abc123")
assert _parse_quadrant_log_key(key) == ("3", 32.4, 54.6) assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
# Existing pre-value keys still parse so old installs do not need migration. assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3") == ("3", None, None) assert _parse_quadrant_log_key("3") == (None, "3", None, None)