feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+42 -99
View File
@@ -1,4 +1,4 @@
"""Unit tests for the breadth indicator and the event-study measurement."""
"""Tests for v2 correction events and warning alarm episodes."""
from __future__ import annotations
@@ -6,124 +6,67 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
_lead,
_percentile,
alarm_episodes,
detect_events,
event_centered,
signal_centered,
evaluate_alarms,
)
def _days(n: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=i) for i in range(n)]
def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
return [start + timedelta(days=index) for index in range(count)]
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
def test_detect_events_single_drawdown():
closes = [100.0] * 300 + [85.0] * 5 # 15% off the trailing high -> one event
dates = _days(len(closes))
events = detect_events(closes, dates, threshold_pct=15.0)
assert len(events) == 1
assert events[0]["index"] == 300
def test_detect_events_uses_rising_edge_and_cooldown():
closes = [100.0] * 300 + [85.0] * 5 + [100.0] * 50 + [85.0] * 5
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0, cooldown=40)
assert [event["index"] for event in events] == [300, 355]
def test_detect_events_dedup_without_recovery():
closes = [100.0] * 300 + [85.0] * 5 + [80.0] * 5 # deepens but never recovers
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
assert len(events) == 1
def test_percentile_is_fixed_from_supplied_values():
values = [float(value) for value in range(0, 101, 10)]
assert _percentile(values, 50) == 50.0
assert _percentile(values, 80) == 80.0
assert _percentile([], 80) is None
def test_detect_events_two_after_recovery():
closes = [100.0] * 300 + [85.0] * 10 + [100.0] * 300 + [85.0] * 10
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
assert len(events) == 2
def test_alarm_requires_upward_crossing_and_reset():
dates = _days(10)
values = [10, 70, 80, 75, 20, 70, 80, 20, 20, 70]
indicator = dict(zip(dates, values))
assert alarm_episodes(indicator, dates, threshold=60) == [1, 5, 9]
def test_detect_events_cooldown_suppresses_close_recross():
# Dips below threshold then re-crosses only a few bars later.
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3
dates = _days(len(closes))
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=40)) == 1
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=3)) == 2
def test_holdout_start_does_not_invent_crossing_when_already_high():
dates = _days(6)
indicator = dict(zip(dates, [10, 70, 80, 80, 20, 70]))
assert alarm_episodes(indicator, dates, threshold=60, start_index=3) == [5]
def test_percentile_interpolation():
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100
assert _percentile(vals, 50) == 50.0
assert _percentile(vals, 80) == 80.0
assert _percentile([], 50) is None
def test_evaluate_alarms_counts_episodes_not_alarm_days():
dates = _days(100)
result = evaluate_alarms([10, 50, 80], [25, 70], dates, horizon=20)
assert result["events_warned"] == 2
assert result["events_missed"] == 0
assert result["false_alarms"] == 1
assert result["median_lead_days"] == 17.5
def test_lead_earliest_crossing():
dates = _days(200)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
assert _lead(indicator, t0, dates, pre=60, threshold=60.0) == 30
assert _lead(indicator, t0, dates, pre=60, threshold=80.0) is None
# ---------------------------------------------------------------------------
# Event-centered lead time
# ---------------------------------------------------------------------------
def test_event_centered_lead_time():
dates = _days(200)
t0 = 120
# Indicator goes hot 30 days before t0 and stays hot through t0.
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
res = event_centered(indicator, [t0], dates, pre=60, post=20, threshold=60.0)
assert res["median_lead_days"] == 30
assert res["events_with_signal"] == 1
def test_breadth_divergence_leads_coincident():
dates = _days(200)
t0 = 120
breadth_ind = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
coincident = {dates[i]: (70.0 if t0 - 2 <= i <= t0 else 10.0) for i in range(len(dates))}
bd = event_centered(breadth_ind, [t0], dates, threshold=60.0)
cd = event_centered(coincident, [t0], dates, threshold=60.0)
assert bd["median_lead_days"] > cd["median_lead_days"]
# ---------------------------------------------------------------------------
# Signal-centered precision / recall
# ---------------------------------------------------------------------------
def test_signal_centered_base_rate_and_recall():
dates = _days(200)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
res = signal_centered(indicator, [t0], dates, horizon=20)
assert 0.0 < res["base_rate"] < 1.0
# An aligned indicator should catch some of the pre-event window at a mid threshold.
row60 = next(r for r in res["rows"] if r["threshold"] == 60)
assert row60["recall"] is not None and row60["recall"] > 0
# ---------------------------------------------------------------------------
# Breadth aggregation + divergence
# ---------------------------------------------------------------------------
def test_breadth_from_closes_fraction_above_sma():
dates = _days(5)
def test_breadth_from_fixed_closes_and_pure_divergence():
dates = _days(10)
closes_by_symbol = {
"A": list(zip(dates, [1.0, 2.0, 3.0, 4.0, 5.0])), # rising -> above its SMA
"B": list(zip(dates, [5.0, 4.0, 3.0, 2.0, 1.0])), # falling -> below
"C": list(zip(dates, [3.0, 3.0, 3.0, 3.0, 3.0])), # flat -> not strictly above
"A": list(zip(dates, [1.0 + index for index in range(10)])),
"B": list(zip(dates, [10.0 - index for index in range(10)])),
"C": list(zip(dates, [5.0] * 10)),
}
breadth = _breadth_from_closes(closes_by_symbol, window=3, min_tickers=2)
# At d2: SMA(3) over each -> only A is strictly above -> 1/3.
assert breadth[dates[2]] == round(1 / 3 * 100, 2)
falling_breadth = {dates[index]: 80.0 - index * 3 for index in range(10)}
rising_benchmark = list(zip(dates, [100.0 + index for index in range(10)]))
divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3)
assert divergence[dates[-1]] > 0
def test_divergence_high_when_price_up_breadth_down():
dates = _days(10)
breadth = {dates[i]: 80.0 - i * 3 for i in range(len(dates))} # falling breadth
benchmark = list(zip(dates, [100.0 + i for i in range(len(dates))])) # rising price
div = compute_divergence_series(breadth, benchmark, lookback=3)
last = div[dates[-1]]
assert last > 50.0 # fragile: price up while breadth deteriorates
falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
assert no_divergence[dates[-1]] == 0
+155 -121
View File
@@ -1,166 +1,200 @@
"""Unit tests for the regime-monitor pure functions and aggregation."""
"""Pure-function tests for the v2 Regime Monitor contract."""
from __future__ import annotations
import copy
import json
from datetime import date, timedelta
import pytest
from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot
from app.services import regime_monitor_service as rms
from app.services.regime_monitor_service import (
DEFAULT_CONFIG,
_attach_early_warning,
HY_OAS_ELEVATED,
HY_OAS_MILD,
HY_OAS_STRESSED,
_compute_index,
_fundamental_scores_asof,
_score_pillars,
band_for,
compute_regime_score,
breadth_level_score,
f2_credit_spreads,
p1_trend_break,
p2_death_cross,
p3_drawdown,
p4_relative_strength,
p5_volatility,
p6_canary,
_compute_index,
)
def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[date, float]]:
n = len(values)
return [(end - timedelta(days=(n - 1 - i)), v) for i, v in enumerate(values)]
return [
(end - timedelta(days=len(values) - 1 - index), value)
for index, value in enumerate(values)
]
# ---------------------------------------------------------------------------
# Bands
# ---------------------------------------------------------------------------
def test_band_for():
def test_band_for_keeps_documented_boundaries():
assert band_for(10) == "stable"
assert band_for(45) == "watch"
assert band_for(70) == "elevated"
assert band_for(90) == "breaking"
assert band_for(30) == "watch"
assert band_for(60) == "elevated"
assert band_for(80) == "breaking"
def test_attach_early_warning_blends():
result = {"total_score": 80.0}
_attach_early_warning(result, 40.0, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] == 40.0
assert result["early_warning"]["band"] == "watch"
# combined = (80*0.6 + 40*0.4) / 1.0 = 64
assert result["combined"]["score"] == 64.0
assert result["combined"]["band"] == "elevated"
def test_price_sensors_are_stress_only():
smh_under = [100.0] * 199 + [50.0]
qqq_above = [100.0] * 200
assert round(p1_trend_break(smh_under, qqq_above) or 0, 1) == 66.7
bearish = [300.0 - index for index in range(260)]
healthy = [100.0 + index * 0.5 for index in range(260)]
assert (p2_death_cross(bearish, bearish) or 0) > 0
assert p2_death_cross(healthy, healthy) == 0
def test_attach_early_warning_none_falls_back_to_index():
result = {"total_score": 80.0}
_attach_early_warning(result, None, {"coincident": 0.6, "early_warning": 0.4})
assert result["early_warning"]["score"] is None
assert result["combined"]["score"] == 80.0 # no early warning -> just the index
def test_divergence_asof_tolerates_small_lag():
from app.services.regime_monitor_service import _divergence_asof
items = [(date(2026, 6, 1), 55.0), (date(2026, 6, 3), 60.0)]
assert _divergence_asof(items, date(2026, 6, 3)) == 60.0 # exact date
assert _divergence_asof(items, date(2026, 6, 4)) == 60.0 # 1-day lag -> newest
assert _divergence_asof(items, date(2026, 6, 20)) is None # too stale
assert _divergence_asof([], date(2026, 6, 3)) is None
# ---------------------------------------------------------------------------
# Price sub-scores
# ---------------------------------------------------------------------------
def test_p1_blends_leader_double():
smh_under = [100.0] * 199 + [50.0] # last below its 200-DMA
qqq_above = [100.0] * 200 # last at/above its 200-DMA -> healthy
score = p1_trend_break(smh_under, qqq_above, leader_weight=2.0)
# leader(100) weighted 2, confirm(0) weighted 1 -> 66.7
assert round(score, 1) == 66.7
def test_p1_none_without_history():
assert p1_trend_break([100.0] * 50, [100.0] * 50, 2.0) is None
def test_p2_death_cross_bearish_vs_healthy():
bearish = [300.0 - i for i in range(260)] # falling: 50 < 200, slope down
healthy = [100.0 + i * 0.5 for i in range(260)] # rising: 50 > 200
assert p2_death_cross(bearish, bearish, 2.0) > 0
assert p2_death_cross(healthy, healthy, 2.0) == 0
def test_p3_drawdown_linear():
closes = [100.0] * 252 + [80.0] # 20% below the 52w high -> 100
closes = [100.0] * 252 + [80.0]
assert p3_drawdown(closes, [100.0] * 253) == 100.0
def test_p4_relative_strength_direction():
falling = [100.0 - i * 0.5 for i in range(70)] # SMH underperforms flat SPY
rising = [100.0 + i * 0.5 for i in range(70)]
spy = [100.0] * 70
assert p4_relative_strength(falling, spy, 60) > 50
assert p4_relative_strength(rising, spy, 60) < 50
def test_relative_strength_flat_or_better_is_zero():
flat = [100.0] * 70
rising = [100.0 + index for index in range(70)]
falling = [100.0 - index * 0.5 for index in range(70)]
assert p4_relative_strength(flat, flat) == 0.0
assert p4_relative_strength(rising, flat) == 0.0
assert (p4_relative_strength(falling, flat) or 0) > 0
def test_p5_volatility_linear():
def test_volatility_and_breadth_zero_points():
assert p5_volatility(15) == 0
assert p5_volatility(30) == 100
assert p5_volatility(22.5) == 50
assert p5_volatility(None) is None
assert breadth_level_score(60) == 0
assert breadth_level_score(20) == 100
assert breadth_level_score(None) is None
def test_f2_credit_percentile():
rising = [float(i) for i in range(1, 31)] # latest is the max -> ~100th pct
assert f2_credit_spreads(rising) == 100.0
falling = [float(i) for i in range(30, 0, -1)] # latest is the min
assert f2_credit_spreads(falling) < 10
assert f2_credit_spreads([1.0] * 5) is None # too short
def test_credit_uses_named_anchors_and_constant_series_is_not_extreme():
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0
rising = [3.0 + index * 0.01 for index in range(100)]
assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100)
def test_p6_canary_divergence():
nvda_weak = [100.0] * 49 + [80.0] # below its 50-DMA
smh_intact = [100.0] * 199 + [120.0] # above its 200-DMA
assert p6_canary(nvda_weak, smh_intact) == 100.0
assert p6_canary([100.0] * 50, smh_intact) == 0.0
def test_score_pillars_gates_band_below_75_percent_coverage():
pillars = [
{"id": "price", "label": "Price", "score": 80.0, "sensors": []},
{"id": "breadth", "label": "Breadth", "score": 20.0, "sensors": []},
{"id": "credit", "label": "Credit", "score": None, "sensors": []},
{"id": "volatility", "label": "Vol", "score": None, "sensors": []},
]
result = _score_pillars(pillars, {"price": 40, "breadth": 25, "credit": 20, "volatility": 15})
assert result["coverage"] == 65.0
assert result["score"] is not None
assert result["band"] is None
# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
def test_compute_regime_score_excludes_na_and_zero_weight():
weights = {"P1": 10, "P2": 0, "F2": 5}
subs = {"P1": 80.0, "P2": 50.0, "F2": None}
result = compute_regime_score(subs, weights)
# Only P1 counts: P2 weight 0, F2 unavailable.
assert result["total_score"] == 80.0
ids = {row["id"]: row for row in result["breakdown"]}
assert "P2" not in ids # zero-weight signals are hidden
assert ids["F2"]["available"] is False
assert ids["P1"]["contribution"] == 80.0
def test_fundamentals_never_replay_before_effective_date_and_expire():
overrides = {
"f1_score": 0.0,
"f3_score": 100.0,
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 1))[:2] == (None, None)
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 2))[:2] == (0.0, 100.0)
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
def test_compute_regime_score_contributions_sum_to_total():
weights = {"P1": 10, "F2": 10}
subs = {"P1": 80.0, "F2": 40.0}
result = compute_regime_score(subs, weights)
assert result["total_score"] == 60.0
total = sum(row["contribution"] for row in result["breakdown"])
assert round(total, 1) == 60.0
@pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = {
"f1_score": 100.0,
"f3_score": 0.0,
"locked": True,
"source": "manual",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
saved: dict = {}
async def fake_get(_db):
return dict(stored)
async def fake_update(_db, _key, value):
saved.update(json.loads(value))
monkeypatch.setattr(rms, "get_fundamental_overrides", fake_get)
monkeypatch.setattr(rms, "update_setting", fake_update)
result = await rms.set_fundamental_overrides(object(), locked=False)
assert result["locked"] is False
assert result["fetched_at"] == stored["fetched_at"]
assert result["effective_date"] == stored["effective_date"]
assert saved == result
# ---------------------------------------------------------------------------
# As-of index replay (backfill mechanics)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26)
first = {
"methodology": "v2",
"date": snapshot_date.isoformat(),
"state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"},
}
changed = copy.deepcopy(first)
changed["state"] = {"score": 90.0, "band": "breaking"}
def test_compute_index_as_of_truncates_history():
rising = [100.0 + i * 0.2 for i in range(260)]
prices = {sym: _dated(rising) for sym in ("SMH", "QQQ", "SPY", "RSP", "NVDA")}
overrides = {"f1_score": 50.0, "f3_score": 50.0}
written, _ = await rms._upsert_snapshot(
db_session, first, rewrite_existing_v2=True
)
await db_session.flush()
rewritten, persisted = await rms._upsert_snapshot(
db_session, changed, rewrite_existing_v2=False
)
row = (
await db_session.execute(
select(RegimeSnapshot).where(RegimeSnapshot.date == snapshot_date)
)
).scalar_one()
full = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26))
by_id = {r["id"]: r for r in full["breakdown"]}
assert by_id["P1"]["available"] is True # 200-DMA computable on full history
assert 0 <= full["total_score"] <= 100
assert full["band"] in {"stable", "watch", "elevated", "breaking"}
assert written is True
assert rewritten is False
assert persisted["state"]["score"] == 10.0
assert row.total_score == 10.0
# As-of 250 days earlier: only ~10 bars are in scope -> long-lookback signals n/a.
early = _compute_index(prices, None, None, overrides, DEFAULT_CONFIG, date(2026, 6, 26) - timedelta(days=250))
early_by_id = {r["id"]: r for r in early["breakdown"]}
assert early_by_id["P1"]["available"] is False
def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
qqq = rising.copy()
smh = rising[:-1] + [rising[-1] * 0.75]
prices = {
"SMH": _dated(smh, end),
"QQQ": _dated(qqq, end),
"SPY": _dated(rising, end),
}
breadth = [(end, 55.0)]
divergence = [(end, 20.0)]
result = _compute_index(
prices,
[(end, 20.0)],
[(end - timedelta(days=index), 4.0) for index in reversed(range(100))],
{"f1_score": None, "f3_score": None},
copy.deepcopy(DEFAULT_CONFIG),
end,
breadth,
divergence,
{end: 25},
)
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
assert price["score"] == max(sensor_scores)
assert result["methodology"] == "v2"
assert "combined" not in result
assert result["basket"]["members_available"] == 25
+28 -40
View File
@@ -1,52 +1,40 @@
"""Tests for the regime quadrant classification + hysteresis (anti-flicker)."""
"""Tests for v2 State/Warning quadrant hysteresis and basket reseeding keys."""
from __future__ import annotations
from app.services.alert_service import _classify_quadrant, _parse_quadrant_log_key, _quadrant_log_key
from app.services.alert_service import (
_classify_quadrant,
_parse_quadrant_log_key,
_quadrant_log_key,
)
# Quadrant ids: 1=① hot&brittle (regime low, warning high), 2=② transition
# (both high), 3=③ healthy (both low), 4=④ real downturn (regime high, warning low).
# Dividers: regime 40, early-warning 60; margin 5.
def test_fresh_classification_uses_60_60_boundaries():
assert _classify_quadrant(20, 90, None) == "1"
assert _classify_quadrant(70, 90, None) == "2"
assert _classify_quadrant(20, 30, None) == "3"
assert _classify_quadrant(70, 30, None) == "4"
def test_fresh_classification():
assert _classify_quadrant(20, 90, None) == "1" # low regime, high warning
assert _classify_quadrant(70, 90, None) == "2" # both high
assert _classify_quadrant(20, 30, None) == "3" # both low
assert _classify_quadrant(70, 30, None) == "4" # high regime, low warning
def test_warning_axis_hysteresis():
assert _classify_quadrant(20, 62, prev="3") == "3"
assert _classify_quadrant(20, 66, prev="3") == "1"
assert _classify_quadrant(20, 58, prev="1") == "1"
assert _classify_quadrant(20, 54, prev="1") == "3"
def test_hysteresis_holds_inside_deadband():
# From ③ (both low): early-warning nudging just past 60 stays ③ until it
# clears 60 + margin (65).
assert _classify_quadrant(20, 62, prev="3") == "3" # within deadband → no flip
assert _classify_quadrant(20, 66, prev="3") == "1" # clears 65 → flips to ①
def test_hysteresis_sticky_when_already_high():
# From ① (warning high): a dip below 60 keeps ① until it drops past 60 - margin (55).
assert _classify_quadrant(20, 58, prev="1") == "1" # still high (deadband)
assert _classify_quadrant(20, 54, prev="1") == "3" # drops past 55 → back to ③
def test_hysteresis_on_regime_axis():
# From ③: regime rising past 40 stays ③ until it clears 45.
assert _classify_quadrant(43, 30, prev="3") == "3"
assert _classify_quadrant(46, 30, prev="3") == "4"
# From ④: regime easing keeps ④ until below 35.
assert _classify_quadrant(37, 30, prev="4") == "4"
assert _classify_quadrant(34, 30, prev="4") == "3"
def test_state_axis_hysteresis():
assert _classify_quadrant(63, 30, prev="3") == "3"
assert _classify_quadrant(66, 30, prev="3") == "4"
assert _classify_quadrant(57, 30, prev="4") == "4"
assert _classify_quadrant(54, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip():
# A point parked exactly on both dividers keeps whatever quadrant it had.
for q in ("1", "2", "3", "4"):
assert _classify_quadrant(40, 60, prev=q) == q
for quadrant in ("1", "2", "3", "4"):
assert _classify_quadrant(60, 60, prev=quadrant) == quadrant
def test_quadrant_log_key_keeps_previous_values():
key = _quadrant_log_key("3", 32.4, 54.6)
assert _parse_quadrant_log_key(key) == ("3", 32.4, 54.6)
# Existing pre-value keys still parse so old installs do not need migration.
assert _parse_quadrant_log_key("3") == ("3", None, None)
def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():
key = _quadrant_log_key("3", 32.4, 54.6, "abc123")
assert _parse_quadrant_log_key(key) == ("abc123", "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3:32.4:54.6") == (None, "3", 32.4, 54.6)
assert _parse_quadrant_log_key("3") == (None, "3", None, None)