From ebff19940b1e6a3099c8ed4d8d388e737553991e Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 26 Jun 2026 11:51:45 +0200 Subject: [PATCH] feat: add standalone AI/Tech regime-change monitor tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new /regime tab scoring how far the AI/Tech bull regime has deteriorated toward a re-rating as a single 0-100 index with per-signal breakdown and a 7/30-day trend. Intentionally decoupled: nothing reads its output to gate or score trades — the daily-pipeline membership is scheduling only. - regime_monitor_service: price sub-scores (P1-P6 via Alpaca, like market_regime), VIX + HY credit spreads via a small FRED helper, weighted aggregation over available signals (missing source -> n/a, dropped from the denominator), one snapshot row/day, and a ~90-day history backfill by replaying the already-fetched series as-of each past day. - F1/F3 fundamentals proposed by the configured grounded LLM (reuses sentiment_provider_service config resolution), with a manual override + lock. - regime_snapshots table (migration 011); endpoints on the existing market router; admin-editable weights/threshold; standalone /regime page. Data needs: prices via Alpaca, VIX/credit via FRED (optional key — signals show n/a without it). No LLM needed for history. Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 + alembic/versions/011_add_regime_snapshots.py | 43 ++ app/config.py | 4 + app/models/__init__.py | 2 + app/models/regime_snapshot.py | 26 + app/routers/market.py | 91 ++- app/scheduler.py | 41 ++ app/services/admin_service.py | 3 + app/services/regime_monitor_service.py | 735 +++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/regime.ts | 32 + frontend/src/components/layout/MobileNav.tsx | 1 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/lib/types.ts | 48 ++ frontend/src/pages/RegimePage.tsx | 354 +++++++++ regime-monitor-anforderungen.md | 73 ++ tests/unit/test_regime_monitor.py | 139 ++++ tests/unit/test_scheduler.py | 2 + 18 files changed, 1600 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/011_add_regime_snapshots.py create mode 100644 app/models/regime_snapshot.py create mode 100644 app/services/regime_monitor_service.py create mode 100644 frontend/src/api/regime.ts create mode 100644 frontend/src/pages/RegimePage.tsx create mode 100644 regime-monitor-anforderungen.md create mode 100644 tests/unit/test_regime_monitor.py diff --git a/.env.example b/.env.example index 030202a..2362898 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ FINNHUB_API_KEY= # Fundamentals Provider — Alpha Vantage (optional fallback) ALPHA_VANTAGE_API_KEY= +# Regime Monitor — FRED (VIX + HY credit spreads). Free key: https://fred.stlouisfed.org/docs/api/api_key.html +# Optional: without it the VIX (P5) and credit-spread (F2) signals show as n/a. +FRED_API_KEY= + # Scheduled Jobs DATA_COLLECTOR_FREQUENCY=daily SENTIMENT_POLL_INTERVAL_MINUTES=30 diff --git a/alembic/versions/011_add_regime_snapshots.py b/alembic/versions/011_add_regime_snapshots.py new file mode 100644 index 0000000..c3ffff6 --- /dev/null +++ b/alembic/versions/011_add_regime_snapshots.py @@ -0,0 +1,43 @@ +"""add regime_snapshots table + +Stores the daily AI/Tech regime-change index (one row per date) so the monitor +tab can show a 7/30-day trend. Standalone, observational feature: no other table +or job reads this. + +Revision ID: 011 +Revises: 010 +Create Date: 2026-06-26 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "011" +down_revision: Union[str, None] = "010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "regime_snapshots", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("date", sa.Date(), nullable=False), + sa.Column("total_score", sa.Float(), nullable=False), + sa.Column("band", sa.String(length=20), nullable=False), + sa.Column("breakdown_json", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_regime_snapshots_date", "regime_snapshots", ["date"], unique=True + ) + + +def downgrade() -> None: + op.drop_index("uq_regime_snapshots_date", table_name="regime_snapshots") + op.drop_table("regime_snapshots") diff --git a/app/config.py b/app/config.py index 57f9b00..0266bef 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,10 @@ class Settings(BaseSettings): # Fundamentals Provider — Alpha Vantage (optional fallback) alpha_vantage_api_key: str = "" + # Regime Monitor — FRED (VIX level + HY credit spreads). Optional: without it + # the volatility (P5) and credit-spread (F2) signals are reported as n/a. + fred_api_key: str = "" + # Alerts — Telegram (optional env fallback; can also be set in Admin) telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/app/models/__init__.py b/app/models/__init__.py index 7b89dd8..29e309b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.watchlist import WatchlistEntry from app.models.settings import SystemSetting, IngestionProgress from app.models.alert import AlertLog from app.models.paper_trade import PaperTrade +from app.models.regime_snapshot import RegimeSnapshot __all__ = [ "Ticker", @@ -26,4 +27,5 @@ __all__ = [ "IngestionProgress", "AlertLog", "PaperTrade", + "RegimeSnapshot", ] diff --git a/app/models/regime_snapshot.py b/app/models/regime_snapshot.py new file mode 100644 index 0000000..884a51b --- /dev/null +++ b/app/models/regime_snapshot.py @@ -0,0 +1,26 @@ +from datetime import date as date_type +from datetime import datetime + +from sqlalchemy import Date, DateTime, Float, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class RegimeSnapshot(Base): + """Daily snapshot of the AI/Tech regime-change index. + + One row per calendar date (unique). ``breakdown_json`` holds the full + per-signal breakdown plus the raw inputs, so reads need no recomputation and + the 7/30-day trend is just a query over ``total_score``. Decoupled from the + rest of the platform: nothing reads this to gate or score trades. + """ + + __tablename__ = "regime_snapshots" + + id: Mapped[int] = mapped_column(primary_key=True) + date: Mapped[date_type] = mapped_column(Date, nullable=False, unique=True, index=True) + total_score: Mapped[float] = mapped_column(Float, nullable=False) + band: Mapped[str] = mapped_column(String(20), nullable=False) + breakdown_json: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/app/routers/market.py b/app/routers/market.py index 1626134..63964d3 100644 --- a/app/routers/market.py +++ b/app/routers/market.py @@ -1,11 +1,13 @@ -"""Market-level endpoints (benchmark regime).""" +"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor).""" from fastapi import APIRouter, Depends +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.dependencies import get_db, require_access +from app.dependencies import get_db, require_access, require_admin from app.models.user import User from app.schemas.common import APIEnvelope +from app.services import regime_monitor_service from app.services.backtest_service import get_backtest_report from app.services.market_regime_service import get_market_regime @@ -30,3 +32,88 @@ async def backtest_report( """Latest cached historical backtest report (None until the job runs).""" data = await get_backtest_report(db) return APIEnvelope(status="success", data=data) + + +# --------------------------------------------------------------------------- +# AI/Tech Regime-Change Monitor (standalone, observational) +# --------------------------------------------------------------------------- + + +class RegimeConfigUpdate(BaseModel): + weights: dict[str, float] | None = None + alert_threshold: float | None = None + tickers: dict | None = None + leader_weight: float | None = None + rs_lookback: int | None = None + fundamental_staleness_days: int | None = None + + +class RegimeFundamentalsUpdate(BaseModel): + f1_score: float | None = None + f3_score: float | None = None + locked: bool | None = None + + +@router.get("/regime/monitor", response_model=APIEnvelope) +async def regime_monitor( + _user: User = Depends(require_access), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Latest AI/Tech regime-change index (0-100) + per-signal breakdown + trend.""" + data = await regime_monitor_service.get_regime_monitor(db) + return APIEnvelope(status="success", data=data) + + +@router.get("/regime/config", response_model=APIEnvelope) +async def regime_config( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Editable weights / thresholds / ticker lists for the regime monitor.""" + data = await regime_monitor_service.get_regime_config(db) + return APIEnvelope(status="success", data=data) + + +@router.put("/regime/config", response_model=APIEnvelope) +async def update_regime_config( + body: RegimeConfigUpdate, + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Merge the supplied fields into the stored regime-monitor config.""" + updates = body.model_dump(exclude_none=True) + data = await regime_monitor_service.update_regime_config(db, updates) + return APIEnvelope(status="success", data=data) + + +@router.get("/regime/fundamentals", response_model=APIEnvelope) +async def regime_fundamentals( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Current F1 (capex) / F3 (earnings reaction) override (LLM-proposed or manual).""" + data = await regime_monitor_service.get_fundamental_overrides(db) + return APIEnvelope(status="success", data=data) + + +@router.put("/regime/fundamentals", response_model=APIEnvelope) +async def update_regime_fundamentals( + body: RegimeFundamentalsUpdate, + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Manually override F1/F3 (locks out the LLM refresh until unlocked).""" + data = await regime_monitor_service.set_fundamental_overrides( + db, f1_score=body.f1_score, f3_score=body.f3_score, locked=body.locked + ) + return APIEnvelope(status="success", data=data) + + +@router.post("/regime/fundamentals/refresh", response_model=APIEnvelope) +async def refresh_regime_fundamentals( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> APIEnvelope: + """Ask the configured LLM to re-estimate F1/F3 now (forces past a lock).""" + data = await regime_monitor_service.refresh_fundamental_overrides(db, force=True) + return APIEnvelope(status="success", data=data) diff --git a/app/scheduler.py b/app/scheduler.py index d8b540d..8f336af 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -37,6 +37,7 @@ from app.services import fundamental_service, ingestion_service, sentiment_servi from app.services.alert_service import dispatch_alerts from app.services.backtest_service import run_and_store as run_backtest_and_store from app.services.market_regime_service import update_market_regime +from app.services.regime_monitor_service import update_regime_monitor from app.services.outcome_service import evaluate_pending_setups from app.services.rr_scanner_service import scan_all_tickers from app.services.sentiment_provider_service import build_sentiment_provider @@ -80,6 +81,7 @@ _JOB_NAMES = [ "ticker_universe_sync", "alerts", "market_regime", + "regime_monitor", "backtest", "daily_pipeline", "intraday_pipeline", @@ -799,6 +801,42 @@ async def compute_market_regime() -> None: _log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc)) +# --------------------------------------------------------------------------- +# Job: Regime Monitor +# --------------------------------------------------------------------------- + + +async def compute_regime_monitor() -> None: + """Refresh the standalone AI/Tech regime-change index (observational only). + + Pulls sector/benchmark prices via Alpaca + VIX/credit spreads via FRED, + computes the 0-100 index, and persists a daily snapshot. Output feeds nothing + else — it only powers its own tab. Pipeline membership is scheduling only. + """ + job_name = "regime_monitor" + _log_event(logging.INFO, "job_start", job=job_name) + _runtime_start(job_name, total=1) + + try: + async with async_session_factory() as db: + if not await _is_job_enabled(db, job_name): + _log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled") + _runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled") + return + + result = await update_regime_monitor(db) + + _runtime_progress(job_name, processed=1, total=1) + _runtime_finish( + job_name, "completed", processed=1, total=1, + message=f"Index: {result.get('total_score')} ({result.get('band')})", + ) + _log_event(logging.INFO, "job_complete", job=job_name, score=result.get("total_score")) + except Exception as exc: + _runtime_finish(job_name, "error", processed=0, total=1, message=str(exc)) + _log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc)) + + # --------------------------------------------------------------------------- # Job: Backtest # --------------------------------------------------------------------------- @@ -881,6 +919,8 @@ _DAILY_PIPELINE_STEPS = [ ("rr_scanner", "scan_rr"), ("outcome_evaluator", "evaluate_outcomes"), ("market_regime", "compute_market_regime"), + # Observational only — runs here for scheduling; its output feeds nothing else. + ("regime_monitor", "compute_regime_monitor"), ] # Intraday (light): keep prices current and resolve outcomes through the day, @@ -1039,6 +1079,7 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None: (scan_rr, "rr_scanner", "R:R Scanner"), (evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"), (compute_market_regime, "market_regime", "Market Regime"), + (compute_regime_monitor, "regime_monitor", "Regime Monitor"), ] for fn, job_id, job_name in _members: scheduler.add_job( diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 8185d68..6e2557b 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -519,6 +519,7 @@ VALID_JOB_NAMES = { "outcome_evaluator", "alerts", "market_regime", + "regime_monitor", "backtest", "daily_pipeline", "intraday_pipeline", @@ -534,6 +535,7 @@ JOB_LABELS = { "outcome_evaluator": "Outcome Evaluator", "alerts": "Alerts Dispatcher", "market_regime": "Market Regime", + "regime_monitor": "Regime Monitor", "backtest": "Backtest", "daily_pipeline": "Daily Pipeline", "intraday_pipeline": "Intraday Pipeline", @@ -546,6 +548,7 @@ PIPELINE_MEMBERS = { "rr_scanner", "outcome_evaluator", "market_regime", + "regime_monitor", } diff --git a/app/services/regime_monitor_service.py b/app/services/regime_monitor_service.py new file mode 100644 index 0000000..64921fe --- /dev/null +++ b/app/services/regime_monitor_service.py @@ -0,0 +1,735 @@ +"""AI/Tech Regime-Change Monitor. + +A standalone, observational tool: it scores how far the AI/Tech bull regime has +deteriorated toward a re-rating, as a single 0-100 **index** (not a calibrated +probability), broken down per signal. It is intentionally decoupled — nothing +here feeds gates, scoring, alerts, or trade logic. It only computes a number for +its own tab. + +Design mirrors ``market_regime_service``: benchmark/sector bars are pulled +directly via Alpaca (no Universe membership needed), macro inputs (VIX, HY credit +spreads) come from FRED, and the daily result is persisted as one +``RegimeSnapshot`` row per date so the UI can show a 7/30-day trend. On the first +run the history is backfilled by replaying the (already-fetched) price/FRED series +as-of each past day, so the trend is populated immediately. + +Signals (sub-score 0 = healthy … 100 = regime breaking): + P1 trend break (% under 200-DMA, SMH-led) P2 death cross + 200-slope + P3 drawdown from 52w high P4 relative strength SMH/SPY + P5 volatility (VIX) P6 NVDA canary divergence (opt.) + F1 hyperscaler capex guidance (LLM/manual) F2 HY credit-spread percentile + F3 "good news, stock down" (LLM/manual) F4 market breadth RSP/SPY +""" + +from __future__ import annotations + +import copy +import json +import logging +import os +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import httpx +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.exceptions import ProviderError +from app.models.regime_snapshot import RegimeSnapshot +from app.providers.alpaca import AlpacaOHLCVProvider +from app.services import settings_store +from app.services.admin_service import update_setting +from app.services.sentiment_provider_service import _resolve as resolve_llm_config + +logger = logging.getLogger(__name__) + +_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "") + +KEY_CONFIG = "regime_monitor_config" +KEY_FUNDAMENTALS = "regime_fundamental_overrides" + +# All weights/thresholds are admin-editable via the KEY_CONFIG SystemSetting. +# Default weights sum to 100 (P6 off). SMH is the leading sensor, QQQ confirms. +DEFAULT_CONFIG: dict = { + "tickers": { + "leaders": ["SMH"], # semis — fast early signal + "confirm": ["QQQ"], # broad tech — confirmation + "market": "SPY", + "breadth": "RSP", # equal-weight S&P for breadth + "canary": "NVDA", # sector lead-stock (optional early warning) + "hyperscalers": ["GOOGL", "AMZN", "META", "MSFT"], + }, + "weights": { + "P1": 12, "P2": 8, "P3": 10, "P4": 8, "P5": 7, "P6": 0, + "F1": 25, "F2": 15, "F3": 8, "F4": 7, + }, + "alert_threshold": 65, + "leader_weight": 2.0, # SMH counts 2x vs QQQ where both feed a signal + "rs_lookback": 60, # trading days for relative-strength / breadth trend + "fundamental_staleness_days": 80, +} + +SIGNAL_LABELS: dict[str, str] = { + "P1": "Trend break (200-DMA)", + "P2": "Death cross + slope", + "P3": "Drawdown from 52w high", + "P4": "Relative strength SMH/SPY", + "P5": "Volatility (VIX)", + "P6": "NVDA canary divergence", + "F1": "Hyperscaler capex guidance", + "F2": "Credit spreads (HY OAS)", + "F3": "Good news, stock down", + "F4": "Market breadth RSP/SPY", +} + +_PRICE_SIGNALS = {"P1", "P2", "P3", "P4", "P6"} + + +# --------------------------------------------------------------------------- +# Small numeric helpers +# --------------------------------------------------------------------------- + +def _clamp(x: float, lo: float = 0.0, hi: float = 100.0) -> float: + return max(lo, min(hi, x)) + + +def _sma(values: list[float], window: int) -> float | None: + if len(values) < window: + return None + return sum(values[-window:]) / window + + +def _mean(values: list[float]) -> float | None: + return sum(values) / len(values) if values else None + + +def _blend(leader: float | None, confirm: float | None, leader_weight: float) -> float | None: + """Weighted blend of a leading vs a confirming sub-score (SMH vs QQQ).""" + parts: list[tuple[float, float]] = [] + if leader is not None: + parts.append((leader, leader_weight)) + if confirm is not None: + parts.append((confirm, 1.0)) + if not parts: + return None + num = sum(v * w for v, w in parts) + den = sum(w for _, w in parts) + return num / den + + +def band_for(score: float) -> str: + """Map the 0-100 index onto its label band.""" + if score < 30: + return "stable" + if score < 60: + return "watch" + if score < 80: + return "elevated" + return "breaking" + + +# --------------------------------------------------------------------------- +# Pure sub-score functions (0 = healthy, 100 = regime breaking). None = no data. +# --------------------------------------------------------------------------- + +def _under_200(closes: list[float]) -> float | None: + sma200 = _sma(closes, 200) + if sma200 is None: + return None + return 100.0 if closes[-1] < sma200 else 0.0 + + +def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float) -> float | None: + """Weighted share trading below the 200-DMA (SMH leads, QQQ confirms).""" + return _blend(_under_200(smh), _under_200(qqq), leader_weight) + + +def _death_cross(closes: list[float]) -> float | None: + sma50 = _sma(closes, 50) + sma200 = _sma(closes, 200) + if sma50 is None or sma200 is None or len(closes) < 221 or sma200 == 0: + return None + gap_pct = (sma50 / sma200 - 1.0) * 100.0 + severity = 0.0 if gap_pct >= 0 else _clamp(-gap_pct * 20.0) # -5% gap -> 100 + sma200_past = _sma(closes[:-20], 200) + slope_factor = 1.0 + if sma200_past: + slope_pct = (sma200 / sma200_past - 1.0) * 100.0 + slope_factor = 1.0 if slope_pct < 0 else 0.5 # damp if 200 still rising + return severity * slope_factor + + +def p2_death_cross(smh: list[float], qqq: list[float], leader_weight: float) -> float | None: + return _blend(_death_cross(smh), _death_cross(qqq), leader_weight) + + +def _drawdown(closes: list[float]) -> float | None: + if len(closes) < 30: + return None + window = closes[-252:] + peak = max(window) + if peak <= 0: + return None + dd_pct = (peak - closes[-1]) / peak * 100.0 + return _clamp(dd_pct * 5.0) # 20% below 52w high -> 100 + + +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 _ratio_trend(a: list[float], b: list[float], lookback: int) -> float | None: + """Falling a/b (a underperforming b) -> higher score. Flat -> 50.""" + if len(a) < lookback + 1 or len(b) < lookback + 1: + return None + if b[-1] == 0 or b[-lookback - 1] == 0: + return None + now = a[-1] / b[-1] + past = a[-lookback - 1] / b[-lookback - 1] + if past == 0: + return None + chg_pct = (now / past - 1.0) * 100.0 + return _clamp(50.0 - chg_pct * 5.0) # -10% -> 100, +10% -> 0 + + +def p4_relative_strength(smh: list[float], spy: list[float], lookback: int) -> float | None: + return _ratio_trend(smh, spy, lookback) + + +def f4_breadth(rsp: list[float], spy: list[float], lookback: int) -> float | None: + """Narrowing breadth (equal-weight lagging cap-weight) -> RSP/SPY falls -> higher.""" + return _ratio_trend(rsp, spy, lookback) + + +def p5_volatility(vix: float | None) -> float | None: + if vix is None: + return None + return _clamp((vix - 15.0) / 15.0 * 100.0) # <=15 -> 0, >=30 -> 100 + + +def f2_credit_spreads(oas_values: list[float]) -> float | None: + """Percentile rank of the latest HY OAS within the window. Wider = higher.""" + if len(oas_values) < 30: + return None + latest = oas_values[-1] + below = sum(1 for v in oas_values if v <= latest) + return _clamp(below / len(oas_values) * 100.0) + + +def p6_canary(nvda: list[float], smh: list[float]) -> float | None: + """NVDA below its 50-DMA while SMH's trend is still intact = lead divergence.""" + sma50 = _sma(nvda, 50) + if sma50 is None: + return None + nvda_weak = nvda[-1] < sma50 + sma200 = _sma(smh, 200) + smh_intact = sma200 is not None and smh[-1] > sma200 + if nvda_weak and smh_intact: + return 100.0 + return 50.0 if nvda_weak else 0.0 + + +# --------------------------------------------------------------------------- +# Aggregation +# --------------------------------------------------------------------------- + +def compute_regime_score(sub_scores: dict[str, float | None], weights: dict[str, float]) -> dict: + """Weighted mean over the *available* signals (weight>0 and score present). + + Missing-data signals drop out of both numerator and denominator and are + reported with ``available=False``. Contributions sum to the total. + """ + denom = sum( + weights.get(sid, 0) + for sid, score in sub_scores.items() + if score is not None and weights.get(sid, 0) > 0 + ) + total = 0.0 + breakdown: list[dict] = [] + for sid in SIGNAL_LABELS: + weight = weights.get(sid, 0) + if weight <= 0: + continue + score = sub_scores.get(sid) + available = score is not None + contribution = (score * weight / denom) if (available and denom > 0) else 0.0 + if available: + total += contribution + breakdown.append({ + "id": sid, + "label": SIGNAL_LABELS[sid], + "sub_score": round(score, 1) if available else None, + "weight": weight, + "available": available, + "contribution": round(contribution, 2), + }) + return {"total_score": round(total, 1), "band": band_for(total), "breakdown": breakdown} + + +# --------------------------------------------------------------------------- +# As-of series helpers (for backfill replay) +# --------------------------------------------------------------------------- + +Series = list[tuple[date, float]] + + +def _closes_asof(series: Series, as_of: date) -> list[float]: + return [v for d, v in series if d <= as_of] + + +def _value_asof(series: Series | None, as_of: date) -> float | None: + if not series: + return None + vals = [v for d, v in series if d <= as_of] + return vals[-1] if vals else None + + +def _window_asof(series: Series | None, as_of: date, years: float) -> list[float]: + if not series: + return [] + start = as_of - timedelta(days=int(365 * years)) + return [v for d, v in series if start <= d <= as_of] + + +def _compute_index( + prices: dict[str, Series], + vix_series: Series | None, + oas_series: Series | None, + overrides: dict, + config: dict, + as_of: date, +) -> dict: + """Compute the full index result as-of *as_of* from raw series.""" + t = config["tickers"] + lw = float(config.get("leader_weight", 2.0)) + lb = int(config.get("rs_lookback", 60)) + + smh = _closes_asof(prices.get(t["leaders"][0], []), as_of) if t["leaders"] else [] + qqq = _closes_asof(prices.get(t["confirm"][0], []), as_of) if t["confirm"] else [] + spy = _closes_asof(prices.get(t["market"], []), as_of) + rsp = _closes_asof(prices.get(t["breadth"], []), as_of) + nvda = _closes_asof(prices.get(t["canary"], []), as_of) + vix = _value_asof(vix_series, as_of) + oas = _window_asof(oas_series, as_of, 3) + + sub_scores: dict[str, float | None] = { + "P1": p1_trend_break(smh, qqq, lw), + "P2": p2_death_cross(smh, qqq, lw), + "P3": p3_drawdown(smh, qqq), + "P4": p4_relative_strength(smh, spy, lb), + "P5": p5_volatility(vix), + "P6": p6_canary(nvda, smh), + "F1": overrides.get("f1_score"), + "F2": f2_credit_spreads(oas), + "F3": overrides.get("f3_score"), + "F4": f4_breadth(rsp, spy, lb), + } + + result = compute_regime_score(sub_scores, config["weights"]) + result["date"] = as_of.isoformat() + result["alert_threshold"] = config.get("alert_threshold", 65) + result["inputs"] = { + "vix": round(vix, 2) if vix is not None else None, + "hy_oas": round(oas[-1], 2) if oas else None, + "fundamentals_fetched_at": overrides.get("fetched_at"), + } + return result + + +# --------------------------------------------------------------------------- +# Config + fundamental-override storage +# --------------------------------------------------------------------------- + +async def get_regime_config(db: AsyncSession) -> dict: + """DEFAULT_CONFIG deep-merged with the stored override (nested for dicts).""" + cfg = copy.deepcopy(DEFAULT_CONFIG) + raw = await settings_store.get_value(db, KEY_CONFIG) + if raw: + try: + stored = json.loads(raw) + for k, v in stored.items(): + if isinstance(v, dict) and isinstance(cfg.get(k), dict): + cfg[k].update(v) + else: + cfg[k] = v + except (TypeError, ValueError): + logger.warning("Corrupt %s; using defaults", KEY_CONFIG) + return cfg + + +async def update_regime_config(db: AsyncSession, updates: dict) -> dict: + """Merge *updates* into the stored config and persist. Returns the new config.""" + cfg = await get_regime_config(db) + for k, v in (updates or {}).items(): + if isinstance(v, dict) and isinstance(cfg.get(k), dict): + cfg[k].update(v) + else: + cfg[k] = v + await update_setting(db, KEY_CONFIG, json.dumps(cfg)) + return cfg + + +async def get_fundamental_overrides(db: AsyncSession) -> dict: + """Current F1/F3 override (LLM-proposed or manual). Defaults to neutral 50.""" + raw = await settings_store.get_value(db, KEY_FUNDAMENTALS) + default = {"f1_score": 50.0, "f3_score": 50.0, "locked": False, + "reasoning": None, "fetched_at": None, "source": "default"} + if not raw: + return default + try: + stored = json.loads(raw) + except (TypeError, ValueError): + return default + return {**default, **stored} + + +async def set_fundamental_overrides( + db: AsyncSession, + f1_score: float | None = None, + f3_score: float | None = None, + locked: bool | None = None, +) -> dict: + """Manual override of F1/F3. Setting any value locks out the LLM refresh + unless ``locked`` is explicitly cleared.""" + current = await get_fundamental_overrides(db) + if f1_score is not None: + current["f1_score"] = _clamp(float(f1_score)) + if f3_score is not None: + current["f3_score"] = _clamp(float(f3_score)) + if locked is not None: + current["locked"] = bool(locked) + elif f1_score is not None or f3_score is not None: + current["locked"] = True + current["source"] = "manual" + current["fetched_at"] = datetime.now(timezone.utc).isoformat() + await update_setting(db, KEY_FUNDAMENTALS, json.dumps(current)) + return current + + +# --------------------------------------------------------------------------- +# Data fetching: Alpaca prices + FRED macro +# --------------------------------------------------------------------------- + +def _price_symbols(config: dict) -> list[str]: + t = config["tickers"] + syms = list(t["leaders"]) + list(t["confirm"]) + [t["market"], t["breadth"], t["canary"]] + seen: list[str] = [] + for s in syms: + if s and s not in seen: + seen.append(s) + return seen + + +async def _fetch_prices(config: dict, start: date, end: date) -> dict[str, Series]: + if not settings.alpaca_api_key or not settings.alpaca_api_secret: + return {} + provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret) + out: dict[str, Series] = {} + for sym in _price_symbols(config): + try: + bars = await provider.fetch_ohlcv(sym, start, end) + out[sym] = sorted(((b.date, float(b.close)) for b in bars), key=lambda x: x[0]) + except Exception as exc: + logger.warning("Regime monitor: price fetch failed for %s: %s", sym, exc) + return out + + +async def _fetch_fred_series(series_id: str, start: date, end: date) -> Series | None: + """Fetch a FRED series as [(date, value)]. None if no API key configured.""" + if not settings.fred_api_key: + return None + verify = _CA_BUNDLE if (_CA_BUNDLE and Path(_CA_BUNDLE).exists()) else True + params = { + "series_id": series_id, + "api_key": settings.fred_api_key, + "file_type": "json", + "observation_start": start.isoformat(), + "observation_end": end.isoformat(), + } + try: + async with httpx.AsyncClient(timeout=30, verify=verify) as client: + resp = await client.get( + "https://api.stlouisfed.org/fred/series/observations", params=params + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: + logger.warning("Regime monitor: FRED fetch failed for %s: %s", series_id, exc) + return None + + out: Series = [] + for obs in payload.get("observations", []): + value = obs.get("value") + if value in (None, ".", ""): + continue + try: + out.append((date.fromisoformat(obs["date"]), float(value))) + except (TypeError, ValueError): + continue + return sorted(out, key=lambda x: x[0]) + + +# --------------------------------------------------------------------------- +# Snapshot persistence +# --------------------------------------------------------------------------- + +async def _upsert_snapshot(db: AsyncSession, result: dict) -> None: + d = date.fromisoformat(result["date"]) + existing = await db.execute(select(RegimeSnapshot).where(RegimeSnapshot.date == d)) + row = existing.scalar_one_or_none() + payload = json.dumps(result) + if row is None: + db.add(RegimeSnapshot( + date=d, + total_score=result["total_score"], + band=result["band"], + breakdown_json=payload, + created_at=datetime.now(timezone.utc), + )) + else: + row.total_score = result["total_score"] + row.band = result["band"] + row.breakdown_json = payload + + +async def _snapshot_count(db: AsyncSession) -> int: + res = await db.execute(select(func.count()).select_from(RegimeSnapshot)) + return int(res.scalar() or 0) + + +# --------------------------------------------------------------------------- +# Job entrypoint + reads +# --------------------------------------------------------------------------- + +async def update_regime_monitor(db: AsyncSession, backfill_days: int = 90) -> dict: + """Compute the latest index, persist it, and backfill history on first run. + + Job entrypoint (daily-pipeline step). Best-effort throughout: missing keys or + a failed source degrade gracefully (signals drop to n/a) rather than abort. + """ + config = await get_regime_config(db) + + # Refresh the LLM fundamentals if stale (and not manually locked). Best-effort. + overrides = await get_fundamental_overrides(db) + if _fundamentals_stale(overrides, config) and not overrides.get("locked"): + try: + overrides = await refresh_fundamental_overrides(db, config=config) + except Exception as exc: + logger.warning("Regime monitor: fundamentals refresh skipped: %s", exc) + + end = date.today() + start = end - timedelta(days=400) + prices = await _fetch_prices(config, start, end) + vix_series = await _fetch_fred_series("VIXCLS", start, end) + oas_series = await _fetch_fred_series("BAMLH0A0HYM2", end - timedelta(days=1200), end) + + # Anchor "today" on the latest actual trading day we have prices for. + leader = config["tickers"]["leaders"][0] if config["tickers"]["leaders"] else None + leader_series = prices.get(leader or "", []) + latest_date = leader_series[-1][0] if leader_series else end + + dates = {latest_date} + if await _snapshot_count(db) < 5 and leader_series: + cutoff = end - timedelta(days=backfill_days) + dates |= {d for d, _ in leader_series if d >= cutoff} + + latest_result: dict | None = None + for d in sorted(dates): + result = _compute_index(prices, vix_series, oas_series, overrides, config, d) + await _upsert_snapshot(db, result) + latest_result = result + await db.commit() + + logger.info(json.dumps({ + "event": "regime_monitor_updated", + "date": latest_result["date"] if latest_result else None, + "score": latest_result["total_score"] if latest_result else None, + "snapshots_written": len(dates), + })) + return latest_result or {"available": False, "reason": "no data"} + + +async def _score_at_or_before(db: AsyncSession, target: date) -> float | None: + res = await db.execute( + select(RegimeSnapshot.total_score) + .where(RegimeSnapshot.date <= target) + .order_by(RegimeSnapshot.date.desc()) + .limit(1) + ) + val = res.scalar_one_or_none() + return float(val) if val is not None else None + + +async def get_regime_monitor(db: AsyncSession) -> dict: + """Latest snapshot result + 7/30-day trend deltas. Cheap (one+ row reads).""" + res = await db.execute( + select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1) + ) + latest = res.scalar_one_or_none() + if latest is None: + return {"available": False, "reason": "not computed yet"} + + try: + result = json.loads(latest.breakdown_json) + except (TypeError, ValueError): + result = {"date": latest.date.isoformat(), "total_score": latest.total_score, + "band": latest.band, "breakdown": []} + + score_7 = await _score_at_or_before(db, latest.date - timedelta(days=7)) + score_30 = await _score_at_or_before(db, latest.date - timedelta(days=30)) + result["available"] = True + result["trend"] = { + "delta_7": round(latest.total_score - score_7, 1) if score_7 is not None else None, + "delta_30": round(latest.total_score - score_30, 1) if score_30 is not None else None, + } + return result + + +# --------------------------------------------------------------------------- +# F1/F3 via grounded LLM (reuses the configured sentiment provider) +# --------------------------------------------------------------------------- + +_CAPEX_PROMPT = """\ +You are a markets analyst. Search the web for the MOST RECENT (last reported \ +quarter) capital-expenditure (capex) guidance from these hyperscalers: {names}. + +For each name, classify the direction of its forward capex/AI-infrastructure \ +guidance vs. the prior quarter as exactly one of: "raising", "holding", "cutting". + +Also judge the recent "good news, stock down" dynamic: across these names and \ +the semiconductor sector, did stocks broadly FALL despite earnings/revenue beats \ +in the last reporting season? Answer "yes", "no", or "mixed". + +Respond ONLY with a JSON object (no markdown): +{{"capex": {{ {example} }}, "good_news_stock_down": "yes|no|mixed", \ +"reasoning": "<2-3 sentences citing the specific guidance you found>"}} +""" + + +def _fundamentals_stale(overrides: dict, config: dict) -> bool: + fetched = overrides.get("fetched_at") + if not fetched: + return True + try: + ts = datetime.fromisoformat(fetched) + except (TypeError, ValueError): + return True + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + max_age = timedelta(days=int(config.get("fundamental_staleness_days", 80))) + return datetime.now(timezone.utc) - ts > max_age + + +def _strip_fences(text: str) -> str: + clean = (text or "").strip() + if clean.startswith("```"): + clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:] + if clean.endswith("```"): + clean = clean[:-3] + return clean.strip() + + +def _extract_responses_text(response: object) -> str: + for item in getattr(response, "output", []) or []: + if getattr(item, "type", None) == "message" and getattr(item, "content", None): + for block in item.content: + if getattr(block, "text", None): + return block.text + return "" + + +async def _call_llm_json(cfg: dict, prompt: str) -> dict: + """Send one grounded prompt via the configured LLM and parse its JSON reply.""" + provider, model, api_key = cfg["provider"], cfg["model"], cfg["api_key"] + base_url = cfg.get("base_url") + + if provider == "gemini": + from google import genai + from google.genai import types + client = genai.Client(api_key=api_key) + resp = await client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + tools=[types.Tool(google_search=types.GoogleSearch())], + response_mime_type="application/json", + ), + ) + return json.loads(_strip_fences(resp.text)) + + from openai import AsyncOpenAI + verify = _CA_BUNDLE if (_CA_BUNDLE and Path(_CA_BUNDLE).exists()) else True + client = AsyncOpenAI( + api_key=api_key, + base_url=base_url or None, + http_client=httpx.AsyncClient(verify=verify), + ) + if provider in ("openai", "xai"): + tool = "web_search_preview" if provider == "openai" else "web_search" + resp = await client.responses.create( + model=model, + tools=[{"type": tool}], + instructions="Respond with valid JSON only, no markdown fences.", + input=prompt, + ) + return json.loads(_strip_fences(_extract_responses_text(resp))) + + # deepseek / generic OpenAI-compatible: no web search, knowledge-based. + resp = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + return json.loads(_strip_fences(resp.choices[0].message.content)) + + +_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 50.0, "cutting": 100.0} +_GNSD_SCORES = {"yes": 100.0, "mixed": 50.0, "no": 0.0} + + +async def refresh_fundamental_overrides( + db: AsyncSession, config: dict | None = None, force: bool = False +) -> dict: + """Ask the configured LLM to propose F1 (capex) and F3 (earnings reaction). + + Skips (returns current) if a manual override is locked, unless ``force``. + """ + current = await get_fundamental_overrides(db) + if current.get("locked") and not force: + return current + + config = config or await get_regime_config(db) + cfg = await resolve_llm_config(db) + if not cfg.get("api_key"): + raise ProviderError(f"No API key configured for LLM provider '{cfg.get('provider')}'") + + names = config["tickers"]["hyperscalers"] + example = ", ".join(f'"{n}": "holding"' for n in names) + prompt = _CAPEX_PROMPT.format(names=", ".join(names), example=example) + parsed = await _call_llm_json(cfg, prompt) + + capex = parsed.get("capex", {}) if isinstance(parsed, dict) else {} + scores = [ + _CAPEX_STATE_SCORES[str(capex.get(n, "")).strip().lower()] + for n in names + if str(capex.get(n, "")).strip().lower() in _CAPEX_STATE_SCORES + ] + f1 = _mean(scores) if scores else 50.0 + gnsd = str(parsed.get("good_news_stock_down", "")).strip().lower() + f3 = _GNSD_SCORES.get(gnsd, 50.0) + + result = { + "f1_score": round(f1, 1), + "f3_score": f3, + "capex": capex, + "good_news_stock_down": gnsd or None, + "reasoning": parsed.get("reasoning") if isinstance(parsed, dict) else None, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "locked": False, + "source": cfg.get("provider"), + } + await update_setting(db, KEY_FUNDAMENTALS, json.dumps(result)) + logger.info(json.dumps({"event": "regime_fundamentals_refreshed", "f1": result["f1_score"], "f3": result["f3_score"]})) + return result diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 873eac7..bc56c16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import RegisterPage from './pages/RegisterPage'; import DashboardPage from './pages/DashboardPage'; import MarketPage from './pages/MarketPage'; import SignalsPage from './pages/SignalsPage'; +import RegimePage from './pages/RegimePage'; import TickerDetailPage from './pages/TickerDetailPage'; import AdminPage from './pages/AdminPage'; @@ -19,6 +20,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Legacy routes from the old 6-page layout */} } /> diff --git a/frontend/src/api/regime.ts b/frontend/src/api/regime.ts new file mode 100644 index 0000000..865ac8a --- /dev/null +++ b/frontend/src/api/regime.ts @@ -0,0 +1,32 @@ +import apiClient from './client'; +import type { RegimeMonitor, RegimeConfig, RegimeFundamentals } from '../lib/types'; + +export function getRegimeMonitor() { + return apiClient.get('regime/monitor').then((r) => r.data); +} + +export function getRegimeConfig() { + return apiClient.get('regime/config').then((r) => r.data); +} + +export function updateRegimeConfig(updates: Partial) { + return apiClient.put('regime/config', updates).then((r) => r.data); +} + +export function getRegimeFundamentals() { + return apiClient.get('regime/fundamentals').then((r) => r.data); +} + +export function updateRegimeFundamentals(body: { + f1_score?: number; + f3_score?: number; + locked?: boolean; +}) { + return apiClient.put('regime/fundamentals', body).then((r) => r.data); +} + +export function refreshRegimeFundamentals() { + return apiClient + .post('regime/fundamentals/refresh') + .then((r) => r.data); +} diff --git a/frontend/src/components/layout/MobileNav.tsx b/frontend/src/components/layout/MobileNav.tsx index 0ce5ac9..7f9794b 100644 --- a/frontend/src/components/layout/MobileNav.tsx +++ b/frontend/src/components/layout/MobileNav.tsx @@ -6,6 +6,7 @@ const navItems = [ { to: '/', label: 'Overview', end: true }, { to: '/market', label: 'Market', end: false }, { to: '/signals', label: 'Signals', end: false }, + { to: '/regime', label: 'Regime', end: false }, ]; export default function MobileNav() { diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 5d7129d..cf90d9e 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ const navItems = [ { to: '/', label: 'Overview', index: '01', end: true }, { to: '/market', label: 'Market', index: '02', end: false }, { to: '/signals', label: 'Signals', index: '03', end: false }, + { to: '/regime', label: 'Regime', index: '04', end: false }, ]; const linkClasses = (isActive: boolean) => @@ -62,7 +63,7 @@ export default function Sidebar() { ))} {role === 'admin' && ( linkClasses(isActive)}> - 04 + 05 Admin )} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 12647b0..cce7c27 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -263,6 +263,54 @@ export interface MarketRegime { computed_at?: string; } +// AI/Tech Regime-Change Monitor (standalone, observational) +export type RegimeBand = 'stable' | 'watch' | 'elevated' | 'breaking'; + +export interface RegimeSignal { + id: string; + label: string; + sub_score: number | null; + weight: number; + available: boolean; + contribution: number; +} + +export interface RegimeMonitor { + available: boolean; + reason?: string; + date?: string; + total_score?: number; + band?: RegimeBand; + alert_threshold?: number; + breakdown?: RegimeSignal[]; + inputs?: { + vix: number | null; + hy_oas: number | null; + fundamentals_fetched_at: string | null; + }; + trend?: { delta_7: number | null; delta_30: number | null }; +} + +export interface RegimeFundamentals { + f1_score: number; + f3_score: number; + locked: boolean; + reasoning: string | null; + fetched_at: string | null; + source: string; + capex?: Record; + good_news_stock_down?: string | null; +} + +export interface RegimeConfig { + weights: Record; + alert_threshold: number; + tickers: Record; + leader_weight: number; + rs_lookback: number; + fundamental_staleness_days: number; +} + export interface AlertConfig { enabled: boolean; telegram_chat_id: string; diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx new file mode 100644 index 0000000..64bbb49 --- /dev/null +++ b/frontend/src/pages/RegimePage.tsx @@ -0,0 +1,354 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { PageHeader } from '../components/ui/PageHeader'; +import { Callout } from '../components/ui/Callout'; +import { Disclosure } from '../components/ui/Disclosure'; +import { Badge } from '../components/ui/Badge'; +import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; +import { useAuthStore } from '../stores/authStore'; +import { + getRegimeMonitor, + getRegimeConfig, + updateRegimeConfig, + getRegimeFundamentals, + updateRegimeFundamentals, + refreshRegimeFundamentals, +} from '../api/regime'; +import type { + RegimeBand, + RegimeMonitor, + RegimeSignal, + RegimeConfig, + RegimeFundamentals, +} from '../lib/types'; + +const BAND_STYLES: Record = { + stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' }, + watch: { text: 'text-amber-400', bar: 'bg-amber-400', ring: 'border-amber-400/30', label: 'Watch' }, + elevated: { text: 'text-orange-400', bar: 'bg-orange-400', ring: 'border-orange-400/30', label: 'Elevated' }, + breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'Breaking' }, +}; + +function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) { + if (delta == null) { + return {label}: n/a; + } + const rising = delta > 0; + const flat = delta === 0; + // Higher index = worse, so a rising score is the warning direction. + const color = flat ? 'text-gray-400' : rising ? 'text-red-400' : 'text-emerald-400'; + const arrow = flat ? '→' : rising ? '↑' : '↓'; + return ( + + {label}: {arrow} {delta > 0 ? '+' : ''}{delta} + + ); +} + +function Gauge({ data }: { data: RegimeMonitor }) { + const band = (data.band ?? 'stable') as RegimeBand; + const style = BAND_STYLES[band]; + const score = data.total_score ?? 0; + const threshold = data.alert_threshold ?? 65; + const clamp = (v: number) => Math.min(100, Math.max(0, v)); + return ( +
+
+
+
+ {Math.round(score)} + / 100 +
+

