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)
|
# Fundamentals Provider — Alpha Vantage (optional fallback)
|
||||||
ALPHA_VANTAGE_API_KEY=
|
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
|
# Scheduled Jobs
|
||||||
DATA_COLLECTOR_FREQUENCY=daily
|
DATA_COLLECTOR_FREQUENCY=daily
|
||||||
SENTIMENT_POLL_INTERVAL_MINUTES=30
|
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)
|
# Fundamentals Provider — Alpha Vantage (optional fallback)
|
||||||
alpha_vantage_api_key: str = ""
|
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)
|
# Alerts — Telegram (optional env fallback; can also be set in Admin)
|
||||||
telegram_bot_token: str = ""
|
telegram_bot_token: str = ""
|
||||||
telegram_chat_id: 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.settings import SystemSetting, IngestionProgress
|
||||||
from app.models.alert import AlertLog
|
from app.models.alert import AlertLog
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.regime_snapshot import RegimeSnapshot
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Ticker",
|
"Ticker",
|
||||||
@@ -26,4 +27,5 @@ __all__ = [
|
|||||||
"IngestionProgress",
|
"IngestionProgress",
|
||||||
"AlertLog",
|
"AlertLog",
|
||||||
"PaperTrade",
|
"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 fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.user import User
|
||||||
from app.schemas.common import APIEnvelope
|
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.backtest_service import get_backtest_report
|
||||||
from app.services.market_regime_service import get_market_regime
|
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)."""
|
"""Latest cached historical backtest report (None until the job runs)."""
|
||||||
data = await get_backtest_report(db)
|
data = await get_backtest_report(db)
|
||||||
return APIEnvelope(status="success", data=data)
|
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.alert_service import dispatch_alerts
|
||||||
from app.services.backtest_service import run_and_store as run_backtest_and_store
|
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.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.outcome_service import evaluate_pending_setups
|
||||||
from app.services.rr_scanner_service import scan_all_tickers
|
from app.services.rr_scanner_service import scan_all_tickers
|
||||||
from app.services.sentiment_provider_service import build_sentiment_provider
|
from app.services.sentiment_provider_service import build_sentiment_provider
|
||||||
@@ -80,6 +81,7 @@ _JOB_NAMES = [
|
|||||||
"ticker_universe_sync",
|
"ticker_universe_sync",
|
||||||
"alerts",
|
"alerts",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
|
"regime_monitor",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
"intraday_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))
|
_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
|
# Job: Backtest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -881,6 +919,8 @@ _DAILY_PIPELINE_STEPS = [
|
|||||||
("rr_scanner", "scan_rr"),
|
("rr_scanner", "scan_rr"),
|
||||||
("outcome_evaluator", "evaluate_outcomes"),
|
("outcome_evaluator", "evaluate_outcomes"),
|
||||||
("market_regime", "compute_market_regime"),
|
("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,
|
# 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"),
|
(scan_rr, "rr_scanner", "R:R Scanner"),
|
||||||
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
(evaluate_outcomes, "outcome_evaluator", "Outcome Evaluator"),
|
||||||
(compute_market_regime, "market_regime", "Market Regime"),
|
(compute_market_regime, "market_regime", "Market Regime"),
|
||||||
|
(compute_regime_monitor, "regime_monitor", "Regime Monitor"),
|
||||||
]
|
]
|
||||||
for fn, job_id, job_name in _members:
|
for fn, job_id, job_name in _members:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
|
|||||||
@@ -519,6 +519,7 @@ VALID_JOB_NAMES = {
|
|||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"alerts",
|
"alerts",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
|
"regime_monitor",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
"intraday_pipeline",
|
"intraday_pipeline",
|
||||||
@@ -534,6 +535,7 @@ JOB_LABELS = {
|
|||||||
"outcome_evaluator": "Outcome Evaluator",
|
"outcome_evaluator": "Outcome Evaluator",
|
||||||
"alerts": "Alerts Dispatcher",
|
"alerts": "Alerts Dispatcher",
|
||||||
"market_regime": "Market Regime",
|
"market_regime": "Market Regime",
|
||||||
|
"regime_monitor": "Regime Monitor",
|
||||||
"backtest": "Backtest",
|
"backtest": "Backtest",
|
||||||
"daily_pipeline": "Daily Pipeline",
|
"daily_pipeline": "Daily Pipeline",
|
||||||
"intraday_pipeline": "Intraday Pipeline",
|
"intraday_pipeline": "Intraday Pipeline",
|
||||||
@@ -546,6 +548,7 @@ PIPELINE_MEMBERS = {
|
|||||||
"rr_scanner",
|
"rr_scanner",
|
||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"market_regime",
|
"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 DashboardPage from './pages/DashboardPage';
|
||||||
import MarketPage from './pages/MarketPage';
|
import MarketPage from './pages/MarketPage';
|
||||||
import SignalsPage from './pages/SignalsPage';
|
import SignalsPage from './pages/SignalsPage';
|
||||||
|
import RegimePage from './pages/RegimePage';
|
||||||
import TickerDetailPage from './pages/TickerDetailPage';
|
import TickerDetailPage from './pages/TickerDetailPage';
|
||||||
import AdminPage from './pages/AdminPage';
|
import AdminPage from './pages/AdminPage';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/market" element={<MarketPage />} />
|
<Route path="/market" element={<MarketPage />} />
|
||||||
<Route path="/signals" element={<SignalsPage />} />
|
<Route path="/signals" element={<SignalsPage />} />
|
||||||
|
<Route path="/regime" element={<RegimePage />} />
|
||||||
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
|
<Route path="/ticker/:symbol" element={<TickerDetailPage />} />
|
||||||
{/* Legacy routes from the old 6-page layout */}
|
{/* Legacy routes from the old 6-page layout */}
|
||||||
<Route path="/watchlist" element={<Navigate to="/market" replace />} />
|
<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: '/', label: 'Overview', end: true },
|
||||||
{ to: '/market', label: 'Market', end: false },
|
{ to: '/market', label: 'Market', end: false },
|
||||||
{ to: '/signals', label: 'Signals', end: false },
|
{ to: '/signals', label: 'Signals', end: false },
|
||||||
|
{ to: '/regime', label: 'Regime', end: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MobileNav() {
|
export default function MobileNav() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ to: '/', label: 'Overview', index: '01', end: true },
|
{ to: '/', label: 'Overview', index: '01', end: true },
|
||||||
{ to: '/market', label: 'Market', index: '02', end: false },
|
{ to: '/market', label: 'Market', index: '02', end: false },
|
||||||
{ to: '/signals', label: 'Signals', index: '03', end: false },
|
{ to: '/signals', label: 'Signals', index: '03', end: false },
|
||||||
|
{ to: '/regime', label: 'Regime', index: '04', end: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const linkClasses = (isActive: boolean) =>
|
const linkClasses = (isActive: boolean) =>
|
||||||
@@ -62,7 +63,7 @@ export default function Sidebar() {
|
|||||||
))}
|
))}
|
||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<NavLink to="/admin" className={({ isActive }) => linkClasses(isActive)}>
|
<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
|
Admin
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -263,6 +263,54 @@ export interface MarketRegime {
|
|||||||
computed_at?: string;
|
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 {
|
export interface AlertConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
telegram_chat_id: string;
|
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",
|
"outcome_evaluator",
|
||||||
"alerts",
|
"alerts",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
|
"regime_monitor",
|
||||||
"backtest",
|
"backtest",
|
||||||
"daily_pipeline",
|
"daily_pipeline",
|
||||||
"intraday_pipeline",
|
"intraday_pipeline",
|
||||||
@@ -107,6 +108,7 @@ class TestConfigureScheduler:
|
|||||||
"data_backfill",
|
"data_backfill",
|
||||||
"fundamental_collector",
|
"fundamental_collector",
|
||||||
"market_regime",
|
"market_regime",
|
||||||
|
"regime_monitor",
|
||||||
"outcome_evaluator",
|
"outcome_evaluator",
|
||||||
"rr_scanner",
|
"rr_scanner",
|
||||||
"sentiment_collector",
|
"sentiment_collector",
|
||||||
|
|||||||
Reference in New Issue
Block a user