Files
OpenFin/backend/services/impact_service.py
OpenSquared 0b1fcff49c feat: Market Events — catégories, cleanup sidebar, rename
- Supprime Timeline & ImpactMonitor du routing (redirects /impact + /timeline → /market-events)
- Renomme 'Instrument Snap.' → 'Instrument Analysis' dans la sidebar
- Onglet 'Catégories & Defaults' dans Market Events: CRUD complet (créer/éditer/supprimer)
  chaque catégorie a une table d'impacts par défaut (instrument, sensibilité, direction, notes)
- impact_service: meilleur matching catégorie (sub_type fuzzy + type fallback)
- DB: delete_event_category() + DELETE /api/impact/categories/{name}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:11:24 +02:00

273 lines
9.5 KiB
Python

"""
Impact evaluation service.
Given a market_event (calendar or geopolitical), calls the AI to estimate
instrument-level impacts (score 0-1, direction, rationale, confidence).
Results are persisted in instrument_impacts table.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
ALL_INSTRUMENTS = [
"SPY","QQQ","IWM","EEM","EFA",
"GLD","SLV","USO","UNG","TLT",
"HYG","EURUSD=X","USDJPY=X","GBPUSD=X",
"VXX","AAPL","NVDA","GS","XOM","BTC-USD",
]
INSTRUMENT_NAMES = {
"SPY": "S&P 500 ETF", "QQQ": "Nasdaq 100 ETF", "IWM": "Russell 2000 ETF",
"EEM": "Emerging Markets ETF", "EFA": "MSCI EAFE ETF",
"GLD": "Gold ETF", "SLV": "Silver ETF", "USO": "WTI Oil ETF",
"UNG": "Natural Gas ETF", "TLT": "US 20Y Treasury ETF",
"HYG": "High Yield Credit ETF", "EURUSD=X": "EUR/USD",
"USDJPY=X": "USD/JPY", "GBPUSD=X": "GBP/USD",
"VXX": "VIX Tracker", "AAPL": "Apple Inc",
"NVDA": "NVIDIA Corp", "GS": "Goldman Sachs",
"XOM": "ExxonMobil", "BTC-USD": "Bitcoin",
}
_INST_LIST_STR = ", ".join(f"{k} ({v})" for k, v in INSTRUMENT_NAMES.items())
def _get_api_key() -> str:
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 _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]] = None) -> str:
cat_context = ""
if category_defaults:
top = sorted(category_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8]
lines = [f"{d['instrument_id']}: sensibilité {d.get('sensitivity',0):.0%}, direction usuelle = {d.get('typical_direction','?')}" for d in top]
cat_context = "\nImpacts par défaut de cette catégorie (ajuste si l'événement est atypique) :\n" + "\n".join(lines)
return f"""Tu es un analyste macro senior spécialisé en options et trading multi-actifs.
ÉVÉNEMENT À ANALYSER :
- Nom : {event.get('name', '')}
- Date : {event.get('start_date', '')}
- Type : {event.get('category', '')} / {event.get('sub_type', '')}
- Description : {event.get('description', '')}
- Impact attendu (résumé) : {event.get('market_impact', '')}
{cat_context}
MISSION :
Évalue l'impact potentiel de cet événement sur chacun des 20 instruments suivants.
Instruments disponibles : {_INST_LIST_STR}
Pour chaque instrument IMPACTÉ (score >= 0.15), retourne :
- instrument_id : identifiant exact (ex: "SPY", "EURUSD=X", "BTC-USD")
- impact_score : 0.0 (aucun) → 1.0 (impact majeur, mouvement > 2%)
- direction : "bullish" | "bearish" | "neutral" | "depends_on_outcome"
- rationale : 1 phrase MAX expliquant le mécanisme
- confidence : 0.0-1.0 (ta certitude sur l'estimation)
Critères de scoring :
- 0.0-0.2 : négligeable (bruit)
- 0.2-0.4 : faible (< 0.5% mouvement)
- 0.4-0.6 : modéré (0.5-1% mouvement)
- 0.6-0.8 : fort (1-2% mouvement)
- 0.8-1.0 : majeur (> 2%, event driver primaire)
FORMAT JSON STRICT (pas de texte hors du JSON) :
{{
"impacts": [
{{"instrument_id": "SPY", "impact_score": 0.85, "direction": "bearish", "rationale": "Hausse taux comprime les multiples P/E", "confidence": 0.90}}
],
"summary": "Contexte macro de cet événement en 1 phrase",
"key_driver": "Instrument le plus impacté et pourquoi"
}}"""
def evaluate_event_impacts(
event_id: int,
force: bool = False,
) -> Dict[str, Any]:
"""
Evaluate instrument impacts for a market_event via AI.
Saves results to instrument_impacts table.
Returns the list of impacts + AI summary.
"""
from services.database import (
get_all_market_events, get_event_category,
get_impacts_for_source, delete_impacts_for_source,
save_instrument_impacts,
)
# Fetch event
events = get_all_market_events()
event = next((e for e in events if e["id"] == event_id), None)
if not event:
return {"error": f"Event {event_id} not found"}
# Check if already evaluated
existing = get_impacts_for_source("event", event_id)
if existing and not force:
return {
"event_id": event_id,
"impacts": existing,
"summary": "",
"from_cache": True,
"n_instruments": len(existing),
}
if force and existing:
delete_impacts_for_source("event", event_id)
# Get category defaults — try exact name match first, then fuzzy by sub_type/type
category_defaults = None
from services.database import get_all_event_categories
all_cats = get_all_event_categories()
def _find_cat(candidates):
for c in candidates:
if c is None:
continue
row = next((x for x in all_cats if x["name"].lower() == c.lower()), None)
if row:
try:
return json.loads(row.get("default_impacts") or "[]")
except Exception:
pass
return None
sub = (event.get("sub_type") or "").strip()
cat = (event.get("category") or "").strip()
name = (event.get("name") or "").upper()
# Priority: exact sub_type name → fuzzy keyword in category name → type match
candidates = [sub]
for row in all_cats:
rname = row["name"].upper()
if sub and sub.upper() in rname:
candidates.append(row["name"])
elif any(k in name for k in ["FOMC", "FED"] if k in rname):
candidates.append(row["name"])
elif row.get("type") == cat and not sub:
candidates.append(row["name"])
category_defaults = _find_cat(candidates)
api_key = _get_api_key()
if not api_key:
return {"error": "No OpenAI API key configured"}
prompt = _build_prompt(event, category_defaults)
try:
from openai import OpenAI
client = OpenAI(api_key=api_key)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.25,
max_tokens=1200,
)
raw = json.loads(response.choices[0].message.content)
except Exception as e:
logger.error(f"[impact_service] AI call failed for event {event_id}: {e}")
return {"error": str(e)}
ai_impacts = raw.get("impacts", [])
summary = raw.get("summary", "")
key_driver = raw.get("key_driver", "")
# Persist
to_save = []
for imp in ai_impacts:
if imp.get("instrument_id") not in ALL_INSTRUMENTS:
continue
to_save.append({
"source_type": "event",
"source_id": event_id,
"source_name": event["name"],
"source_date": event["start_date"],
"category_name": cat_name,
"instrument_id": imp["instrument_id"],
"impact_score": float(imp.get("impact_score", 0.5)),
"direction": imp.get("direction", "neutral"),
"rationale": imp.get("rationale", ""),
"confidence": float(imp.get("confidence", 0.7)),
"ai_generated": 1,
})
save_instrument_impacts(to_save)
saved = get_impacts_for_source("event", event_id)
return {
"event_id": event_id,
"event_name": event["name"],
"impacts": saved,
"summary": summary,
"key_driver": key_driver,
"n_instruments": len(saved),
"from_cache": False,
}
def get_impact_monitor_data(days: int = 7, min_score: float = 0.3) -> Dict[str, Any]:
"""
Return all evaluated sources (events + news) from the last N days,
filtered by min_score, with their instrument impacts.
Also includes unevaluated events for quick launch.
"""
from services.database import (
get_weekly_impact_sources, get_all_market_events,
get_impacts_for_source,
)
from datetime import datetime, timedelta
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
# Evaluated sources
evaluated = get_weekly_impact_sources(days=days, min_score=min_score)
# All recent events not yet evaluated
all_events = get_all_market_events()
evaluated_ids = {(s["source_type"], s.get("source_id")) for s in evaluated}
unevaluated = []
for ev in all_events:
if ev.get("start_date", "") < cutoff:
continue
if ("event", ev["id"]) not in evaluated_ids:
unevaluated.append({
"id": ev["id"],
"name": ev["name"],
"start_date": ev["start_date"],
"category": ev.get("category",""),
"sub_type": ev.get("sub_type",""),
"impact_score": ev.get("impact_score", 0.5),
})
# Top impacted instruments this period
from collections import defaultdict
inst_scores: Dict[str, List[float]] = defaultdict(list)
for source in evaluated:
for imp in source.get("impacts", []):
score = imp.get("adjusted_score") or imp.get("impact_score", 0)
if score >= min_score:
inst_scores[imp["instrument_id"]].append(score)
top_instruments = sorted(
[{"instrument_id": k, "avg_score": sum(v)/len(v), "n_events": len(v)}
for k, v in inst_scores.items()],
key=lambda x: x["avg_score"] * x["n_events"], reverse=True
)[:10]
return {
"evaluated": evaluated,
"unevaluated": sorted(unevaluated, key=lambda x: x["start_date"], reverse=True),
"top_instruments": top_instruments,
"period_days": days,
"min_score": min_score,
}