""" 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, }