Files
signal-platform/app/routers/market.py
T
dennisthiessen f714782fa4
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m13s
Deploy / deploy (push) Successful in 37s
fix: use categorical regime fundamentals
2026-07-15 10:13:22 +02:00

157 lines
5.9 KiB
Python

"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from typing import Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin
from app.models.user import User
from app.schemas.common import APIEnvelope
from app.services import event_study_service, regime_monitor_service
from app.services.backtest_service import get_backtest_report
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)
@router.get("/backtest/report", response_model=APIEnvelope)
async def backtest_report(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Latest cached historical backtest report (None until the job runs)."""
data = await get_backtest_report(db)
return APIEnvelope(status="success", data=data)
# ---------------------------------------------------------------------------
# AI/Tech Regime-Change Monitor (standalone, observational)
# ---------------------------------------------------------------------------
class RegimeConfigUpdate(BaseModel):
breadth_basket: list[str] | None = Field(default=None, min_length=20, max_length=100)
fundamental_staleness_days: int | None = Field(default=None, ge=30, le=180)
@field_validator("breadth_basket")
@classmethod
def normalise_basket(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
cleaned = [symbol.strip().upper().replace(".", "-") for symbol in value if symbol.strip()]
if len(cleaned) != len(set(cleaned)):
raise ValueError("breadth basket symbols must be unique")
return cleaned
class RegimeFundamentalsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
capex: dict[str, Literal["raising", "holding", "cutting", "unknown"]] | None = None
good_news_stock_down: Literal["yes", "no", "mixed"] | 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 v2 State and Warning risk-thermometer readings."""
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 fixed breadth basket and fundamental freshness window."""
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:
"""Update the deliberately small v2 operator configuration."""
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 categorical F1/F3 observations."""
data = await regime_monitor_service.set_fundamental_overrides(
db,
capex=body.capex,
good_news_stock_down=body.good_news_stock_down,
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:
"""Refresh F1/F3 via LLM, then recompute the latest eligible snapshot."""
data = await regime_monitor_service.refresh_fundamental_overrides(db, force=True)
await regime_monitor_service.update_regime_monitor(db)
return APIEnvelope(status="success", data=data)
@router.get("/regime/event-study", response_model=APIEnvelope)
async def regime_event_study(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Cached early-warning event study (lead time vs. historical drawdowns).
None until the manual "Event Study" job has run (Admin → Jobs)."""
data = await event_study_service.get_event_study_report(db)
return APIEnvelope(status="success", data=data)
@router.get("/regime/history", response_model=APIEnvelope)
async def regime_history(
days: int = Query(default=800, ge=7, le=2000),
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Point-in-time v2 State/Warning history. Legacy rows are excluded."""
data = await regime_monitor_service.get_regime_history(db, days=days)
return APIEnvelope(status="success", data=data)