Files
OpenFin/backend/services/ai_analyzer.py
OpenSquared 7c0ff703b0 fix: filtres Journal de Bord — direction + asset_class tous onglets
Direction (Ouvert) : t.direction n'existe pas dans trade_entry_prices → utilisait
undefined, excluait tout. Remplacé par _isBearishStr(t.strategy) comme Fermés.

Asset class (Ouvert, Fermés, Non loggés) : l'IA retournait parfois "commodities",
"currencies", "fx", "equity" au lieu des clés canoniques. Double correction :
- Frontend : _normalizeAssetClass() mappe les variantes → energy|metals|agriculture|
  indices|equities|forex dans les 3 sections filtrées
- Backend database.py : _normalize_asset_class() appliqué à l'INSERT dans
  trade_entry_prices et skipped_trades (nouveaux trades normalisés au stockage)
- Prompt ai_analyzer.py : suggested_trades[].asset_class contraint à l'enum explicite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:44:14 +02:00

1273 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
import math
from datetime import datetime, timezone
_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]:
import time as _time
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"}
last_exc = None
for attempt in range(4): # up to 3 retries
try:
resp = client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
if json_mode:
return json.loads(content)
return {"text": content}
except Exception as e:
last_exc = e
err_str = str(e)
# 429 rate-limit: respect the retry-after hint, then back off
if "429" in err_str or "rate_limit" in err_str:
import re as _re
m = _re.search(r"try again in ([\d.]+)s", err_str)
wait = float(m.group(1)) + 1.0 if m else 2 ** (attempt + 1) * 5.0
_time.sleep(min(wait, 60.0))
continue
raise # non-429 errors propagate immediately
raise last_exc
# ── 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": <float 0.0-1.0>,
"direction": "bullish|bearish|neutral|volatile",
"affected_assets": {{
"energy": <float -1.0 à 1.0 ou null>,
"metals": <float -1.0 à 1.0 ou null>,
"agriculture": <float -1.0 à 1.0 ou null>,
"indices": <float -1.0 à 1.0 ou null>,
"forex": <float -1.0 à 1.0 ou null>
}},
"key_entities": [<max 5 entités clés: pays, personnes, organisations>],
"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": "<nom si détecté>",
"tone": "hawkish|dovish|aggressive|conciliatory|ambiguous",
"key_statements": [<liste des 3-5 phrases/points les plus impactants>],
"market_signals": [
{{
"asset": "<symbole ou classe>",
"direction": "up|down|volatile",
"magnitude": "low|medium|high|extreme",
"reasoning": "<pourquoi>",
"timeframe": "<immédiat|1 semaine|1 mois|3 mois>"
}}
],
"options_opportunities": [
{{
"underlying": "<symbole ETF ou futur>",
"strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle",
"strike_guidance": "<ATM|5% OTM|10% OTM>",
"expiry_guidance": "<30j|60j|90j>",
"rationale": "<pourquoi cette stratégie>",
"confidence": <int 0-100>,
"capital_1000eur": "<comment allouer 1000€>"
}}
],
"risk_level": "low|medium|high|extreme",
"geo_pattern_triggered": "<nom du pattern si applicable ou null>",
"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": <int 0-100>,
"validity": "excellent|good|fair|poor",
"strengths": [<liste des points forts>],
"weaknesses": [<liste des faiblesses ou lacunes>],
"suggested_improvements": {{
"additional_keywords": [<mots-clés manquants pertinents>],
"additional_triggers": [<catégories manquantes>],
"probability_estimate": <float 0.0-1.0, ton estimation>,
"expected_move_revision": <float % ou null si ok>,
"horizon_revision": <int jours ou null si ok>
}},
"historical_validation": [
{{
"date": "<YYYY-MM-DD>",
"event": "<événement réel qui confirme le pattern>",
"outcome": "<ce qui s'est passé sur les marchés>"
}}
],
"counter_scenarios": [<2-3 scénarios qui invalideraient ce pattern>],
"overall_recommendation": "<conseil général en 2-3 phrases>",
"risk_warnings": [<risques spécifiques à ce pattern>]
}}"""
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": "<nom concis du pattern>",
"description": "<description précise du mécanisme>",
"triggers": [<catégories parmi: military, sanctions, elections, natural_disaster, health_crisis, resource_scarcity, trade_war, energy, political_speech, financial_crisis>],
"keywords": [<10-15 mots-clés anglais pour détecter ce pattern dans les news>],
"historical_instances": [
{{"date": "<YYYY-MM-DD>", "event": "<événement réel>", "outcome": "<mouvement de marché observé>"}}
],
"suggested_trades": [
{{"strategy": "<stratégie>", "underlying": "<symbole>", "rationale": "<pourquoi>"}}
],
"asset_class": "<classe principale>",
"expected_move_pct": <float>,
"probability": <float 0.0-1.0>,
"horizon_days": <int>,
"confidence_in_pattern": <int 0-100>,
"caveats": [<mises en garde importantes>]
}}"""
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": "<titre court>",
"underlying": "<symbole ETF/futur liquide>",
"strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Long Strangle",
"asset_class": "<classe>",
"rationale": "<raisonnement géopolitique en 2 phrases>",
"geo_trigger": "<pattern ou événement déclencheur>",
"strike_guidance": "<ATM|5% OTM|10% OTM>",
"expiry_days": <int>,
"expected_move_pct": <float>,
"max_loss_eur": <float, max 1000>,
"target_gain_eur": <float>,
"confidence": <int 0-100>,
"risk_level": "low|medium|high|extreme",
"timing": "<entrer maintenant|attendre catalyseur|après date X>",
"invalidation": "<condition qui invalide le trade>"
}}
],
"portfolio_note": "<note générale sur l'allocation des 1000€ entre ces idées>",
"current_bias": "bullish|bearish|neutral|volatile",
"key_risk": "<risque principal à surveiller>"
}}"""
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 = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
price_discovery_block: 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]
"""
# Temporal context block for scoring
_cm = cycle_meta or {}
_delta_min_sc = _cm.get("delta_minutes", 180)
_calib_sc = _cm.get("calibration_label", "")
temporal_section_sc = ""
if _cm:
# Apply decay to news used in scoring
news_decayed_sc = apply_news_decay(recent_news)
_partitioned_sc = partition_news_by_age(news_decayed_sc, _delta_min_sc)
_inter_sc = _partitioned_sc["inter_cycle"]
temporal_section_sc = (
f"\nCONTEXTE TEMPOREL DU CYCLE:\n"
f"- Dernier cycle il y a : {_delta_min_sc:.0f} minutes | {_calib_sc}\n"
f"- NEWS INTER-CYCLE (non encore pricées, {len(_inter_sc)} articles) : "
+ " | ".join(n.get("title", "")[:60] for n in _inter_sc[:3]) + "\n"
f"⚠️ Tiens compte du délai depuis le dernier cycle pour évaluer si les news sont déjà intégrées.\n"
)
_tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
_fred_sc_section = f"\n{fred_block}\n" if fred_block else ""
_pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
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', [])}
{temporal_section_sc}
{macro_section}
{_fred_sc_section}
{_pd_sc_section}
{_tech_sc_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": "<id>",
"score": <int 0-100, somme exacte des 4 piliers>,
"confidence": <int 0-100>,
"buckets": [
{{
"id": "actualites",
"label": "Actualités & Géo-contexte",
"score": <0-30>,
"max": 30,
"comment": "<synthèse 1-2 phrases>",
"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": "<synthèse>",
"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": "<synthèse>",
"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": "<synthèse>",
"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": "<catalyseur principal en 1 phrase>",
"recommended_trade": {{
"underlying": "<symbole>",
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>",
"strike_guidance": "<ATM|5% OTM|...>",
"expiry_days": <int>,
"rationale": "<pourquoi ce trade maintenant, 2 phrases max>",
"target_gain_eur": <float>,
"max_loss_eur": <float max 1000>,
"timing_note": "<entrer maintenant|attendre X|surveiller Y>",
"invalidation": "<condition qui invalide>"
}},
"asset_class": "<classe>",
"geo_trigger": "<pattern name>",
"summary": "<synthèse 1 phrase>",
"trade_rankings": [
{{
"underlying": "<ticker>",
"strategy": "<stratégie>",
"rank": <1-N>,
"score_delta": <int -20 à +20, positif si ce trade est supérieur à la moyenne du pattern>,
"rationale": "<1 phrase: pourquoi ce trade mérite plus/moins que les autres du même pattern>",
"expected_move_pct": <float, RENDEMENT OPTION ATTENDU en % si thèse confirmée, levier inclus. Long Call ATM: 80-200%, Spread: 40-120%, Straddle: 60-180%. Réévalue par rapport au contexte actuel.>
}}
]
}}
],
"analysis_meta": {{
"patterns_analyzed": <int>,
"top_bias": "bullish|bearish|neutral|volatile",
"key_risk": "<risque principal>"
}}
}}"""
# 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 batches sequentially (max_workers=1) — parallel workers both retry on 429
# simultaneously, defeating the sleep backoff. Sequential ensures the retry window
# is fully cleared before the next batch fires.
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=1) 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 ─────────────────────────────
# ── Temporal news utilities ───────────────────────────────────────────────────
# Halflife par catégorie de news (heures) — après combien de temps l'impact est réduit de moitié
_NEWS_HALFLIFE_HOURS: Dict[str, float] = {
"economic": 4.0, # CPI, NFP, PIB, FOMC → pricés très vite
"geopolitical": 48.0, # conflit, sanction, accord → digestion lente
"corporate": 12.0, # earnings, deal, fusion → digestion moyenne
"default": 24.0,
}
_ECONOMIC_KEYWORDS = {"cpi", "inflation", "nfp", "jobs", "fomc", "fed", "gdp", "pmi", "ecb", "bce", "earnings", "taux"}
_GEO_KEYWORDS = {"war", "guerre", "sanction", "conflit", "ceasefire", "accord", "treaty", "nuclear", "missile", "invasion", "trump", "israel", "ukraine", "russia", "iran", "china"}
def _news_category(title: str) -> str:
t = title.lower()
if any(k in t for k in _ECONOMIC_KEYWORDS):
return "economic"
if any(k in t for k in _GEO_KEYWORDS):
return "geopolitical"
return "corporate"
def _article_age_hours(article: Dict) -> float:
"""Return age of article in hours from now. Falls back to 6h if no timestamp."""
for field in ("published", "published_at", "fetched_at"):
raw = article.get(field, "")
if not raw:
continue
try:
# Strip timezone info for naive comparison
raw_clean = str(raw).replace("Z", "+00:00")
dt = datetime.fromisoformat(raw_clean)
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None) - dt.utcoffset()
age = (datetime.utcnow() - dt).total_seconds() / 3600
return max(0.0, age)
except Exception:
continue
return 6.0 # default: assume 6h old
def apply_news_decay(news: List[Dict]) -> List[Dict]:
"""
Add `decayed_score` to each article: impact_score × exp(-age / halflife).
Does NOT modify original list — returns new list with added field.
"""
result = []
for n in news:
cat = _news_category(n.get("title", ""))
halflife = _NEWS_HALFLIFE_HOURS.get(cat, _NEWS_HALFLIFE_HOURS["default"])
age_h = _article_age_hours(n)
raw_score = float(n.get("impact_score") or 0)
decayed = raw_score * math.exp(-age_h / halflife)
result.append({**n, "decayed_score": round(decayed, 4), "age_hours": round(age_h, 1), "news_category": cat})
return result
def partition_news_by_age(news: List[Dict], delta_minutes: float) -> Dict[str, List[Dict]]:
"""
Split news into temporal buckets relative to cycle delta:
- inter_cycle : published within delta_minutes → potentiellement PAS ENCORE dans les prix
- recent_24h : published 24h → delta_minutes ago → partiellement pricés
- older : > 24h → considérés comme pricés par le marché
"""
delta_hours = delta_minutes / 60.0
inter, recent, older = [], [], []
for n in news:
age_h = n.get("age_hours", _article_age_hours(n))
if age_h <= delta_hours:
inter.append(n)
elif age_h <= 24.0:
recent.append(n)
else:
older.append(n)
# Sort each bucket by decayed_score desc
key = lambda x: -(x.get("decayed_score") or x.get("impact_score") or 0)
return {
"inter_cycle": sorted(inter, key=key),
"recent_24h": sorted(recent, key=key),
"older": sorted(older, key=key),
}
def _build_temporal_news_block(partitioned: Dict[str, List], cycle_meta: Dict) -> str:
"""Build the news section of the IA prompt with temporal framing."""
delta_min = cycle_meta.get("delta_minutes", 180)
calib = cycle_meta.get("calibration_label", "")
def _fmt_news(articles: List[Dict], limit: int = 6) -> str:
lines = []
for n in articles[:limit]:
score = n.get("decayed_score") or n.get("impact_score") or 0
age = n.get("age_hours", "?")
lines.append(
f" • [{n.get('source','')}] {n.get('title','')[:110]}"
f" (impact={score:.2f}, âge={age:.0f}h)"
)
return "\n".join(lines) if lines else " (aucune)"
inter = partitioned.get("inter_cycle", [])
recent = partitioned.get("recent_24h", [])
older = partitioned.get("older", [])
block = f"""
## ⏱ CONTEXTE TEMPOREL DU CYCLE
- Dernier cycle il y a : {delta_min:.0f} minutes
- Calibration : {calib}
## 📍 NEWS INTER-CYCLE — dernières {delta_min:.0f}min (POTENTIELLEMENT PAS ENCORE dans les prix)
{_fmt_news(inter, 6)}
*→ Ce sont les nouvelles les plus susceptibles de créer des opportunités non-pricées.*
## 📰 NEWS RÉCENTES — 24h (partiellement pricées, decay appliqué)
{_fmt_news(recent, 5)}
## 📦 NEWS PLUS ANCIENNES — >24h (considérées comme déjà intégrées par le marché)
{_fmt_news(older, 3)}
"""
return block
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,
iv_context: str = "",
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
price_discovery_block: str = "",
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
_cycle_meta = cycle_meta or {}
_delta_min = _cycle_meta.get("delta_minutes", 180.0)
# Apply temporal decay + partition news by age relative to cycle delta
news_decayed = apply_news_decay(news)
_partitioned = partition_news_by_age(news_decayed, _delta_min)
# For backward-compat blocks that still use a flat list: use decayed inter+recent
top_news = sorted(
_partitioned["inter_cycle"] + _partitioned["recent_24h"],
key=lambda x: -(x.get("decayed_score") or 0)
)[:12]
news_block = "\n".join([
f"- [{n.get('source','')}] {n.get('title','')} (impact_decay {n.get('decayed_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"
)
iv_block = ""
if iv_context:
iv_block = f"""
{iv_context}
## ⚠️ RÈGLES STRICTES IV → STRATÉGIE (OBLIGATOIRE pour chaque suggested_trade)
Le choix de stratégie doit tenir compte du COÛT de la volatilité implicite (IVR = IV Rank 52 semaines):
| IVR | Stratégie AUTORISÉE | Stratégie INTERDITE |
|--------------|----------------------------------------------------------|------------------------------|
| < 30% (cheap)| Long Call, Long Put, Long Straddle | Iron Condor, Short Strangle |
| 3060% (mod) | Bull Call Spread, Bear Put Spread | Long Straddle, Strangle naked|
| 6080% (cher)| Bull/Bear Spread, Cash-Secured Put | Long Call/Put naked |
| > 80% (pic) | Iron Condor, Short Strangle, Covered Call, Cash-Secured Put | TOUTE option long naked |
Règles supplémentaires:
- Skew put élevé (> 5 pts) → Long Put trop cher → préférer Bear Put Spread
- Term structure backwardation → ne pas vendre la vol (vendeur piégé)
- Si IVR inconnu → utiliser spreads débiteurs par défaut (neutre au coût de vol)
- Long Straddle UNIQUEMENT si IVR < 25% ET catalyseur clairement identifié
⚠️ INTERDICTION: Ne jamais suggérer "Long Call" ou "Long Put" (naked) si IVR > 60% sur ce ticker.
"""
# Build temporal news block (partitioned by cycle delta)
temporal_news_block = _build_temporal_news_block(_partitioned, _cycle_meta) if _cycle_meta else (
"## Actualités géopolitiques du moment (triées par impact)\n" + news_block
)
tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
fred_section = f"\n{fred_block}\n" if fred_block else ""
pd_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
{temporal_news_block}
## Prix des marchés (variation J-1)
{market_block}
{fred_section}
{pd_section}
{tech_block_section}
## 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 — STRATÉGIE: Respecte ABSOLUMENT les règles IV→stratégie définies ci-dessus si l'IVR est connu.
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": "<nom court et percutant>",
"description": "<mécanisme géopolitique → impact marché, 2-3 phrases>",
"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": ["<trigger1>", "<trigger2>"],
"keywords": ["<kw1>", "<kw2>", "<kw3>"],
"asset_class": "<energy|metals|agriculture|indices|equities|forex>",
"expected_move_pct": <float, RENDEMENT OPTION MOYEN en % pour ce pattern, levier inclus. Typiquement 50-300%.>,
"probability": <float 0-1>,
"horizon_days": <int>,
"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": <float 0-1, probabilité que ce trigger d'invalidation se réalise dans l'horizon>,
"suggested_trades": [
{{
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Iron Condor|Short Strangle|Cash-Secured Put|Covered Call — respecter règles IVR>",
"underlying": "<ticker Yahoo Finance>",
"rationale": "<pourquoi ce trade dans ce contexte macro+géo+vol>",
"asset_class": "<energy|metals|agriculture|indices|equities|forex>",
"expected_move_pct": <float, RENDEMENT OPTION en % pour CE trade si thèse confirmée. Long Call: 80-250%, Spread: 40-120%, Straddle: 60-180%.>
}},
{{
"strategy": "<autre stratégie respectant les règles IVR>",
"underlying": "<ticker Yahoo Finance>",
"rationale": "<rationale>",
"asset_class": "<energy|metals|agriculture|indices|equities|forex>",
"expected_move_pct": <float, rendement option attendu en % pour ce trade spécifique>
}}
]
}}
]
}}"""
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":<int>,"impact_score":<float>,"dir_energy":"...","dir_metals":"...","dir_indices":"...","resolution":<bool>,"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