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.
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).
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.
@@ -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)
- 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
- 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)
- 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
+4 -4
View File
@@ -8,12 +8,12 @@ from app.database import 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
per-signal breakdown plus the raw inputs, so reads need no recomputation and
the 7/30-day trend is just a query over ``total_score``. Decoupled from the
rest of the platform: nothing reads this to gate or score trades.
``breakdown_json`` is authoritative for v2 State, Warning, source dates,
coverage, and fixed-basket metadata. ``total_score``/``band`` retain the v2
State reading for schema compatibility. Nothing reads this to gate trades.
"""
__tablename__ = "regime_snapshots"
+18 -12
View File
@@ -1,7 +1,7 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin
@@ -40,12 +40,18 @@ async def backtest_report(
class RegimeConfigUpdate(BaseModel):
weights: dict[str, float] | None = None
alert_threshold: float | None = None
tickers: dict | None = None
leader_weight: float | None = None
rs_lookback: int | None = None
fundamental_staleness_days: int | None = None
breadth_basket: list[str] | None = Field(default=None, min_length=20, max_length=100)
fundamental_staleness_days: int | None = Field(default=None, ge=30, le=180)
@field_validator("breadth_basket")
@classmethod
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):
@@ -59,7 +65,7 @@ async def regime_monitor(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> 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)
return APIEnvelope(status="success", data=data)
@@ -69,7 +75,7 @@ async def regime_config(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> 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)
return APIEnvelope(status="success", data=data)
@@ -80,7 +86,7 @@ async def update_regime_config(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> 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)
data = await regime_monitor_service.update_regime_config(db, updates)
return APIEnvelope(status="success", data=data)
@@ -133,10 +139,10 @@ async def regime_event_study(
@router.get("/regime/history", response_model=APIEnvelope)
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),
db: AsyncSession = Depends(get_db),
) -> 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)
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)
state = result.get("state") or {}
warning = result.get("warning") or {}
_runtime_progress(job_name, processed=1, total=1)
_runtime_finish(
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:
_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))
@@ -1086,7 +1094,11 @@ async def run_event_study_job() -> None:
_runtime_progress(job_name, processed=1, total=1)
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:
msg = report.get("reason", "no data")
_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_SCORE_DROP: 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,
}
@@ -90,19 +92,19 @@ SIGNAL_BUNDLE_SECTIONS = (
)
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
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 40.0 # regime index divider (matches the frontend quadrant)
QUAD_Y_DIV = 60.0 # early-warning divider
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning 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_LABELS = {
"1": "① Hot & brittle",
"2": "② Transition",
"3": "③ Healthy & broad",
"4": "④ Real downturn",
"1": "Early warning",
"2": "Active stress",
"3": "Healthy",
"4": "Stressed / stabilizing",
}
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:
if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
return "4" if x_high else "3" # ④ Real downturn / Healthy & broad
return "2" if x_high else "1" # Active stress / Early warning
return "4" if x_high else "3" # Stressed/stabilizing / Healthy
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
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:
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
def _classify_quadrant(
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
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.
"""
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)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
x_high = (x >= x_div - margin) if px else (x >= x_div + margin)
y_high = (y >= y_div - margin) if py else (y >= y_div + margin)
return _bools_to_quadrant(x_high, y_high)
def _quadrant_log_key(q: str, x: float, y: float) -> str:
return f"{q}:{x:.1f}:{y:.1f}"
def _quadrant_log_key(q: str, x: float, y: float, basket_hash: str | None = None) -> str:
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:
return None, None, None
return None, None, None, None
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:
return None, None, None
if len(parts) >= 3:
return None, None, None, None
if len(values) >= 2:
try:
return q, float(parts[1]), float(parts[2])
return basket_hash, q, float(values[0]), float(values[1])
except ValueError:
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."""
result = await db.execute(
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()
if not row:
return None, None, None, None
prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return prev_q, prev_x, prev_y, row[1]
return None, None, None, None, None
basket_hash, prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return basket_hash, prev_q, prev_x, prev_y, row[1]
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
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)
if not data.get("available"):
return []
x = data.get("total_score")
y = (data.get("early_warning") or {}).get("score")
state = data.get("state") or {}
warning = data.get("warning") or {}
x = state.get("score")
y = warning.get("score")
if x is None or y is None:
return []
prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None:
_log_alert(db, QUAD_TYPE, _quadrant_log_key(_classify_quadrant(x, y, None), x, y)) # seed, no alert
quality = data.get("data_quality") or {}
if (
float(state.get("coverage") or 0) < 75
or float(warning.get("coverage") or 0) < 75
or not quality.get("is_fresh")
):
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:
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.tzinfo is None:
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:
metrics = (
f"regime {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"early-warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
f"State {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"Warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
)
else:
metrics = f"regime {x:.0f} · early-warning {y:.0f}"
metrics = f"State {x:.0f} · Warning {y:.0f}"
text = (
f"🧭 <b>Regime quadrant change</b>\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
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
no new data source.
V2 measures an explicit, frozen basket rather than every ticker currently stored
in the database. That keeps the live series reproducible when the wider product
universe changes.
Two layers:
- breadth = % of the universe trading above its own 200-DMA (0-100).
- 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
live regime index yet — the event study measures whether it actually leads before
it earns any weight.
The live monitor uses the breadth level in State and the pure divergence in
Warning. The event study evaluates the latter chronologically.
"""
from __future__ import annotations
@@ -31,9 +32,9 @@ logger = logging.getLogger(__name__)
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
) -> dict[date, float]:
) -> tuple[dict[date, float], dict[date, int]]:
"""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
@@ -55,11 +56,20 @@ def _breadth_from_closes(
entry[1] += 1
if closes[i] > sma:
entry[0] += 1
return {
values = {
d: round(above / total * 100.0, 2)
for d, (above, total) in counts.items()
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(
@@ -67,10 +77,10 @@ def compute_divergence_series(
) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date.
Fragility rises when the benchmark price climbs over ``lookback`` days while
breadth deteriorates over the same window, and is nudged up when the absolute
breadth level is already low. It is the *divergence* (not the level) that
makes this leading.
This is deliberately a pure divergence: it is positive only when benchmark
price holds/rises while breadth falls. Absolute low breadth belongs in the
State score, so it is not counted again here. A 20 percentage-point breadth
deterioration maps to 100.
"""
bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth)
@@ -82,14 +92,19 @@ def compute_divergence_series(
continue
price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points
raw = price_ret - breadth_chg # price up & breadth down -> large
score = 50.0 + raw * 2.0 + (50.0 - breadth[d]) * 0.4
deterioration = max(0.0, -breadth_chg)
score = deterioration * 5.0 if price_ret >= 0 else 0.0
out[d] = max(0.0, min(100.0, round(score, 2)))
return out
async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
async def _load_universe_closes(
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] = {}
for ticker in result.scalars().all():
try:
@@ -103,13 +118,27 @@ async def _load_universe_closes(db: AsyncSession) -> dict[str, 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]:
"""Historical breadth series across the stored universe (for the event study)."""
closes_by_symbol = await _load_universe_closes(db)
"""Historical breadth series across an explicit basket (or all stored names)."""
closes_by_symbol = await _load_universe_closes(db, symbols)
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:
"""Latest breadth reading (thin wrapper, for future live use)."""
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**
(historical drawdowns), not trades. For each candidate indicator it answers:
- how many days of warning did it give before the break (event-centered)?
- at what false-alarm cost (signal-centered precision/recall vs. the base rate)?
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.
The study calls its outcome a 10% correction, uses the first 70% of sessions to
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
the final 30%. It is still labelled exploratory while the fixed breadth basket
is reconstructed before its freeze date.
"""
from __future__ import annotations
@@ -34,304 +22,268 @@ logger = logging.getLogger(__name__)
KEY_REPORT = "regime_event_study"
# Defaults. The 15% threshold gave only 2 events in 5y (statistically useless),
# so the default is lower with a cooldown-based dedup to surface more, cleaner
# events. Each indicator "warns" at its OWN 80th percentile rather than a shared
# absolute level, so the leading vs. coincident comparison is fair across scales.
EVENT_THRESHOLD_PCT = 10.0 # drawdown from the 52w high that counts as a "break"
COOLDOWN_DAYS = 40 # min trading days between event onsets (dedup)
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)
EVENT_THRESHOLD_PCT = 10.0
EVENT_COOLDOWN_DAYS = 40
DRAWDOWN_LOOKBACK = 252
HORIZON_DAYS = 20
WARN_PERCENTILE = 80.0
TRAIN_FRACTION = 0.70
def _median(values: list[float]) -> float | None:
if not values:
return None
s = sorted(values)
n = len(s)
mid = n // 2
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
ordered = sorted(values)
middle = len(ordered) // 2
return (
float(ordered[middle])
if len(ordered) % 2
else (ordered[middle - 1] + ordered[middle]) / 2.0
)
def _percentile(values: list[float], pct: float) -> float | None:
"""Linear-interpolated percentile of the non-None values."""
vals = sorted(v for v in values if v is not None)
if not vals:
ordered = sorted(v for v in values if v is not None)
if not ordered:
return None
k = (len(vals) - 1) * (pct / 100.0)
lo = int(k)
hi = min(lo + 1, len(vals) - 1)
return vals[lo] + (vals[hi] - vals[lo]) * (k - lo)
position = (len(ordered) - 1) * pct / 100.0
lower = int(position)
upper = min(lower + 1, len(ordered) - 1)
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
def detect_events(
closes: list[float],
dates: list[date],
threshold_pct: float = EVENT_THRESHOLD_PCT,
lookback: int = DRAWDOWN_LOOKBACK,
cooldown: int = COOLDOWN_DAYS,
cooldown: int = EVENT_COOLDOWN_DAYS,
) -> list[dict]:
"""Drawdown events: ``t0`` = a day the drawdown from the trailing 52w 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."""
"""Rising-edge corrections from the trailing 52-week high."""
events: list[dict] = []
prev_dd = 0.0
previous_drawdown = 0.0
last_event = -10**9
for i in range(len(closes)):
window = closes[max(0, i - lookback + 1): i + 1]
hi = max(window)
dd = (hi - closes[i]) / hi * 100.0 if hi > 0 else 0.0
if dd >= threshold_pct and prev_dd < threshold_pct and (i - last_event) >= cooldown:
events.append({"date": dates[i].isoformat(), "index": i, "depth_pct": round(dd, 1)})
last_event = i
prev_dd = dd
for index, close in enumerate(closes):
high = max(closes[max(0, index - lookback + 1): index + 1])
drawdown = (high - close) / high * 100.0 if high > 0 else 0.0
if (
drawdown >= threshold_pct
and previous_drawdown < threshold_pct
and index - last_event >= cooldown
):
events.append({
"date": dates[index].isoformat(),
"index": index,
"depth_pct": round(drawdown, 1),
})
last_event = index
previous_drawdown = drawdown
return events
# ---------------------------------------------------------------------------
# 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(
def alarm_episodes(
indicator: dict[date, float],
events_idx: list[int],
dates: list[date],
pre: int = PRE,
post: int = POST,
threshold: float = 60.0,
) -> dict:
"""Align the indicator at each event's ``t0`` and measure how early it warned.
threshold: float,
start_index: int = 1,
) -> list[int]:
"""Indices where the warning crosses upward; it must reset below first."""
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] = []
sums: dict[int, float] = {}
counts: dict[int, int] = {}
for t0 in events_idx:
lead = _lead(indicator, t0, dates, pre, threshold)
if lead is not None:
leads.append(lead)
for rel in range(-pre, post + 1):
idx = t0 + rel
if 0 <= idx < len(dates):
v = indicator.get(dates[idx])
if v is not None:
sums[rel] = sums.get(rel, 0.0) + v
counts[rel] = counts.get(rel, 0) + 1
mean_path = [
{"rel_day": rel, "value": round(sums[rel] / counts[rel], 1)} for rel in sorted(sums)
per_event: list[dict] = []
warned = 0
for event_index in event_indices:
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:
warned += 1
leads.append(float(lead))
per_event.append({
"date": dates[event_index].isoformat(),
"warned": lead is not None,
"lead_days": lead,
})
false_alarms = sum(
1
for alarm in alarm_indices
if not any(0 < event - alarm <= horizon for event in event_indices)
)
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),
"events_with_signal": len(leads),
"events_total": len(events_idx),
"warn_threshold": round(threshold, 1),
"mean_path": mean_path,
"per_event": per_event,
}
# ---------------------------------------------------------------------------
# Signal-centered: precision / recall vs. base rate
# ---------------------------------------------------------------------------
def signal_centered(
indicator: dict[date, float],
events_idx: list[int],
def _warning_series(
prices: dict[str, rms.Series],
breadth_divergence: dict[date, float],
dates: list[date],
horizon: int = HORIZON_DAYS,
thresholds: list[float] | None = None,
) -> dict:
"""Treat ``indicator >= threshold`` as predicting a break within ``horizon``
days. Sweep thresholds → precision/recall/alarm count, plus the base rate."""
thresholds = thresholds or [50, 55, 60, 65, 70, 75, 80]
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"], [])
config: dict,
) -> dict[date, float]:
"""Technical Warning score used historically (fundamentals have no PIT history)."""
tickers = config["tickers"]
smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {}
for d in dates:
smh = rms._closes_asof(smh_full, d)
qqq = rms._closes_asof(qqq_full, d)
spy = rms._closes_asof(spy_full, d)
subs = [
rms.p1_trend_break(smh, qqq, lw),
rms.p2_death_cross(smh, qqq, lw),
rms.p3_drawdown(smh, qqq),
rms.p4_relative_strength(smh, spy, lb),
]
vals = [v for v in subs if v is not None]
if vals:
out[d] = round(sum(vals) / len(vals), 2)
for session in dates:
divergence = breadth_divergence.get(session)
relative = rms.p4_relative_strength(
rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session),
)
values: list[tuple[float, float]] = []
if divergence is not None:
values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"]))
if relative is not None:
values.append((relative, rms.WARNING_WEIGHTS["relative_strength"]))
if values:
out[session] = round(
sum(value * weight for value, weight in values)
/ sum(weight for _, weight in values),
2,
)
return out
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
async def run_event_study(
db: AsyncSession,
threshold_pct: float = EVENT_THRESHOLD_PCT,
horizon: int = HORIZON_DAYS,
cooldown: int = COOLDOWN_DAYS,
warn_percentile: float = WARN_PERCENTILE,
) -> 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)
end = date.today()
start = end - timedelta(days=5 * 365 + 30)
prices = await rms._fetch_prices(config, start, end)
leader = config["tickers"]["leaders"][0] if config["tickers"]["leaders"] else "SMH"
bench = sorted(prices.get(leader, []), key=lambda x: x[0])
if len(bench) < 260:
leader = config["tickers"]["leaders"][0]
benchmark = sorted(prices.get(leader, []), key=lambda item: item[0])
if len(benchmark) < 500:
return {"available": False, "reason": "insufficient benchmark history"}
dates = [d for d, _ in bench]
closes = [c for _, c in bench]
events = detect_events(closes, dates, threshold_pct, cooldown=cooldown)
events_idx = [e["index"] for e in events]
dates = [d for d, _ in benchmark]
closes = [value for _, value in benchmark]
breadth, _ = await breadth_service.compute_breadth_details(
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)
divergence = breadth_service.compute_divergence_series(breadth, bench)
coincident = _coincident_series(prices, dates, config)
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
train_values = [warning[d] for d in dates[:split] if d in warning]
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
# indicator isn't penalised for living on a different scale than the baseline.
warn = {
"breadth_divergence": _percentile(list(divergence.values()), warn_percentile) or 60.0,
"coincident_price": _percentile(list(coincident.values()), warn_percentile) or 60.0,
}
series_by_key = {"breadth_divergence": divergence, "coincident_price": coincident}
all_events = detect_events(closes, dates, threshold_pct)
holdout_events = [event["index"] for event in all_events if event["index"] >= split]
alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
metrics = evaluate_alarms(alarms, holdout_events, dates, horizon)
holdout_sessions = max(1, len(dates) - split)
metrics["false_alarms_per_year"] = round(
metrics["false_alarms"] / (holdout_sessions / 252.0), 2
)
def _evaluate(series: dict[date, float], threshold: float) -> dict:
return {
**event_centered(series, events_idx, dates, threshold=threshold),
"signal": signal_centered(series, events_idx, dates, horizon),
}
indicators = {key: _evaluate(series_by_key[key], warn[key]) for key in series_by_key}
# Per-event comparison: which event, and each indicator's lead on THAT event —
# so a median over a tiny sample can't hide an apples-to-oranges comparison.
per_event = [
{
"date": e["date"],
"depth_pct": e["depth_pct"],
"breadth_lead": _lead(divergence, e["index"], dates, PRE, warn["breadth_divergence"]),
"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
]
basket_asof = date.fromisoformat(config["basket_asof"])
retrospective = dates[split] < basket_asof
evaluation = "exploratory" if retrospective else "holdout"
lead_text = (
f"median lead {metrics['median_lead_days']:.0f} sessions"
if metrics["median_lead_days"] is not None
else "no successful warning lead"
)
summary = (
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
f"false alarms/year, {lead_text}."
)
per_event = metrics.pop("per_event")
report = {
"available": True,
"methodology": rms.METHODOLOGY,
"generated_at": datetime.now(timezone.utc).isoformat(),
"evaluation": evaluation,
"summary": summary,
"params": {
"benchmark": leader,
"outcome": "10% correction from trailing 52-week high",
"event_threshold_pct": threshold_pct,
"cooldown_days": cooldown,
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
"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,
"indicators": indicators,
"per_event": per_event,
"lead_delta_days": lead_delta,
"recent_breadth": recent_breadth,
"sample": {
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
"train_end": dates[split - 1].isoformat(),
"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({
"event": "event_study_complete", "events": len(events),
"breadth_lead": bd, "coincident_lead": cd,
"event": "regime_event_study_complete",
"evaluation": evaluation,
"events": metrics["events"],
"warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"],
}))
return report
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)
await update_setting(db, KEY_REPORT, json.dumps(report))
return report
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)
if setting is None:
return None
try:
return json.loads(setting.value)
report = json.loads(setting.value)
except (TypeError, ValueError):
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);
}
export function getRegimeHistory(days = 400) {
export function getRegimeHistory(days = 800) {
return apiClient
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
.then((r) => r.data);
@@ -13,15 +13,13 @@ import {
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated.
const X_DIV = 40; // regime index
const Y_DIV = 60; // early warning
// Quadrant boundaries come from the backend v2 methodology response.
const TRAIL = 60; // sessions shown
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="text-gray-300">{p.date}</div>
<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>
</div>
</div>
@@ -72,14 +70,17 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
}
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 data = history.data ?? [];
return data
.filter((p) => p.early_warning != null)
.filter((p) => p.state != null && p.warning != null)
.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]);
const trail = useMemo(() => smoothTrail(points), [points]);
@@ -89,11 +90,11 @@ export default function RegimeQuadrant() {
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Regime quadrant last {TRAIL} sessions
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<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>
</div>
)}
@@ -103,7 +104,7 @@ export default function RegimeQuadrant() {
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<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>
) : (
<>
@@ -111,13 +112,13 @@ export default function RegimeQuadrant() {
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={X_DIV} y1={Y_DIV} 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={0} x2={X_DIV} y1={0} y2={Y_DIV} 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={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={X_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
@@ -126,7 +127,7 @@ export default function RegimeQuadrant() {
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
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
type="number"
@@ -137,7 +138,7 @@ export default function RegimeQuadrant() {
width={30}
tickLine={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]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
@@ -166,15 +167,15 @@ export default function RegimeQuadrant() {
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400"> Hot &amp; brittle</span> narrow melt-up, shakeout risk</span>
<span><span className="text-orange-400"> Transition</span> break may be starting</span>
<span><span className="text-emerald-400"> Healthy &amp; broad</span> calm uptrend</span>
<span><span className="text-red-400"> Real downturn</span> regime breaking, broad</span>
<span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The tell isn&apos;t a single spot but the move (early warning rolling over while
the regime index climbs = divergence resolving downward). Observational not wired into trades.
sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
or sizing signal.
</p>
</>
)}
@@ -26,14 +26,13 @@ const HISTORY_RANGES = [
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'index', label: 'Index', color: '#60a5fa' },
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' },
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
{ key: 'state', label: 'State', color: '#60a5fa' },
{ key: 'warning', label: 'Warning', color: '#fb923c' },
] as const;
export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const filtered = useMemo(() => {
const data = history.data ?? [];
@@ -113,7 +112,6 @@ export default function ScoreHistoryChart() {
stroke={s.color}
dot={false}
strokeWidth={1.5}
connectNulls
isAnimationActive={false}
/>
))}
+81 -53
View File
@@ -439,106 +439,134 @@ export type RegimeBand = 'stable' | 'watch' | 'elevated' | 'breaking';
export interface RegimeSignal {
id: string;
label: string;
sub_score: number | null;
weight: number;
score: number | null;
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;
band: RegimeBand | null;
delta_7?: number | null;
delta_30?: number | null;
coverage: number;
minimum_coverage: number;
available_pillars: string[];
pillars: RegimePillar[];
trend?: { delta_7: number | null; delta_30: number | null };
}
export interface RegimeHistoryPoint {
date: string;
index: number;
early_warning: number | null;
combined: number | null;
state: number | null;
warning: number | null;
state_coverage: number | null;
warning_coverage: number | null;
basket_hash: string | null;
}
export interface RegimeMonitor {
available: boolean;
reason?: string;
methodology?: string;
date?: string;
total_score?: number;
band?: RegimeBand;
alert_threshold?: number;
breakdown?: RegimeSignal[];
state?: RegimeReading;
warning?: RegimeReading;
inputs?: {
vix: number | null;
vix_date: string | 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_effective_date: string | null;
fundamentals_age_days: number | null;
};
trend?: { delta_7: number | null; delta_30: number | null };
// Separate, observational early-warning score (breadth divergence) + a small
// combined blend. Decoupled from the index above.
early_warning?: RegimeSubScore;
combined?: RegimeSubScore;
basket?: {
symbols: string[];
hash: string;
basket_asof: string;
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 {
f1_score: number;
f3_score: number;
f1_score: number | null;
f3_score: number | null;
locked: boolean;
reasoning: string | null;
fetched_at: string | null;
effective_date: string | null;
source: string;
capex?: Record<string, string>;
good_news_stock_down?: string | null;
}
export interface RegimeConfig {
weights: Record<string, number>;
alert_threshold: number;
tickers: Record<string, unknown>;
leader_weight: number;
rs_lookback: number;
breadth_basket: string[];
basket_asof: string;
fundamental_staleness_days: number;
}
// 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 {
available: boolean;
reason?: string;
methodology?: string;
generated_at?: string;
evaluation?: 'exploratory' | 'holdout';
summary?: string;
params?: {
benchmark: string;
outcome: string;
event_threshold_pct: number;
cooldown_days: number;
event_cooldown_days: number;
horizon_days: number;
train_fraction: number;
warn_percentile: number;
warn_threshold: number;
basket_hash: string;
basket_asof: string;
};
events?: { date: string; index: number; depth_pct: number }[];
indicators?: {
breadth_divergence: EventStudyLeadStats;
coincident_price: EventStudyLeadStats;
sample?: {
start: string;
end: string;
train_end: string;
test_start: string;
sessions: number;
holdout_sessions: number;
};
per_event?: EventStudyPerEvent[];
lead_delta_days?: number | null;
recent_breadth?: { date: string; breadth: number; divergence: number | null }[];
metrics?: {
events: number;
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 {
+200 -423
View File
@@ -1,5 +1,5 @@
import { useState, lazy, Suspense, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PageHeader } from '../components/ui/PageHeader';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
@@ -7,44 +7,38 @@ import { Badge } from '../components/ui/Badge';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { useAuthStore } from '../stores/authStore';
import {
getRegimeMonitor,
getRegimeConfig,
updateRegimeConfig,
getRegimeFundamentals,
updateRegimeFundamentals,
refreshRegimeFundamentals,
getEventStudy,
getRegimeConfig,
getRegimeFundamentals,
getRegimeMonitor,
refreshRegimeFundamentals,
updateRegimeConfig,
updateRegimeFundamentals,
} 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 {
EventStudyReport,
RegimeBand,
RegimeSignal,
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
EventStudyLeadStats,
EventStudyPerEvent,
RegimeReading,
} 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 }> = {
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' },
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 }) {
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>;
}
const rising = delta > 0;
const flat = 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 ? '↑' : '↓';
const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
return (
<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>
@@ -54,61 +48,51 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
function ScoreGauge({
label,
score,
band,
trend,
threshold,
reading,
divider,
footnote,
size = 'lg',
}: {
label: string;
score: number | null | undefined;
band: RegimeBand | null | undefined;
trend?: { delta_7?: number | null; delta_30?: number | null };
threshold?: number;
footnote?: ReactNode;
size?: 'lg' | 'md';
reading: RegimeReading | undefined;
divider?: number;
footnote: ReactNode;
}) {
const naa = score == null;
const style = BAND_STYLES[(band ?? 'stable') as RegimeBand];
const s = score ?? 0;
const clamp = (v: number) => Math.min(100, Math.max(0, v));
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
const score = reading?.score;
const complete = reading?.band != null;
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0));
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>
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}>
{naa ? '—' : Math.round(s)}
<span className={`font-display text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
{score == null ? '—' : Math.round(score)}
</span>
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
{score != null && <span className="text-sm text-gray-500">/ 100</span>}
</div>
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
<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>
{trend && (
<div className="flex flex-wrap gap-2">
<TrendChip label="7d" delta={trend.delta_7} />
<TrendChip label="30d" delta={trend.delta_30} />
</div>
)}
<div className="flex gap-2">
<TrendChip label="7d" delta={reading?.trend?.delta_7} />
<TrendChip label="30d" delta={reading?.trend?.delta_30} />
</div>
{!naa && (
</div>
{score != null && (
<>
{/* Band track with score (+ optional threshold) markers */}
<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">
{threshold != null && (
<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 className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{divider != null && (
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
)}
<div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
style={{ left: `${clamp(s)}%` }}
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
style={{ left: `${position}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
@@ -116,68 +100,110 @@ function ScoreGauge({
</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>
);
}
function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) {
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
return (
<div className="glass overflow-hidden">
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-sm">
<thead>
<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">Signal</th>
<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">Score</th>
<th className="px-4 py-3 text-right font-medium">Weight</th>
<th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr>
</thead>
<tbody>
{breakdown.map((s) => (
<tr key={s.id} className="border-b border-white/[0.03] last:border-0">
{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">
<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 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>
<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>
<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 SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
function EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics;
return (
<label className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{label}</span>
<input
type="range"
min={0}
max={100}
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>
<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>
))}
</div>
)}
{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>
);
}
function EventStudyPanel() {
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
return (
<Disclosure summary="Warning study · chronological correction alarms">
{study.isLoading && <SkeletonCard className="h-24" />}
{study.data === null && <Callout variant="empty">Not run yet trigger Event Study in Admin Jobs.</Callout>}
{study.data && !study.data.available && <Callout variant="warning">{study.data.reason ?? 'No data'}</Callout>}
{study.data?.available && <EventStudyBody report={study.data} />}
</Disclosure>
);
}
@@ -194,379 +220,130 @@ function FundamentalsEditor({
saving: boolean;
refreshing: boolean;
}) {
const [f1, setF1] = useState(Math.round(data.f1_score));
const [f3, setF3] = useState(Math.round(data.f3_score));
const [f1, setF1] = useState(data.f1_score ?? 0);
const [f3, setF3] = useState(data.f3_score ?? 0);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<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" />}
</div>
{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} />
<div className="flex flex-wrap gap-2 pt-1">
<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>
<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={refreshing}
onClick={onRefresh}
>
{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>
);
}
function WeightsEditor({
data,
onSave,
saving,
}: {
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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Object.keys(weights).map((key) => (
<label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-400">
<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"
/>
{[
['F1 · Capex cuts', f1, setF1],
['F3 · Good news, stock down', f3, setF3],
].map(([label, value, setter]) => (
<label key={String(label)} className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{String(label)}</span>
<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>
</label>
))}
<div className="flex flex-wrap gap-2">
<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>
<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>
{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>}
</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"
/>
</div>
);
}
function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (updates: Partial<RegimeConfig>) => void; saving: boolean }) {
const [basket, setBasket] = useState(data.breadth_basket.join(', '));
const [staleness, setStaleness] = useState(data.fundamental_staleness_days);
const symbols = basket.split(/[\s,]+/).map((symbol) => symbol.trim().toUpperCase()).filter(Boolean);
return (
<div className="space-y-3">
<label className="block text-xs text-gray-400">
<span>Fixed breadth basket · {symbols.length} symbols</span>
<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" />
</label>
<button
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ weights, alert_threshold: threshold })}
>
Save weights
</button>
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Fundamental staleness</span>
<input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
<span>days</span>
</label>
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save monitor settings</button>
</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() {
const qc = useQueryClient();
const queryClient = useQueryClient();
const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['regime'] });
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 });
return (
<div className="space-y-3">
<Disclosure summary="Admin · Fundamentals (F1 / F3)">
{fundamentals.isLoading && <SkeletonCard className="h-24" />}
{fundamentals.data && (
<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 summary="Admin · Fundamental observations">
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
</Disclosure>
<Disclosure summary="Admin · Weights & threshold">
{config.isLoading && <SkeletonCard className="h-24" />}
{config.data && (
<WeightsEditor
key={config.dataUpdatedAt}
data={config.data}
onSave={(updates) => saveConfig.mutate(updates)}
saving={saveConfig.isPending}
/>
)}
<Disclosure summary="Admin · Fixed basket & freshness">
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
</Disclosure>
</div>
);
}
export default function RegimePage() {
const role = useAuthStore((s) => s.role);
const isAdmin = role === 'admin';
const isAdmin = useAuthStore((state) => state.role) === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data;
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Regime Monitor"
subtitle="AI/Tech regime-change index — observational, feeds no trades"
/>
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
<Callout variant="info"><strong>Risk thermometer not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
{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>}
{data && !data.available && <Callout variant="empty">V2 is not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
{data?.available && data.state && data.warning && (
<>
<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}
{(!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>
)}
{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 && (
<>
<div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge
label="Regime index · coincident"
score={monitor.data.total_score}
band={monitor.data.band}
trend={monitor.data.trend}
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>
)}
</>
}
label="State · current structural stress"
reading={data.state}
divider={data.quadrant_config?.state_divider}
footnote={<>One capped price vote plus fixed-basket breadth, HY credit, and volatility. As of {data.date}. VIX {data.inputs?.vix ?? '—'} · HY OAS {data.inputs?.hy_oas ?? '—'}.</>}
/>
<ScoreGauge
label="Early warning · breadth divergence"
score={monitor.data.early_warning?.score}
band={monitor.data.early_warning?.band}
trend={monitor.data.early_warning}
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.
</>
}
label="Warning · deterioration & divergence"
reading={data.warning}
divider={data.quadrant_config?.warning_divider}
footnote={<>Breadth divergence, SMH/SPY rollover, and point-in-time fundamental observations. Unknown or stale fundamentals reduce coverage; they never default to 50.</>}
/>
</div>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<RegimeQuadrant />
</Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}>
<ScoreHistoryChart />
</Suspense>
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
<div className="grid gap-3 lg:grid-cols-2">
<PillarBreakdown title="State" reading={data.state} />
<PillarBreakdown title="Warning" reading={data.warning} />
</div>
{data.basket && (
<p className="text-xs leading-relaxed text-gray-600">
Fixed basket {data.basket.members_available ?? '—'}/{data.basket.members_expected} available · hash {data.basket.hash} · frozen {data.basket.basket_asof}. History reconstructed before the freeze date is retrospective/exploratory; readings after it form the trustworthy forward series.
</p>
)}
</>
)}
<EventStudyPanel />
{isAdmin && <AdminControls />}
</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
@@ -6,124 +6,67 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
_lead,
_percentile,
alarm_episodes,
detect_events,
event_centered,
signal_centered,
evaluate_alarms,
)
def _days(n: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=i) for i in range(n)]
def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=index) for index in range(count)]
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
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_uses_rising_edge_and_cooldown():
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_dedup_without_recovery():
closes = [100.0] * 300 + [85.0] * 5 + [80.0] * 5 # deepens but never recovers
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
assert len(events) == 1
def test_percentile_is_fixed_from_supplied_values():
values = [float(value) for value in range(0, 101, 10)]
assert _percentile(values, 50) == 50.0
assert _percentile(values, 80) == 80.0
assert _percentile([], 80) is None
def test_detect_events_two_after_recovery():
closes = [100.0] * 300 + [85.0] * 10 + [100.0] * 300 + [85.0] * 10
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
assert len(events) == 2
def test_alarm_requires_upward_crossing_and_reset():
dates = _days(10)
values = [10, 70, 80, 75, 20, 70, 80, 20, 20, 70]
indicator = dict(zip(dates, values))
assert alarm_episodes(indicator, dates, threshold=60) == [1, 5, 9]
def test_detect_events_cooldown_suppresses_close_recross():
# Dips below threshold then re-crosses only a few bars later.
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3
dates = _days(len(closes))
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_holdout_start_does_not_invent_crossing_when_already_high():
dates = _days(6)
indicator = dict(zip(dates, [10, 70, 80, 80, 20, 70]))
assert alarm_episodes(indicator, dates, threshold=60, start_index=3) == [5]
def test_percentile_interpolation():
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100
assert _percentile(vals, 50) == 50.0
assert _percentile(vals, 80) == 80.0
assert _percentile([], 50) is None
def test_evaluate_alarms_counts_episodes_not_alarm_days():
dates = _days(100)
result = evaluate_alarms([10, 50, 80], [25, 70], dates, horizon=20)
assert result["events_warned"] == 2
assert result["events_missed"] == 0
assert result["false_alarms"] == 1
assert result["median_lead_days"] == 17.5
def test_lead_earliest_crossing():
dates = _days(200)
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)
def test_breadth_from_fixed_closes_and_pure_divergence():
dates = _days(10)
closes_by_symbol = {
"A": list(zip(dates, [1.0, 2.0, 3.0, 4.0, 5.0])), # rising -> above its SMA
"B": list(zip(dates, [5.0, 4.0, 3.0, 2.0, 1.0])), # falling -> below
"C": list(zip(dates, [3.0, 3.0, 3.0, 3.0, 3.0])), # flat -> not strictly above
"A": list(zip(dates, [1.0 + index for index in range(10)])),
"B": list(zip(dates, [10.0 - index for index in range(10)])),
"C": list(zip(dates, [5.0] * 10)),
}
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)
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():
dates = _days(10)
breadth = {dates[i]: 80.0 - i * 3 for i in range(len(dates))} # falling breadth
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
falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
assert no_divergence[dates[-1]] == 0
+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
import copy
import json
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 (
DEFAULT_CONFIG,
_attach_early_warning,
HY_OAS_ELEVATED,
HY_OAS_MILD,
HY_OAS_STRESSED,
_compute_index,
_fundamental_scores_asof,
_score_pillars,
band_for,
compute_regime_score,
breadth_level_score,
f2_credit_spreads,
p1_trend_break,
p2_death_cross,
p3_drawdown,
p4_relative_strength,
p5_volatility,
p6_canary,
_compute_index,
)
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
n = len(values)
return [(end - timedelta(days=(n - 1 - i)), v) for i, v in enumerate(values)]
return [
(end - timedelta(days=len(values) - 1 - index), value)
for index, value in enumerate(values)
]
# ---------------------------------------------------------------------------
# Bands
# ---------------------------------------------------------------------------
def test_band_for():
def test_band_for_keeps_documented_boundaries():
assert band_for(10) == "stable"
assert band_for(45) == "watch"
assert band_for(70) == "elevated"
assert band_for(90) == "breaking"
assert band_for(30) == "watch"
assert band_for(60) == "elevated"
assert band_for(80) == "breaking"
def test_attach_early_warning_blends():
result = {"total_score": 80.0}
_attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] == 40.0
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"
def test_price_sensors_are_stress_only():
smh_under = [100.0] * 199 + [50.0]
qqq_above = [100.0] * 200
assert round(p1_trend_break(smh_under, qqq_above) or 0, 1) == 66.7
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():
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
closes = [100.0] * 252 + [80.0]
assert p3_drawdown(closes, [100.0] * 253) == 100.0
def test_p4_relative_strength_direction():
falling = [100.0 - i * 0.5 for i in range(70)] # SMH underperforms flat SPY
rising = [100.0 + i * 0.5 for i in range(70)]
spy = [100.0] * 70
assert p4_relative_strength(falling, spy, 60) > 50
assert p4_relative_strength(rising, spy, 60) < 50
def test_relative_strength_flat_or_better_is_zero():
flat = [100.0] * 70
rising = [100.0 + index for index in range(70)]
falling = [100.0 - index * 0.5 for index in range(70)]
assert p4_relative_strength(flat, flat) == 0.0
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(30) == 100
assert p5_volatility(22.5) == 50
assert p5_volatility(None) is None
assert breadth_level_score(60) == 0
assert breadth_level_score(20) == 100
assert breadth_level_score(None) is None
def test_f2_credit_percentile():
rising = [float(i) for i in range(1, 31)] # latest is the max -> ~100th pct
assert f2_credit_spreads(rising) == 100.0
falling = [float(i) for i in range(30, 0, -1)] # latest is the min
assert f2_credit_spreads(falling) < 10
assert f2_credit_spreads([1.0] * 5) is None # too short
def test_credit_uses_named_anchors_and_constant_series_is_not_extreme():
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0
rising = [3.0 + index * 0.01 for index in range(100)]
assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100)
def test_p6_canary_divergence():
nvda_weak = [100.0] * 49 + [80.0] # below its 50-DMA
smh_intact = [100.0] * 199 + [120.0] # above its 200-DMA
assert p6_canary(nvda_weak, smh_intact) == 100.0
assert p6_canary([100.0] * 50, smh_intact) == 0.0
def test_score_pillars_gates_band_below_75_percent_coverage():
pillars = [
{"id": "price", "label": "Price", "score": 80.0, "sensors": []},
{"id": "breadth", "label": "Breadth", "score": 20.0, "sensors": []},
{"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
# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
def test_compute_regime_score_excludes_na_and_zero_weight():
weights = {"P1": 10, "P2": 0, "F2": 5}
subs = {"P1": 80.0, "P2": 50.0, "F2": None}
result = compute_regime_score(subs, weights)
# Only P1 counts: P2 weight 0, F2 unavailable.
assert result["total_score"] == 80.0
ids = {row["id"]: row for row in result["breakdown"]}
assert "P2" not in ids # zero-weight signals are hidden
assert ids["F2"]["available"] is False
assert ids["P1"]["contribution"] == 80.0
def test_fundamentals_never_replay_before_effective_date_and_expire():
overrides = {
"f1_score": 0.0,
"f3_score": 100.0,
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 1))[:2] == (None, None)
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 2))[:2] == (0.0, 100.0)
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
def test_compute_regime_score_contributions_sum_to_total():
weights = {"P1": 10, "F2": 10}
subs = {"P1": 80.0, "F2": 40.0}
result = compute_regime_score(subs, weights)
assert result["total_score"] == 60.0
total = sum(row["contribution"] for row in result["breakdown"])
assert round(total, 1) == 60.0
@pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = {
"f1_score": 100.0,
"f3_score": 0.0,
"locked": True,
"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
# ---------------------------------------------------------------------------
# As-of index replay (backfill mechanics)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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():
rising = [100.0 + i * 0.2 for i in range(260)]
prices = {sym: _dated(rising) for sym in ("SMH", "QQQ", "SPY", "RSP", "NVDA")}
overrides = {"f1_score": 50.0, "f3_score": 50.0}
written, _ = await rms._upsert_snapshot(
db_session, first, rewrite_existing_v2=True
)
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))
by_id = {r["id"]: r for r in full["breakdown"]}
assert by_id["P1"]["available"] is True # 200-DMA computable on full history
assert 0 <= full["total_score"] <= 100
assert full["band"] in {"stable", "watch", "elevated", "breaking"}
assert written is True
assert rewritten is False
assert persisted["state"]["score"] == 10.0
assert row.total_score == 10.0
# 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))
early_by_id = {r["id"]: r for r in early["breakdown"]}
assert early_by_id["P1"]["available"] is False
def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
end = date(2026, 6, 26)
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, _parse_quadrant_log_key, _quadrant_log_key
from app.services.alert_service import (
_classify_quadrant,
_parse_quadrant_log_key,
_quadrant_log_key,
)
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low).
# Dividers: regime 40, early-warning 60; margin 5.
def test_fresh_classification_uses_60_60_boundaries():
assert _classify_quadrant(20, 90, None) == "1"
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():
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning
assert _classify_quadrant(70, 90, None) == "2" # both high
assert _classify_quadrant(20, 30, None) == "3" # both low
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning
def test_warning_axis_hysteresis():
assert _classify_quadrant(20, 62, prev="3") == "3"
assert _classify_quadrant(20, 66, prev="3") == "1"
assert _classify_quadrant(20, 58, prev="1") == "1"
assert _classify_quadrant(20, 54, prev="1") == "3"
def test_hysteresis_holds_inside_deadband():
# From ③ (both low): early-warning nudging just past 60 stays ③ until it
# clears 60 + margin (65).
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ①
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_state_axis_hysteresis():
assert _classify_quadrant(63, 30, prev="3") == "3"
assert _classify_quadrant(66, 30, prev="3") == "4"
assert _classify_quadrant(57, 30, prev="4") == "4"
assert _classify_quadrant(54, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip():
# A point parked exactly on both dividers keeps whatever quadrant it had.
for q in ("1", "2", "3", "4"):
assert _classify_quadrant(40, 60, prev=q) == q
for quadrant in ("1", "2", "3", "4"):
assert _classify_quadrant(60, 60, prev=quadrant) == quadrant
def test_quadrant_log_key_keeps_previous_values():
key = _quadrant_log_key("3", 32.4, 54.6)
assert _parse_quadrant_log_key(key) == ("3", 32.4, 54.6)
# Existing pre-value keys still parse so old installs do not need migration.
assert _parse_quadrant_log_key("3") == ("3", None, None)
def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
key = _quadrant_log_key("3", 32.4, 54.6, "abc123")
assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3") == (None, "3", None, None)