feat: isolated cycle action — Check New Market Events

Décompose le cycle en 8 actions appelables individuellement.
Action 1 implémentée : scan de 4 sources (news RSS, surprises FRED,
MA crossovers yfinance, rapports institutionnels) → création de
market_events avec déduplication. UI CycleActions page + sidebar link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 18:07:38 +02:00
parent 50a4a55b9e
commit 9afc01c7f5
6 changed files with 1043 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ from routers import specialist_desks as specialist_desks_router
from routers import timeline as timeline_router
from routers import instruments as instruments_router
from routers import impact as impact_router
from routers import cycle_actions as cycle_actions_router
from routers import logs as logs_router
from routers import var as var_router
from routers import reports as reports_router
@@ -139,6 +140,7 @@ app.include_router(specialist_desks_router.router)
app.include_router(timeline_router.router)
app.include_router(instruments_router.router)
app.include_router(impact_router.router)
app.include_router(cycle_actions_router.router)
@app.get("/")

View File

@@ -0,0 +1,194 @@
"""
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
news_lookback_hours: int = 48
eco_z_threshold: float = 1.5
eco_days: int = 7
technical_lookback_days: int = 7
report_days: int = 7
report_min_importance: int = 3
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,
},
{"id": "refresh-price-data", "label": "Refresh Price Data", "description": "Download fresh OHLCV for all watchlist instruments.", "status": "idle", "implemented": False},
{"id": "compute-indicators", "label": "Compute Indicators", "description": "Recalculate MA/RSI/BB/ATR for all instruments.", "status": "idle", "implemented": False},
{"id": "evaluate-impacts", "label": "Evaluate Impacts", "description": "AI-score market_events that lack impact_score.", "status": "idle", "implemented": False},
{"id": "generate-signals", "label": "Generate Signals", "description": "Compute trading signals from indicators + events.", "status": "idle", "implemented": False},
{"id": "update-regime", "label": "Update Regime", "description": "Re-classify the current macro regime.", "status": "idle", "implemented": False},
{"id": "snapshot-portfolio", "label": "Snapshot Portfolio", "description": "Store a risk snapshot (VaR, PnL, delta).", "status": "idle", "implemented": False},
{"id": "generate-report", "label": "Generate Report", "description": "Build and persist the daily cycle report.", "status": "idle", "implemented": False},
]
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,
news_lookback_hours=req.news_lookback_hours,
eco_z_threshold=req.eco_z_threshold,
eco_days=req.eco_days,
technical_lookback_days=req.technical_lookback_days,
report_days=req.report_days,
report_min_importance=req.report_min_importance,
)
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,
news_lookback_hours=req.news_lookback_hours,
eco_z_threshold=req.eco_z_threshold,
eco_days=req.eco_days,
technical_lookback_days=req.technical_lookback_days,
report_days=req.report_days,
report_min_importance=req.report_min_importance,
)
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"}

View File

@@ -0,0 +1,520 @@
"""
Isolated cycle action: Check New Market Events.
Scans 4 sources and creates market_events for significant findings:
- news : geopolitical/macro news (RSS feeds, rule-scored)
- eco : FRED economic releases with high surprise z-score
- technical: MA50/MA100/MA200 crossovers on key instruments
- reports : institutional reports (COT, EIA) with high importance
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Instruments monitored for technical crossovers
WATCH_INSTRUMENTS = [
"SPY", "QQQ", "IWM", "EEM", "EFA",
"GLD", "SLV", "USO", "TLT", "HYG",
"USDJPY=X", "EURUSD=X", "VXX", "NVDA", "BTC-USD",
]
SUBTYPE_FROM_SERIES = {
"UNRATE": "NFP", "PAYEMS": "NFP",
"CPIAUCSL": "CPI", "CPILFESL": "CPI",
"A191RL1Q225SBEA": "GDP", "GDP": "GDP",
"FEDFUNDS": "FOMC", "DFF": "FOMC",
"PCEPILFE": "PCE", "PCEPI": "PCE",
"BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_api_key() -> str:
import os
key = os.environ.get("OPENAI_API_KEY", "")
if not key:
from services.database import get_config
key = get_config("openai_api_key") or ""
return key
def _existing_event_keys() -> set:
"""Lowercase 50-char prefix of existing market_event names for fast dedup."""
from services.database import get_all_market_events
return {ev["name"].lower()[:50] for ev in get_all_market_events()}
def _is_dup(name: str, existing: set) -> bool:
return name.lower()[:50] in existing
def _parse_date(raw: str) -> str:
"""Return YYYY-MM-DD from any date string; fallback to today."""
if not raw:
return datetime.utcnow().strftime("%Y-%m-%d")
# ISO-like
try:
return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d")
except Exception:
pass
# RFC 2822 (RSS)
try:
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(raw).strftime("%Y-%m-%d")
except Exception:
pass
return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d")
# ── Source 1: Geopolitical / macro news ──────────────────────────────────────
def _check_news(
min_impact: float = 0.55,
lookback_hours: int = 48,
max_to_evaluate: int = 15,
) -> List[Dict[str, Any]]:
"""
Fetch recent RSS news, keep those with impact_score >= threshold,
ask GPT-4o-mini which ones deserve a permanent market_event record.
"""
from services.data_fetcher import fetch_geo_news
from services.database import save_market_event
api_key = _get_api_key()
if not api_key:
logger.warning("[check_events/news] no OpenAI key — skipping")
return []
try:
all_news = fetch_geo_news()
except Exception as e:
logger.warning(f"[check_events/news] fetch failed: {e}")
return []
cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours)
candidates = []
for n in all_news:
if (n.get("impact_score") or 0) < min_impact:
continue
pub_date = _parse_date(n.get("date", ""))
try:
if datetime.fromisoformat(pub_date) < cutoff_dt:
continue
except Exception:
pass
candidates.append(n)
candidates = candidates[:max_to_evaluate]
if not candidates:
return []
existing = _existing_event_keys()
created: List[Dict] = []
try:
from openai import OpenAI
client = OpenAI(api_key=api_key)
except Exception as e:
logger.warning(f"[check_events/news] OpenAI init failed: {e}")
return []
for n in candidates:
title = n.get("title", "")
if not title or _is_dup(title, existing):
continue
prompt = f"""Tu es un analyste macro. Cette news représente-t-elle un événement marché structurant qui mérite un enregistrement permanent ?
TITRE: {title}
SOURCE: {n.get('source', '')}
DATE: {n.get('date', '')}
RÉSUMÉ: {str(n.get('summary', ''))[:400]}
SCORE IMPACT (règle): {n.get('impact_score', 0):.2f}
Réponds OUI seulement si c'est un fait avéré, pas une rumeur ou une opinion, et qu'il a un impact macro ou géopolitique mesurable.
FORMAT JSON STRICT:
{{
"qualifies": true/false,
"reason": "une phrase",
"name": "Nom court (≤ 60 chars)",
"category": "geopolitical|event_calendar|fundamental|report",
"sub_type": "ex: Conflit, Tarifs, Sanctions, OPEC+, Crise...",
"description": "1-2 phrases analytiques",
"affected_assets": ["SPY","GLD",...],
"impact_score": 0.5,
"level": "short|medium|long"
}}"""
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.1,
max_tokens=350,
)
parsed = json.loads(resp.choices[0].message.content)
except Exception as e:
logger.debug(f"[check_events/news] AI call failed for '{title[:40]}': {e}")
continue
if not parsed.get("qualifies"):
continue
ev_name = (parsed.get("name") or title)[:60]
if _is_dup(ev_name, existing):
continue
ev = {
"name": ev_name,
"start_date": _parse_date(n.get("date", "")),
"level": parsed.get("level", "short"),
"category": parsed.get("category", "geopolitical"),
"sub_type": parsed.get("sub_type", ""),
"description": parsed.get("description", title),
"market_impact": "",
"affected_assets": parsed.get("affected_assets", []),
"impact_score": float(parsed.get("impact_score", 0.6)),
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": ev["category"], "date": ev["start_date"], "source": "news"})
logger.info(f"[check_events/news] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/news] save failed: {e}")
return created
# ── Source 2: Eco calendar — FRED surprises ───────────────────────────────────
def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
"""
Reads economic_events table (FRED releases already stored by fred_fetcher).
Creates market_events for releases with |z-score| >= threshold.
"""
from services.database import get_recent_economic_surprises, save_market_event
try:
releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold)
except Exception as e:
logger.warning(f"[check_events/eco] query failed: {e}")
return []
existing = _existing_event_keys()
created: List[Dict] = []
for rel in releases:
z = abs(rel.get("surprise_zscore") or 0)
s_pct = rel.get("surprise_pct") or 0
ev_date = (rel.get("event_date") or "")[:10]
s_id = rel.get("series_id", "")
ev_name_base = rel.get("event_name", s_id)
direction = rel.get("surprise_direction", "neutral")
sign = "+" if s_pct >= 0 else ""
ev_name = f"{ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})"
if _is_dup(ev_name, existing):
continue
sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10]
level = "long" if z >= 3 else ("medium" if z >= 2 else "short")
assets = rel.get("assets_impacted") or []
if isinstance(assets, str):
try:
assets = json.loads(assets)
except Exception:
assets = []
ev = {
"name": ev_name,
"start_date": ev_date,
"level": level,
"category": "event_calendar",
"sub_type": sub_type,
"description": (
f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline "
f"(z-score: {z:.1f}σ). "
f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} "
f"/ Prévision: {rel.get('forecast_value', '?')}."
),
"market_impact": "",
"affected_assets": assets,
"impact_score": min(0.95, 0.35 + z * 0.15),
"actual_value": str(rel.get("actual_value", "")),
"expected_value": str(rel.get("forecast_value", "")),
"surprise_pct": float(s_pct),
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "event_calendar", "date": ev_date, "source": "eco"})
logger.info(f"[check_events/eco] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/eco] save failed: {e}")
return created
# ── Source 3: MA crossovers (technical) ──────────────────────────────────────
def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> List[Dict[str, Any]]:
"""
Downloads recent OHLCV for each instrument, detects MA50/MA100/MA200 crossovers
in the last `lookback_days` days. Creates technical market_events.
"""
try:
import yfinance as yf
import pandas as pd
except ImportError:
logger.warning("[check_events/technical] yfinance/pandas not available")
return []
from services.database import save_market_event
if instruments is None:
instruments = WATCH_INSTRUMENTS
existing = _existing_event_keys()
created: List[Dict] = []
cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
for ticker in instruments:
try:
df = yf.download(ticker, period="1y", interval="1d", progress=False, auto_adjust=True)
if df is None or len(df) < 210:
continue
close = df["Close"].squeeze()
df["ma50"] = close.rolling(50).mean()
df["ma100"] = close.rolling(100).mean()
df["ma200"] = close.rolling(200).mean()
recent = df.tail(lookback_days + 2)
# Check consecutive row pairs for crossovers
for i in range(1, len(recent)):
date_str = str(recent.index[i])[:10]
if date_str < cutoff:
continue
prev = recent.iloc[i - 1]
curr = recent.iloc[i]
def cross(fast_prev, fast_curr, slow_prev, slow_curr):
if any(pd.isna(v) for v in [fast_prev, fast_curr, slow_prev, slow_curr]):
return None
if fast_prev < slow_prev and fast_curr >= slow_curr:
return "golden"
if fast_prev > slow_prev and fast_curr <= slow_curr:
return "death"
return None
pairs = [
("MA50", "MA200", prev["ma50"], curr["ma50"], prev["ma200"], curr["ma200"]),
("MA50", "MA100", prev["ma50"], curr["ma50"], prev["ma100"], curr["ma100"]),
]
for fast_lbl, slow_lbl, fp, fc, sp, sc in pairs:
kind = cross(fp, fc, sp, sc)
if kind is None:
continue
cross_label = "Golden Cross" if kind == "golden" else "Death Cross"
ev_name = f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})"
if _is_dup(ev_name, existing):
continue
direction = "bullish" if kind == "golden" else "bearish"
level = "medium" if slow_lbl == "MA200" else "short"
ev = {
"name": ev_name,
"start_date": date_str,
"level": level,
"category": "technical",
"sub_type": f"{fast_lbl}/{slow_lbl} Cross",
"description": (
f"{cross_label} : {fast_lbl} passe {'au-dessus' if kind == 'golden' else 'en-dessous'} "
f"de la {slow_lbl} sur {ticker}. "
f"Signal {direction} de tendance {'long terme' if slow_lbl == 'MA200' else 'moyen terme'}."
),
"market_impact": f"Signal {direction} sur {ticker}",
"affected_assets": [ticker],
"impact_score": 0.65 if slow_lbl == "MA200" else 0.45,
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "technical", "date": date_str, "source": "technical"})
logger.info(f"[check_events/technical] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/technical] save failed: {e}")
except Exception as e:
logger.debug(f"[check_events/technical] {ticker} failed: {e}")
return created
# ── Source 4: Institutional reports ──────────────────────────────────────────
def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]:
"""
Reads institutional_reports table for recent high-importance entries.
Creates report market_events.
"""
from services.database import get_conn, save_market_event
try:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM institutional_reports
WHERE report_date >= ? AND importance >= ?
ORDER BY importance DESC, report_date DESC
LIMIT 15""",
(cutoff, min_importance),
).fetchall()
conn.close()
reports = [dict(r) for r in rows]
except Exception as e:
logger.warning(f"[check_events/reports] query failed: {e}")
return []
existing = _existing_event_keys()
created: List[Dict] = []
for rpt in reports:
title = rpt.get("title", "")
rpt_type = rpt.get("report_type", "Report")
rpt_date = (rpt.get("report_date") or "")[:10]
if not title or _is_dup(title, existing):
continue
ev_name = title[:60]
summary = rpt.get("ai_summary") or rpt.get("trading_implications", "")
try:
kp = json.loads(rpt.get("key_points_json") or "[]")
if kp:
summary = " ".join(kp[:2]) + " " + summary
except Exception:
pass
# Derive affected assets from signal columns
assets: List[str] = []
for sig_col, asset_list in [
("signal_energy", ["USO", "XOM"]),
("signal_metals", ["GLD", "SLV"]),
("signal_indices", ["SPY", "QQQ"]),
("signal_forex", ["EURUSD=X", "USDJPY=X"]),
]:
if rpt.get(sig_col, "neutral") not in ("neutral", "", None):
assets.extend(asset_list)
ev = {
"name": ev_name,
"start_date": rpt_date,
"level": "medium" if rpt.get("importance", 2) >= 4 else "short",
"category": "report",
"sub_type": rpt_type.upper(),
"description": summary[:500] or f"Rapport {rpt_type} du {rpt_date}.",
"market_impact": rpt.get("trading_implications", ""),
"affected_assets": list(set(assets)),
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "report", "date": rpt_date, "source": "reports"})
logger.info(f"[check_events/reports] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/reports] save failed: {e}")
return created
# ── Main entry point ──────────────────────────────────────────────────────────
def check_new_market_events(
sources: Optional[List[str]] = None,
news_impact_min: float = 0.55,
news_lookback_hours: int = 48,
eco_z_threshold: float = 1.5,
eco_days: int = 7,
technical_lookback_days: int = 7,
report_days: int = 7,
report_min_importance: int = 3,
) -> Dict[str, Any]:
"""
Isolated cycle action — scans all (or selected) sources and creates
market_events for significant findings.
sources: subset of ['news', 'eco', 'technical', 'reports']
default = all four
"""
if sources is None:
sources = ["news", "eco", "technical", "reports"]
results: Dict[str, Any] = {
"news": [],
"eco": [],
"technical": [],
"reports": [],
"total_created": 0,
"ran_at": datetime.utcnow().isoformat(),
}
if "news" in sources:
try:
results["news"] = _check_news(
min_impact=news_impact_min,
lookback_hours=news_lookback_hours,
)
except Exception as e:
logger.error(f"[check_events] news source error: {e}")
if "eco" in sources:
try:
results["eco"] = _check_eco(
z_threshold=eco_z_threshold,
days=eco_days,
)
except Exception as e:
logger.error(f"[check_events] eco source error: {e}")
if "technical" in sources:
try:
results["technical"] = _check_technical(
lookback_days=technical_lookback_days,
)
except Exception as e:
logger.error(f"[check_events] technical source error: {e}")
if "reports" in sources:
try:
results["reports"] = _check_reports(
days=report_days,
min_importance=report_min_importance,
)
except Exception as e:
logger.error(f"[check_events] reports source error: {e}")
results["total_created"] = sum(
len(results[s]) for s in ["news", "eco", "technical", "reports"]
)
logger.info(
f"[check_events] Done — {results['total_created']} new events: "
f"news={len(results['news'])} eco={len(results['eco'])} "
f"technical={len(results['technical'])} reports={len(results['reports'])}"
)
return results