The LLM-sourced capex/earnings observations carried 12+8 of 100 Warning points, so both pegged at 100 produced a Warning of 20.0 -- below the event study's 25.3 alarm threshold and still inside the "stable" band. The reading was arithmetically incapable of changing anything on screen, which is why refreshing it appeared to do nothing. They are now a qualitative overlay reported beside the scores rather than diluted into them. Calibrated against the 408 v2 sessions to 2026-07-24, reproduced offline from Alpaca + FRED; the harness matched the stored prod distribution exactly before any parameter was changed. State: - P3 used dd_pct * 5, reaching 100 at a 20% drawdown -- the 90th percentile of the observed distribution -- so 39/408 sessions sat at exactly 100 with no resolution left during the part of a selloff that matters most. Replaced with anchored breakpoints keeping headroom past the observed 36% maximum, blended 2:1 like P1/P2 instead of max(). P3's realized share of State falls from 65% to 40%, matching its nominal weight. - Credit level is now anchors-only. ICE capped FRED's BAMLH0A0HYM2 at a rolling 3-year window in April 2026, silently turning the 10-year percentile leg into a 3-year one that scored 20 points of stress at an OAS of 3.5 -- the level its own anchors call "mild". The anchors already encode the long-run distribution. Warning: - Added HY OAS 20-session widening (25%). The level is pinned at zero below the 3.5 anchor; its rate of change is not. - Divergence tapers to a 0.35 floor instead of a hard price_ret >= 0 gate, which zeroed the sensor through every decline: on 2026-07-24 the basket shed 10 points of participation in 20 sessions and Warning printed exactly 0. - The event study and the live monitor now share one sensor definition, so they cannot silently drift apart. Bands are per axis (State 20/50/80, Warning 20/40/60) with quadrant dividers at 50/40; v2 Warning never exceeded 64.9 against a shared 60, leaving that half of the quadrant unreachable. Realized shares: State 73/15/8/3%, Warning 69/20/8/3%. Snapshots now record credit_history_days and vix_history_days -- the percentile defect went unnoticed for months because nothing asserted the window the code claimed. Cutover: the first run rebuilds 400 sessions automatically; the Event Study job must be re-run, as its cached report self-invalidates on the methodology check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""Tests for v2 correction events and warning alarm episodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
|
|
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
|
|
from app.services.event_study_service import (
|
|
_percentile,
|
|
alarm_episodes,
|
|
detect_events,
|
|
evaluate_alarms,
|
|
)
|
|
|
|
|
|
def _days(count: int, start: date = date(2021, 1, 1)) -> list[date]:
|
|
return [start + timedelta(days=index) for index in range(count)]
|
|
|
|
|
|
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_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_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_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_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_breadth_from_fixed_closes_and_tapered_divergence():
|
|
dates = _days(10)
|
|
closes_by_symbol = {
|
|
"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)
|
|
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
|
|
|
|
# v3: breadth loss with price confirming it is still deterioration, scored at
|
|
# DIVERGENCE_CONFIRMED_FLOOR of the masked case rather than discarded. v2's
|
|
# hard gate zeroed this and left Warning at 0 through every selloff.
|
|
falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
|
|
confirmed = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
|
|
assert 0 < confirmed[dates[-1]] < divergence[dates[-1]]
|
|
|
|
# Flat breadth is not deterioration regardless of price direction.
|
|
flat_breadth = {day: 60.0 for day in dates}
|
|
assert compute_divergence_series(flat_breadth, falling_benchmark, lookback=3)[dates[-1]] == 0
|