data_fetcher.py - MACRO_GAUGE_CONFIG: 15 → 29 tickers (+silver, vvix, skew, ovx, gvz, usdjpy, xlk, xlf, xlp, xlu, eem, emb, fxi, tlt) - 5 new derived metrics: silver_gold_ratio, xlk_xlp_momentum, xlf_spx_ratio, eem_spx_ratio, vol_surface_regime (composite classification) - ThreadPoolExecutor max_workers raised to 20 - score_macro_scenarios: +15 new variables; each of 8 scenarios enriched with vol-surface (SKEW, VVIX), sector rotation (XLK, XLF, XLP, XLU), EM/carry (EEM, EMB, USDJPY), long bonds (TLT), silver signals ai_analyzer.py - macro_ctx: 5 → 21 fields per pattern (vol surface, sectors, EM, carry, long bonds, silver/gold ratio — all with interpretation comments) - macro_section in scoring prompt: describes surface de vol regime, sector rotation, global/carry signals with explicit GPT instructions for pilier 3e - DEFAULT_ANALYSIS_TEMPLATE: pilier 3e expanded with SKEW/VVIX/OVX/GVZ guidance SIGNALS_FUTURES.md: reference document listing 30+ signals not yet available (FRED, CFTC COT, EIA, Baltic Dry, LME, credit spreads, hedge fund positioning, central bank balance sheets) with implementation priority and cost estimate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1050 lines
51 KiB
Python
1050 lines
51 KiB
Python
"""
|
||
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": <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 = "",
|
||
) -> 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": "<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 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": "<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>",
|
||
"underlying": "<ticker Yahoo Finance>",
|
||
"rationale": "<pourquoi ce trade dans ce contexte macro+géo>",
|
||
"asset_class": "<classe>",
|
||
"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>",
|
||
"underlying": "<ticker Yahoo Finance>",
|
||
"rationale": "<rationale>",
|
||
"asset_class": "<classe>",
|
||
"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
|