add market-regime guard (SPY trend) — inform + warn
New market_regime_service computes a benchmark (SPY) trend from its 50/200-day SMAs, cached in a SystemSetting and refreshed by a nightly job; GET /market/regime exposes it. Dashboard shows a regime banner; setup cards flag a counter-trend caution when a setup fights the regime (LONG in a bearish market / SHORT in a bullish one). Informational only — nothing is suppressed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,7 @@ from app.routers.sentiment import router as sentiment_router
|
||||
from app.routers.sr_levels import router as sr_levels_router
|
||||
from app.routers.tickers import router as tickers_router
|
||||
from app.routers.jobs import router as jobs_router
|
||||
from app.routers.market import router as market_router
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
@@ -160,3 +161,4 @@ app.include_router(scores_router, prefix="/api/v1")
|
||||
app.include_router(trades_router, prefix="/api/v1")
|
||||
app.include_router(watchlist_router, prefix="/api/v1")
|
||||
app.include_router(jobs_router, prefix="/api/v1")
|
||||
app.include_router(market_router, prefix="/api/v1")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Market-level endpoints (benchmark regime)."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.models.user import User
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.services.market_regime_service import get_market_regime
|
||||
|
||||
router = APIRouter(tags=["market"])
|
||||
|
||||
|
||||
@router.get("/market/regime", response_model=APIEnvelope)
|
||||
async def market_regime(
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Current benchmark (SPY) trend regime: bullish / bearish / neutral."""
|
||||
data = await get_market_regime(db)
|
||||
return APIEnvelope(status="success", data=data)
|
||||
@@ -35,6 +35,7 @@ from app.providers.fundamentals_chain import build_fundamental_provider_chain
|
||||
from app.providers.protocol import SentimentData
|
||||
from app.services import fundamental_service, ingestion_service, sentiment_service
|
||||
from app.services.alert_service import dispatch_alerts
|
||||
from app.services.market_regime_service import update_market_regime
|
||||
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
|
||||
@@ -133,6 +134,17 @@ _job_runtime: dict[str, dict[str, object]] = {
|
||||
"finished_at": None,
|
||||
"message": None,
|
||||
},
|
||||
"market_regime": {
|
||||
"running": False,
|
||||
"status": "idle",
|
||||
"processed": 0,
|
||||
"total": None,
|
||||
"progress_pct": None,
|
||||
"current_ticker": None,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"message": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -802,6 +814,42 @@ async def dispatch_alerts_job() -> None:
|
||||
}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Market Regime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def compute_market_regime() -> None:
|
||||
"""Refresh the cached benchmark (SPY) trend regime."""
|
||||
job_name = "market_regime"
|
||||
logger.info(json.dumps({"event": "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):
|
||||
logger.info(json.dumps({"event": "job_skipped", "job": job_name, "reason": "disabled"}))
|
||||
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
|
||||
return
|
||||
|
||||
regime = await update_market_regime(db)
|
||||
|
||||
_runtime_progress(job_name, processed=1, total=1)
|
||||
_runtime_finish(
|
||||
job_name, "completed", processed=1, total=1,
|
||||
message=f"Regime: {regime.get('label')}",
|
||||
)
|
||||
logger.info(json.dumps({"event": "job_complete", "job": job_name, "label": regime.get("label")}))
|
||||
except Exception as exc:
|
||||
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
|
||||
logger.error(json.dumps({
|
||||
"event": "job_error",
|
||||
"job": job_name,
|
||||
"error_type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job: Ticker Universe Sync
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -951,6 +999,16 @@ def configure_scheduler() -> None:
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Market Regime — nightly benchmark trend refresh
|
||||
scheduler.add_job(
|
||||
compute_market_regime,
|
||||
"interval",
|
||||
hours=24,
|
||||
id="market_regime",
|
||||
name="Market Regime",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
json.dumps({
|
||||
"event": "scheduler_configured",
|
||||
|
||||
@@ -483,6 +483,7 @@ VALID_JOB_NAMES = {
|
||||
"ticker_universe_sync",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
}
|
||||
|
||||
JOB_LABELS = {
|
||||
@@ -493,6 +494,7 @@ JOB_LABELS = {
|
||||
"ticker_universe_sync": "Ticker Universe Sync",
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
"alerts": "Alerts Dispatcher",
|
||||
"market_regime": "Market Regime",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Market-regime guard.
|
||||
|
||||
Computes a simple, robust trend regime for a benchmark (SPY by default) from its
|
||||
own moving averages, so the UI can warn against fighting the broad market — e.g.
|
||||
going long while SPY is below its 200-day average. Result is cached in a
|
||||
SystemSetting (JSON) and refreshed by a daily job; reads are cheap.
|
||||
|
||||
Regime is informational: it warns, it doesn't suppress setups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.services.admin_service import update_setting
|
||||
from app.models.settings import SystemSetting
|
||||
from sqlalchemy import select
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_REGIME = "market_regime"
|
||||
BENCHMARK = "SPY"
|
||||
_SMA_SHORT = 50
|
||||
_SMA_LONG = 200
|
||||
|
||||
|
||||
def _sma(values: list[float], window: int) -> float | None:
|
||||
if len(values) < window:
|
||||
return None
|
||||
return sum(values[-window:]) / window
|
||||
|
||||
|
||||
def compute_regime(closes: list[float]) -> dict:
|
||||
"""Derive a regime label from a benchmark's close series.
|
||||
|
||||
bullish = price above the 200-day and the 50-day confirms (50 ≥ 200)
|
||||
bearish = price below the 200-day and the 50-day confirms (50 ≤ 200)
|
||||
neutral = mixed / chop
|
||||
unknown = not enough history
|
||||
"""
|
||||
if not closes:
|
||||
return {"label": "unknown", "reason": "no benchmark data"}
|
||||
|
||||
price = closes[-1]
|
||||
sma50 = _sma(closes, _SMA_SHORT)
|
||||
sma200 = _sma(closes, _SMA_LONG)
|
||||
|
||||
if sma200 is None:
|
||||
# Fall back to the 50-day alone when we lack a full year of history.
|
||||
if sma50 is None:
|
||||
return {"label": "unknown", "reason": "insufficient history"}
|
||||
label = "bullish" if price > sma50 else "bearish"
|
||||
return {
|
||||
"label": label,
|
||||
"price": round(price, 2),
|
||||
"sma50": round(sma50, 2),
|
||||
"sma200": None,
|
||||
"pct_above_200": None,
|
||||
"reason": "based on 50-day only (under 200 bars)",
|
||||
}
|
||||
|
||||
above_200 = price > sma200
|
||||
if above_200 and sma50 >= sma200:
|
||||
label = "bullish"
|
||||
elif not above_200 and sma50 <= sma200:
|
||||
label = "bearish"
|
||||
else:
|
||||
label = "neutral"
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"price": round(price, 2),
|
||||
"sma50": round(sma50, 2),
|
||||
"sma200": round(sma200, 2),
|
||||
"pct_above_200": round((price / sma200 - 1.0) * 100, 2),
|
||||
"reason": None,
|
||||
}
|
||||
|
||||
|
||||
async def update_market_regime(db: AsyncSession) -> dict:
|
||||
"""Fetch the benchmark, compute the regime, and cache it. Job entrypoint."""
|
||||
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
||||
return {"label": "unknown", "reason": "Alpaca keys not configured"}
|
||||
|
||||
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
||||
end = date.today()
|
||||
start = end - timedelta(days=400) # ~280 trading days → covers the 200-day SMA
|
||||
bars = await provider.fetch_ohlcv(BENCHMARK, start, end)
|
||||
closes = [b.close for b in sorted(bars, key=lambda b: b.date)]
|
||||
|
||||
regime = compute_regime(closes)
|
||||
regime["benchmark"] = BENCHMARK
|
||||
regime["computed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
await update_setting(db, KEY_REGIME, json.dumps(regime))
|
||||
logger.info(json.dumps({"event": "market_regime_updated", "regime": regime["label"]}))
|
||||
return regime
|
||||
|
||||
|
||||
async def get_market_regime(db: AsyncSession) -> dict:
|
||||
"""Return the cached regime (computed by the daily job)."""
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == KEY_REGIME))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is None:
|
||||
return {"label": "unknown", "benchmark": BENCHMARK, "reason": "not computed yet"}
|
||||
try:
|
||||
return json.loads(setting.value)
|
||||
except (TypeError, ValueError):
|
||||
return {"label": "unknown", "benchmark": BENCHMARK, "reason": "corrupt cache"}
|
||||
Reference in New Issue
Block a user