feat: replace regime monitor with v2 methodology
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user