""" Isolated Cycle Actions — callable individually from UI or cron, to decompose a full auto-cycle into steps runnable/inspectable one at a time. ACTION 1 — check-market-events : RSS news + FRED surprises + MA crossovers + institutional reports -> new market_events ACTION 2 — bootstrap-macro : seed historical macro/geopolitical events (idempotent) ACTION 3 — bootstrap-eco : seed economic calendar events (idempotent) ACTION 4 — bootstrap-categories : seed default impact categories (idempotent) ACTION 5 — bootstrap-ma : historical MA50/100/200 crossover detection -> market_events (idempotent, >100 events skip unless force) ACTION 6 — refresh-price-data : yfinance OHLCV for the watchlist -> price_data_cache ACTION 7 — compute-indicators : MA/RSI/BB/ATR per watchlist ticker -> instrument_indicators ACTION 8 — evaluate-impacts : AI-score market_events not yet in instrument_impacts ACTION 9 — generate-signals : technical desk signals (check-market-events sources=[technical]) + wavelet scan ACTION 10 — update-regime : macro gauges -> scenario scoring -> regime history -> cluster reclassification ACTION 11 — snapshot-portfolio : VaR + PnL snapshot (same functions as /api/var/run-now + /api/var/pnl/run-now) ACTION 12 — generate-report : best-effort standalone cycle report (see services.auto_cycle.generate_standalone_report) """ 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": "running" if _running.get("bootstrap-ma") else "idle", "implemented": True, "group": "bootstrap"}, {"id": "refresh-price-data", "label": "Refresh Price Data", "description": "Download fresh OHLCV for all watchlist instruments.", "status": "running" if _running.get("refresh-price-data") else "idle", "implemented": True, "group": "data"}, {"id": "compute-indicators", "label": "Compute Indicators", "description": "Recalculate MA/RSI/BB/ATR for all instruments.", "status": "running" if _running.get("compute-indicators") else "idle", "implemented": True, "group": "data"}, {"id": "evaluate-impacts", "label": "Evaluate Impacts", "description": "AI-score market_events that lack impact_score.", "status": "running" if _running.get("evaluate-impacts") else "idle", "implemented": True, "group": "ai"}, {"id": "generate-signals", "label": "Generate Signals", "description": "Compute trading signals from indicators + events.", "status": "running" if _running.get("generate-signals") else "idle", "implemented": True, "group": "ai"}, {"id": "update-regime", "label": "Update Regime", "description": "Re-classify the current macro regime.", "status": "running" if _running.get("update-regime") else "idle", "implemented": True, "group": "ai"}, {"id": "snapshot-portfolio", "label": "Snapshot Portfolio", "description": "Store a risk snapshot (VaR, PnL, delta).", "status": "running" if _running.get("snapshot-portfolio") else "idle", "implemented": True, "group": "portfolio"}, {"id": "generate-report", "label": "Generate Report", "description": "Build and persist the daily cycle report.", "status": "running" if _running.get("generate-report") else "idle", "implemented": True, "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} # ── POST /api/actions/bootstrap-ma ─────────────────────────────────────────── @router.post("/bootstrap-ma") async def run_bootstrap_ma(force: bool = Query(False)): """Detect historical MA50/100/200 crossovers for the 5 core underlyings. Idempotent by default (skips if DB already has >100 market_events).""" _guard("bootstrap-ma") try: from services.ma_analyzer import bootstrap_ma_events result = await bootstrap_ma_events(force=force) except Exception as e: _release("bootstrap-ma") raise HTTPException(500, str(e)) _release("bootstrap-ma") return {"action": "bootstrap-ma", "status": "success", **result} # ── POST /api/actions/refresh-price-data ───────────────────────────────────── @router.post("/refresh-price-data") def run_refresh_price_data(period: str = Query("3mo")): """Download fresh OHLCV for every watchlist instrument into price_data_cache.""" _guard("refresh-price-data") try: from services.price_cache import refresh_watchlist_price_data result = refresh_watchlist_price_data(period=period) except Exception as e: _release("refresh-price-data") raise HTTPException(500, str(e)) _release("refresh-price-data") return {"action": "refresh-price-data", "status": "success", **result} # ── POST /api/actions/compute-indicators ───────────────────────────────────── @router.post("/compute-indicators") def run_compute_indicators(horizon_days: int = Query(45)): """Recalculate RSI/MA/Bollinger/ATR for every watchlist instrument, persisted into instrument_indicators (calibration matches the horizon, same default as the auto-cycle's own tech-indicators step).""" _guard("compute-indicators") try: from services.technical_indicators import compute_and_save_indicators result = compute_and_save_indicators(horizon_days=horizon_days) except Exception as e: _release("compute-indicators") raise HTTPException(500, str(e)) _release("compute-indicators") return {"action": "compute-indicators", "status": "success", **result} # ── POST /api/actions/evaluate-impacts ─────────────────────────────────────── @router.post("/evaluate-impacts") def run_evaluate_impacts(limit: int = Query(20)): """AI-score every market_event that has no row yet in instrument_impacts (i.e. was never evaluated) — same per-event function as the manual POST /api/impact/evaluate/bulk, with auto-discovered IDs instead of caller-supplied ones.""" _guard("evaluate-impacts") try: from services.database import get_conn from services.impact_service import evaluate_event_impacts conn = get_conn() rows = conn.execute( """SELECT id FROM market_events WHERE id NOT IN (SELECT source_id FROM instrument_impacts WHERE source_type='event') ORDER BY id DESC LIMIT ?""", (limit,), ).fetchall() conn.close() event_ids = [r["id"] for r in rows] evaluated = [] failed = [] for eid in event_ids: try: evaluate_event_impacts(eid, force=False) evaluated.append(eid) except Exception as e: logger.warning(f"[action/evaluate-impacts] event {eid} failed: {e}") failed.append(eid) except Exception as e: _release("evaluate-impacts") raise HTTPException(500, str(e)) _release("evaluate-impacts") return { "action": "evaluate-impacts", "status": "success", "candidates_found": len(event_ids), "evaluated": len(evaluated), "failed": failed, } # ── POST /api/actions/generate-signals ─────────────────────────────────────── @router.post("/generate-signals") def run_generate_signals(): """Technical desk signals (ma_cross/rsi_extreme/bb_squeeze/new_52w_extreme, via the same path as check-market-events sources=[technical]) + a fresh wavelet band scan of the watchlist.""" _guard("generate-signals") try: from datetime import datetime as _dt from services.market_event_detector import check_new_market_events from services.wavelet_signals import compute_and_save_wavelet_signals technical = check_new_market_events(sources=["technical"]) run_id = _dt.utcnow().isoformat() wavelet_results = compute_and_save_wavelet_signals(run_id) except Exception as e: _release("generate-signals") raise HTTPException(500, str(e)) _release("generate-signals") return { "action": "generate-signals", "status": "success", "technical_events_created": technical.get("total_created", 0), "wavelet_signals_computed": len(wavelet_results), "wavelet_signals_fired": len([w for w in wavelet_results if w.get("signal_kind")]), } # ── POST /api/actions/update-regime ────────────────────────────────────────── @router.post("/update-regime") def run_update_regime(): """Re-fetch macro gauges, re-score scenarios, log the regime snapshot, and re-run cluster reclassification (same sequence as auto_cycle's own step, reading n_clusters/lookback_days from the configurable cycle_step_config).""" _guard("update-regime") try: from services.data_fetcher import get_macro_gauges, score_macro_scenarios from services.database import log_macro_regime from services.database import detect_and_save_regime_clusters from services.auto_cycle import get_cycle_step_config gauges = get_macro_gauges() scenarios = score_macro_scenarios(gauges) dominant = scenarios.get("dominant", "incertain") gauges_summary = { k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")} for k, v in gauges.items() if v.get("value") is not None or v.get("change_pct") is not None } log_macro_regime(dominant, scenarios.get("scores", {}), scenarios.get("reasons", {}), gauges_summary) regime_cfg = get_cycle_step_config()["regime_clustering"] cluster_result = None if regime_cfg.get("enabled", True): cluster_result = detect_and_save_regime_clusters( n_clusters=regime_cfg["n_clusters"], days=regime_cfg["lookback_days"], ) except Exception as e: _release("update-regime") raise HTTPException(500, str(e)) _release("update-regime") return { "action": "update-regime", "status": "success", "dominant_regime": dominant, "scenario_scores": scenarios.get("scores", {}), "cluster_result": cluster_result, } # ── POST /api/actions/snapshot-portfolio ───────────────────────────────────── @router.post("/snapshot-portfolio") def run_snapshot_portfolio(): """VaR + PnL snapshot — same functions already exposed individually at POST /api/var/run-now and POST /api/var/pnl/run-now, combined into one action for cycle-decomposition purposes.""" _guard("snapshot-portfolio") try: from services.var_service import compute_var, save_var_snapshot, save_pnl_snapshot from services.auto_cycle import get_cycle_step_config var_cfg = get_cycle_step_config()["var_snapshot"] var_result = compute_var( confidence=var_cfg["confidence"], horizon_days=var_cfg["horizon_days"], lookback_days=var_cfg["lookback_days"], default_iv=var_cfg["default_iv"], ) var_snapshot_id = None if "error" not in var_result: var_snapshot_id = save_var_snapshot( var_result, confidence=var_cfg["confidence"], horizon_days=var_cfg["horizon_days"], lookback_days=var_cfg["lookback_days"], default_iv=var_cfg["default_iv"], ) pnl_snapshot_id = save_pnl_snapshot() except Exception as e: _release("snapshot-portfolio") raise HTTPException(500, str(e)) _release("snapshot-portfolio") return { "action": "snapshot-portfolio", "status": "success", "var_snapshot_id": var_snapshot_id, "pnl_snapshot_id": pnl_snapshot_id, "var_result": var_result, } # ── POST /api/actions/generate-report ──────────────────────────────────────── @router.post("/generate-report") def run_generate_report(): """Best-effort standalone cycle report — see services.auto_cycle.generate_standalone_report() for exactly which pieces are DB-recovered vs. freshly recomputed vs. necessarily left empty (artifacts that only exist inside a live run_cycle_once()).""" _guard("generate-report") try: from services.auto_cycle import generate_standalone_report result = generate_standalone_report() except Exception as e: _release("generate-report") raise HTTPException(500, str(e)) _release("generate-report") return {"action": "generate-report", "status": "success", "run_id": result["run_id"]}