""" AI analysis engine using OpenAI GPT-4o. Tasks: news scoring, speech analysis, pattern evaluation, trade idea ranking. """ from openai import OpenAI from typing import Optional, List, Dict, Any import json import os _client: Optional[OpenAI] = None def get_client() -> Optional[OpenAI]: global _client key = os.environ.get("OPENAI_API_KEY", "") if not key: return None if _client is None or _client.api_key != key: _client = OpenAI(api_key=key) return _client def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = True, max_tokens: int = 1500) -> Optional[Dict]: client = get_client() if not client: return None kwargs: Dict[str, Any] = { "model": model, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "temperature": 0.2, "max_tokens": max_tokens, } if json_mode: kwargs["response_format"] = {"type": "json_object"} resp = client.chat.completions.create(**kwargs) content = resp.choices[0].message.content if json_mode: return json.loads(content) return {"text": content} # ── News / Article Analysis ─────────────────────────────────────────────────── SYSTEM_NEWS = """Tu es un analyste financier géopolitique senior spécialisé en options. Tu analyses des actualités et identifies leur impact potentiel sur les marchés financiers. Réponds UNIQUEMENT en JSON selon le schéma demandé. Sois précis et concis.""" def analyze_news_item(title: str, summary: str) -> Dict[str, Any]: """Classify and score a single news item with GPT.""" user = f"""Analyse cet article géopolitique/économique: Titre: {title} Résumé: {summary} Retourne ce JSON: {{ "category": "military|sanctions|elections|natural_disaster|health_crisis|resource_scarcity|trade_war|energy|political_speech|financial_crisis|general", "impact_score": , "direction": "bullish|bearish|neutral|volatile", "affected_assets": {{ "energy": , "metals": , "agriculture": , "indices": , "forex": }}, "key_entities": [], "horizon": "immediate|days|weeks|months", "reasoning": "<1 phrase expliquant l'impact>" }}""" result = _chat(SYSTEM_NEWS, user) if not result: return {"category": "general", "impact_score": 0.1, "direction": "neutral", "affected_assets": {}, "key_entities": [], "horizon": "days", "reasoning": "AI non disponible"} return result # ── Speech / Text Analysis (Trump, Powell, etc.) ───────────────────────────── SYSTEM_SPEECH = """Tu es un analyste quantitatif géopolitique. Tu décodes les discours et déclarations de personnalités politiques/économiques pour identifier des opportunités de trading en options. Tu te spécialises dans: discours Trump (tarifs, énergie, dollar), Powell/Fed (taux), leaders géopolitiques (sanctions, guerres, ressources). Réponds en JSON uniquement.""" def analyze_speech(text: str, speaker: str = "") -> Dict[str, Any]: """Deep analysis of a speech/statement for trading signals.""" user = f"""Analyse cette déclaration{'de ' + speaker if speaker else ''} pour des signaux de trading: --- {text[:3000]} --- Retourne ce JSON: {{ "speaker_identified": "", "tone": "hawkish|dovish|aggressive|conciliatory|ambiguous", "key_statements": [], "market_signals": [ {{ "asset": "", "direction": "up|down|volatile", "magnitude": "low|medium|high|extreme", "reasoning": "", "timeframe": "" }} ], "options_opportunities": [ {{ "underlying": "", "strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle", "strike_guidance": "", "expiry_guidance": "<30j|60j|90j>", "rationale": "", "confidence": , "capital_1000eur": "" }} ], "risk_level": "low|medium|high|extreme", "geo_pattern_triggered": "", "summary": "<2-3 phrases de synthèse pour un trader>" }}""" result = _chat(SYSTEM_SPEECH, user, model="gpt-4o") if not result: return {"error": "OpenAI non disponible — vérifier la clé API"} return result # ── Pattern Evaluation & Creation ──────────────────────────────────────────── SYSTEM_PATTERN = """Tu es un expert en analyse géopolitique quantitative et en trading d'options. Tu évalues et améliores des patterns géopolitiques pour un système de trading algorithmique. Tes évaluations se basent sur des faits historiques vérifiables. Réponds en JSON.""" def evaluate_pattern(pattern: Dict[str, Any]) -> Dict[str, Any]: """AI evaluation of a user-defined pattern.""" user = f"""Évalue ce pattern géopolitique de trading: {json.dumps(pattern, ensure_ascii=False, indent=2)} Retourne ce JSON: {{ "quality_score": , "validity": "excellent|good|fair|poor", "strengths": [], "weaknesses": [], "suggested_improvements": {{ "additional_keywords": [], "additional_triggers": [], "probability_estimate": , "expected_move_revision": , "horizon_revision": }}, "historical_validation": [ {{ "date": "", "event": "<événement réel qui confirme le pattern>", "outcome": "" }} ], "counter_scenarios": [<2-3 scénarios qui invalideraient ce pattern>], "overall_recommendation": "", "risk_warnings": [] }}""" result = _chat(SYSTEM_PATTERN, user, model="gpt-4o") if not result: return {"error": "OpenAI non disponible", "quality_score": 0} return result def suggest_pattern_from_context(context: str) -> Dict[str, Any]: """AI creates a pattern structure from a free-text context description.""" user = f"""Un trader décrit ce contexte géopolitique et veut créer un pattern de trading: "{context}" Génère un pattern complet en JSON: {{ "id": "P_USER_<3 lettres aléatoires>", "name": "", "description": "", "triggers": [], "keywords": [<10-15 mots-clés anglais pour détecter ce pattern dans les news>], "historical_instances": [ {{"date": "", "event": "<événement réel>", "outcome": ""}} ], "suggested_trades": [ {{"strategy": "", "underlying": "", "rationale": ""}} ], "asset_class": "", "expected_move_pct": , "probability": , "horizon_days": , "confidence_in_pattern": , "caveats": [] }}""" result = _chat(SYSTEM_PATTERN, user, model="gpt-4o") if not result: return {"error": "OpenAI non disponible"} return result # ── Top 10 Trade Ideas Ranking ──────────────────────────────────────────────── SYSTEM_RANKING = """Tu es un gestionnaire de portefeuille spécialisé en options. Tu dois sélectionner et classer les 10 meilleures opportunités de trading options pour un capital de ~1000€ avec horizon 3 mois, en intégrant le contexte géopolitique actuel. Privilégie: risque/rendement optimal, liquidité des options, clarté du catalyseur. Réponds en JSON.""" def rank_trade_ideas( pattern_matches: List[Dict], geo_score: Dict, recent_news: List[Dict], market_quotes: Dict, ) -> List[Dict[str, Any]]: """Generate and rank top 10 trade ideas using GPT-4o.""" context = { "geo_risk_score": geo_score.get("score", 50), "geo_risk_level": geo_score.get("level", "medium"), "top_risks": geo_score.get("top_risks", []), "active_patterns": [ {"name": p["name"], "similarity": p["similarity"], "asset_class": p["asset_class"], "expected_move": p["expected_move_pct"]} for p in pattern_matches[:5] ], "top_news": [ {"title": n["title"], "category": n["category"], "impact": n["impact_score"]} for n in recent_news[:10] ], } user = f"""Contexte géopolitique et marché actuel: {json.dumps(context, ensure_ascii=False, indent=2)} Génère les 10 meilleures idées de trades en options pour 1000€ / horizon 3 mois. Diversifie les classes d'actifs. Inclus au moins: 2 énergie, 1 métal, 1 agri, 2 indices/actions, 1 forex. Retourne ce JSON: {{ "ideas": [ {{ "rank": <1-10>, "title": "", "underlying": "", "strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Long Strangle", "asset_class": "", "rationale": "", "geo_trigger": "", "strike_guidance": "", "expiry_days": , "expected_move_pct": , "max_loss_eur": , "target_gain_eur": , "confidence": , "risk_level": "low|medium|high|extreme", "timing": "", "invalidation": "" }} ], "portfolio_note": "", "current_bias": "bullish|bearish|neutral|volatile", "key_risk": "" }}""" result = _chat(SYSTEM_RANKING, user, model="gpt-4o") if not result: return [] return result.get("ideas", []) # ── Pattern Scoring with Rich Context ──────────────────────────────────────── DEFAULT_ANALYSIS_TEMPLATE = """Pour chaque pattern, note chaque sous-pilier ET fournis un commentaire 1-2 phrases en français. Score total = somme exacte des 4 piliers (0-100). PILIER 1 — ACTUALITÉS & GÉO-CONTEXTE (30 pts max) 1a. News géopolitiques (0-12): pertinence des événements récents vs keywords/triggers du pattern 1b. News macro/économiques (0-10): données macro, publications éco, politiques monétaires/fiscales 1c. Volume & récence signal (0-8) : nb de sources indépendantes, fraîcheur (<48h = max), cohérence PILIER 2 — CALENDRIER ÉCONOMIQUE (20 pts max) 2a. Banques centrales (0-10): décisions FOMC/BCE/BoJ/BoE à venir, minutes, discours membres 2b. Publications macro (0-10): CPI, NFP, PIB, PMI, rapport OPEC — alignement avec le pattern PILIER 3 — SIGNAUX DE PRIX (35 pts max) 3a. Taux & Obligations (0-7): mouvements yields, courbe de taux, spreads crédit 3b. Énergie & Matières prem. (0-7): or, pétrole, gaz, cuivre, blé — direction et momentum 3c. Forex (0-7): USD index, EUR/USD, paires émergentes — cohérence avec pattern 3d. Actions & Indices (0-7): SPX, NDX, rotation sectorielle, breadth, sentiment 3e. Volatilité & Surface de vol (0-7): - Régime VIX (niveau absolu et tendance) - SKEW Index: si >135 → queues chères → PRÉFÉRER spreads (budget = max loss limité) si <115 → vol bon marché → envisager straddles/strangles si catalyseur binaire - VVIX: si >100 → straddles/strangles trop chers → préférer directionnels - OVX (>40) / GVZ (>22): vol sectorielle élevée sur le sous-jacent → primes gonflées, spreads - Régime surface: contango_calm (idéal vendre vol) vs backwardation_panic (primes explosées) PILIER 4 — RISQUE / RÉCOMPENSE (15 pts max) 4a. Asymétrie R/R (0-10): ratio gain potentiel / prime payée / perte max pour ~1000€ 4b. Timing d'entrée (0-5) : qualité du point d'entrée vs analogues historiques du pattern Règles: score total = 1a+1b+1c+2a+2b+3a+3b+3c+3d+3e+4a+4b; ne pas dépasser les max; commenter chaque sous-pilier. ⚠️ RÈGLE ANTI-BIAIS DIRECTIONNELLE (IMPÉRATIVE): - "expected_direction" indique si le pattern attend une hausse ou une baisse. - "contra_signals" liste les news AI-scorées qui CONTREDISENT cette direction. - "has_strong_contra": true = le contexte actuel ANNULE ou INVERSE la thèse du pattern. → Si has_strong_contra=true: score total ≤ 40/100 ; sous-pilier 1a geo ≤ 3/12. → Si resolution=true dans contra_signals (accord/cessez-le-feu résolvant le conflit trigger): 1a geo = 0-2/12. → Indique toujours dans "summary" si le signal est [SUPPORTING], [NEUTRAL] ou [CONTRA].""" SYSTEM_SCORER = """Tu es un gestionnaire de portefeuille senior spécialisé en options géopolitiques. Tu analyses des patterns géopolitiques avec leur contexte marché enrichi (news, prix, IV) pour identifier les meilleures opportunités de trading options (~1000€, horizon 3 mois). Tu es rigoureux, quantitatif et pragmatique. Réponds UNIQUEMENT en JSON valide. RÈGLE DE DISTRIBUTION DES SCORES (IMPÉRATIVE): - Les scores doivent refléter une vraie distribution : certains patterns sont forts (70+), d'autres faibles (30-) - Il est INTERDIT de noter tous les patterns au même score ou proche de 50 - Base tes scores sur : relevant_news_count (catalyseurs actifs), macro_regime.asset_class_bias, has_strong_contra, horizon vs catalyseurs imminents - Un pattern sans aucune news récente pertinente (relevant_news_count=0) ne peut pas dépasser 35/100 - Un pattern avec has_strong_contra=true ne dépasse pas 40/100 - Un pattern avec 3+ news pertinentes ET macro favorable peut atteindre 70-85/100""" def score_patterns_with_context( patterns: List[Dict], recent_news: List[Dict], quotes_by_class: Dict, geo_score: Dict, template: str = None, top_n: int = 10, category_filter: str = None, macro_regime: Optional[Dict] = None, portfolio_lessons: Optional[Dict] = None, iv_context: str = "", risk_context: str = "", ) -> List[Dict[str, Any]]: """Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o.""" if not get_client(): return [] scoring_template = template or DEFAULT_ANALYSIS_TEMPLATE # Flatten quotes to symbol -> data dict for fast lookup quotes_flat: Dict[str, Dict] = {} for cls_quotes in quotes_by_class.values(): for q in cls_quotes: quotes_flat[q.get("symbol", "")] = q # Build per-pattern context blocks pattern_blocks = [] for pat in patterns: if category_filter and category_filter != "all": if pat.get("asset_class") != category_filter: # also check suggested trades trade_classes = [t.get("asset_class", "") for t in pat.get("suggested_trades", [])] if category_filter not in trade_classes: continue # Filter news relevant to this pattern keywords = [kw.lower() for kw in pat.get("keywords", [])] relevant_news = [] for n in recent_news[:50]: text = (n.get("title", "") + " " + n.get("summary", "")).lower() if any(kw in text for kw in keywords): relevant_news.append({ "title": n.get("title", ""), "date": n.get("published", "")[:10], "source": n.get("source", ""), "impact": n.get("impact_score", 0), }) if len(relevant_news) >= 4: break # Market data for each suggested underlying market_data = {} for trade in pat.get("suggested_trades", []): sym = trade.get("underlying", "") if sym and sym in quotes_flat: q = quotes_flat[sym] from services.data_fetcher import compute_historical_iv try: iv = compute_historical_iv(sym) except Exception: iv = None market_data[sym] = { "price": q.get("price"), "change_1d_pct": q.get("change_pct"), "iv_pct": round(iv * 100, 1) if iv else None, "name": q.get("name", sym), } # Detect contra-signals: AI-scored news that contradicts this pattern's direction expected_up = pat.get("expected_move_pct", 0) > 0 asset_cls = pat.get("asset_class", "") _dir_field = {"energy": "ai_dir_energy", "metals": "ai_dir_metals"}.get(asset_cls, "ai_dir_indices") contra_signals = [] for n in recent_news[:25]: if not n.get("ai_scored"): continue ai_dir = n.get(_dir_field, "neutral") impact = float(n.get("impact_score") or 0) is_contra = (expected_up and ai_dir == "bearish") or (not expected_up and ai_dir == "bullish") if is_contra and impact >= 0.35: contra_signals.append({ "title": (n.get("title") or "")[:100], "impact": round(impact, 2), "direction": ai_dir, "resolution": n.get("ai_resolution", False), "insight": n.get("ai_insight", ""), }) has_strong_contra = any(c["impact"] >= 0.55 or c.get("resolution") for c in contra_signals) # Macro regime context for this pattern macro_ctx = None if macro_regime: scenarios = macro_regime.get("scenarios", {}) gauges = macro_regime.get("gauges", {}) dominant = scenarios.get("dominant", "incertain") asset_bias = scenarios.get("asset_bias", {}) pat_cls = pat.get("asset_class", "") bias_for_class = asset_bias.get(dominant, {}).get(pat_cls, "neutral") if dominant != "incertain" else "neutral" macro_ctx = { "dominant_scenario": dominant, "scenario_scores": scenarios.get("scores", {}), "asset_class_bias": bias_for_class, # ── Signaux historiques (phase 1) ───────────────────────────── "vix": gauges.get("vix", {}).get("value"), "yield_slope_pct": gauges.get("slope_10y3m", {}).get("value"), "gold_copper_ratio": gauges.get("gold_copper_ratio", {}).get("value"), "brent_1d_pct": gauges.get("brent", {}).get("change_pct"), "spx_vs_200d_pct": gauges.get("spx_vs_200d", {}).get("value"), # ── Surface de volatilité (phase 2) ────────────────────────── # SKEW >135 = queues chères → spreads; <115 = vol bon marché → straddles "skew_index": gauges.get("skew", {}).get("value"), # VVIX >100 = straddles/strangles trop chers → préférer directionnels "vvix": gauges.get("vvix", {}).get("value"), # Vol sectorielle spécifique au sous-jacent "oil_vol_ovx": gauges.get("ovx", {}).get("value"), "gold_vol_gvz": gauges.get("gvz", {}).get("value"), # Régime composite: contango_calm|normal|tail_risk_elevated|backwardation_panic|complacency_hedged "vol_surface_regime": gauges.get("vol_surface_regime", {}).get("note"), # ── Rotation sectorielle (phase 2) ──────────────────────────── # Positif = tech > défensifs = risk-on; négatif = rotation défensive "tech_vs_staples_pct": gauges.get("xlk_xlp_momentum", {}).get("value"), # Négatif = banques < marché = stress économique anticipé "financials_vs_spx_pct": gauges.get("xlf_spx_ratio", {}).get("value"), # ── Global / Marchés émergents (phase 2) ───────────────────── "em_equity_1d_pct": gauges.get("eem", {}).get("change_pct"), "em_bonds_1d_pct": gauges.get("emb", {}).get("change_pct"), "china_equity_1d_pct": gauges.get("fxi", {}).get("change_pct"), # Ratio EM vs SPX: positif = croissance globale; négatif = fuite vers US "em_vs_us_divergence_pct": gauges.get("eem_spx_ratio", {}).get("value"), # ── Carry / Risk-off (phase 2) ──────────────────────────────── # Négatif = JPY s'apprécie = carry unwind = risk-off global "usdjpy_1d_pct": gauges.get("usdjpy", {}).get("change_pct"), # ── Long bonds / Qualité (phase 2) ──────────────────────────── # Positif = flight to quality = risk-off; négatif = taux longs remontent "long_bond_tlt_1d_pct": gauges.get("tlt", {}).get("change_pct"), # ── Métaux (phase 2) ────────────────────────────────────────── # Ratio Ag/Au: >0.016 = argent > or = risk-on industriel; <0.012 = defensive metals "silver_gold_ratio": gauges.get("silver_gold_ratio", {}).get("value"), } pattern_blocks.append({ "id": pat.get("id", pat.get("pattern_id", "")), "name": pat.get("name", ""), "description": pat.get("description", ""), "asset_class": pat.get("asset_class", ""), "triggers": pat.get("triggers", []), "historical_instances": pat.get("historical_instances", [])[:2], "suggested_trades": pat.get("suggested_trades", []), "expected_move_pct": pat.get("expected_move_pct", 0), "expected_direction": "hausse" if expected_up else "baisse", "horizon_days": pat.get("horizon_days", 90), "relevant_news_count": len(relevant_news), "relevant_news": relevant_news, "market_data": market_data, "contra_signals": contra_signals[:3], "has_strong_contra": has_strong_contra, "macro_regime": macro_ctx, }) if not pattern_blocks: return [] macro_section = "" if macro_regime: sc = macro_regime.get("scenarios", {}) gauges_g = macro_regime.get("gauges", {}) dom = sc.get("dominant", "incertain") sc_scores = sc.get("scores", {}) # Extract key new signals for global macro summary _skew = gauges_g.get("skew", {}).get("value") _vvix = gauges_g.get("vvix", {}).get("value") _ovx = gauges_g.get("ovx", {}).get("value") _vol_r = gauges_g.get("vol_surface_regime", {}).get("note", "normal") _tech_st = gauges_g.get("xlk_xlp_momentum", {}).get("value") _xlf_spx = gauges_g.get("xlf_spx_ratio", {}).get("value") _eem_c = gauges_g.get("eem", {}).get("change_pct") _usdjpy_c = gauges_g.get("usdjpy", {}).get("change_pct") _tlt_c = gauges_g.get("tlt", {}).get("change_pct") _sgr = gauges_g.get("silver_gold_ratio", {}).get("value") def _fmt(v, unit="", decimals=1): return f"{v:.{decimals}f}{unit}" if v is not None else "n/d" macro_section = f""" RÉGIME MACRO ACTUEL (50 compteurs — 29 tickers + 9 métriques dérivées): - Scénario dominant: {dom.upper()} | Scores: {json.dumps(sc_scores, ensure_ascii=False)} SURFACE DE VOLATILITÉ (pilier 3e — impact direct stratégie options): - SKEW Index: {_fmt(_skew, '', 0)} | Régime vol: {_vol_r} | VVIX: {_fmt(_vvix, '', 0)} | OVX: {_fmt(_ovx, '', 0)} → SKEW >135 = queues chères → SPREADS (pas de straddles). VVIX >100 = primes gonflées → directionnels. → {_vol_r} = {"contango calme: idéal spreads bon marché" if _vol_r == "contango_calm" else "backwardation/panique: options très chères, primes à vendre" if _vol_r == "backwardation_panic" else "tail risk élevé: protection queues = faveur spreads définis" if _vol_r == "tail_risk_elevated" else "environnement normal"} ROTATION SECTORIELLE & RISQUE (pilier 3d + 3e): - Tech vs Défensifs (XLK-XLP): {_fmt(_tech_st, '%pts')} | Financières vs S&P: {_fmt(_xlf_spx, '%pts')} → {"Risk-on sectoriel fort" if _tech_st and _tech_st > 0.5 else "Rotation défensive ⚠️" if _tech_st and _tech_st < -0.5 else "Neutre sectoriel"} → {"Banques saines = pas de récession" if _xlf_spx and _xlf_spx > 0.3 else "Stress bancaire anticipé ⚠️" if _xlf_spx and _xlf_spx < -0.3 else ""} GLOBAL / CARRY / QUALITÉ (pilier 3a + 3d): - EM Actions J+1: {_fmt(_eem_c, '%')} | USD/JPY J+1: {_fmt(_usdjpy_c, '%')} | TLT (20Y bonds) J+1: {_fmt(_tlt_c, '%')} - Ratio Ag/Or: {_fmt(_sgr, '', 5)} → {"EM surperforme = croissance globale" if _eem_c and _eem_c > 0.5 else "EM stress = fuite vers US ⚠️" if _eem_c and _eem_c < -1.0 else ""} → {"JPY s'apprécie = carry unwind = risk-off ⚠️" if _usdjpy_c and _usdjpy_c < -0.8 else "JPY faible = risk-on carry actif" if _usdjpy_c and _usdjpy_c > 0.5 else ""} → {"TLT monte = flight to quality = bonds longs demandés" if _tlt_c and _tlt_c > 0.3 else "TLT baisse = taux longs remontent = inflation/risk-on" if _tlt_c and _tlt_c < -0.3 else ""} Instructions de notation: - Intègre le régime dans 3a (taux: slope+TLT), 3b (énergie+OVX), 3d (indices+XLF+EM), 3e (SKEW+VVIX+régime) - "macro_regime.asset_class_bias" dans chaque pattern → majore/minore 3b ou 3d - "macro_regime.skew_index" + "vol_surface_regime" → influence directe sur le choix de stratégie dans "recommended_trade" - Indique dans "summary": [GOLDILOCKS|STAGFLATION|RÉCESSION|DÉSINFLATION|CRISE] + [SUPPORTING|NEUTRAL|CONTRA] """ user = f"""CONTEXTE GLOBAL: - Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) - Top risques: {geo_score.get('top_risks', [])} {macro_section} TEMPLATE DE NOTATION: {scoring_template} PATTERNS À SCORER ({len(pattern_blocks)} patterns): {json.dumps(pattern_blocks, ensure_ascii=False, indent=2)} Pour chacun des {len(pattern_blocks)} patterns, score chaque sous-pilier + commente en français. Le champ "score" = somme exacte de tous les sous-piliers. ⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE). Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis "trade_rankings" en: - classant les trades du meilleur (rank 1) au moins bon - assignant un "score_delta" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon) - expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern - la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern) Retourne UNIQUEMENT ce JSON valide: {{ "scored_patterns": [ {{ "pattern_id": "", "score": , "confidence": , "buckets": [ {{ "id": "actualites", "label": "Actualités & Géo-contexte", "score": <0-30>, "max": 30, "comment": "", "subs": [ {{"id": "geo", "label": "News géopolitiques", "score": <0-12>, "max": 12, "comment": "<1-2 phrases>"}}, {{"id": "eco", "label": "News macro/éco", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, {{"id": "flux", "label": "Volume & récence", "score": <0-8>, "max": 8, "comment": "<1-2 phrases>"}} ] }}, {{ "id": "calendrier", "label": "Calendrier économique", "score": <0-20>, "max": 20, "comment": "", "subs": [ {{"id": "banques", "label": "Banques centrales", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, {{"id": "macro_cal", "label": "Publications macro", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}} ] }}, {{ "id": "prix", "label": "Signaux de prix", "score": <0-35>, "max": 35, "comment": "", "subs": [ {{"id": "taux", "label": "Taux & Obligations", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, {{"id": "energie", "label": "Énergie & Matières", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, {{"id": "forex_sig", "label": "Forex", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, {{"id": "actions", "label": "Actions & Indices", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}, {{"id": "vix", "label": "Volatilité (VIX/IV)", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}} ] }}, {{ "id": "rr", "label": "Risque / Récompense", "score": <0-15>, "max": 15, "comment": "", "subs": [ {{"id": "asymetrie", "label": "Asymétrie R/R", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}, {{"id": "timing_rr", "label": "Timing d'entrée", "score": <0-5>, "max": 5, "comment": "<1-2 phrases>"}} ] }} ], "key_catalyst": "", "recommended_trade": {{ "underlying": "", "strategy": "", "strike_guidance": "", "expiry_days": , "rationale": "", "target_gain_eur": , "max_loss_eur": , "timing_note": "", "invalidation": "" }}, "asset_class": "", "geo_trigger": "", "summary": "", "trade_rankings": [ {{ "underlying": "", "strategy": "", "rank": <1-N>, "score_delta": , "rationale": "<1 phrase: pourquoi ce trade mérite plus/moins que les autres du même pattern>", "expected_move_pct": }} ] }} ], "analysis_meta": {{ "patterns_analyzed": , "top_bias": "bullish|bearish|neutral|volatile", "key_risk": "" }} }}""" # Extract the return-schema portion from `user` so batches use the identical full schema # (includes bucket id/label/max definitions that GPT-4o needs to populate correctly) _return_schema = user.split("Retourne UNIQUEMENT ce JSON valide:\n", 1)[1] # Build lessons feedback block for the scorer lessons_header = "" if portfolio_lessons: lessons = portfolio_lessons.get("key_lessons") or [] super_ctx = portfolio_lessons.get("super_context", "") priorities = portfolio_lessons.get("strategic_priorities", []) mistakes = portfolio_lessons.get("recurring_mistakes", []) super_scoring_block = "" if super_ctx: super_scoring_block = f""" 🧠 SUPER CONTEXTE (base de raisonnement accumulée) : {super_ctx[:400]} Priorités: {' | '.join(str(p) for p in priorities[:2])} Erreurs à éviter: {' | '.join(str(m) for m in mistakes[:2])} """ lessons_header = f""" {super_scoring_block} RETOUR DE PERFORMANCE (rapport du {portfolio_lessons.get('created_at','?')[:10]}) : Bilan global : {portfolio_lessons.get('headline', '')[:150]} Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]} Priorités : {portfolio_lessons.get('next_cycle_priorities', '')[:150]} Leçons : {' | '.join(str(l)[:80] for l in lessons[:3])} ⚠️ Tiens compte du Super Contexte et de ces leçons pour ajuster les scores et les commentaires par pilier. """ # Build the per-batch prompt template (static parts) prompt_header = f"""CONTEXTE GLOBAL: - Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')}) - Top risques: {geo_score.get('top_risks', [])} {macro_section}{lessons_header}{iv_context} {risk_context} TEMPLATE DE NOTATION: {scoring_template} """ import logging as _logging _scorer_log = _logging.getLogger(__name__) def _score_batch(batch: list) -> list: ids = [p.get("id", "?") for p in batch] _scorer_log.info(f"[Scorer] Batch of {len(batch)} patterns: {ids}") batch_user = ( prompt_header + f"PATTERNS À SCORER ({len(batch)} patterns):\n" + json.dumps(batch, ensure_ascii=False, indent=2) + f"\n\n⚠️ OBLIGATOIRE: Tu dois retourner EXACTEMENT {len(batch)} objets dans scored_patterns — un pour CHAQUE pattern de la liste, SANS EXCEPTION. Même si un pattern a score=0 (non pertinent actuellement), il doit figurer dans la liste.\n\n" + f"Pour chacun des {len(batch)} patterns, score chaque sous-pilier + commente en français.\n" + "Le champ \"score\" = somme exacte de tous les sous-piliers.\n\n" + "🎯 CALIBRATION OBLIGATOIRE — Les scores DOIVENT être différenciés, jamais tous identiques:\n" + " score 75-100 = catalyseur actif fort, signal clair, timing excellent\n" + " score 55-74 = signal modéré, contexte favorable mais incertain\n" + " score 35-54 = neutre, peu de signal, attente\n" + " score 15-34 = contra-signal, régime défavorable, risque élevé\n" + " score 0-14 = aucune pertinence dans le contexte actuel\n" + "⛔ Ne pas mettre tous les patterns à 50 — c'est un échec de calibration.\n" + " Utilise les relevant_news_count, macro_regime.asset_class_bias, et contra_signals pour vraiment différencier.\n\n" + "⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE).\n" + "Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis \"trade_rankings\" en:\n" + "- classant les trades du meilleur (rank 1) au moins bon\n" + "- assignant un \"score_delta\" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon)\n" + "- expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern\n" + "- la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern)\n\n" + "Retourne UNIQUEMENT ce JSON valide:\n" + _return_schema ) try: res = _chat(SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000) except Exception as e: _scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}") res = None scored = res.get("scored_patterns", []) if res else [] _scorer_log.info(f"[Scorer] Batch returned {len(scored)} scored_patterns (expected {len(batch)})") # Guarantee every pattern in the batch has an entry — prevents silent drops on truncation scored_ids = {str(s.get("pattern_id", "")) for s in scored} for p in batch: if str(p.get("id", "")) not in scored_ids: _scorer_log.warning(f"[Scorer] Pattern id='{p.get('id')}' name='{p.get('name')}' missing from GPT-4o response — adding stub score=0") scored.append({ "pattern_id": p["id"], "score": 0, "confidence": 0, "buckets": [], "key_catalyst": "Non pertinent dans le contexte actuel", "recommended_trade": {}, "asset_class": p.get("asset_class", ""), "geo_trigger": p.get("name", ""), "summary": "[CONTRA] Pattern non pertinent dans le contexte actuel.", "trade_rankings": [], }) return scored BATCH_SIZE = 4 # 4 patterns × ~800 tokens output = ~3200 tokens, safely within gpt-4o limits # Score all batches in parallel from concurrent.futures import ThreadPoolExecutor, as_completed batches = [pattern_blocks[i:i+BATCH_SIZE] for i in range(0, len(pattern_blocks), BATCH_SIZE)] _scorer_log.info(f"[Scorer] Scoring {len(pattern_blocks)} patterns in {len(batches)} batches of max {BATCH_SIZE}") all_scored = [] with ThreadPoolExecutor(max_workers=min(len(batches), 4)) as executor: futures = [executor.submit(_score_batch, b) for b in batches] for future in as_completed(futures): try: results = future.result() all_scored.extend(results) except Exception as e: _scorer_log.error(f"[Scorer] Batch future raised: {e}") # Hardcoded max values per bucket id — used as fallback if GPT-4o omits the max field _BUCKET_MAX = {"actualites": 30, "calendrier": 20, "prix": 35, "rr": 15} _SUB_MAX = { "geo": 12, "eco": 10, "flux": 8, "banques": 10, "macro_cal": 10, "taux": 7, "energie": 7, "forex_sig": 7, "actions": 7, "vix": 7, "asymetrie": 10, "timing_rr": 5, } # Normalize bucket scores and recompute total from sub-buckets for p in all_scored: if p.get("buckets"): total = 0 for b in p["buckets"]: bid = b.get("id", "") b_max = int(b.get("max") or _BUCKET_MAX.get(bid, 30)) sub_sum = 0 for sub in b.get("subs", []): sid = sub.get("id", "") s_max = int(sub.get("max") or _SUB_MAX.get(sid, 10)) sub["score"] = max(0, min(int(sub.get("score") or 0), s_max)) sub["max"] = s_max # ensure max is always set for frontend display sub_sum += sub["score"] b["score"] = max(0, min(int(b.get("score") or sub_sum), b_max)) b["max"] = b_max # ensure max is always set for frontend display total += b["score"] p["score"] = min(total, 100) all_scored.sort(key=lambda x: x.get("score", 0), reverse=True) return all_scored[:top_n] # ── Suggest new patterns from live market context ───────────────────────────── def suggest_patterns_from_market_context( news: List[Dict], quotes_by_class: Dict[str, List[Dict]], calendar: List[Dict], macro_regime: Optional[Dict] = None, geo_score: Optional[Dict] = None, portfolio_lessons: Optional[Dict] = None, reliability_map: Optional[Dict] = None, ) -> List[Dict]: """Ask GPT-4o to propose new patterns based on current geo/market + macro regime context.""" top_news = sorted(news, key=lambda x: x.get("impact_score", 0), reverse=True)[:12] news_block = "\n".join([ f"- [{n.get('source','')}] {n.get('title','')} (impact {n.get('impact_score',0):.2f})" for n in top_news ]) market_lines = [] for cls, qs in quotes_by_class.items(): for q in qs[:3]: if q.get("price"): market_lines.append(f" {cls} | {q.get('name', q['symbol'])}: {q['price']} ({q.get('change_pct', 0):+.1f}%)") market_block = "\n".join(market_lines) cal_block = "\n".join([ f"- {e.get('date','')} [{e.get('importance','')}] {e.get('title','')}" for e in (calendar or [])[:8] ]) # Macro regime block macro_block = "" if macro_regime: sc = macro_regime.get("scenarios", {}) gauges = macro_regime.get("gauges", {}) dominant = sc.get("dominant", "incertain") scores = sc.get("scores", {}) asset_bias = sc.get("asset_bias", {}).get(dominant, {}) reasons = sc.get("reasons", {}).get(dominant, []) vix = gauges.get("vix", {}).get("value") slope = gauges.get("slope_10y3m", {}).get("value") gold_cu = gauges.get("gold_copper_ratio", {}).get("value") spx_200 = gauges.get("spx_vs_200d", {}).get("value") brent_chg = gauges.get("brent", {}).get("change_pct") bias_lines = "\n".join([f" - {cls}: {b}" for cls, b in asset_bias.items()]) brent_str = f"{brent_chg:+.2f}%" if brent_chg is not None else "N/A" macro_block = f""" ## Régime macro actuel (30 compteurs institutionnels) - Scénario dominant: {dominant.upper()} | Scores: {json.dumps(scores, ensure_ascii=False)} - Signaux clés: {', '.join(reasons[:4])} - Compteurs: VIX={vix} | Pente 10Y-3M={slope}% | Or/Cuivre={gold_cu} | SPX vs 200j={spx_200}% | Brent J-1={brent_str} - Biais par classe d'actif (scénario {dominant.upper()}): {bias_lines} ⚠️ CONTRAINTE: Les patterns proposés doivent être COHÉRENTS avec ce régime macro. - Favorise les patterns dont l'asset_class a un biais "bullish" ou "bullish+" dans le régime actuel. - Évite les patterns haussiers sur des classes "bearish" ou "bearish+" sauf si un catalyseur géopolitique exceptionnel le justifie. - Chaque pattern doit expliquer dans "macro_fit" pourquoi il est compatible (ou en tension) avec le régime {dominant.upper()}. """ geo_block = "" if geo_score: geo_block = f"\n## Risque géopolitique global\n- Score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})\n- Top risques: {', '.join(str(r) for r in geo_score.get('top_risks', [])[:3])}\n" lessons_block = "" if portfolio_lessons: super_ctx = portfolio_lessons.get("super_context", "") priorities = portfolio_lessons.get("strategic_priorities", []) mistakes = portfolio_lessons.get("recurring_mistakes", []) lessons = portfolio_lessons.get("key_lessons") or [] super_block = "" if super_ctx: super_block = f""" ## 🧠 SUPER CONTEXTE — Base de raisonnement accumulée {super_ctx[:600]} Priorités stratégiques: {' | '.join(str(p) for p in priorities[:3])} Erreurs récurrentes à éviter: {' | '.join(str(m) for m in mistakes[:3])} """ lessons_block = f""" {super_block} ## ⚡ RETOUR DE PERFORMANCE — cycles précédents (rapport du {portfolio_lessons.get('created_at','?')[:10]}) Performance globale : {portfolio_lessons.get('headline', '')} Pourquoi les gains : {portfolio_lessons.get('winners_analysis', '')[:200]} Pourquoi les pertes : {portfolio_lessons.get('losers_analysis', '')[:200]} Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]} Priorités identifiées : {portfolio_lessons.get('next_cycle_priorities', '')[:200]} Leçons clés : {chr(10).join(f' - {l}' for l in lessons[:4])} ⚠️ CONSIGNE : Tiens compte de ce retour de performance et du Super Contexte pour proposer des patterns MIEUX CIBLÉS. Évite les erreurs identifiées dans les pertes. Privilégie les types de thèses qui ont fonctionné. """ reliability_block = "" if reliability_map: top_reliable = sorted(reliability_map.values(), key=lambda r: -r["reliability_score"])[:5] bottom_reliable = [r for r in sorted(reliability_map.values(), key=lambda r: r["reliability_score"]) if r["trade_count"] >= 3][:3] lines = [] for r in top_reliable: lines.append( f" ✅ {r['pattern_name']}: WR={r['win_rate_pct']}% | {r['trade_count']} trades | " f"avgPnL={r['avg_pnl_pct']:+.1f}% | fiabilité={r['reliability_score']:.2f}" ) for r in bottom_reliable: lines.append( f" ❌ {r['pattern_name']}: WR={r['win_rate_pct']}% | {r['trade_count']} trades | " f"avgPnL={r['avg_pnl_pct']:+.1f}% → À ÉVITER ou reformuler" ) if lines: reliability_block = ( "\n## 📊 FIABILITÉ HISTORIQUE DES PATTERNS (trades matures uniquement)\n" + "\n".join(lines) + "\n⚠️ Inspire-toi des patterns fiables. Évite de reproduire les patterns en bas de liste.\n" ) user = f"""Tu es un stratège géopolitique et financier senior. {macro_block}{geo_block}{lessons_block}{reliability_block} ## Actualités géopolitiques du moment (triées par impact) {news_block} ## Prix des marchés (variation J-1) {market_block} ## Calendrier économique à venir {cal_block} En analysant ce panorama, propose 4 à 6 NOUVEAUX patterns géopolitiques qui sont en train d'émerger RIGHT NOW et qui méritent d'être surveillés pour des opportunités d'options. Ne reprend pas les patterns classiques connus (Middle East Oil Spike, Gold Flight to Safety, etc.) — propose des patterns SPÉCIFIQUES au contexte actuel, cohérents avec le régime macro. IMPORTANT — CHAMP expected_move_pct: Ce champ représente le RENDEMENT OPTION ATTENDU en % (levier inclus), PAS le mouvement du sous-jacent. Raisonne: si le sous-jacent bouge de X% dans la direction attendue, combien gagne l'option en %? - Long Call ATM (delta ~0.5, 30-90j): sous-jacent +5% → option +60 à +150% - Long Call OTM (delta ~0.25): sous-jacent +8% → option +100 à +300% - Bull Call Spread: sous-jacent +5% → spread +50 à +120% (plafonné) - Long Straddle: mouvement ±10% → option +80 à +200% Exemples réalistes: Long Call énergie sur catalyseur fort → 80-200%. Spread défensif → 40-100%. Retourne UNIQUEMENT ce JSON: {{ "patterns": [ {{ "name": "", "description": "", "macro_fit": "<1-2 phrases: pourquoi ce pattern est cohérent ou en tension avec le régime macro actuel, et quel catalyseur géopolitique le justifie>", "triggers": ["", ""], "keywords": ["", "", ""], "asset_class": "", "expected_move_pct": , "probability": , "horizon_days": , "counter_thesis": "<1-2 phrases: principal scénario adverse qui invaliderait ce pattern — sois spécifique (ex: accord de paix inattendu, données CPI sous 3%, etc.)>", "invalidation_trigger": "<événement précis et mesurable à surveiller — ex: 'prix pétrole < 70$/b 3j consécutifs', 'FOMC hawkish surprise', 'cessez-le-feu Russie-Ukraine'>", "invalidation_probability": , "suggested_trades": [ {{ "strategy": "", "underlying": "", "rationale": "", "asset_class": "", "expected_move_pct": }}, {{ "strategy": "", "underlying": "", "rationale": "", "asset_class": "", "expected_move_pct": }} ] }} ] }}""" result = _chat(SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000) if not result: return [] return result.get("patterns", []) # ── AI news batch scoring: impact magnitude + directional signals ───────────── def ai_score_news_batch(news_items: List[Dict]) -> List[Dict]: """Score news items with AI: accurate impact + per-asset directional signal. Adds ai_dir_energy/metals/indices, ai_resolution, ai_insight, ai_scored fields. Called before pattern scoring so contra-signals can be detected. """ if not get_client() or not news_items: return news_items to_score = [n for n in news_items[:20] if not n.get("ai_scored")] if not to_score: return news_items compact = [ {"i": idx, "t": n.get("title", ""), "s": (n.get("summary", "") or "")[:150]} for idx, n in enumerate(to_score) ] user = f"""Score these geopolitical news items for TRUE market impact. CRITICAL: Resolution events (peace deals, ceasefires, truces, agreements ending conflicts) have HIGH impact (0.7-0.9) but are BEARISH for oil/energy and BEARISH for safe-haven patterns. Items: {json.dumps(compact, ensure_ascii=False)} For each item return: - impact_score: 0.0-1.0 real magnitude (resolution = high, sports/culture = low) - dir_energy: "bullish"|"bearish"|"neutral" (for oil/gas/energy) - dir_metals: "bullish"|"bearish"|"neutral" (for gold/silver/copper) - dir_indices: "bullish"|"bearish"|"neutral" (risk-on vs risk-off) - resolution: true if this is a de-escalation/peace/deal that REDUCES a prior conflict - insight: "<1 short French sentence on main market effect>" JSON: {{"items": [{{"i":,"impact_score":,"dir_energy":"...","dir_metals":"...","dir_indices":"...","resolution":,"insight":"..."}}]}}""" result = _chat(SYSTEM_NEWS, user, model="gpt-4o-mini", json_mode=True, max_tokens=2000) if not result: return news_items scored_map = {s["i"]: s for s in result.get("items", [])} for idx, n in enumerate(to_score): s = scored_map.get(idx) if s: n["impact_score"] = max(0.0, min(1.0, float(s.get("impact_score") or n.get("impact_score", 0.1)))) n["ai_dir_energy"] = s.get("dir_energy", "neutral") n["ai_dir_metals"] = s.get("dir_metals", "neutral") n["ai_dir_indices"] = s.get("dir_indices", "neutral") n["ai_resolution"] = bool(s.get("resolution", False)) n["ai_insight"] = s.get("insight", "") n["ai_scored"] = True return news_items # ── Re-score news batch with AI ─────────────────────────────────────────────── def ai_rescore_news(news_items: List[Dict]) -> List[Dict]: """Batch re-score news items using AI for better classification.""" if not get_client() or not news_items: return news_items rescored = [] for item in news_items[:20]: try: ai = analyze_news_item(item.get("title", ""), item.get("summary", "")) item["ai_category"] = ai.get("category", item.get("category")) item["ai_impact"] = ai.get("impact_score", item.get("impact_score")) item["ai_direction"] = ai.get("direction", "neutral") item["ai_reasoning"] = ai.get("reasoning", "") item["ai_entities"] = ai.get("key_entities", []) if ai.get("affected_assets"): item["asset_impacts"] = ai["affected_assets"] except Exception: pass rescored.append(item) return rescored