feat: replace regime monitor with v2 methodology
This commit is contained in:
@@ -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 12‑1 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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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)
|
||||
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:
|
||||
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)
|
||||
]
|
||||
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
@@ -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.**
|
||||
@@ -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 & 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 & 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'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
@@ -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 {
|
||||
|
||||
+213
-436
@@ -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>
|
||||
<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>
|
||||
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{!naa && (
|
||||
{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">
|
||||
<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 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">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[10px] text-gray-600">{s.id}</span>{' '}
|
||||
<span className="text-gray-300">{s.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.available && s.sub_score != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-blue-400/70" style={{ width: `${s.sub_score}%` }} />
|
||||
</div>
|
||||
<span className="num text-gray-300">{s.sub_score}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">n/a</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-400">{s.weight}</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-300">
|
||||
{s.available ? s.contribution.toFixed(1) : '—'}
|
||||
</td>
|
||||
<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">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>
|
||||
{reading.pillars.map((pillar) => (
|
||||
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-200">{pillar.label}</div>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{pillar.sensors.map((sensor) => (
|
||||
<div key={sensor.id} className="text-xs text-gray-500">
|
||||
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
|
||||
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
|
||||
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
||||
const metrics = report.metrics;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
|
||||
{report.generated_at && <span className="text-xs text-gray-500">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
|
||||
{report.sample && <span className="text-xs text-gray-500">test {report.sample.test_start} → {report.sample.end}</span>}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
['Warned', `${metrics.events_warned}/${metrics.events}`],
|
||||
['Missed', metrics.events_missed],
|
||||
['False alarms/year', metrics.false_alarms_per_year.toFixed(1)],
|
||||
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">{label}</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
|
||||
function EventStudyPanel() {
|
||||
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
{[
|
||||
['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>
|
||||
</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 }));
|
||||
};
|
||||
|
||||
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">
|
||||
<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"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Alert threshold</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
|
||||
/>
|
||||
<label 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 (% > 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'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 && (
|
||||
<>
|
||||
<SkeletonCard className="h-44" />
|
||||
<SkeletonTable rows={6} cols={4} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{monitor.isError && (
|
||||
<Callout variant="error" onRetry={() => monitor.refetch()}>
|
||||
Failed to load: {(monitor.error as Error).message}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{monitor.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 && (
|
||||
{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 && (
|
||||
<>
|
||||
{(!data.data_quality?.is_fresh || data.state.band == null || data.warning.band == null) && (
|
||||
<Callout variant="warning">
|
||||
Reading is incomplete or stale. State coverage {Math.round(data.state.coverage)}%, Warning coverage {Math.round(data.warning.coverage)}%
|
||||
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
|
||||
</Callout>
|
||||
)}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<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'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'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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user