)}
@@ -103,7 +104,7 @@ export default function RegimeQuadrant() {
) : !points.length ? (
- Not enough history yet — the early-warning fills in as the daily job runs.
+ Not enough coverage-qualified v2 history yet.
) : (
<>
@@ -111,13 +112,13 @@ export default function RegimeQuadrant() {
{/* Quadrant shading (drawn first, behind everything) */}
-
-
-
-
+
+
+
+
-
-
+
+ } />
@@ -166,15 +167,15 @@ export default function RegimeQuadrant() {
- ① Hot & brittle — narrow melt-up, shakeout risk
- ② Transition — break may be starting
- ③ Healthy & broad — calm uptrend
- ④ Real downturn — regime breaking, broad
+ Early warning — state calm, fragility rising
+ Active stress — damaged and deteriorating
+ Healthy — calm and broadly supported
+ Stressed / stabilizing — damage remains, warning lower
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.
+ The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
+ pre-freeze basket history remains exploratory.
+
- {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.
-
-
-
-
-
-
- Breadth divergence warned before{' '}
- {bd.events_with_signal}/{bd.events_total} drawdowns
- {bd.median_lead_days != null ? ` (median ${bd.median_lead_days}d lead)` : ''}; the coincident baseline only{' '}
- {cd.events_with_signal}/{cd.events_total}. The median-lead
- comparison is unreliable when coverage differs this much — see per-drawdown below.
-
- 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.
-
-
+
+ Risk thermometer — not an entry, exit, or sizing signal. State measures current stress; Warning measures deterioration and divergence.
- {monitor.isLoading && (
- <>
-
-
- >
- )}
-
- {monitor.isError && (
- monitor.refetch()}>
- Failed to load: {(monitor.error as Error).message}
-
- )}
-
- {monitor.data && !monitor.data.available && (
-
- Not computed yet — run the “Regime Monitor” job from Admin → Jobs, or wait for the daily pipeline.
-
- )}
-
- {monitor.data && monitor.data.available && (
+ {monitor.isLoading && <>>}
+ {monitor.isError && monitor.refetch()}>Failed to load: {(monitor.error as Error).message}}
+ {data && !data.available && V2 is not computed yet — run “Regime Monitor” from Admin → Jobs or wait for the daily pipeline.}
+
+ {data?.available && data.state && data.warning && (
<>
+ {(!data.data_quality?.is_fresh || data.state.band == null || data.warning.band == null) && (
+
+ 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(', ')}` : ''}.
+
+ )}
- An index (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) && (
-
- VIX {monitor.data.inputs.vix ?? '—'} · HY OAS {monitor.data.inputs.hy_oas ?? '—'}
-
- )}
- >
- }
+ 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 ?? '—'}.>}
/>
- 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.>}
/>
+ 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.
+
+ )}
>
)}
-
{isAdmin && }
);
diff --git a/tests/unit/test_event_study.py b/tests/unit/test_event_study.py
index 2ec4a03..629b184 100644
--- a/tests/unit/test_event_study.py
+++ b/tests/unit/test_event_study.py
@@ -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
diff --git a/tests/unit/test_regime_monitor.py b/tests/unit/test_regime_monitor.py
index dab83c1..a4c794e 100644
--- a/tests/unit/test_regime_monitor.py
+++ b/tests/unit/test_regime_monitor.py
@@ -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
diff --git a/tests/unit/test_regime_quadrant_alert.py b/tests/unit/test_regime_quadrant_alert.py
index 71eae9b..c6fdbe5 100644
--- a/tests/unit/test_regime_quadrant_alert.py
+++ b/tests/unit/test_regime_quadrant_alert.py
@@ -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)