feat: add standalone AI/Tech regime-change monitor tab
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:
@@ -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")
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user