feat: add standalone AI/Tech regime-change monitor tab
A new /regime tab scoring how far the AI/Tech bull regime has deteriorated toward a re-rating as a single 0-100 index with per-signal breakdown and a 7/30-day trend. Intentionally decoupled: nothing reads its output to gate or score trades — the daily-pipeline membership is scheduling only. - regime_monitor_service: price sub-scores (P1-P6 via Alpaca, like market_regime), VIX + HY credit spreads via a small FRED helper, weighted aggregation over available signals (missing source -> n/a, dropped from the denominator), one snapshot row/day, and a ~90-day history backfill by replaying the already-fetched series as-of each past day. - F1/F3 fundamentals proposed by the configured grounded LLM (reuses sentiment_provider_service config resolution), with a manual override + lock. - regime_snapshots table (migration 011); endpoints on the existing market router; admin-editable weights/threshold; standalone /regime page. Data needs: prices via Alpaca, VIX/credit via FRED (optional key — signals show n/a without it). No LLM needed for history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for the regime-monitor pure functions and aggregation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.services.regime_monitor_service import (
|
||||
DEFAULT_CONFIG,
|
||||
band_for,
|
||||
compute_regime_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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_band_for():
|
||||
assert band_for(10) == "stable"
|
||||
assert band_for(45) == "watch"
|
||||
assert band_for(70) == "elevated"
|
||||
assert band_for(90) == "breaking"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price sub-scores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_p1_blends_leader_double():
|
||||
smh_under = [100.0] * 199 + [50.0] # last below its 200-DMA
|
||||
qqq_above = [100.0] * 200 # last at/above its 200-DMA -> healthy
|
||||
score = p1_trend_break(smh_under, qqq_above, leader_weight=2.0)
|
||||
# leader(100) weighted 2, confirm(0) weighted 1 -> 66.7
|
||||
assert round(score, 1) == 66.7
|
||||
|
||||
|
||||
def test_p1_none_without_history():
|
||||
assert p1_trend_break([100.0] * 50, [100.0] * 50, 2.0) is None
|
||||
|
||||
|
||||
def test_p2_death_cross_bearish_vs_healthy():
|
||||
bearish = [300.0 - i for i in range(260)] # falling: 50 < 200, slope down
|
||||
healthy = [100.0 + i * 0.5 for i in range(260)] # rising: 50 > 200
|
||||
assert p2_death_cross(bearish, bearish, 2.0) > 0
|
||||
assert p2_death_cross(healthy, healthy, 2.0) == 0
|
||||
|
||||
|
||||
def test_p3_drawdown_linear():
|
||||
closes = [100.0] * 252 + [80.0] # 20% below the 52w high -> 100
|
||||
assert p3_drawdown(closes, [100.0] * 253) == 100.0
|
||||
|
||||
|
||||
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_p5_volatility_linear():
|
||||
assert p5_volatility(15) == 0
|
||||
assert p5_volatility(30) == 100
|
||||
assert p5_volatility(22.5) == 50
|
||||
assert p5_volatility(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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# As-of index replay (backfill mechanics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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}
|
||||
|
||||
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"}
|
||||
|
||||
# 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
|
||||
@@ -87,6 +87,7 @@ class TestConfigureScheduler:
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"backtest",
|
||||
"daily_pipeline",
|
||||
"intraday_pipeline",
|
||||
@@ -107,6 +108,7 @@ class TestConfigureScheduler:
|
||||
"data_backfill",
|
||||
"fundamental_collector",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"outcome_evaluator",
|
||||
"rr_scanner",
|
||||
"sentiment_collector",
|
||||
|
||||
Reference in New Issue
Block a user