feat: separate live early-warning + combined score on the regime tab
The event study showed the breadth-divergence signal genuinely leads (warned before 7/11 drawdowns, ~6 weeks median, where the coincident baseline almost never did). Surface it live to observe before deciding how to embed it — kept separate from the index, not folded into its weights. - regime_monitor daily job now computes breadth-divergence live and attaches a separate early_warning score plus a combined blend (weighted mean, default 0.6/0.4, configurable via combined_weights) to each snapshot, including the backfill so the 7/30-day trends populate immediately. Stored in breakdown_json — no schema change. Best-effort: a breadth failure can't break the index. - get_regime_monitor returns the index, early_warning, and combined scores each with 7/30-day deltas. - Regime tab shows three gauges (generalized ScoreGauge): coincident index, early warning, and a compact combined blend. Stale snapshots render "—". Note: the daily regime job now also does a universe-wide breadth scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ from app.config import settings
|
|||||||
from app.exceptions import ProviderError
|
from app.exceptions import ProviderError
|
||||||
from app.models.regime_snapshot import RegimeSnapshot
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||||
from app.services import settings_store
|
from app.services import breadth_service, settings_store
|
||||||
from app.services.admin_service import update_setting
|
from app.services.admin_service import update_setting
|
||||||
from app.services.sentiment_provider_service import _resolve as resolve_llm_config
|
from app.services.sentiment_provider_service import _resolve as resolve_llm_config
|
||||||
|
|
||||||
@@ -65,6 +65,11 @@ DEFAULT_CONFIG: dict = {
|
|||||||
"F1": 25, "F2": 15, "F3": 8, "F4": 7,
|
"F1": 25, "F2": 15, "F3": 8, "F4": 7,
|
||||||
},
|
},
|
||||||
"alert_threshold": 65,
|
"alert_threshold": 65,
|
||||||
|
# Observational early-warning blend: a small Combined score = weighted mean of
|
||||||
|
# the coincident index and the breadth-divergence early-warning score. Kept
|
||||||
|
# separate from the index weights above so the early-warning side stays
|
||||||
|
# decoupled until proven. Tunable; need not sum to 1 (normalised).
|
||||||
|
"combined_weights": {"coincident": 0.6, "early_warning": 0.4},
|
||||||
"leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal
|
"leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal
|
||||||
"rs_lookback": 60, # trading days for relative-strength / breadth trend
|
"rs_lookback": 60, # trading days for relative-strength / breadth trend
|
||||||
"fundamental_staleness_days": 80,
|
"fundamental_staleness_days": 80,
|
||||||
@@ -530,6 +535,18 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
|||||||
leader_series = prices.get(leader or "", [])
|
leader_series = prices.get(leader or "", [])
|
||||||
latest_date = leader_series[-1][0] if leader_series else end
|
latest_date = leader_series[-1][0] if leader_series else end
|
||||||
|
|
||||||
|
# Early-warning signal: breadth-divergence over the stored universe (leads but
|
||||||
|
# noisy). Computed once here so the daily job carries it live, as a SEPARATE
|
||||||
|
# score next to the coincident index — not folded into the index weights.
|
||||||
|
# Best-effort: a breadth failure must not stop the index update.
|
||||||
|
try:
|
||||||
|
breadth = await breadth_service.compute_breadth_series(db)
|
||||||
|
divergence = breadth_service.compute_divergence_series(breadth, sorted(leader_series))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Regime monitor: breadth/divergence skipped: %s", exc)
|
||||||
|
divergence = {}
|
||||||
|
cw = config.get("combined_weights") or {"coincident": 0.6, "early_warning": 0.4}
|
||||||
|
|
||||||
dates = {latest_date}
|
dates = {latest_date}
|
||||||
if await _snapshot_count(db) < 5 and leader_series:
|
if await _snapshot_count(db) < 5 and leader_series:
|
||||||
cutoff = end - timedelta(days=backfill_days)
|
cutoff = end - timedelta(days=backfill_days)
|
||||||
@@ -538,6 +555,7 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
|||||||
latest_result: dict | None = None
|
latest_result: dict | None = None
|
||||||
for d in sorted(dates):
|
for d in sorted(dates):
|
||||||
result = _compute_index(prices, vix_series, oas_series, overrides, config, d)
|
result = _compute_index(prices, vix_series, oas_series, overrides, config, d)
|
||||||
|
_attach_early_warning(result, divergence.get(d), cw)
|
||||||
await _upsert_snapshot(db, result)
|
await _upsert_snapshot(db, result)
|
||||||
latest_result = result
|
latest_result = result
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -551,19 +569,51 @@ async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> di
|
|||||||
return latest_result or {"available": False, "reason": "no data"}
|
return latest_result or {"available": False, "reason": "no data"}
|
||||||
|
|
||||||
|
|
||||||
async def _score_at_or_before(db: AsyncSession, target: date) -> float | None:
|
def _attach_early_warning(result: dict, ew: float | None, weights: dict) -> None:
|
||||||
|
"""Attach the separate early-warning score and a combined blend to a snapshot.
|
||||||
|
|
||||||
|
``ew`` is the breadth-divergence value as-of this date (or None). The combined
|
||||||
|
score is a normalised weighted mean of the coincident index and the early
|
||||||
|
warning — observational, kept apart from the index itself.
|
||||||
|
"""
|
||||||
|
result["early_warning"] = {
|
||||||
|
"score": round(ew, 1) if ew is not None else None,
|
||||||
|
"band": band_for(ew) if ew is not None else None,
|
||||||
|
}
|
||||||
|
if ew is None:
|
||||||
|
combined = result["total_score"]
|
||||||
|
else:
|
||||||
|
wc = float(weights.get("coincident", 0.6))
|
||||||
|
we = float(weights.get("early_warning", 0.4))
|
||||||
|
wsum = (wc + we) or 1.0
|
||||||
|
combined = (result["total_score"] * wc + ew * we) / wsum
|
||||||
|
result["combined"] = {"score": round(combined, 1), "band": band_for(combined)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _result_at_or_before(db: AsyncSession, target: date) -> dict | None:
|
||||||
|
"""Parsed snapshot result for the latest date on/before ``target``."""
|
||||||
res = await db.execute(
|
res = await db.execute(
|
||||||
select(RegimeSnapshot.total_score)
|
select(RegimeSnapshot.breakdown_json)
|
||||||
.where(RegimeSnapshot.date <= target)
|
.where(RegimeSnapshot.date <= target)
|
||||||
.order_by(RegimeSnapshot.date.desc())
|
.order_by(RegimeSnapshot.date.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
val = res.scalar_one_or_none()
|
raw = res.scalar_one_or_none()
|
||||||
return float(val) if val is not None else None
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _delta(curr: float | None, prev: float | None) -> float | None:
|
||||||
|
return round(curr - prev, 1) if (curr is not None and prev is not None) else None
|
||||||
|
|
||||||
|
|
||||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||||
"""Latest snapshot result + 7/30-day trend deltas. Cheap (one+ row reads)."""
|
"""Latest snapshot + 7/30-day trend deltas for the index, early-warning, and
|
||||||
|
combined scores. Cheap (a few row reads)."""
|
||||||
res = await db.execute(
|
res = await db.execute(
|
||||||
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1)
|
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1)
|
||||||
)
|
)
|
||||||
@@ -577,13 +627,23 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
|||||||
result = {"date": latest.date.isoformat(), "total_score": latest.total_score,
|
result = {"date": latest.date.isoformat(), "total_score": latest.total_score,
|
||||||
"band": latest.band, "breakdown": []}
|
"band": latest.band, "breakdown": []}
|
||||||
|
|
||||||
score_7 = await _score_at_or_before(db, latest.date - timedelta(days=7))
|
r7 = await _result_at_or_before(db, latest.date - timedelta(days=7))
|
||||||
score_30 = await _score_at_or_before(db, latest.date - timedelta(days=30))
|
r30 = await _result_at_or_before(db, latest.date - timedelta(days=30))
|
||||||
|
|
||||||
|
def _nested(r: dict | None, key: str) -> float | None:
|
||||||
|
return (r.get(key) or {}).get("score") if r else None
|
||||||
|
|
||||||
result["available"] = True
|
result["available"] = True
|
||||||
|
cur_total = result.get("total_score")
|
||||||
result["trend"] = {
|
result["trend"] = {
|
||||||
"delta_7": round(latest.total_score - score_7, 1) if score_7 is not None else None,
|
"delta_7": _delta(cur_total, (r7 or {}).get("total_score")),
|
||||||
"delta_30": round(latest.total_score - score_30, 1) if score_30 is not None else None,
|
"delta_30": _delta(cur_total, (r30 or {}).get("total_score")),
|
||||||
}
|
}
|
||||||
|
for key in ("early_warning", "combined"):
|
||||||
|
block = result.get(key) or {"score": None, "band": None}
|
||||||
|
block["delta_7"] = _delta(block.get("score"), _nested(r7, key))
|
||||||
|
block["delta_30"] = _delta(block.get("score"), _nested(r30, key))
|
||||||
|
result[key] = block
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -275,6 +275,13 @@ export interface RegimeSignal {
|
|||||||
contribution: number;
|
contribution: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegimeSubScore {
|
||||||
|
score: number | null;
|
||||||
|
band: RegimeBand | null;
|
||||||
|
delta_7?: number | null;
|
||||||
|
delta_30?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegimeMonitor {
|
export interface RegimeMonitor {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -289,6 +296,10 @@ export interface RegimeMonitor {
|
|||||||
fundamentals_fetched_at: string | null;
|
fundamentals_fetched_at: string | null;
|
||||||
};
|
};
|
||||||
trend?: { delta_7: number | null; delta_30: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegimeFundamentals {
|
export interface RegimeFundamentals {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from '../api/regime';
|
} from '../api/regime';
|
||||||
import type {
|
import type {
|
||||||
RegimeBand,
|
RegimeBand,
|
||||||
RegimeMonitor,
|
|
||||||
RegimeSignal,
|
RegimeSignal,
|
||||||
RegimeConfig,
|
RegimeConfig,
|
||||||
RegimeFundamentals,
|
RegimeFundamentals,
|
||||||
@@ -49,54 +48,71 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Gauge({ data }: { data: RegimeMonitor }) {
|
function ScoreGauge({
|
||||||
const band = (data.band ?? 'stable') as RegimeBand;
|
label,
|
||||||
const style = BAND_STYLES[band];
|
score,
|
||||||
const score = data.total_score ?? 0;
|
band,
|
||||||
const threshold = data.alert_threshold ?? 65;
|
trend,
|
||||||
|
threshold,
|
||||||
|
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';
|
||||||
|
}) {
|
||||||
|
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 clamp = (v: number) => Math.min(100, Math.max(0, v));
|
||||||
|
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
|
||||||
return (
|
return (
|
||||||
<div className={`glass border ${style.ring} p-6`}>
|
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}>
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
|
||||||
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
<span className="text-sm text-gray-500">/ 100</span>
|
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}>
|
||||||
|
{naa ? '—' : Math.round(s)}
|
||||||
|
</span>
|
||||||
|
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
|
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{trend && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<TrendChip label="7d" delta={data.trend?.delta_7} />
|
<TrendChip label="7d" delta={trend.delta_7} />
|
||||||
<TrendChip label="30d" delta={data.trend?.delta_30} />
|
<TrendChip label="30d" delta={trend.delta_30} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Band track with score + threshold markers */}
|
{!naa && (
|
||||||
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
<>
|
||||||
|
{/* 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
|
<div
|
||||||
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
|
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
|
||||||
style={{ left: `${clamp(threshold)}%` }}
|
style={{ left: `${clamp(threshold)}%` }}
|
||||||
title={`Alert threshold ${threshold}`}
|
title={`Alert threshold ${threshold}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
|
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
|
||||||
style={{ left: `${clamp(score)}%` }}
|
style={{ left: `${clamp(s)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<p className="mt-4 text-xs leading-relaxed text-gray-500">
|
|
||||||
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech bull regime
|
|
||||||
has deteriorated. Mostly coincident signals — it shortens reaction time, it doesn't predict the exact turn.
|
|
||||||
{data.date && <> As of {data.date}.</>}
|
|
||||||
{data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && (
|
|
||||||
<span className="ml-1 text-gray-600">
|
|
||||||
VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -501,7 +517,52 @@ export default function RegimePage() {
|
|||||||
|
|
||||||
{monitor.data && monitor.data.available && (
|
{monitor.data && monitor.data.available && (
|
||||||
<>
|
<>
|
||||||
<Gauge data={monitor.data} />
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScoreGauge
|
||||||
|
label="Combined · observational blend"
|
||||||
|
score={monitor.data.combined?.score}
|
||||||
|
band={monitor.data.combined?.band}
|
||||||
|
trend={monitor.data.combined}
|
||||||
|
size="md"
|
||||||
|
footnote={
|
||||||
|
<>A weighted mean of the index and the early warning — for observation only. Tune the mix via the
|
||||||
|
regime config.</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from app.services.regime_monitor_service import (
|
from app.services.regime_monitor_service import (
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
|
_attach_early_warning,
|
||||||
band_for,
|
band_for,
|
||||||
compute_regime_score,
|
compute_regime_score,
|
||||||
f2_credit_spreads,
|
f2_credit_spreads,
|
||||||
@@ -35,6 +36,23 @@ def test_band_for():
|
|||||||
assert band_for(90) == "breaking"
|
assert band_for(90) == "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_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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Price sub-scores
|
# Price sub-scores
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user