diff --git a/README.md b/README.md index d875a8e..fe90cf2 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ indicators. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years. 2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only. -3. **Market Regime** + **Regime Monitor** — breadth/trend and the v2 risk thermometer; feed no trades. +3. **Market Regime** + **Regime Monitor** — breadth/trend and the v3 risk thermometer; feed no trades. 4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan. **Near-close** (~15:30 ET Mon–Fri) — the only full-universe qualifying observation: @@ -319,7 +319,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m - Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge) - Recommendation layer — directional confidence, conflict detection, per-target reach-probability - Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation -- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit, PIT fundamentals) with a manual chronological correction study +- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit level + impulse) with a manual chronological correction study - Telegram alerts (e.g. regime-quadrant changes) - User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary - JWT auth with admin role, configurable registration, user access control diff --git a/app/services/alert_service.py b/app/services/alert_service.py index 7ba9f3e..7fd2bcd 100644 --- a/app/services/alert_service.py +++ b/app/services/alert_service.py @@ -97,8 +97,8 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par # Hysteresis (a deadband around each divider) stops a point sitting on a boundary # from flip-flopping; the cooldown caps how often a genuine change can re-alert. QUAD_TYPE = "regime_quadrant" -QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative) -QUAD_Y_DIV = 60.0 # v2 Warning divider +QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative) +QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts QUAD_LABELS = { diff --git a/app/services/breadth_service.py b/app/services/breadth_service.py index dc1047b..be4f9a6 100644 --- a/app/services/breadth_service.py +++ b/app/services/breadth_service.py @@ -72,15 +72,25 @@ def _breadth_from_closes( return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0] +# Breadth deterioration counts fully when price masks it (true divergence, the +# dangerous pre-top case) and at CONFIRMED_FLOOR when price falls with it. +# v2 used a hard ``price_ret >= 0`` cliff, which zeroed the sensor during every +# decline -- so on 2026-07-24, with the basket shedding 10 percentage points +# above their 200-DMA in 20 sessions, Warning read exactly 0. Breadth *level* +# lives in State but breadth *velocity* appears nowhere else, so partial credit +# here is not double counting. +DIVERGENCE_CONFIRMED_FLOOR = 0.35 +DIVERGENCE_TAPER_PCT = 3.0 + + def compute_divergence_series( breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20 ) -> dict[date, float]: """Early-warning score (0-100, high = fragile) per date. - This is deliberately a pure divergence: it is positive only when benchmark - price holds/rises while breadth falls. Absolute low breadth belongs in the - State score, so it is not counted again here. A 20 percentage-point breadth - deterioration maps to 100. + A 20 percentage-point breadth deterioration maps to 100 when the benchmark + is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once + the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window. """ bench = {d: c for d, c in benchmark_closes} common = sorted(d for d in bench if d in breadth) @@ -93,8 +103,9 @@ def compute_divergence_series( price_ret = (bench[d] / price_past - 1.0) * 100.0 # % breadth_chg = breadth[d] - breadth[d0] # percentage points deterioration = max(0.0, -breadth_chg) - score = deterioration * 5.0 if price_ret >= 0 else 0.0 - out[d] = max(0.0, min(100.0, round(score, 2))) + taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT)) + gate = DIVERGENCE_CONFIRMED_FLOOR + (1.0 - DIVERGENCE_CONFIRMED_FLOOR) * taper + out[d] = max(0.0, min(100.0, round(deterioration * 5.0 * gate, 2))) return out diff --git a/app/services/event_study_service.py b/app/services/event_study_service.py index a822c2c..d107fb9 100644 --- a/app/services/event_study_service.py +++ b/app/services/event_study_service.py @@ -149,29 +149,29 @@ def _warning_series( breadth_divergence: dict[date, float], dates: list[date], config: dict, + oas_series: rms.Series | None = None, ) -> dict[date, float]: - """Technical Warning score used historically (fundamentals have no PIT history).""" + """Warning score per session, from the monitor's own sensor definitions. + + v2 re-derived this by hand from ``WARNING_WEIGHTS`` and so would have kept + measuring the old construct after a scoring change. Since v3 dropped + fundamentals from the score, this is now exactly the live Warning score + rather than a technical-only approximation of it. + """ tickers = config["tickers"] smh_full = prices.get(tickers["leaders"][0], []) spy_full = prices.get(tickers["market"], []) out: dict[date, float] = {} for session in dates: - divergence = breadth_divergence.get(session) - relative = rms.p4_relative_strength( + sensors = rms.warning_sensor_scores( + breadth_divergence.get(session), rms._closes_asof(smh_full, session), rms._closes_asof(spy_full, session), + rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS), ) - values: list[tuple[float, float]] = [] - if divergence is not None: - values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"])) - if relative is not None: - values.append((relative, rms.WARNING_WEIGHTS["relative_strength"])) - if values: - out[session] = round( - sum(value * weight for value, weight in values) - / sum(weight for _, weight in values), - 2, - ) + score = rms.score_warning_sensors(sensors) + if score is not None: + out[session] = round(score, 2) return out @@ -195,7 +195,13 @@ async def run_event_study( db, config["breadth_basket"], window=200, min_tickers=20 ) divergence = breadth_service.compute_divergence_series(breadth, benchmark) - warning = _warning_series(prices, divergence, dates, config) + oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end) + warning = _warning_series(prices, divergence, dates, config, oas_series) + # The credit sensor cannot reach back as far as the price history does (the + # upstream series is capped at ~3 years), so the earlier part of the sample + # scores on W1+W2 alone via renormalisation. Report where W3 starts rather + # than letting the threshold quietly straddle two sensor sets. + credit_from = oas_series[0][0].isoformat() if oas_series else None split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION))) train_values = [warning[d] for d in dates[:split] if d in warning] @@ -243,6 +249,7 @@ async def run_event_study( "train_fraction": TRAIN_FRACTION, "warn_percentile": WARN_PERCENTILE, "warn_threshold": round(warn_threshold, 1), + "credit_sensor_from": credit_from, "basket_hash": rms._basket_hash(config["breadth_basket"]), "basket_asof": config["basket_asof"], }, diff --git a/app/services/regime_monitor_service.py b/app/services/regime_monitor_service.py index 4e22c69..da91233 100644 --- a/app/services/regime_monitor_service.py +++ b/app/services/regime_monitor_service.py @@ -1,16 +1,23 @@ -"""AI/Tech Regime Monitor v2. +"""AI/Tech Regime Monitor v3. The monitor is a risk thermometer, not a probability or trading rule. It keeps two deliberately separate outputs: * State: current structural stress (price, breadth, credit, volatility). -* Warning: deterioration/divergence that may precede State (breadth, relative - strength, and sourced fundamental observations). +* Warning: deterioration/divergence that may precede State (breadth divergence, + relative strength, credit impulse). -Daily snapshots are the point-in-time record. The first v2 run rewrites the -latest ``REBUILD_SESSIONS`` trading sessions once; ordinary runs thereafter only -upsert the latest trading date. Fundamental observations are never replayed -before their effective date. +Both scores are quantitative and daily. The sourced hyperscaler capex and +earnings-reaction observations are a qualitative *overlay* in v3 rather than +weighted sensors: at a combined 20 points they could not reach the event +study's alarm threshold even when both pegged, so refreshing them appeared to +do nothing. They are reported next to the scores instead of inside them. + +Daily snapshots are the point-in-time record. The first run under a new +``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once; +ordinary runs thereafter only upsert the latest trading date. The overlay is +still gated by its effective date so a rebuild cannot stamp today's observation +onto historical snapshots. """ from __future__ import annotations @@ -41,19 +48,51 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "") KEY_CONFIG = "regime_monitor_config" KEY_FUNDAMENTALS = "regime_fundamental_overrides" -METHODOLOGY = "v2" +METHODOLOGY = "v3" +# Snapshots are reseeded on a methodology bump, but fundamental observations are +# collected by hand/LLM and carried across it when the format is compatible. +CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"}) REBUILD_SESSIONS = 400 MIN_COVERAGE = 75.0 SOURCE_MAX_LAG_DAYS = 7 -QUADRANT_STATE_DIVIDER = 60.0 -QUADRANT_WARNING_DIVIDER = 60.0 +# Bands are per axis: the two scores have genuinely different realized ranges, +# so one shared set made Warning's top bands unreachable (v2 Warning never +# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round +# numbers chosen so each band covers a sane share of history, not percentile +# fits -- percentile-derived bands would drift on every rebuild and silently +# rewrite what past snapshots meant. Realized shares over the 408 sessions to +# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%. +STATE_BANDS = (20.0, 50.0, 80.0) +WARNING_BANDS = (20.0, 40.0, 60.0) + +QUADRANT_STATE_DIVIDER = 50.0 +QUADRANT_WARNING_DIVIDER = 40.0 QUADRANT_MARGIN = 5.0 HY_OAS_MILD = 3.5 HY_OAS_ELEVATED = 5.0 HY_OAS_STRESSED = 7.0 -HY_OAS_REFERENCE_YEARS = 10.0 +# ICE restricted FRED to a rolling 3-year window for BAMLH0A0HYM2 in April 2026 +# ("Starting in April 2026, this series will only include 3 years of +# observations"), so v2's 10-year reference window silently became 3. Against 3 +# years of uniformly tight spreads (2.59-4.61 over the calibration window) the +# blended upper-tail percentile saturated at an OAS of ~4.5 and scored 20 points +# of stress at 3.5 -- the level these anchors call "mild". The anchors already +# encode the long-run distribution, so the credit *level* is now purely anchored +# and credit *dynamics* live in W3 on the Warning axis where they belong. +HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now +W3_OAS_LOOKBACK = 20 +W3_OAS_FULL_SCALE_PCT = 35.0 + +# Drawdown anchors (drawdown %, stress score). v2 used a bare ``dd_pct * 5``, +# which pegged at a 20% drawdown -- the 90th percentile of the observed +# distribution -- so 39 of 408 sessions sat at exactly 100 with no resolution +# left during the part of a selloff that matters most. These anchors keep +# headroom past the observed 36% maximum. +P3_DRAWDOWN_ANCHORS = ( + (0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0), +) STATE_WEIGHTS = { "price": 40.0, @@ -61,11 +100,15 @@ STATE_WEIGHTS = { "credit": 20.0, "volatility": 15.0, } +# Fundamentals left the score in v3. At 12 + 8 points they could not reach the +# event study's alarm threshold even when both pegged at 100, so the LLM read was +# decorative; it is now a separate qualitative overlay. Credit *impulse* takes +# their place because the OAS level is pinned at zero below the 3.5 anchor while +# its rate of change is not. WARNING_WEIGHTS = { - "breadth_divergence": 50.0, + "breadth_divergence": 45.0, "relative_strength": 30.0, - "capex": 12.0, - "earnings_reaction": 8.0, + "credit_impulse": 25.0, } # Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor, @@ -92,7 +135,10 @@ DEFAULT_CONFIG: dict = { CAPEX_STATES = ("raising", "holding", "cutting", "unknown") GNSD_STATES = ("yes", "no", "mixed") -_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 0.0, "cutting": 100.0} +# v2 scored raising and holding identically at 0, so in a capex boom the reading +# was pinned at 0 and could not express the raising -> holding deceleration that +# is the actual early warning. Display-only in v3, but it should still describe. +_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 50.0, "cutting": 100.0} _GNSD_SCORES = {"yes": 100.0, "no": 0.0} Series = list[tuple[date, float]] @@ -127,12 +173,23 @@ def _blend(leader: float | None, confirm: float | None, leader_weight: float = 2 return sum(v * w for v, w in parts) / sum(w for _, w in parts) -def band_for(score: float) -> str: - if score < 30: +def _interpolate(x: float, anchors: tuple[tuple[float, float], ...]) -> float: + """Piecewise-linear lookup, flat outside the first and last anchor.""" + if x <= anchors[0][0]: + return anchors[0][1] + for (x0, y0), (x1, y1) in zip(anchors, anchors[1:]): + if x <= x1: + return y0 + (y1 - y0) * (x - x0) / (x1 - x0) + return anchors[-1][1] + + +def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> str: + watch, elevated, breaking = bands + if score < watch: return "stable" - if score < 60: + if score < elevated: return "watch" - if score < 80: + if score < breaking: return "elevated" return "breaking" @@ -167,19 +224,29 @@ def p2_death_cross(smh: list[float], qqq: list[float], leader_weight: float = 2. return _blend(_death_cross(smh), _death_cross(qqq), leader_weight) -def _drawdown(closes: list[float]) -> float | None: +def drawdown_pct(closes: list[float]) -> float | None: + """Percentage below the trailing 52-week closing high.""" if len(closes) < 30: return None peak = max(closes[-252:]) if peak <= 0: return None - dd_pct = (peak - closes[-1]) / peak * 100.0 - return _clamp(dd_pct * 5.0) + return (peak - closes[-1]) / peak * 100.0 -def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None: - vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None] - return max(vals) if vals else None +def _drawdown(closes: list[float]) -> float | None: + dd_pct = drawdown_pct(closes) + return None if dd_pct is None else _clamp(_interpolate(dd_pct, P3_DRAWDOWN_ANCHORS)) + + +def p3_drawdown(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None: + """Anchored drawdown stress on the same 2:1 leader/confirm blend P1 and P2 use. + + v2 took ``max()`` here, which meant the more volatile leader always won and + the price pillar reduced to this one sensor: its realized share of State was + 65% against a nominal 40% weight. Blending brings that back to 40%. + """ + return _blend(_drawdown(smh), _drawdown(qqq), leader_weight) def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60) -> float | None: @@ -220,18 +287,68 @@ def _oas_absolute_score(value: float) -> float: def f2_credit_spreads(oas_values: list[float]) -> float | None: - """HY OAS stress: 70% named absolute anchors + 30% upper-tail percentile.""" + """HY OAS level against named absolute anchors (3.5 mild / 5.0 / 7.0). + + v2 blended 70% of this with a 30% upper-tail percentile over the available + history. That leg was always a second, noisier estimate of what the anchors + already encode -- and once the usable window shrank to 3 uniformly tight + years it saturated far below any real stress level. Removed rather than + repaired: see ``HY_OAS_WINDOW_DAYS``. + """ if not oas_values: return None - latest = oas_values[-1] - absolute = _oas_absolute_score(latest) - if len(oas_values) < 30: - return round(absolute, 2) - less = sum(1 for v in oas_values if v < latest) - equal = sum(1 for v in oas_values if v == latest) - percentile = (less + 0.5 * equal) / len(oas_values) * 100.0 - relative = _clamp((percentile - 50.0) / 45.0 * 100.0) - return round(absolute * 0.7 + relative * 0.3, 2) + return round(_oas_absolute_score(oas_values[-1]), 2) + + +def w3_credit_impulse( + oas_values: list[float], lookback: int = W3_OAS_LOOKBACK +) -> float | None: + """HY OAS rate of change: widening only, relative so it works at any level. + + The credit *level* (C1) sits at zero for as long as spreads stay under the + 3.5 mild anchor -- 2.77 as of the v3 cutover -- so it contributes nothing to + State in a calm tape. The rate of change still does, and spread widening is + a classic lead, which is what Warning is for. Relative rather than absolute + because +0.5pp means something very different at 2.7 than at 8.0. + """ + if len(oas_values) < lookback + 1: + return None + past = oas_values[-lookback - 1] + if past <= 0: + return None + change_pct = (oas_values[-1] / past - 1.0) * 100.0 + return _clamp(change_pct / W3_OAS_FULL_SCALE_PCT * 100.0) + + +def warning_sensor_scores( + divergence: float | None, + leader_closes: list[float], + market_closes: list[float], + oas_window: list[float], +) -> dict[str, float | None]: + """The three Warning sensors, by pillar id. + + Single definition so the live monitor and the event study cannot drift apart + -- in v2 the study re-derived the score from ``WARNING_WEIGHTS`` by hand and + would have silently kept measuring the old construct through this change. + """ + return { + "breadth_divergence": divergence, + "relative_strength": p4_relative_strength(leader_closes, market_closes), + "credit_impulse": w3_credit_impulse(oas_window), + } + + +def score_warning_sensors(sensors: dict[str, float | None]) -> float | None: + """Weighted Warning score, renormalised over the sensors that are available.""" + live = [ + (float(score), float(WARNING_WEIGHTS[key])) + for key, score in sensors.items() + if score is not None and key in WARNING_WEIGHTS + ] + if not live: + return None + return sum(s * w for s, w in live) / sum(w for _, w in live) def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict: @@ -244,7 +361,11 @@ def _sensor(sensor_id: str, label: str, score: float | None, **details: object) } -def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict: +def _score_pillars( + pillars: list[dict], + weights: dict[str, float], + bands: tuple[float, float, float] = STATE_BANDS, +) -> dict: expected = sum(max(0.0, float(w)) for w in weights.values()) available_weight = sum( max(0.0, float(weights.get(p["id"], 0.0))) @@ -276,7 +397,12 @@ def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict: rounded = round(score, 1) if score is not None else None return { "score": rounded, - "band": band_for(rounded) if rounded is not None and coverage >= MIN_COVERAGE else None, + "band": ( + band_for(rounded, bands) + if rounded is not None and coverage >= MIN_COVERAGE + else None + ), + "bands": {"watch": bands[0], "elevated": bands[1], "breaking": bands[2]}, "coverage": round(coverage, 1), "minimum_coverage": MIN_COVERAGE, "available_pillars": [p["id"] for p in rows if p["available"]], @@ -309,13 +435,24 @@ def _value_asof(series: Series | None, as_of: date) -> float | None: return item[1] if item else None -def _window_asof(series: Series | None, as_of: date, years: float) -> list[float]: +def _window_asof(series: Series | None, as_of: date, days: int) -> list[float]: if not series: return [] - start = as_of - timedelta(days=int(365.25 * years)) + start = as_of - timedelta(days=days) return [v for d, v in series if start <= d <= as_of] +def _coverage_days(series: Series | None, as_of: date) -> int | None: + """Span of history actually available at ``as_of``. + + Recorded in every snapshot because the v2 credit percentile degraded from a + 10-year to a 3-year reference silently when the upstream licence changed -- + nothing asserted the window it claimed, so nothing noticed for months. + """ + dates = [d for d, _ in series or [] if d <= as_of] + return (as_of - dates[0]).days if dates else None + + def _next_weekday(d: date) -> date: candidate = d + timedelta(days=1) while candidate.weekday() >= 5: @@ -340,19 +477,31 @@ def _fundamental_effective_date(overrides: dict) -> date | None: return _next_weekday(fetched) if fetched else None -def _fundamental_scores_asof(overrides: dict, config: dict, as_of: date) -> tuple[float | None, float | None, dict]: +def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict: + """Point-in-time qualitative overlay. Never feeds State or Warning in v3. + + The effective-date gate stays even though nothing is scored from this: the + 400-session rebuild replays historical dates, and stamping today's LLM read + onto 2024 snapshots would be plain lookahead in the stored record. + """ effective = _fundamental_effective_date(overrides) - if effective is None or as_of < effective: - return None, None, {"effective_date": effective.isoformat() if effective else None, "age_days": None} - age = (as_of - effective).days - stale = age > int(config.get("fundamental_staleness_days", 80)) - f1 = overrides.get("f1_score") - f3 = overrides.get("f3_score") - return ( - None if stale or f1 is None else _clamp(float(f1)), - None if stale or f3 is None else _clamp(float(f3)), - {"effective_date": effective.isoformat(), "age_days": age, "stale": stale}, - ) + pending = effective is None or as_of < effective + age = None if pending else (as_of - effective).days + stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80))) + return { + "available": not pending and not stale, + "pending": pending, + "stale": stale, + "effective_date": effective.isoformat() if effective else None, + "age_days": age, + "capex": None if pending else overrides.get("capex"), + "good_news_stock_down": None if pending else overrides.get("good_news_stock_down"), + "capex_stress": None if pending else overrides.get("f1_score"), + "earnings_stress": None if pending else overrides.get("f3_score"), + "reasoning": None if pending else overrides.get("reasoning"), + "source": overrides.get("source"), + "fetched_at": overrides.get("fetched_at"), + } def _basket_hash(symbols: list[str]) -> str: @@ -393,12 +542,14 @@ def _compute_index( vix_item = _item_asof(vix_series, as_of) vix_score = p5_volatility(vix_item[1] if vix_item else None) oas_item = _item_asof(oas_series, as_of) - oas_window = _window_asof(oas_series, as_of, HY_OAS_REFERENCE_YEARS) + oas_window = _window_asof(oas_series, as_of, HY_OAS_WINDOW_DAYS) credit_score = f2_credit_spreads(oas_window) divergence = _value_asof(divergence_series, as_of) - relative_strength = p4_relative_strength(smh, spy) - f1, f3, fundamental_meta = _fundamental_scores_asof(overrides, config, as_of) + sensors = warning_sensor_scores(divergence, smh, spy, oas_window) + relative_strength = sensors["relative_strength"] + credit_impulse = sensors["credit_impulse"] + overlay = fundamental_overlay(overrides, config, as_of) state_pillars = [ { @@ -444,21 +595,22 @@ def _compute_index( "sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)], }, { - "id": "capex", - "label": "Hyperscaler capex revisions", - "score": round(f1, 1) if f1 is not None else None, - "sensors": [_sensor("F1", "Capex guidance cuts", f1)], - }, - { - "id": "earnings_reaction", - "label": "Good news, stock down", - "score": round(f3, 1) if f3 is not None else None, - "sensors": [_sensor("F3", "Abnormal earnings reaction", f3)], + "id": "credit_impulse", + "label": "Credit impulse", + "score": round(credit_impulse, 1) if credit_impulse is not None else None, + "sensors": [ + _sensor( + "W3", + f"HY OAS {W3_OAS_LOOKBACK}-session widening", + credit_impulse, + oas=oas_item[1] if oas_item else None, + ) + ], }, ] - state = _score_pillars(state_pillars, STATE_WEIGHTS) - warning = _score_pillars(warning_pillars, WARNING_WEIGHTS) + state = _score_pillars(state_pillars, STATE_WEIGHTS, STATE_BANDS) + warning = _score_pillars(warning_pillars, WARNING_WEIGHTS, WARNING_BANDS) price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of) dated_sources = { @@ -481,6 +633,7 @@ def _compute_index( "date": as_of.isoformat(), "state": state, "warning": warning, + "fundamental_overlay": overlay, "quadrant_config": { "state_divider": QUADRANT_STATE_DIVIDER, "warning_divider": QUADRANT_WARNING_DIVIDER, @@ -502,14 +655,18 @@ def _compute_index( "breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None, "breadth_date": breadth_item[0].isoformat() if breadth_item else None, "fundamentals_fetched_at": overrides.get("fetched_at"), - "fundamentals_effective_date": fundamental_meta.get("effective_date"), - "fundamentals_age_days": fundamental_meta.get("age_days"), + "fundamentals_effective_date": overlay.get("effective_date"), + "fundamentals_age_days": overlay.get("age_days"), }, "data_quality": { "minimum_coverage": MIN_COVERAGE, "oldest_market_input_age_days": max(source_ages.values()) if source_ages else None, "stale_inputs": stale_inputs, "inputs_fresh": not stale_inputs, + # Upstream history spans, so a provider silently truncating a series + # shows up in the record instead of quietly reshaping a sensor. + "credit_history_days": _coverage_days(oas_series, as_of), + "vix_history_days": _coverage_days(vix_series, as_of), }, } @@ -581,7 +738,11 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict: stored = json.loads(raw) except (TypeError, ValueError): return default - if stored.get("methodology") != METHODOLOGY: + # The guard rejects pre-v2 blobs, where f1/f3 were arbitrary numbers with no + # categorical source. v2 and v3 share the categorical format and both derive + # f1/f3 from it below, so a methodology bump must not discard a live + # observation -- only the capex *scale* changed, and that is recomputed. + if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES: return default capex = _normalise_capex_states(stored.get("capex"), names) reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower() @@ -745,7 +906,7 @@ async def _upsert_snapshot( created_at=datetime.now(timezone.utc), )) else: - existing_v2 = _parse_v2(row.breakdown_json) + existing_v2 = _parse_snapshot(row.breakdown_json) if existing_v2 is not None and not rewrite_existing_v2: return False, existing_v2 row.total_score = float(state_score or 0.0) @@ -754,7 +915,7 @@ async def _upsert_snapshot( return True, result -def _parse_v2(raw: str) -> dict | None: +def _parse_snapshot(raw: str) -> dict | None: try: parsed = json.loads(raw) except (TypeError, ValueError): @@ -762,12 +923,12 @@ def _parse_v2(raw: str) -> dict | None: return parsed if parsed.get("methodology") == METHODOLOGY else None -async def _latest_v2_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None: +async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None: result = await db.execute( select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000) ) for row in result.scalars().all(): - parsed = _parse_v2(row.breakdown_json) + parsed = _parse_snapshot(row.breakdown_json) if parsed is not None: return row, parsed return None @@ -791,8 +952,10 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL latest_date = leader_series[-1][0] vix_series = await _fetch_fred_series("VIXCLS", end - timedelta(days=1200), end) + # Asking for 13 years was misleading once the licence capped the series at 3; + # the level needs the latest point and W3 needs its lookback, nothing more. oas_series = await _fetch_fred_series( - "BAMLH0A0HYM2", end - timedelta(days=int(365.25 * 13)), end + "BAMLH0A0HYM2", end - timedelta(days=HY_OAS_WINDOW_DAYS), end ) basket = config["breadth_basket"] @@ -805,7 +968,7 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc) breadth, breadth_counts, divergence = {}, {}, {} - latest_v2 = await _latest_v2_row(db) + latest_v2 = await _latest_snapshot_row(db) rebuilding = latest_v2 is None and bool(leader_series) if rebuilding: dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]] @@ -860,7 +1023,7 @@ async def _result_at_or_before( .limit(1000) ) for raw in result.scalars().all(): - parsed = _parse_v2(raw) + parsed = _parse_snapshot(raw) parsed_hash = ((parsed or {}).get("basket") or {}).get("hash") if parsed is not None and (basket_hash is None or parsed_hash == basket_hash): return parsed @@ -877,7 +1040,7 @@ def _delta(current: dict, previous: dict | None) -> float | None: async def get_regime_monitor(db: AsyncSession) -> dict: - latest = await _latest_v2_row(db) + latest = await _latest_snapshot_row(db) if latest is None: return {"available": False, "reason": "v2 not computed yet"} row, result = latest @@ -902,6 +1065,15 @@ async def get_regime_monitor(db: AsyncSession) -> dict: quality["snapshot_age_days"] = snapshot_age quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4 result["data_quality"] = quality + + # The snapshot's overlay is the point-in-time record; the reader also wants + # the current observation even when it is not effective until the next + # session, because otherwise refreshing it looks like it did nothing. + config = await get_regime_config(db) + overrides = await get_fundamental_overrides(db) + live = fundamental_overlay(overrides, config, date.today()) + live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available")) + result["fundamental_context"] = live result["available"] = True return result @@ -915,7 +1087,7 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]: ) out: list[dict] = [] for row in result.scalars().all(): - data = _parse_v2(row.breakdown_json) + data = _parse_snapshot(row.breakdown_json) if data is None: continue state, warning = data.get("state") or {}, data.get("warning") or {} diff --git a/docs/research/regime-monitor-v2.md b/docs/research/regime-monitor-v2.md deleted file mode 100644 index cfafbad..0000000 --- a/docs/research/regime-monitor-v2.md +++ /dev/null @@ -1,75 +0,0 @@ -# Regime Monitor v2 methodology - -The Regime Monitor is an observational AI/Tech risk thermometer. It does not -gate entries, exits, position size, ranking, or alerts about individual setups. - -## Outputs - -**State** measures current structural stress: - -- Price structure, 40%: `max(P1, P2, P3)`, so the correlated 200-DMA, death-cross, - and drawdown readings receive one capped vote. -- Fixed-basket breadth level, 25%. -- HY option-adjusted credit spread, 20%. -- VIX level, 15%. - -**Warning** measures deterioration and divergence: - -- Fixed-basket breadth divergence while SMH holds/rises, 50%. -- 60-session SMH/SPY relative-strength deterioration, 30%. -- Hyperscaler capex cuts, 12%. -- Good-news-stock-down earnings reactions, 8%. - -Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v2. - -## Scale and missing data - -Zero means ordinary/healthy, and only stress contributes positively. Automated -capex `raising`/`holding` and no good-news-stock-down pattern map to zero; -`mixed`, unknown, and stale observations are unavailable rather than neutral 50. -Manual observations use the same categories: each hyperscaler is marked -`raising`, `holding`, `cutting`, or `unknown`, while the earnings reaction is -`yes`, `no`, or `mixed`. F1 is derived from the share of at least three known -hyperscalers marked `cutting`; arbitrary numeric overrides are not accepted. - -Scores renormalize over available fixed weights, but a band is published only at -75% or greater coverage. Trend deltas are suppressed when the participating -pillar set changes. Bands are stable `<30`, watch `<60`, elevated `<80`, and -breaking `>=80`. - -Credit uses named HY OAS anchors (3.5 mild, 5.0 elevated, 7.0 stressed) for 70% -of its score and a ten-year upper-tail percentile for 30%. - -## Point-in-time record - -The first v2 run rebuilds the latest 400 trading sessions with sufficient sensor -warm-up. Routine runs thereafter insert/update only the latest trading date. -Fundamental observations have an effective date (normally the next session after -collection) and are never replayed backward. The history API and main chart show -only snapshots marked `methodology: v2`. - -Each snapshot stores the fixed basket symbols, hash, and freeze date. Reconstructed -history before that freeze date is retrospective/exploratory; readings after it -form the forward record. - -The automatic 400-session rebuild is intentionally one-shot: it runs only when -no v2 snapshot exists. If an initial seed used partial data or the wrong basket, -the operational reseed procedure is to remove the v2 snapshot rows and run the -Regime Monitor job again. There is no routine force-rebuild flag. - -## Warning study - -The study calls the outcome a **10% correction**, not a regime break. The first -70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are -measured on the final 30%. An alarm requires an upward crossing and another alarm -requires a reset below the threshold. The report exposes warned/missed events, -false alarms per year, median lead, sample dates, event count, report date, and -whether the result is exploratory or a true forward holdout. UI claims are -generated from that report; no performance sentence is hard-coded. - -## Operator rule - -Quadrant alerts default off for new/reset configurations. When enabled they -require fresh inputs, at least 75% coverage on both axes, two consecutive daily -confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer — -not a trade signal.** diff --git a/docs/research/regime-monitor-v3.md b/docs/research/regime-monitor-v3.md new file mode 100644 index 0000000..92254ec --- /dev/null +++ b/docs/research/regime-monitor-v3.md @@ -0,0 +1,168 @@ +# Regime Monitor v3 methodology + +The Regime Monitor is an observational AI/Tech risk thermometer. It does not +gate entries, exits, position size, ranking, or alerts about individual setups. + +v3 supersedes v2. Every parameter below was calibrated against the 408 v2 +sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED +inputs the live job uses; the reproduction matched the stored prod distribution +exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108). + +## What changed and why + +**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down) +carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a +Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and +still inside the "stable" band. The sourced observation could not change any +published conclusion, so refreshing it looked like it did nothing. They are now +a qualitative overlay reported beside the scores. Capex also stopped scoring +`raising` and `holding` identically at 0: `holding` is the deceleration case and +now scores 50, so a boom no longer reads the same as a stall. + +**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at +a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408 +sessions sat at exactly 100 with no resolution left, and the price pillar showed +the top band on 13.5% of sessions. v3 uses named anchors with headroom past the +observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did +instead of taking `max()`. P3's realized share of State falls from 65% to 40%, +matching its nominal weight. + +**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero +below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing +in a calm tape. Its 20-session rate of change still does, and spread widening is +a classic lead. + +**The credit percentile leg was removed.** Its reference window silently shrank +from 10 years to 3 when ICE restricted the upstream series in April 2026, after +which it scored 20 points of stress at a spread the same sensor's anchors call +"mild". See Calibration below. + +**Breadth loss counts during declines.** v2's divergence gate was +`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the +basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and +Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts +fully when price masks it (true divergence, the dangerous pre-top case) and at +35% when price confirms it. Breadth *level* lives in State, but breadth +*velocity* appears nowhere else, so this is not double counting. + +**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while +State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The +upper half of the Warning axis was unreachable. + +## Outputs + +**State** — current structural stress: + +- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads. +- Fixed-basket breadth level, 25%. +- HY option-adjusted credit spread level, 20%. +- VIX level, 15%. + +**Warning** — deterioration and divergence: + +- Fixed-basket breadth divergence, 45%. +- 60-session SMH/SPY relative-strength deterioration, 30%. +- HY OAS 20-session widening, 25%. + +Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3. + +## Calibration + +P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78, +40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100) +rather than absolute, because +0.5pp means something very different at an OAS of +2.7 than at 8.0. + +Bands are round, meaning-anchored numbers, not percentile fits — percentile +thresholds would drift on every rebuild and silently rewrite what past snapshots +meant. Realized shares over the calibration window: + +| Axis | stable | watch | elevated | breaking | thresholds | +|------|--------|-------|----------|----------|------------| +| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 | +| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 | + +Quadrant dividers sit at each axis's watch/elevated boundary: State 50, +Warning 40. + +Scores renormalize over available fixed weights, but a band is published only at +75% or greater coverage. Trend deltas are suppressed when the participating +pillar set changes. Zero means ordinary/healthy; only stress contributes. + +Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0 +stressed, linear between, and nothing else. v2 blended those anchors at 70% with +a 30% upper-tail percentile over a nominally 10-year window. + +That leg was removed rather than repaired. ICE restricted FRED to a rolling +3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it +outright ("Starting in April 2026, this series will only include 3 years of +observations"), and an unbounded request returns the same 795 observations as a +30-year one. The v2 percentile therefore ranked the current spread against three +uniformly tight years (range 2.59–4.61 over the calibration window), which made +it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors +call *mild*, scoring zero stress — the blended sensor read 20.1, and the +percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it +roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled +its nonzero days (60 vs 27). + +The anchors already encode the long-run distribution as constants, so the +percentile was a second, noisier estimate of the same thing. What it was +genuinely reaching for — "unusual versus recent history" — is now W3 on the +Warning axis, computed as a rate of change, which is where deterioration +belongs. Removing it moved State's average by −0.4 and its maximum by −3.8, left +Warning bit-identical, and did not shift any band threshold. + +A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to +1997) was considered and rejected: ranking an HY spread against investment-grade +history is not a coherent statistic, and it would rescue a leg that is redundant +anyway. + +Every snapshot now records `data_quality.credit_history_days` and +`vix_history_days`. This defect was invisible for roughly three months because +nothing asserted the window the code claimed; the spans make a future upstream +truncation show up in the record instead of quietly reshaping a sensor. + +**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration +window reaches back to 2024, so names were partly selected for having done well. +Every distribution above inherits that bias. It is the same bias v2 carried, so +the v2/v3 comparison is like-for-like, but the absolute band shares are +optimistic. + +## Point-in-time record + +The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions +with sufficient sensor warm-up; routine runs thereafter insert/update only the +latest trading date. The history API and main chart show only snapshots matching +the current methodology, so a bump reseeds the series rather than splicing two +formulas into one line. + +The fundamental overlay keeps its effective date (normally the next session after +collection) and is never replayed backward, so a rebuild cannot stamp today's +observation onto historical snapshots. Because the observation is stored in a +single slot, a refresh replaces the previously effective record: the snapshot +therefore reports the overlay as `pending` until the new effective date, and the +live reading additionally carries `fundamental_context` so a just-collected +observation is visible immediately rather than appearing to have done nothing. + +Each snapshot stores the fixed basket symbols, hash, and freeze date. +Reconstructed history before that freeze date is retrospective/exploratory. + +## Warning study + +The study calls the outcome a **10% correction**, not a regime break. The first +70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are +measured on the final 30%. Because v3 dropped fundamentals from the score, the +study now measures exactly the live Warning score rather than a technical-only +approximation of it, and both are computed from one shared sensor definition +(`warning_sensor_scores`) so they cannot drift apart. + +A cached report is discarded when its methodology no longer matches, so the panel +reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run +the Event Study job after cutting over to v3.** + +## Operator rule + +Quadrant alerts default off for new/reset configurations. When enabled they +require fresh inputs, at least 75% coverage on both axes, two consecutive daily +confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer — +not a trade signal.** diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea4d858..3d924be 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -480,6 +480,9 @@ export interface RegimePillar { export interface RegimeReading { score: number | null; band: RegimeBand | null; + // Per axis: State and Warning have different realized ranges, so they do not + // share thresholds. + bands?: { watch: number; elevated: number; breaking: number }; coverage: number; minimum_coverage: number; available_pillars: string[]; @@ -487,6 +490,23 @@ export interface RegimeReading { trend?: { delta_7: number | null; delta_30: number | null }; } +/** Qualitative capex / earnings-reaction context. Not part of either score. */ +export interface RegimeFundamentalOverlay { + available: boolean; + pending: boolean; + stale: boolean; + effective_date: string | null; + age_days: number | null; + capex: Record | null; + good_news_stock_down: GoodNewsReaction | null; + capex_stress: number | null; + earnings_stress: number | null; + reasoning: string | null; + source: string | null; + fetched_at: string | null; + observed_in_snapshot?: boolean; +} + export interface RegimeHistoryPoint { date: string; state: number | null; @@ -503,6 +523,10 @@ export interface RegimeMonitor { date?: string; state?: RegimeReading; warning?: RegimeReading; + /** Point-in-time overlay recorded in the snapshot. */ + fundamental_overlay?: RegimeFundamentalOverlay; + /** Current observation, even when it is not effective until the next session. */ + fundamental_context?: RegimeFundamentalOverlay; inputs?: { vix: number | null; vix_date: string | null; @@ -534,7 +558,7 @@ export interface RegimeMonitor { } export interface RegimeFundamentals { - methodology: 'v2'; + methodology: 'v3'; f1_score: number | null; f3_score: number | null; locked: boolean; diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx index fa4fb70..cf8d6df 100644 --- a/frontend/src/pages/RegimePage.tsx +++ b/frontend/src/pages/RegimePage.tsx @@ -21,6 +21,7 @@ import type { GoodNewsReaction, RegimeBand, RegimeConfig, + RegimeFundamentalOverlay, RegimeFundamentals, RegimeFundamentalsUpdate, RegimeReading, @@ -64,6 +65,8 @@ function ScoreGauge({ const complete = reading?.band != null; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const position = Math.min(100, Math.max(0, score ?? 0)); + const bands = reading?.bands; + const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80]; return (
@@ -98,8 +101,15 @@ function ScoreGauge({ style={{ left: `${position}%` }} />
-
- 0306080100 + {/* Thresholds come from the reading: the two axes no longer share them. */} +
+ 0 + {ticks.map((tick) => ( + + {tick} + + ))} + 100
)} @@ -108,6 +118,77 @@ function ScoreGauge({ ); } +const CAPEX_TONE: Record = { + raising: 'text-emerald-400', + holding: 'text-amber-400', + cutting: 'text-red-400', + unknown: 'text-gray-500', +}; + +function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) { + const capex = overlay.capex ?? {}; + const reaction = overlay.good_news_stock_down; + return ( +
+
+
+ Fundamental overlay · context, not scored +
+
+ {overlay.source && {overlay.source}} + {overlay.effective_date && · effective {overlay.effective_date}} + {overlay.pending && } + {overlay.stale && } +
+
+ + {overlay.pending ? ( +

+ A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}. + Observations are never backdated, so the reading below appears from that session onward. +

+ ) : ( + <> +
+
+
+ Hyperscaler capex guidance + {overlay.capex_stress ?? 'n/a'} +
+
+ {Object.entries(capex).map(([symbol, state]) => ( +
+ {symbol} + {state} +
+ ))} +
+
+
+
+ Good news, stock down + {overlay.earnings_stress ?? 'n/a'} +
+
+ {reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'} +
+
+
+ {overlay.reasoning && ( +

{overlay.reasoning}

+ )} + + )} + +

+ These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and + Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even + when both were pegged — so they are reported here rather than diluted into a daily score. +

+
+ ); +} + function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { return ( @@ -235,8 +316,10 @@ function FundamentalsEditor({ const [capex, setCapex] = useState>(() => ({ ...data.capex })); const [reaction, setReaction] = useState(data.good_news_stock_down); const knownCapex = Object.values(capex).filter((state) => state !== 'unknown'); - const cutting = knownCapex.filter((state) => state === 'cutting').length; - const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null; + // Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is + // the deceleration case and used to score identically to raising. + const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0); + const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null; const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null; return (
@@ -266,7 +349,7 @@ function FundamentalsEditor({ ))}
-

Raising/holding = 0 stress; cutting = 100; at least three known names required.

+

Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only — this does not enter Warning.

+ {data.fundamental_context && }

Data quality · oldest market input:{' '} {data.data_quality?.oldest_market_input_age_days == null diff --git a/tests/unit/test_event_study.py b/tests/unit/test_event_study.py index 629b184..72404f1 100644 --- a/tests/unit/test_event_study.py +++ b/tests/unit/test_event_study.py @@ -52,7 +52,7 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days(): assert result["median_lead_days"] == 17.5 -def test_breadth_from_fixed_closes_and_pure_divergence(): +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)])), @@ -67,6 +67,13 @@ def test_breadth_from_fixed_closes_and_pure_divergence(): 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)])) - no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3) - assert no_divergence[dates[-1]] == 0 + 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 diff --git a/tests/unit/test_regime_monitor.py b/tests/unit/test_regime_monitor.py index c05715b..0bde0a7 100644 --- a/tests/unit/test_regime_monitor.py +++ b/tests/unit/test_regime_monitor.py @@ -1,4 +1,4 @@ -"""Pure-function tests for the v2 Regime Monitor contract.""" +"""Pure-function tests for the v3 Regime Monitor contract.""" from __future__ import annotations @@ -12,23 +12,30 @@ from sqlalchemy import select from app.models.regime_snapshot import RegimeSnapshot from app.routers import market as market_router -from app.services import regime_monitor_service as rms +from app.services import breadth_service, regime_monitor_service as rms from app.services.regime_monitor_service import ( DEFAULT_CONFIG, HY_OAS_ELEVATED, HY_OAS_MILD, HY_OAS_STRESSED, + STATE_BANDS, + WARNING_BANDS, + WARNING_WEIGHTS, _compute_index, - _fundamental_scores_asof, _score_pillars, band_for, breadth_level_score, + drawdown_pct, f2_credit_spreads, + fundamental_overlay, p1_trend_break, p2_death_cross, p3_drawdown, p4_relative_strength, p5_volatility, + score_warning_sensors, + w3_credit_impulse, + warning_sensor_scores, ) @@ -39,11 +46,15 @@ def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[dat ] -def test_band_for_keeps_documented_boundaries(): - assert band_for(10) == "stable" - assert band_for(30) == "watch" - assert band_for(60) == "elevated" - assert band_for(80) == "breaking" +def test_band_for_is_per_axis(): + assert band_for(10, STATE_BANDS) == "stable" + assert band_for(20, STATE_BANDS) == "watch" + assert band_for(50, STATE_BANDS) == "elevated" + assert band_for(80, STATE_BANDS) == "breaking" + # Warning's realized range is far narrower, so it gets its own thresholds. + assert band_for(45, STATE_BANDS) == "watch" + assert band_for(45, WARNING_BANDS) == "elevated" + assert band_for(60, WARNING_BANDS) == "breaking" def test_price_sensors_are_stress_only(): @@ -56,8 +67,97 @@ def test_price_sensors_are_stress_only(): assert (p2_death_cross(bearish, bearish) or 0) > 0 assert p2_death_cross(healthy, healthy) == 0 - closes = [100.0] * 252 + [80.0] - assert p3_drawdown(closes, [100.0] * 253) == 100.0 + +def test_drawdown_sensor_keeps_headroom_past_a_twenty_percent_fall(): + """v2 pegged at 100 on a 20% drawdown, losing all resolution deeper in.""" + flat = [100.0] * 253 + down_20 = [100.0] * 252 + [80.0] + down_30 = [100.0] * 252 + [70.0] + down_45 = [100.0] * 252 + [55.0] + + assert drawdown_pct(down_20) == pytest.approx(20.0) + leader_only_20 = p3_drawdown(down_20, flat) + leader_only_30 = p3_drawdown(down_30, flat) + assert leader_only_20 < leader_only_30 < 100.0 + + # Full scale needs both legs at the deepest anchor, not one at 20%. + assert p3_drawdown(down_45, down_45) == 100.0 + assert p3_drawdown(flat, flat) == 0.0 + + +def test_drawdown_blends_leader_and_confirm_instead_of_taking_the_max(): + """max() let the more volatile leader own the whole price pillar.""" + flat = [100.0] * 253 + down = [100.0] * 252 + [72.0] + both = p3_drawdown(down, down) + leader_only = p3_drawdown(down, flat) + assert leader_only == pytest.approx(both * 2.0 / 3.0) + + +def test_credit_impulse_scores_widening_only(): + assert w3_credit_impulse([3.0] * 40) == 0.0 + # Tightening is not stress. + assert w3_credit_impulse([4.0] * 21 + [3.0]) == 0.0 + # +35% over the lookback is full scale; half of it is half the score. + assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.35]) == pytest.approx(100.0) + assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.175]) == pytest.approx(50.0) + # Fires while the OAS *level* is still far below the 3.5 mild anchor. This + # is the pairing that lets the level stay purely anchored: dynamics live on + # the Warning axis rather than being smuggled into State as a percentile. + assert f2_credit_spreads([2.0] * 21 + [2.7]) == 0.0 + assert (w3_credit_impulse([2.0] * 21 + [2.7]) or 0) > 0 + assert w3_credit_impulse([3.0] * 5) is None + + +def test_snapshot_records_upstream_history_spans(): + """Guards the silent-truncation failure mode that caused this change.""" + end = date(2026, 6, 26) + rising = [100.0 + index * 0.2 for index in range(700)] + prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)} + oas = [(end - timedelta(days=index), 4.0) for index in reversed(range(100))] + + result = _compute_index( + prices, [(end, 20.0)], oas, {"f1_score": None, "f3_score": None}, + copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25}, + ) + + assert result["data_quality"]["credit_history_days"] == 99 + assert result["data_quality"]["vix_history_days"] == 0 + + +def test_divergence_still_registers_when_price_confirms_the_breadth_loss(): + """v2's hard price gate zeroed this sensor during every decline. + + On 2026-07-24 the basket shed 10 points of participation in 20 sessions + while SMH fell 11.9%, and Warning printed exactly 0 as a result. + """ + days = [date(2026, 1, 1) + timedelta(days=index) for index in range(21)] + breadth = {day: 70.0 for day in days[:1]} | {day: 70.0 - index for index, day in enumerate(days)} + + holding = [(day, 100.0) for day in days] + falling = [(day, 100.0 - index * 0.9) for index, day in enumerate(days)] + + masked = breadth_service.compute_divergence_series(breadth, holding)[days[-1]] + confirmed = breadth_service.compute_divergence_series(breadth, falling)[days[-1]] + + assert masked > confirmed > 0 + assert confirmed == pytest.approx(masked * breadth_service.DIVERGENCE_CONFIRMED_FLOOR) + + +def test_warning_score_renormalises_over_available_sensors(): + full = {"breadth_divergence": 40.0, "relative_strength": 0.0, "credit_impulse": 20.0} + assert score_warning_sensors(full) == pytest.approx( + (40 * 45 + 0 * 30 + 20 * 25) / 100 + ) + partial = {"breadth_divergence": 40.0, "relative_strength": None, "credit_impulse": None} + assert score_warning_sensors(partial) == 40.0 + assert score_warning_sensors(dict.fromkeys(full, None)) is None + + +def test_warning_sensor_scores_covers_every_weighted_pillar(): + """Guards the study/monitor shared definition against silent drift.""" + sensors = warning_sensor_scores(10.0, [100.0] * 70, [100.0] * 70, [3.0] * 40) + assert set(sensors) == set(WARNING_WEIGHTS) def test_relative_strength_flat_or_better_is_zero(): @@ -77,12 +177,24 @@ def test_volatility_and_breadth_zero_points(): assert breadth_level_score(None) is None -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_credit_level_is_anchored_and_ignores_the_reference_window(): + """The percentile leg is gone: the anchors already encode the long run. + + It ranked the level against whatever history the upstream series happened to + serve, and that silently shrank from 10 years to 3 in April 2026 -- three + uniformly tight years, against which an unremarkable spread scored as an + extreme. Identical inputs must now score identically regardless of window. + """ + assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0.0 + assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 50.0 + assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 100.0 + assert f2_credit_spreads([]) is None + + # A level at the "mild" anchor is zero stress even when it tops its window. + tight_window = [2.6] * 400 + [HY_OAS_MILD] + assert f2_credit_spreads(tight_window) == 0.0 + # Only the latest observation matters; history cannot move the reading. + assert f2_credit_spreads([9.0] * 400 + [3.0]) == f2_credit_spreads([2.6] * 400 + [3.0]) def test_score_pillars_gates_band_below_75_percent_coverage(): @@ -98,31 +210,75 @@ def test_score_pillars_gates_band_below_75_percent_coverage(): assert result["band"] is None -def test_fundamentals_never_replay_before_effective_date_and_expire(): +def test_fundamental_overlay_never_replays_before_effective_date_and_expires(): overrides = { "f1_score": 0.0, "f3_score": 100.0, + "capex": {"GOOGL": "raising"}, + "good_news_stock_down": "yes", "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) + + pending = fundamental_overlay(overrides, config, date(2026, 6, 1)) + assert pending["pending"] is True + assert pending["available"] is False + assert pending["capex"] is None + # The effective date is still reported so a pending refresh is visible. + assert pending["effective_date"] == "2026-06-02" + + live = fundamental_overlay(overrides, config, date(2026, 6, 2)) + assert live["available"] is True + assert live["good_news_stock_down"] == "yes" + assert live["earnings_stress"] == 100.0 + + expired = fundamental_overlay(overrides, config, date(2026, 8, 22)) + assert expired["stale"] is True + assert expired["available"] is False -def test_capex_score_is_derived_from_company_categories(): +def test_fundamentals_do_not_move_the_warning_score(): + """The v3 complaint: a maxed-out LLM read must not silently do nothing. + + It no longer feeds Warning at all, so Warning is identical either way and + the observation is reported beside the score instead of buried in it. + """ + end = date(2026, 6, 26) + rising = [100.0 + index * 0.2 for index in range(700)] + prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)} + args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))]) + tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25}) + + quiet = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail) + screaming = _compute_index( + *args, + { + "f1_score": 100.0, + "f3_score": 100.0, + "capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"), + "good_news_stock_down": "yes", + "effective_date": "2026-06-01", + }, + *tail, + ) + + assert quiet["warning"]["score"] == screaming["warning"]["score"] + assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS) + assert screaming["fundamental_overlay"]["available"] is True + assert screaming["fundamental_overlay"]["capex_stress"] == 100.0 + + +def test_capex_score_separates_holding_from_raising(): + """v2 mapped raising and holding both to 0, so a boom read identical to a + deceleration and the sensor carried no information.""" names = DEFAULT_CONFIG["tickers"]["hyperscalers"] + assert rms._score_capex_states(dict.fromkeys(names, "raising"), names) == 0.0 + assert rms._score_capex_states(dict.fromkeys(names, "holding"), names) == 50.0 + assert rms._score_capex_states(dict.fromkeys(names, "cutting"), names) == 100.0 assert rms._score_capex_states( - dict.fromkeys(names, "holding"), names - ) == 0.0 - assert rms._score_capex_states( - {names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}, names - ) == 25.0 - assert rms._score_capex_states( - {names[0]: "cutting", names[1]: "holding", names[2]: "holding", names[3]: "unknown"}, - names, - ) == 33.3 + {names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, names + ) == 37.5 assert rms._score_capex_states( {names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"}, names, @@ -135,7 +291,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides(): @pytest.mark.asyncio -async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch): +async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch): async def fake_value(_db, _key): return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"}) @@ -143,16 +299,47 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch): result = await rms.get_fundamental_overrides(object()) - assert result["methodology"] == "v2" + assert result["methodology"] == "v3" assert result["f1_score"] is None assert result["f3_score"] is None assert result["good_news_stock_down"] == "mixed" +@pytest.mark.asyncio +async def test_v2_observation_survives_the_methodology_bump(monkeypatch): + """A snapshot reseed must not throw away a hand/LLM-collected observation. + + The categorical format is unchanged, so the stored capex map is still valid; + only the capex scale moved, and f1 is recomputed from the categories. + """ + names = DEFAULT_CONFIG["tickers"]["hyperscalers"] + + async def fake_value(_db, _key): + return json.dumps({ + "methodology": "v2", + "f1_score": 0.0, # stale v2 scale, must be recomputed + "f3_score": 100.0, + "capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, + "good_news_stock_down": "yes", + "source": "gemini", + "fetched_at": "2026-07-24T14:25:47+00:00", + "effective_date": "2026-07-27", + }) + + monkeypatch.setattr(rms.settings_store, "get_value", fake_value) + + result = await rms.get_fundamental_overrides(object()) + + assert result["source"] == "gemini" + assert result["good_news_stock_down"] == "yes" + assert result["effective_date"] == "2026-07-27" + assert result["f1_score"] == 37.5 # recomputed on the v3 scale, not the stored 0.0 + + @pytest.mark.asyncio async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch): stored = { - "methodology": "v2", + "methodology": "v3", "f1_score": 100.0, "f3_score": 0.0, "capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"), @@ -185,7 +372,7 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch): async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch): names = DEFAULT_CONFIG["tickers"]["hyperscalers"] current = { - "methodology": "v2", + "methodology": "v3", "f1_score": None, "f3_score": None, "capex": dict.fromkeys(names, "unknown"), @@ -212,7 +399,7 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch): object(), capex=capex, good_news_stock_down="mixed" ) - assert result["f1_score"] == 25.0 + assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50) assert result["f3_score"] is None assert result["good_news_stock_down"] == "mixed" assert result["source"] == "manual" @@ -222,10 +409,10 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch): @pytest.mark.asyncio -async def test_prior_v2_snapshot_is_immutable_without_explicit_rebuild(db_session): +async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session): snapshot_date = date(2026, 6, 26) first = { - "methodology": "v2", + "methodology": "v3", "date": snapshot_date.isoformat(), "state": {"score": 10.0, "band": "stable"}, "warning": {"score": 20.0, "band": "stable"}, @@ -281,7 +468,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls( return {}, {} async def fake_latest(_db): - return object(), {"methodology": "v2"} + return object(), {"methodology": "v3"} async def fake_upsert(_db, result, *, rewrite_existing_v2): rewrites.append(rewrite_existing_v2) @@ -296,7 +483,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls( monkeypatch.setattr(rms, "_fetch_prices", fake_prices) monkeypatch.setattr(rms, "_fetch_fred_series", fake_fred) monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth) - monkeypatch.setattr(rms, "_latest_v2_row", fake_latest) + monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest) monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert) result = await rms.update_regime_monitor(FakeDB()) @@ -364,6 +551,6 @@ def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score(): 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 result["methodology"] == "v3" 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 c6fdbe5..4fedd27 100644 --- a/tests/unit/test_regime_quadrant_alert.py +++ b/tests/unit/test_regime_quadrant_alert.py @@ -1,36 +1,46 @@ -"""Tests for v2 State/Warning quadrant hysteresis and basket reseeding keys.""" +"""Tests for v3 State/Warning quadrant hysteresis and basket reseeding keys. + +v3 dividers are per axis (State 50, Warning 40) because the two scores have +different realized ranges -- Warning never exceeded 64.9 in the 408 calibration +sessions, so a shared 60 left the whole upper half of that axis unreachable. +""" from app.services.alert_service import ( + QUAD_X_DIV, + QUAD_Y_DIV, _classify_quadrant, _parse_quadrant_log_key, _quadrant_log_key, ) -def test_fresh_classification_uses_60_60_boundaries(): +def test_fresh_classification_uses_per_axis_boundaries(): + assert (QUAD_X_DIV, QUAD_Y_DIV) == (50.0, 40.0) 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" + # A Warning of 45 is above its own divider but below State's. + assert _classify_quadrant(45, 45, None) == "1" 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" + assert _classify_quadrant(20, 42, prev="3") == "3" + assert _classify_quadrant(20, 46, prev="3") == "1" + assert _classify_quadrant(20, 38, prev="1") == "1" + assert _classify_quadrant(20, 34, prev="1") == "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" + assert _classify_quadrant(53, 30, prev="3") == "3" + assert _classify_quadrant(56, 30, prev="3") == "4" + assert _classify_quadrant(47, 30, prev="4") == "4" + assert _classify_quadrant(44, 30, prev="4") == "3" def test_boundary_sitting_does_not_flip(): for quadrant in ("1", "2", "3", "4"): - assert _classify_quadrant(60, 60, prev=quadrant) == quadrant + assert _classify_quadrant(QUAD_X_DIV, QUAD_Y_DIV, prev=quadrant) == quadrant def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():