feat: add standalone AI/Tech regime-change monitor tab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 27s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:51:45 +02:00
parent 5605915d45
commit ebff19940b
18 changed files with 1600 additions and 3 deletions
+4
View File
@@ -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
@@ -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")
+4
View File
@@ -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 = ""
+2
View File
@@ -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",
]
+26
View File
@@ -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)
+89 -2
View File
@@ -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)
+41
View File
@@ -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(
+3
View File
@@ -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",
}
+735
View File
@@ -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
+2
View File
@@ -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() {
<Route path="/" element={<DashboardPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/signals" element={<SignalsPage />} />
<Route path="/regime" element={<RegimePage />} />
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
{/* Legacy routes from the old 6-page layout */}
<Route path="/watchlist" element={<Navigate to="/market" replace />} />
+32
View File
@@ -0,0 +1,32 @@
import apiClient from './client';
import type { RegimeMonitor, RegimeConfig, RegimeFundamentals } from '../lib/types';
export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
}
export function getRegimeConfig() {
return apiClient.get<RegimeConfig>('regime/config').then((r) => r.data);
}
export function updateRegimeConfig(updates: Partial<RegimeConfig>) {
return apiClient.put<RegimeConfig>('regime/config', updates).then((r) => r.data);
}
export function getRegimeFundamentals() {
return apiClient.get<RegimeFundamentals>('regime/fundamentals').then((r) => r.data);
}
export function updateRegimeFundamentals(body: {
f1_score?: number;
f3_score?: number;
locked?: boolean;
}) {
return apiClient.put<RegimeFundamentals>('regime/fundamentals', body).then((r) => r.data);
}
export function refreshRegimeFundamentals() {
return apiClient
.post<RegimeFundamentals>('regime/fundamentals/refresh')
.then((r) => r.data);
}
@@ -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() {
+2 -1
View File
@@ -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' && (
<NavLink to="/admin" className={({ isActive }) => linkClasses(isActive)}>
<span className="font-mono text-[10px] tracking-widest opacity-50">04</span>
<span className="font-mono text-[10px] tracking-widest opacity-50">05</span>
Admin
</NavLink>
)}
+48
View File
@@ -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<string, string>;
good_news_stock_down?: string | null;
}
export interface RegimeConfig {
weights: Record<string, number>;
alert_threshold: number;
tickers: Record<string, unknown>;
leader_weight: number;
rs_lookback: number;
fundamental_staleness_days: number;
}
export interface AlertConfig {
enabled: boolean;
telegram_chat_id: string;
+354
View File
@@ -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<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
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 <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
}
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 (
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">
{label}: <span className={`font-medium ${color}`}>{arrow} {delta > 0 ? '+' : ''}{delta}</span>
</span>
);
}
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 (
<div className={`glass border ${style.ring} p-6`}>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
<span className="text-sm text-gray-500">/ 100</span>
</div>
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
</div>
<div className="flex flex-wrap gap-2">
<TrendChip label="7d" delta={data.trend?.delta_7} />
<TrendChip label="30d" delta={data.trend?.delta_30} />
</div>
</div>
{/* Band track with score + threshold markers */}
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
<div
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
style={{ left: `${clamp(threshold)}%` }}
title={`Alert threshold ${threshold}`}
/>
<div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
style={{ left: `${clamp(score)}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
An <span className="text-gray-400">index</span> (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) && (
<span className="ml-1 text-gray-600">
VIX {data.inputs.vix ?? ''} · HY OAS {data.inputs.hy_oas ?? ''}
</span>
)}
</p>
</div>
);
}
function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) {
return (
<div className="glass overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3 font-medium">Signal</th>
<th className="px-4 py-3 font-medium">Sub-score</th>
<th className="px-4 py-3 text-right font-medium">Weight</th>
<th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr>
</thead>
<tbody>
{breakdown.map((s) => (
<tr key={s.id} className="border-b border-white/[0.03] last:border-0">
<td className="px-4 py-3">
<span className="font-mono text-[10px] text-gray-600">{s.id}</span>{' '}
<span className="text-gray-300">{s.label}</span>
</td>
<td className="px-4 py-3">
{s.available && s.sub_score != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-blue-400/70" style={{ width: `${s.sub_score}%` }} />
</div>
<span className="num text-gray-300">{s.sub_score}</span>
</div>
) : (
<span className="text-xs text-gray-600">n/a</span>
)}
</td>
<td className="px-4 py-3 text-right num text-gray-400">{s.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">
{s.available ? s.contribution.toFixed(1) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
return (
<label className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{label}</span>
<input
type="range"
min={0}
max={100}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
/>
<span className="w-8 text-right num text-gray-300">{value}</span>
</label>
);
}
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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Source: {data.source}</span>
{data.fetched_at && <span>· {new Date(data.fetched_at).toLocaleDateString()}</span>}
{data.locked && <Badge label="locked" variant="manual" />}
</div>
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
<SliderRow label="F1 · Hyperscaler capex guidance" value={f1} onChange={setF1} />
<SliderRow label="F3 · Good news, stock down" value={f3} onChange={setF3} />
<div className="flex flex-wrap gap-2 pt-1">
<button
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })}
>
Save override
</button>
<button
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
disabled={refreshing}
onClick={onRefresh}
>
{refreshing ? 'Refreshing' : 'Refresh via LLM'}
</button>
{data.locked && (
<button
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ locked: false })}
>
Unlock
</button>
)}
</div>
</div>
);
}
function WeightsEditor({
data,
onSave,
saving,
}: {
data: RegimeConfig;
onSave: (updates: Partial<RegimeConfig>) => void;
saving: boolean;
}) {
const [weights, setWeights] = useState<Record<string, number>>(() => ({ ...data.weights }));
const [threshold, setThreshold] = useState<number>(data.alert_threshold);
const setWeight = (key: string, value: string) => {
const num = parseFloat(value);
setWeights((prev) => ({ ...prev, [key]: isNaN(num) ? 0 : num }));
};
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Object.keys(weights).map((key) => (
<label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-400">
<span className="font-mono text-gray-500">{key}</span>
<input
type="number"
min={0}
value={weights[key]}
onChange={(e) => setWeight(key, e.target.value)}
className="w-16 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
</label>
))}
</div>
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Alert threshold</span>
<input
type="number"
min={0}
max={100}
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value, 10) || 0)}
className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
</label>
<button
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ weights, alert_threshold: threshold })}
>
Save weights
</button>
</div>
);
}
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 (
<div className="space-y-3">
<Disclosure summary="Admin · Fundamentals (F1 / F3)">
{fundamentals.isLoading && <SkeletonCard className="h-24" />}
{fundamentals.data && (
<FundamentalsEditor
key={fundamentals.dataUpdatedAt}
data={fundamentals.data}
onSave={(body) => saveFund.mutate(body)}
onRefresh={() => refresh.mutate()}
saving={saveFund.isPending}
refreshing={refresh.isPending}
/>
)}
{refresh.isError && (
<Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>
)}
</Disclosure>
<Disclosure summary="Admin · Weights & threshold">
{config.isLoading && <SkeletonCard className="h-24" />}
{config.data && (
<WeightsEditor
key={config.dataUpdatedAt}
data={config.data}
onSave={(updates) => saveConfig.mutate(updates)}
saving={saveConfig.isPending}
/>
)}
</Disclosure>
</div>
);
}
export default function RegimePage() {
const role = useAuthStore((s) => s.role);
const isAdmin = role === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Regime Monitor"
subtitle="AI/Tech regime-change index — observational, feeds no trades"
/>
{monitor.isLoading && (
<>
<SkeletonCard className="h-44" />
<SkeletonTable rows={6} cols={4} />
</>
)}
{monitor.isError && (
<Callout variant="error" onRetry={() => monitor.refetch()}>
Failed to load: {(monitor.error as Error).message}
</Callout>
)}
{monitor.data && !monitor.data.available && (
<Callout variant="empty">
Not computed yet run the Regime Monitor job from Admin Jobs, or wait for the daily pipeline.
</Callout>
)}
{monitor.data && monitor.data.available && (
<>
<Gauge data={monitor.data} />
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
</>
)}
{isAdmin && <AdminControls />}
</div>
);
}
+73
View File
@@ -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 **0100** 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 0100** (0 = Regime stabil, 100 = Bruch im Gange) mit Label-Band:
- 030 stabil · 3060 beobachten · 6080 erhöht · 80100 Bruch sichtbar
- **Aufschlüsselung pro Signal** (Sub-Score 0100 + 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 0100 (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.
+139
View File
@@ -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
+2
View File
@@ -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",