{style.label}

+
+
+ + +
+
+ + {/* Band track with score + threshold markers */} +
+
+
+
+
+ 0306080100 +
+ +

+ An index (not a calibrated probability) of how far the AI/Tech bull regime + has deteriorated. Mostly coincident signals — it shortens reaction time, it doesn't predict the exact turn. + {data.date && <> As of {data.date}.} + {data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && ( + + VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'} + + )} +

+
+ ); +} + +function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) { + return ( +
+ + + + + + + + + + + {breakdown.map((s) => ( + + + + + + + ))} + +
SignalSub-scoreWeightContribution
+ {s.id}{' '} + {s.label} + + {s.available && s.sub_score != null ? ( +
+
+
+
+ {s.sub_score} +
+ ) : ( + n/a + )} +
{s.weight} + {s.available ? s.contribution.toFixed(1) : '—'} +
+
+ ); +} + +function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) { + return ( + + ); +} + +function FundamentalsEditor({ + data, + onSave, + onRefresh, + saving, + refreshing, +}: { + data: RegimeFundamentals; + onSave: (body: { f1_score?: number; f3_score?: number; locked?: boolean }) => void; + onRefresh: () => void; + saving: boolean; + refreshing: boolean; +}) { + const [f1, setF1] = useState(Math.round(data.f1_score)); + const [f3, setF3] = useState(Math.round(data.f3_score)); + return ( +
+
+ Source: {data.source} + {data.fetched_at && · {new Date(data.fetched_at).toLocaleDateString()}} + {data.locked && } +
+ {data.reasoning &&

{data.reasoning}

} + + +
+ + + {data.locked && ( + + )} +
+
+ ); +} + +function WeightsEditor({ + data, + onSave, + saving, +}: { + data: RegimeConfig; + onSave: (updates: Partial) => void; + saving: boolean; +}) { + const [weights, setWeights] = useState>(() => ({ ...data.weights })); + const [threshold, setThreshold] = useState(data.alert_threshold); + + const setWeight = (key: string, value: string) => { + const num = parseFloat(value); + setWeights((prev) => ({ ...prev, [key]: isNaN(num) ? 0 : num })); + }; + + return ( +
+
+ {Object.keys(weights).map((key) => ( + + ))} +
+ + +
+ ); +} + +function AdminControls() { + const qc = useQueryClient(); + const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals }); + const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig }); + + const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] }); + const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate }); + const saveFund = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate }); + const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate }); + + return ( +
+ + {fundamentals.isLoading && } + {fundamentals.data && ( + saveFund.mutate(body)} + onRefresh={() => refresh.mutate()} + saving={saveFund.isPending} + refreshing={refresh.isPending} + /> + )} + {refresh.isError && ( + Refresh failed: {(refresh.error as Error).message} + )} + + + + {config.isLoading && } + {config.data && ( + saveConfig.mutate(updates)} + saving={saveConfig.isPending} + /> + )} + +
+ ); +} + +export default function RegimePage() { + const role = useAuthStore((s) => s.role); + const isAdmin = role === 'admin'; + const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); + + return ( +
+ + + {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.data.breakdown && } + + )} + + {isAdmin && } +
+ ); +} diff --git a/regime-monitor-anforderungen.md b/regime-monitor-anforderungen.md new file mode 100644 index 0000000..03b2f32 --- /dev/null +++ b/regime-monitor-anforderungen.md @@ -0,0 +1,73 @@ +# Anforderungsdokument — "AI/Tech Regime Change Monitor" + +**Ziel:** Ein persönliches Hobby-Tool, das fundamentale *und* kursbasierte Signale überwacht und einen einzigen Wert von **0–100** ausgibt: die geschätzte Wahrscheinlichkeit, dass das KI/Tech-Bullenregime in eine Neubewertung kippt. +**Zweck:** Disziplinierte Ausstiegs-Entscheidung für spekulative Einzelpositionen (NVDA, MSFT). **Kein** Auto-Trading, **keine** Anlageberatung, **keine** Timing-Garantie. + +--- + +## 1. Scope + +- **Beobachtete Instrumente:** SMH (Halbleiter, *schnelles* Frühsignal) + QQQ (breiter, *Bestätigung*) als Regime-Sensoren; SPY, RSP (Marktbreite-Kontext); VIX (Volatilität); Hyperscaler GOOGL, AMZN, META, MSFT (Capex-Signal). Bewusst **keine** Einzelaktien-Trades — das Tool misst das *Regime*, nicht einzelne Titel. +- **Optionaler "Kanarienvogel":** NVDA als reiner Frühindikator-Input (Lead-Aktie des Sektors, dreht oft vor SMH) — abschaltbar, **keine** Entscheidungsposition. +- **Read-only.** Tool gibt nur einen Score + Aufschlüsselung aus, führt keine Orders aus. +- **Lauf-Kadenz:** Kurssignale täglich, Fundamentalsignale quartalsweise (bzw. bei Earnings). + +## 2. Output + +- **Gesamtscore 0–100** (0 = Regime stabil, 100 = Bruch im Gange) mit Label-Band: + - 0–30 stabil · 30–60 beobachten · 60–80 erhöht · 80–100 Bruch sichtbar +- **Aufschlüsselung pro Signal** (Sub-Score 0–100 + Gewicht + Beitrag). +- **Trend:** Veränderung des Gesamtscores über 7 und 30 Tage (steigend/fallend). +- Optional: einfacher Alert, wenn Gesamtscore eine konfigurierbare Schwelle (Default 65) überschreitet. + +## 3. Signale + +Jedes Signal liefert einen Sub-Score 0–100 (0 = gesund, 100 = Regime bricht). Gewichte in `config` editierbar. + +### Kursbasiert (automatisierbar, täglich) +Grundprinzip: **SMH ist das führende Signal, QQQ die Bestätigung.** Wo beide eingehen, zählt SMH stärker (Default 2:1), damit du Frühwarnung *und* Filter gegen Fehlalarme hast. + +| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht | +|----|--------|--------------------------|-----------------| +| P1 | Trendbruch 200-Tage-MA | Gewichteter Anteil unter der 200-Tage-MA: SMH zählt doppelt, QQQ einfach | 12 | +| P2 | Death Cross + Slope | 50-Tage-MA unter 200-Tage-MA und 200er-Slope negativ (graduell nach Abstand), SMH führend | 8 | +| P3 | Drawdown vom 52W-Hoch | max(SMH, QQQ)-Drawdown: 0 % → 0, ≥ 20 % → 100 (linear) | 10 | +| P4 | Relative Stärke Tech | Trend des Verhältnisses SMH/SPY (Tech underperformt → höher) | 8 | +| P5 | Volatilität | VIX: ≤ 15 → 0, ≥ 30 → 100 (linear) | 7 | +| P6 | *Optional:* Kanarienvogel NVDA | NVDA unter 50-Tage-MA bei gleichzeitig noch intaktem SMH (Lead-Divergenz) → Frühwarnung; abschaltbar | 0 (opt. 5) | + +### Fundamental (teils manuell, quartalsweise) +| ID | Signal | Logik (Sub-Score 0→100) | Default-Gewicht | +|----|--------|--------------------------|-----------------| +| F1 | Hyperscaler-Capex-Guidance | Manuelle Eingabe je Name: anhebend = 0, haltend = 50, kürzend = 100; Mittel über die 4 | 25 | +| F2 | Kreditspreads | US High-Yield OAS (FRED `BAMLH0A0HYM2`): Perzentil der letzten 3 J → Score; Ausweitung = höher | 15 | +| F3 | Earnings-Reaktion | "Good news, stock down": fielen Hyperscaler/SMH im Schnitt trotz Gewinn-Beats nach den letzten Earnings? (Reaktion ±2 Tage, auto oder manuell) | 8 | +| F4 | Marktbreite | Trend RSP/SPY (gleichgewichtet schlägt kapgewichtet bei Tech-Schwäche → Verschlechterung der Breite → höher) | 7 | + +**Gesamtscore = Σ(Sub-Score × Gewicht) / Σ(Gewichte).** Summe Defaults = 100. + +## 4. Datenquellen (Vorschlag, alle frei) + +- **Kurse/MA/Drawdown/VIX:** `yfinance` (Yahoo Finance). Alternativ deine IBKR-API. +- **Kreditspreads:** FRED-API (`BAMLH0A0HYM2`), kostenloser API-Key. +- **Capex-Guidance (F1):** manuell pflegbar in `signals.yaml` (4 Werte/Quartal). Keine zuverlässige Gratis-API; bewusst manuell. +- **Earnings-Termine/-Reaktion (F3):** `yfinance` earnings dates + Kursreaktion, optional manuell. + +## 5. Konfiguration + +- `config.yaml`: Gewichte je Signal, Alert-Schwelle, Tickerlisten, Lookback-Fenster. +- `signals.yaml`: manuelle Eingaben (F1, optional F3). +- Alle Schwellen/Gewichte ohne Code-Änderung anpassbar. + +## 6. Tech-Vorschlag (optional) + +- **Python** + `pandas` + `yfinance` + `requests` (FRED) + `pyyaml`. +- Ausgabe als **CLI-Report** (Tabelle + Gesamtscore) und/oder kleines **Streamlit**-Dashboard mit Gauge + Verlaufschart. +- Lokal lauffähig, ein `python monitor.py` reicht; Verlauf in lokaler CSV/SQLite für 7/30-Tage-Trend. + +## 7. Explizite Nicht-Ziele / Grenzen + +- Sagt **keinen** exakten Zeitpunkt voraus; ein hoher Score ≠ garantierter Crash. +- Die Gewichte sind subjektiv (Garbage-in → Garbage-out): Default ist ein Startpunkt, kein Optimum. +- Das eindeutige Signal kommt oft erst mit dem Einbruch — das Tool *senkt* die Reaktionszeit, eliminiert sie nicht. +- Reines Informations-/Disziplin-Werkzeug, keine Finanzberatung. diff --git a/tests/unit/test_regime_monitor.py b/tests/unit/test_regime_monitor.py new file mode 100644 index 0000000..1aec47d --- /dev/null +++ b/tests/unit/test_regime_monitor.py @@ -0,0 +1,139 @@ +"""Unit tests for the regime-monitor pure functions and aggregation.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from app.services.regime_monitor_service import ( + DEFAULT_CONFIG, + band_for, + compute_regime_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)] + + +# --------------------------------------------------------------------------- +# Bands +# --------------------------------------------------------------------------- + +def test_band_for(): + assert band_for(10) == "stable" + assert band_for(45) == "watch" + assert band_for(70) == "elevated" + assert band_for(90) == "breaking" + + +# --------------------------------------------------------------------------- +# 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 + 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_p5_volatility_linear(): + assert p5_volatility(15) == 0 + assert p5_volatility(30) == 100 + assert p5_volatility(22.5) == 50 + assert p5_volatility(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_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 + + +# --------------------------------------------------------------------------- +# 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_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 + + +# --------------------------------------------------------------------------- +# As-of index replay (backfill mechanics) +# --------------------------------------------------------------------------- + +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} + + 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"} + + # 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 diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 68164e0..5541ee7 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -87,6 +87,7 @@ class TestConfigureScheduler: "outcome_evaluator", "alerts", "market_regime", + "regime_monitor", "backtest", "daily_pipeline", "intraday_pipeline", @@ -107,6 +108,7 @@ class TestConfigureScheduler: "data_backfill", "fundamental_collector", "market_regime", + "regime_monitor", "outcome_evaluator", "rr_scanner", "sentiment_collector",