465 lines
22 KiB
Python
465 lines
22 KiB
Python
"""
|
|
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"]}
|