Files
OpenFin/backend/routers/cycle_actions.py
2026-06-28 15:38:19 +02:00

270 lines
12 KiB
Python

"""
Isolated Cycle Actions — callable individually from UI or cron.
Decomposition of the full auto_cycle into 8 independent actions:
ACTION 1 — check-market-events ← IMPLEMENTED HERE
Sources: RSS news + FRED eco surprises + MA crossovers + institutional reports
Output : new market_events created in DB
ACTION 2 — refresh-price-data (planned)
Sources: yfinance OHLCV for all watchlist instruments
Output : price_data table updated
ACTION 3 — compute-indicators (planned)
Sources: price_data in DB
Output : ma20/50/100/200 + RSI + BB + ATR stored in instrument_indicators
ACTION 4 — evaluate-impacts (planned)
Sources: market_events without impact scores
Output : GPT-evaluated impact_score + affected_assets stored
ACTION 5 — generate-signals (planned)
Sources: indicators + market_events + portfolio
Output : trading signals for each instrument
ACTION 6 — update-regime (planned)
Sources: signals + macro context
Output : current macro_regime classification
ACTION 7 — snapshot-portfolio (planned)
Sources: portfolio positions + current prices
Output : risk metrics (VaR, PnL, delta) snapshot stored
ACTION 8 — generate-report (planned)
Sources: all of the above
Output : daily cycle report PDF/JSON
"""
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/actions", tags=["Cycle Actions"])
# ── Request / Response models ─────────────────────────────────────────────────
class CheckEventsRequest(BaseModel):
sources: Optional[List[str]] = None # ['news','eco','technical','reports']
news_impact_min: float = 0.55
eco_z_threshold: float = 1.5
technical_lookback_days: int = 7
report_days: int = 7
report_min_importance: int = 3
# Unified date window for news + eco (replaces eco_days + news_lookback_hours)
date_from: Optional[str] = None # ISO date, e.g. "2026-06-21"
date_to: Optional[str] = None # ISO date, e.g. "2026-06-28"
class ActionResult(BaseModel):
action: str
status: str
started_at: str
finished_at: str
duration_s: float
total_created: int
detail: Dict[str, Any]
# ── In-memory run state (prevents concurrent duplicate runs) ──────────────────
_running: Dict[str, bool] = {}
def _guard(action: str) -> None:
if _running.get(action):
raise HTTPException(status_code=409, detail=f"Action '{action}' is already running")
_running[action] = True
def _release(action: str) -> None:
_running.pop(action, None)
# ── GET /api/actions — list all actions with status ───────────────────────────
@router.get("")
def list_actions():
"""Return the catalogue of available cycle actions and their run status."""
actions = [
{
"id": "check-market-events",
"label": "Check New Market Events",
"description": "Scans RSS news, FRED eco surprises, MA crossovers, and institutional reports to create new market_events.",
"status": "running" if _running.get("check-market-events") else "idle",
"implemented": True,
"group": "detection",
},
{
"id": "bootstrap-macro",
"label": "Bootstrap Macro Events",
"description": "Seed 30+ historical macro/geopolitical events (FOMC, NFP, elections…). Idempotent — skip duplicates.",
"status": "running" if _running.get("bootstrap-macro") else "idle",
"implemented": True,
"group": "bootstrap",
},
{
"id": "bootstrap-eco",
"label": "Bootstrap Eco Calendar",
"description": "Seed economic calendar events from FRED/ECB/BOE (last 12 months). Idempotent.",
"status": "running" if _running.get("bootstrap-eco") else "idle",
"implemented": True,
"group": "bootstrap",
},
{
"id": "bootstrap-categories",
"label": "Bootstrap Impact Categories",
"description": "Seed default event categories with instrument impact weights (FOMC, NFP, CPI, BOE…).",
"status": "running" if _running.get("bootstrap-categories") else "idle",
"implemented": True,
"group": "bootstrap",
},
{"id": "bootstrap-ma", "label": "Bootstrap MA Crossovers", "description": "Detect historical MA50/100/200 crossovers for all instruments.", "status": "idle", "implemented": False, "group": "bootstrap"},
{"id": "refresh-price-data", "label": "Refresh Price Data", "description": "Download fresh OHLCV for all watchlist instruments.", "status": "idle", "implemented": False, "group": "data"},
{"id": "compute-indicators", "label": "Compute Indicators", "description": "Recalculate MA/RSI/BB/ATR for all instruments.", "status": "idle", "implemented": False, "group": "data"},
{"id": "evaluate-impacts", "label": "Evaluate Impacts", "description": "AI-score market_events that lack impact_score.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "generate-signals", "label": "Generate Signals", "description": "Compute trading signals from indicators + events.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "update-regime", "label": "Update Regime", "description": "Re-classify the current macro regime.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "snapshot-portfolio", "label": "Snapshot Portfolio", "description": "Store a risk snapshot (VaR, PnL, delta).", "status": "idle", "implemented": False, "group": "portfolio"},
{"id": "generate-report", "label": "Generate Report", "description": "Build and persist the daily cycle report.", "status": "idle", "implemented": False, "group": "portfolio"},
]
return {"actions": actions}
# ── POST /api/actions/check-market-events ─────────────────────────────────────
@router.post("/check-market-events", response_model=ActionResult)
def check_market_events(req: CheckEventsRequest):
"""
ACTION 1 — Check New Market Events.
Runs synchronously (up to ~60 s with yfinance + OpenAI calls).
Returns counts of created events per source.
"""
_guard("check-market-events")
started_at = datetime.utcnow()
try:
from services.market_event_detector import check_new_market_events
result = check_new_market_events(
sources=req.sources,
news_impact_min=req.news_impact_min,
eco_z_threshold=req.eco_z_threshold,
technical_lookback_days=req.technical_lookback_days,
report_days=req.report_days,
report_min_importance=req.report_min_importance,
date_from=req.date_from,
date_to=req.date_to,
)
except Exception as e:
_release("check-market-events")
logger.error(f"[action/check-market-events] {e}")
raise HTTPException(status_code=500, detail=str(e))
_release("check-market-events")
finished_at = datetime.utcnow()
duration_s = (finished_at - started_at).total_seconds()
return ActionResult(
action="check-market-events",
status="success",
started_at=started_at.isoformat(),
finished_at=finished_at.isoformat(),
duration_s=round(duration_s, 2),
total_created=result.get("total_created", 0),
detail={
"news": result.get("news", []),
"eco": result.get("eco", []),
"technical": result.get("technical", []),
"reports": result.get("reports", []),
},
)
# ── POST /api/actions/check-market-events/background ─────────────────────────
@router.post("/check-market-events/background", status_code=202)
def check_market_events_background(
req: CheckEventsRequest,
background_tasks: BackgroundTasks,
):
"""
Fire-and-forget version — returns 202 immediately.
Results are only visible via logs (system_logs table or backend console).
"""
if _running.get("check-market-events"):
raise HTTPException(status_code=409, detail="Action 'check-market-events' is already running")
def _run():
_guard("check-market-events")
try:
from services.market_event_detector import check_new_market_events
check_new_market_events(
sources=req.sources,
news_impact_min=req.news_impact_min,
eco_z_threshold=req.eco_z_threshold,
technical_lookback_days=req.technical_lookback_days,
report_days=req.report_days,
report_min_importance=req.report_min_importance,
date_from=req.date_from,
date_to=req.date_to,
)
except Exception as e:
logger.error(f"[action/check-market-events/bg] {e}")
finally:
_release("check-market-events")
background_tasks.add_task(_run)
return {"status": "accepted", "message": "check-market-events started in background"}
# ── POST /api/actions/bootstrap-macro ────────────────────────────────────────
@router.post("/bootstrap-macro")
def run_bootstrap_macro(force: bool = Query(False)):
"""Seed macro/geopolitical historical events. Idempotent by default."""
_guard("bootstrap-macro")
try:
from services.macro_events_bootstrap import bootstrap_macro_events
result = bootstrap_macro_events(force=force)
except Exception as e:
_release("bootstrap-macro")
raise HTTPException(500, str(e))
_release("bootstrap-macro")
return {"action": "bootstrap-macro", "status": "success", **result}
# ── POST /api/actions/bootstrap-eco ──────────────────────────────────────────
@router.post("/bootstrap-eco")
def run_bootstrap_eco(force: bool = Query(False)):
"""Seed economic calendar events. Idempotent by default."""
_guard("bootstrap-eco")
try:
from services.eco_calendar_bootstrap import bootstrap_eco_events
result = bootstrap_eco_events(force=force)
except Exception as e:
_release("bootstrap-eco")
raise HTTPException(500, str(e))
_release("bootstrap-eco")
return {"action": "bootstrap-eco", "status": "success", **result}
# ── POST /api/actions/bootstrap-categories ────────────────────────────────────
@router.post("/bootstrap-categories")
def run_bootstrap_categories(force: bool = Query(False)):
"""Seed default event categories with impact weights. Idempotent."""
_guard("bootstrap-categories")
try:
from services.impact_categories_bootstrap import bootstrap_impact_categories
result = bootstrap_impact_categories(force=force)
except Exception as e:
_release("bootstrap-categories")
raise HTTPException(500, str(e))
_release("bootstrap-categories")
return {"action": "bootstrap-categories", "status": "success", **result}