feat: cycle

This commit is contained in:
OpenSquared
2026-07-15 14:59:11 +02:00
parent 2d474c9194
commit 16ccc7c2c7
6 changed files with 466 additions and 45 deletions

View File

@@ -1,39 +1,19 @@
"""
Isolated Cycle Actions — callable individually from UI or cron.
Isolated Cycle Actions — callable individually from UI or cron, to decompose
a full auto-cycle into steps runnable/inspectable one at a time.
Decomposition of the full auto_cycle into 8 independent actions:
ACTION 1check-market-events ← IMPLEMENTED HERE
Sources: RSS news + FRED eco surprises + MA crossovers + institutional reports
Output : new market_events created in DB
ACTION 2refresh-price-data (planned)
Sources: yfinance OHLCV for all watchlist instruments
Output : price_data table updated
ACTION 3compute-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
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 11snapshot-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
@@ -122,14 +102,14 @@ def list_actions():
"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"},
{"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}
@@ -267,3 +247,218 @@ def run_bootstrap_categories(force: bool = Query(False)):
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"]}