Initial commit — GeoOptions Intelligence Cockpit v2.0

Stack: FastAPI + React/TypeScript + SQLite + GPT-4o
Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM,
Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA.
Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-16 20:29:59 +02:00
commit d256b65d30
69 changed files with 18301 additions and 0 deletions

View File

View File

@@ -0,0 +1,934 @@
"""
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é (VIX/IV) (0-7): régime de vol, coût options, skew — favorable à la stratégie ?
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."""
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,
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV) 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,
"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"),
}
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", {})
dom = sc.get("dominant", "incertain")
sc_scores = sc.get("scores", {})
macro_section = f"""
RÉGIME MACRO ACTUEL (30 compteurs agrégés):
- Scénario dominant: {dom.upper()} | Scores: {json.dumps(sc_scores, ensure_ascii=False)}
- Instruction: Intègre ce régime dans les piliers prix (3a taux, 3b énergie, 3d indices, 3e VIX).
Chaque pattern reçoit un champ "macro_regime.asset_class_bias" indiquant la compatibilité
(bullish+/bullish/neutral/bearish/bearish+/defensive) de sa classe d'actif avec le scénario dominant.
"bullish+" = conditions très favorables pour ce pattern → majore 3b ou 3d selon la classe
"bearish" ou "bearish+" = conditions défavorables → minore 3b ou 3d
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}
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"
+ "⚠️ 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,
) -> 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é.
"""
user = f"""Tu es un stratège géopolitique et financier senior.
{macro_block}{geo_block}{lessons_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>,
"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

View File

@@ -0,0 +1,697 @@
"""
Auto-cycle orchestration — runs every N hours (configurable).
Cycle steps:
1. Fetch current context (news, quotes, macro, geo)
2. Ask GPT-4o to suggest new patterns
3. Filter: keep only those with Jaccard keyword similarity < threshold vs existing
4. Save filtered patterns to DB
5. Score ALL patterns (existing + new)
6. Log: pattern scores, trade entry prices, geo alert, macro snapshot
7. Generate GPT-4o commentary: why are top/bottom trades performing this way?
8. Update cycle_runs with results + commentary
"""
import logging
import threading
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# ── Global scheduler state ────────────────────────────────────────────────────
_stop_event = threading.Event()
_cycle_thread: Optional[threading.Thread] = None
_cycle_lock = threading.Lock() # prevents concurrent cycles
_current_status: Dict[str, Any] = {
"running": False,
"last_run_id": None,
"last_run_at": None,
"next_run_at": None,
"enabled": False,
"interval_hours": 3,
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _jaccard(a: List[str], b: List[str]) -> float:
sa = {x.lower() for x in (a or [])}
sb = {x.lower() for x in (b or [])}
if not sa and not sb:
return 0.0
union = sa | sb
return len(sa & sb) / len(union) if union else 0.0
def _max_similarity_vs_existing(candidate_kws: List[str], existing: List[Dict]) -> float:
return max((_jaccard(candidate_kws, p.get("keywords") or []) for p in existing), default=0.0)
# ── Core cycle logic ──────────────────────────────────────────────────────────
def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
"""
Execute one full auto-cycle. Thread-safe (skips if already running).
Returns a summary dict.
"""
if not _cycle_lock.acquire(blocking=False):
logger.info("Auto-cycle skipped — another cycle is already running")
return {"skipped": True, "reason": "already_running"}
run_id = datetime.utcnow().isoformat()
summary: Dict[str, Any] = {
"run_id": run_id,
"trigger": trigger,
"patterns_suggested": 0,
"patterns_added": 0,
"patterns_scored": 0,
"geo_score": None,
"dominant_regime": None,
"commentary": None,
"status": "error",
}
try:
from services.database import (
get_config, get_custom_patterns, save_custom_pattern,
save_pattern_scores, log_macro_regime, log_geo_alert, log_trade_entries,
add_cycle_run, update_cycle_run, save_reasoning_trace,
get_latest_portfolio_lessons,
)
from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios
from services.geo_analyzer import compute_geo_risk_score
from services.ai_analyzer import (
suggest_patterns_from_market_context, score_patterns_with_context,
ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE,
)
# Check AI key
ai_key = get_config("openai_api_key") or ""
if not ai_key:
logger.warning("Auto-cycle: no OpenAI key configured, skipping AI steps")
return {**summary, "status": "no_ai_key"}
import os
os.environ["OPENAI_API_KEY"] = ai_key
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
add_cycle_run(run_id, trigger=trigger)
_current_status["running"] = True
_current_status["last_run_id"] = run_id
# ── Step 0: Load portfolio lessons + Super Contexte ──────────────────
portfolio_lessons = get_latest_portfolio_lessons()
# Load Super Contexte (accumulated knowledge base)
try:
from services.database import get_latest_reasoning_state
_reasoning_state = get_latest_reasoning_state()
if _reasoning_state:
if portfolio_lessons is None:
portfolio_lessons = {}
portfolio_lessons["super_context"] = (
f"[Super Contexte v{_reasoning_state.get('version',1)} "
f"du {(_reasoning_state.get('created_at','')[:16])}]\n"
+ _reasoning_state.get("narrative", "")[:800]
)
_synth = _reasoning_state.get("synthesis") or {}
portfolio_lessons["strategic_priorities"] = _synth.get("strategic_priorities", [])
portfolio_lessons["recurring_mistakes"] = [
m.get("mistake", "") for m in _synth.get("recurring_mistakes", [])[:3]
]
logger.info(
f"[Cycle {run_id[:16]}] Super Contexte v{_reasoning_state.get('version')} loaded "
f"({_reasoning_state.get('reports_used',0)} rapports, "
f"{_reasoning_state.get('trades_analyzed',0)} trades)"
)
except Exception as _e:
logger.warning(f"[Cycle] Could not load Super Contexte: {_e}")
if portfolio_lessons:
age_hours = 0
try:
from datetime import datetime as _dt
created = _dt.fromisoformat(portfolio_lessons["created_at"])
age_hours = (_dt.utcnow() - created).total_seconds() / 3600
except Exception:
pass
logger.info(
f"[Cycle {run_id[:16]}] Portfolio lessons loaded "
f"(report from {portfolio_lessons.get('created_at','?')[:10]}, "
f"{age_hours:.0f}h ago, avg_pnl={portfolio_lessons['stats'].get('avg_pnl_pct')}%)"
)
else:
logger.info(f"[Cycle {run_id[:16]}] No portfolio report yet — cycle runs without performance feedback")
# ── Step 1: Fetch context ─────────────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 1: fetching context")
from routers.geopolitical import _news_cache # type: ignore
news = _news_cache.get("data") or fetch_geo_news()
news = ai_score_news_batch(news)
_news_cache["data"] = news
geo_score_obj = compute_geo_risk_score(news)
geo_score_val = int(geo_score_obj.get("score") or 0)
summary["geo_score"] = geo_score_val
quotes = get_all_quotes()
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
macro_regime = {"gauges": gauges, "scenarios": scenarios}
dominant = scenarios.get("dominant", "incertain")
summary["dominant_regime"] = dominant
# ── Step 2: Suggest new patterns ──────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 2: suggesting patterns")
try:
from services.data_fetcher import get_economic_calendar
calendar = get_economic_calendar()
suggestions = suggest_patterns_from_market_context(
news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score_obj,
portfolio_lessons=portfolio_lessons,
)
except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}")
suggestions = []
summary["patterns_suggested"] = len(suggestions)
logger.info(f"[Cycle {run_id[:16]}] Suggested {len(suggestions)} patterns from AI")
# ── Step 3: Filter by similarity ──────────────────────────────────────
existing = get_custom_patterns()
logger.info(f"[Cycle {run_id[:16]}] Step 3: {len(existing)} existing patterns, threshold={sim_threshold}")
added_count = 0
for s in suggestions:
kws = s.get("keywords") or []
sim = _max_similarity_vs_existing(kws, existing)
if sim < sim_threshold:
# Capture returned ID so the pattern has a valid id for scoring
assigned_id = save_custom_pattern(s)
s["id"] = assigned_id
existing.append(s)
added_count += 1
logger.info(f"[Cycle] Added pattern '{s.get('name')}' id={assigned_id} (sim={sim:.2f})")
# ── Save suggestion reasoning trace ───────────────────────────
_top_news_ctx = [
{"title": n.get("title", "")[:120], "impact": round(float(n.get("impact_score") or 0), 2), "source": n.get("source", "")}
for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8]
]
save_reasoning_trace(
run_id=run_id,
trace_type="suggestion",
pattern_id=assigned_id,
input_context={
"geo_score": geo_score_val,
"macro_dominant": dominant,
"macro_scores": scenarios.get("scores", {}),
"top_news": _top_news_ctx,
"cycle_run_id": run_id,
},
output={
"name": s.get("name"),
"description": s.get("description"),
"macro_fit": s.get("macro_fit"),
"expected_move_pct": s.get("expected_move_pct"),
"probability": s.get("probability"),
"horizon_days": s.get("horizon_days"),
"suggested_trades": s.get("suggested_trades", []),
"keywords": s.get("keywords", []),
"triggers": s.get("triggers", []),
},
reasoning_summary=(s.get("macro_fit") or s.get("description") or "")[:300],
geo_score=geo_score_val,
macro_dominant=dominant,
)
else:
logger.debug(f"[Cycle] Filtered '{s.get('name')}' — sim={sim:.2f} >= {sim_threshold}")
summary["patterns_added"] = added_count
# ── Step 4: Score ALL patterns ────────────────────────────────────────
# Verify all patterns have IDs before scoring (guard against stale data)
patterns_with_id = [p for p in existing if p.get("id")]
patterns_without_id = [p.get("name", "?") for p in existing if not p.get("id")]
if patterns_without_id:
logger.warning(f"[Cycle] {len(patterns_without_id)} patterns have no id, skipping: {patterns_without_id}")
logger.info(f"[Cycle {run_id[:16]}] Step 4: scoring {len(patterns_with_id)} patterns (of {len(existing)} total)")
template = get_config("analysis_template") or DEFAULT_ANALYSIS_TEMPLATE
try:
scored = score_patterns_with_context(
patterns=patterns_with_id,
recent_news=news,
quotes_by_class=quotes,
geo_score=geo_score_obj,
template=template,
top_n=len(patterns_with_id),
category_filter=None,
macro_regime=macro_regime,
portfolio_lessons=portfolio_lessons,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]
if scored_without_id:
logger.warning(f"[Cycle] {len(scored_without_id)} scored results have no pattern_id — they will NOT be saved to history")
logger.info(f"[Cycle {run_id[:16]}] Scoring returned {len(scored)} results ({len(scored_with_id)} with valid id)")
except Exception as e:
logger.error(f"[Cycle] Scoring failed: {e}", exc_info=True)
scored = []
summary["patterns_scored"] = len(scored)
# ── Enrich scored patterns with original data not in GPT-4o response ─
# GPT-4o scoring doesn't return expected_move_pct or suggested_trades —
# copy from the original pattern so log_trade_entries can use them.
_pmap = {p.get("id"): p for p in patterns_with_id}
for s in scored:
orig = _pmap.get(s.get("pattern_id", ""), {})
if orig:
if not s.get("expected_move_pct") and orig.get("expected_move_pct"):
s["expected_move_pct"] = orig["expected_move_pct"]
logger.debug(f"[Cycle] Enriched '{orig.get('name')}' expected_move_pct={orig['expected_move_pct']}")
if not s.get("trade_rankings") and not s.get("suggested_trades"):
s["suggested_trades"] = orig.get("suggested_trades", [])
# ── Step 5: Log everything ────────────────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 5: logging")
scoring_run_id = save_pattern_scores(scored, meta={
"geo_score": geo_score_val,
"total": len(scored),
"cycle_run_id": run_id,
"trigger": trigger,
})
# ── Save scoring reasoning traces (one per scored pattern) ────────────
_macro_scores_ctx = scenarios.get("scores", {})
_asset_bias_ctx = scenarios.get("asset_bias", {}).get(dominant, {})
for sp in scored:
pid = sp.get("pattern_id", "")
if not pid:
continue
orig = _pmap.get(pid, {})
save_reasoning_trace(
run_id=scoring_run_id,
trace_type="scoring",
pattern_id=pid,
input_context={
"geo_score": geo_score_val,
"macro_dominant": dominant,
"macro_scores": _macro_scores_ctx,
"asset_class": sp.get("asset_class") or orig.get("asset_class"),
"asset_bias": _asset_bias_ctx.get(sp.get("asset_class") or orig.get("asset_class", ""), "neutral"),
"expected_move_pct": sp.get("expected_move_pct") or orig.get("expected_move_pct"),
"cycle_run_id": run_id,
},
output={
"score": sp.get("score"),
"confidence": sp.get("confidence"),
"buckets": sp.get("buckets", []),
"key_catalyst": sp.get("key_catalyst"),
"summary": sp.get("summary"),
"trade_rankings": sp.get("trade_rankings", []),
"recommended_trade": sp.get("recommended_trade", {}),
"has_strong_contra": sp.get("has_strong_contra", False),
"geo_trigger": sp.get("geo_trigger"),
},
reasoning_summary=((sp.get("key_catalyst") or "") + " | " + (sp.get("summary") or ""))[:400],
geo_score=geo_score_val,
macro_dominant=dominant,
)
logger.info(f"[Cycle {run_id[:16]}] Saved {len([s for s in scored if s.get('pattern_id')])} reasoning traces")
top_patterns_log = sorted(
[{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)}
for sp in scored if sp.get("score", 0) > 0],
key=lambda x: -x["score"]
)[:10]
log_geo_alert(geo_score=geo_score_val, top_patterns=top_patterns_log,
news_count=len(news), run_id=scoring_run_id)
log_trade_entries(run_id=scoring_run_id, scored_patterns=scored, quotes=quotes)
gauges_summary = {
k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")}
for k, v in gauges.items()
if v.get("value") is not None or v.get("change_pct") is not None
}
log_macro_regime(dominant=dominant, scores=scenarios.get("scores", {}),
reasons=scenarios.get("reasons", {}), gauges_summary=gauges_summary)
# Update macro cache so the UI sees fresh data immediately
from routers.market_data import _macro_cache # type: ignore
import datetime as _dt
_macro_cache["data"] = {"gauges": gauges, "scenarios": scenarios,
"fetched_at": datetime.utcnow().isoformat(), "cached": False}
_macro_cache["ts"] = _dt.datetime.utcnow()
# ── Step 6: GPT-4o cycle commentary ──────────────────────────────────
logger.info(f"[Cycle {run_id[:16]}] Step 6: generating commentary")
commentary = _generate_cycle_commentary(
scored=scored, dominant=dominant, scenarios=scenarios,
geo_score_val=geo_score_val, news=news, gauges=gauges,
)
# Attach lessons metadata to commentary so UI can display it
if commentary and portfolio_lessons:
try:
import json as _json
c = _json.loads(commentary) if isinstance(commentary, str) else commentary
c["lessons_from_report"] = portfolio_lessons.get("created_at", "")[:16].replace("T", " ")
c["lessons_headline"] = portfolio_lessons.get("headline", "")[:100]
commentary = _json.dumps(c, ensure_ascii=False)
except Exception:
pass
summary["commentary"] = commentary
# ── Finalize ──────────────────────────────────────────────────────────
summary["status"] = "completed"
update_cycle_run(
run_id,
completed_at=datetime.utcnow().isoformat(),
patterns_suggested=summary["patterns_suggested"],
patterns_added=summary["patterns_added"],
patterns_scored=summary["patterns_scored"],
geo_score=geo_score_val,
dominant_regime=dominant,
commentary=commentary,
status="completed",
)
_current_status["last_run_at"] = datetime.utcnow().isoformat()
logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored")
# ── Step 7: Auto portfolio snapshot ──────────────────────────────────
# Generate (or refresh) the portfolio report so the NEXT cycle has
# fresh performance lessons. Runs in background to not block the cycle.
import threading
threading.Thread(
target=_auto_portfolio_snapshot,
args=(ai_key,),
daemon=True,
name=f"portfolio-snapshot-{run_id[:8]}",
).start()
except Exception as e:
logger.error(f"[Cycle {run_id[:16]}] Fatal error: {e}", exc_info=True)
try:
from services.database import update_cycle_run
update_cycle_run(run_id, status="error", completed_at=datetime.utcnow().isoformat())
except Exception:
pass
finally:
_current_status["running"] = False
_cycle_lock.release()
return summary
def _generate_cycle_commentary(
scored: List[Dict], dominant: str, scenarios: Dict,
geo_score_val: int, news: List[Dict], gauges: Dict,
) -> Optional[str]:
"""Ask GPT-4o to explain current performance of top/bottom patterns."""
try:
from services.database import get_trade_entry_prices
from services.ai_analyzer import _chat
# Get recent trade P&L for context (last 7 days)
entries = get_trade_entry_prices(7)
trade_summary = []
for e in entries[:15]:
trade_summary.append({
"pattern": e.get("pattern_name", ""),
"underlying": e.get("underlying", ""),
"strategy": e.get("strategy", ""),
"score_at_entry": e.get("score_at_entry", 0),
"entry_date": e.get("entry_date", ""),
})
# Top 5 scored patterns now
top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5]
top_scored_summary = [
{"name": s.get("geo_trigger"), "score": s.get("score"), "summary": s.get("summary", "")[:120]}
for s in top_scored
]
# Top recent news headlines
top_news = [{"title": n.get("title", ""), "impact": n.get("impact_score", 0)}
for n in sorted(news, key=lambda x: -(x.get("impact_score") or 0))[:5]]
import json
def gv(key: str) -> str:
v = gauges.get(key, {}).get("value")
return str(round(v, 2)) if v is not None else "N/A"
def gc(key: str) -> str:
v = gauges.get(key, {}).get("change_pct")
return f"{v:+.2f}%" if v is not None else "N/A"
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse la performance de notre système de détection de patterns.
CONTEXTE DU CYCLE (maintenant):
- Régime dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%)
- Score risque géopolitique: {geo_score_val}/100
- VIX: {gv('vix')} | Pente 10Y-3M: {gv('slope_10y3m')}% | DXY: {gc('dxy')} | Brent: {gc('brent')}
- Cuivre: {gc('copper')} | Or: {gc('gold')} | S&P vs 200j: {gv('spx_vs_200d')}%
TOP 5 PATTERNS ACTUELLEMENT LES MIEUX SCORÉS:
{json.dumps(top_scored_summary, ensure_ascii=False, indent=2)}
TRADES LOGUÉS CES 7 DERNIERS JOURS:
{json.dumps(trade_summary, ensure_ascii=False, indent=2)}
NEWS GÉOPOLITIQUES À FORT IMPACT:
{json.dumps(top_news, ensure_ascii=False, indent=2)}
Écris un COMMENTAIRE DE CYCLE concis (4-6 phrases) pour un trader options:
1. Le régime macro confirme-t-il les patterns qui scorent le mieux ?
2. Y a-t-il des news ou événements qui expliquent un écart avec nos prévisions ?
3. Quels patterns/trades méritent attention (confirmation ou invalidation) ?
4. Une recommandation tactique pour le prochain cycle (3h)
Réponds UNIQUEMENT en JSON: {{"commentary": "<ton texte 4-6 phrases>", "key_risk": "<risque principal en 1 phrase>", "top_pattern": "<nom du pattern le plus pertinent maintenant>"}}"""
result = _chat(
"Tu es un stratège macro senior. Analyse concise et actionnable. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=500,
)
if result and result.get("commentary"):
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.warning(f"[Cycle] Commentary generation failed: {e}")
return None
# ── Auto portfolio snapshot ───────────────────────────────────────────────────
def _auto_portfolio_snapshot(ai_key: str) -> None:
"""
Called in a background thread at the end of each cycle.
Fetches live prices, checks if enough trades are priced (P&L ≠ 0),
and if so generates a GPT-4o portfolio report so the NEXT cycle has
fresh performance lessons. Skipped silently if not enough data.
"""
try:
import os
os.environ["OPENAI_API_KEY"] = ai_key
from services.database import (
get_mtm_trades_with_traces, save_ai_report, get_latest_portfolio_lessons,
)
data = get_mtm_trades_with_traces(days=30, limit_movers=5)
winners = data.get("winners", [])
losers = data.get("losers", [])
priced = data.get("priced_count", 0)
# Need at least 3 priced trades with actual movement to make analysis meaningful
meaningful = [
t for t in (winners + losers)
if t.get("pnl_pct") is not None and abs(t.get("pnl_pct", 0)) > 0.05
]
if len(meaningful) < 3:
logger.info(
f"[AutoSnapshot] Skipping GPT-4o report: only {len(meaningful)} trades "
f"with meaningful P&L movement (need ≥ 3)"
)
return
avg_pnl = data.get("avg_pnl_pct")
stats = {
"total_trades": data["total_trades"],
"priced_count": priced,
"avg_pnl_pct": avg_pnl,
}
# Build prompt (reuse same logic as reasoning.py generate endpoint)
from routers.reasoning import _trade_summary_block, _bucket_summary, _rankings_summary
from services.ai_analyzer import _chat
winners_block = _trade_summary_block("TOP GAINS", winners)
losers_block = _trade_summary_block("TOP PERTES", losers)
avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A"
prompt = f"""Tu es un stratège macro-géopolitique senior. Rapport synthétique post-cycle automatique.
═══ STATISTIQUES GLOBALES ═══
Période : 30 derniers jours | Trades total: {data['total_trades']} | Pricés: {priced} | P&L moyen: {avg_str}
═══ {winners_block}
═══ {losers_block}
Génère un rapport JSON :
{{
"headline": "<1 phrase résumant la performance>",
"regime_assessment": "<alignement régime macro avec nos thèses ?>",
"winners_analysis": "<pourquoi ces trades ont marché — 2-3 phrases>",
"losers_analysis": "<pourquoi ces trades ont déçu — 2-3 phrases>",
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
"blind_spots": "<ce que le scoring n'a pas bien capturé>",
"next_cycle_priorities": "<3 priorités pour le prochain cycle>",
"risk_watch": "<1-2 risques à surveiller>"
}}"""
result = _chat(
"Tu es un stratège macro senior. Rapport post-cycle concis. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=800,
)
if not result:
logger.warning("[AutoSnapshot] GPT-4o returned empty response")
return
report_id = save_ai_report(
days=30, stats=stats, winners=winners, losers=losers, report=result,
report_type="portfolio",
)
logger.info(
f"[AutoSnapshot] Portfolio report #{report_id} saved automatically "
f"({len(meaningful)} meaningful trades, avg P&L {avg_str})"
)
except Exception as e:
logger.error(f"[AutoSnapshot] Failed: {e}", exc_info=True)
# ── Scheduler ─────────────────────────────────────────────────────────────────
def _scheduler_loop(stop_event: threading.Event):
"""Background loop that runs the cycle at the configured interval."""
import time
from services.database import get_config
while not stop_event.is_set():
try:
interval_hours = float(get_config("auto_cycle_hours") or "3")
except Exception:
interval_hours = 3.0
_current_status["interval_hours"] = interval_hours
next_run = datetime.utcnow().isoformat()
_current_status["next_run_at"] = next_run
logger.info(f"[Scheduler] Next cycle in {interval_hours}h")
# Wait for the interval (or until stop is signalled)
stop_event.wait(timeout=interval_hours * 3600)
if stop_event.is_set():
break
# Check if still enabled
try:
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
except Exception:
enabled = False
if enabled:
logger.info("[Scheduler] Running scheduled auto-cycle")
try:
run_cycle_once(trigger="auto")
except Exception as e:
logger.error(f"[Scheduler] Cycle error: {e}", exc_info=True)
def start_scheduler():
"""Start the background scheduler thread if auto_cycle is enabled."""
global _cycle_thread, _stop_event
from services.database import get_config
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
_current_status["enabled"] = enabled
if not enabled:
logger.info("[Scheduler] Auto-cycle disabled — skipping scheduler start")
return
if _cycle_thread and _cycle_thread.is_alive():
logger.info("[Scheduler] Already running")
return
_stop_event = threading.Event()
_cycle_thread = threading.Thread(
target=_scheduler_loop,
args=(_stop_event,),
daemon=True,
name="auto-cycle-scheduler",
)
_cycle_thread.start()
logger.info("[Scheduler] Auto-cycle scheduler started")
def stop_scheduler():
"""Stop the background scheduler thread."""
global _cycle_thread
_stop_event.set()
if _cycle_thread:
_cycle_thread.join(timeout=5)
_current_status["enabled"] = False
logger.info("[Scheduler] Stopped")
def restart_scheduler():
"""Restart the scheduler — call after config changes."""
stop_scheduler()
_stop_event.clear()
start_scheduler()
def trigger_manual():
"""Run one cycle immediately in a background thread (non-blocking)."""
t = threading.Thread(target=run_cycle_once, args=("manual",), daemon=True, name="auto-cycle-manual")
t.start()
return t
def get_status() -> Dict[str, Any]:
from services.database import get_config, get_cycle_runs
try:
interval_hours = float(get_config("auto_cycle_hours") or "3")
enabled = (get_config("auto_cycle_enabled") or "false").lower() == "true"
sim_threshold = float(get_config("auto_cycle_similarity_threshold") or "0.30")
min_ev = float(get_config("min_ev_threshold") or "0.0")
min_score = int(get_config("min_score_threshold") or "0")
except Exception:
interval_hours, enabled, sim_threshold, min_ev, min_score = 3.0, False, 0.30, 0.0, 0
recent = get_cycle_runs(limit=1)
last = recent[0] if recent else None
return {
**_current_status,
"enabled": enabled,
"interval_hours": interval_hours,
"similarity_threshold": sim_threshold,
"min_ev_threshold": min_ev,
"min_score_threshold": min_score,
"last_cycle": last,
"scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()),
}

View File

@@ -0,0 +1,593 @@
"""
Market data fetcher using yfinance + free public APIs.
All functions are async-compatible where possible.
"""
import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
import feedparser
import httpx
import asyncio
# ── Watchlist by asset class ──────────────────────────────────────────────────
WATCHLIST: Dict[str, List[Dict[str, str]]] = {
"energy": [
{"symbol": "CL=F", "name": "WTI Crude Oil", "currency": "USD"},
{"symbol": "BZ=F", "name": "Brent Crude Oil", "currency": "USD"},
{"symbol": "NG=F", "name": "Natural Gas", "currency": "USD"},
{"symbol": "XLE", "name": "Energy ETF (XLE)", "currency": "USD"},
{"symbol": "UNG", "name": "US Natural Gas ETF", "currency": "USD"},
],
"metals": [
{"symbol": "GC=F", "name": "Gold Futures", "currency": "USD"},
{"symbol": "SI=F", "name": "Silver Futures", "currency": "USD"},
{"symbol": "HG=F", "name": "Copper Futures", "currency": "USD"},
{"symbol": "PL=F", "name": "Platinum Futures", "currency": "USD"},
{"symbol": "GDX", "name": "Gold Miners ETF", "currency": "USD"},
],
"agriculture": [
{"symbol": "ZC=F", "name": "Corn Futures", "currency": "USD"},
{"symbol": "ZW=F", "name": "Wheat Futures", "currency": "USD"},
{"symbol": "ZS=F", "name": "Soybean Futures", "currency": "USD"},
{"symbol": "KC=F", "name": "Coffee Futures", "currency": "USD"},
{"symbol": "SB=F", "name": "Sugar #11 Futures", "currency": "USD"},
],
"indices": [
{"symbol": "^GSPC", "name": "S&P 500", "currency": "USD"},
{"symbol": "^NDX", "name": "NASDAQ 100", "currency": "USD"},
{"symbol": "^DJI", "name": "Dow Jones", "currency": "USD"},
{"symbol": "^STOXX50E", "name": "Euro Stoxx 50", "currency": "EUR"},
{"symbol": "^N225", "name": "Nikkei 225", "currency": "JPY"},
{"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"},
],
"equities": [
{"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"},
{"symbol": "CVX", "name": "Chevron", "currency": "USD"},
{"symbol": "LMT", "name": "Lockheed Martin", "currency": "USD"},
{"symbol": "RTX", "name": "Raytheon", "currency": "USD"},
{"symbol": "BA", "name": "Boeing", "currency": "USD"},
],
"forex": [
{"symbol": "EURUSD=X", "name": "EUR/USD", "currency": "USD"},
{"symbol": "USDJPY=X", "name": "USD/JPY", "currency": "JPY"},
{"symbol": "GBP=X", "name": "GBP/USD", "currency": "USD"},
{"symbol": "USDCHF=X", "name": "USD/CHF", "currency": "CHF"},
{"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"},
],
}
def get_quote(symbol: str) -> Optional[Dict[str, Any]]:
for period in ("5d", "1mo"):
try:
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
if hist.empty:
continue
# Drop rows where Close is NaN
hist = hist.dropna(subset=["Close"])
if hist.empty:
continue
price = float(hist["Close"].iloc[-1])
prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else price
change = price - prev
change_pct = (change / prev * 100) if prev else 0
return {
"symbol": symbol,
"price": round(price, 4),
"change": round(change, 4),
"change_pct": round(change_pct, 2),
"volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0,
"timestamp": datetime.utcnow().isoformat(),
}
except Exception:
continue
return {"symbol": symbol, "price": None, "error": "no data"}
def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
result = {}
for asset_class, assets in WATCHLIST.items():
quotes = []
for asset in assets:
q = get_quote(asset["symbol"])
if q:
q["name"] = asset["name"]
q["asset_class"] = asset_class
quotes.append(q)
result[asset_class] = quotes
return result
def get_historical(symbol: str, period: str = "1y", interval: str = "1d") -> List[Dict[str, Any]]:
try:
from urllib.parse import unquote
symbol = unquote(symbol)
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period, interval=interval)
if hist.empty:
return []
hist = hist.reset_index()
records = []
for _, row in hist.iterrows():
records.append({
"date": row["Date"].isoformat() if hasattr(row["Date"], "isoformat") else str(row["Date"]),
"open": round(float(row["Open"]), 4),
"high": round(float(row["High"]), 4),
"low": round(float(row["Low"]), 4),
"close": round(float(row["Close"]), 4),
"volume": int(row["Volume"]) if "Volume" in row else 0,
})
return records
except Exception as e:
return []
def compute_historical_iv(symbol: str, window: int = 30) -> float:
"""Estimate realized vol as proxy for IV when options data unavailable."""
try:
ticker = yf.Ticker(symbol)
hist = ticker.history(period="3mo", interval="1d")
if len(hist) < 10:
return 0.25
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
return float(returns.rolling(window).std().iloc[-1] * np.sqrt(252))
except Exception:
return 0.25
# ── News feeds ────────────────────────────────────────────────────────────────
GEO_RSS_FEEDS = [
{"name": "Reuters World", "url": "https://feeds.reuters.com/reuters/worldNews"},
{"name": "Reuters Business", "url": "https://feeds.reuters.com/reuters/businessNews"},
{"name": "Reuters Commodities", "url": "https://feeds.reuters.com/reuters/USenergyNews"},
{"name": "AP Top News", "url": "https://feeds.apnews.com/rss/apf-topnews"},
{"name": "Al Jazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml"},
{"name": "Financial Times", "url": "https://www.ft.com/rss/home"},
{"name": "Bloomberg Markets", "url": "https://feeds.bloomberg.com/markets/news.rss"},
]
GEO_KEYWORDS = {
"military": ["war", "attack", "missile", "troops", "conflict", "invasion", "airstrike", "NATO", "ceasefire"],
"energy": ["OPEC", "oil production", "gas pipeline", "LNG", "energy sanctions", "crude", "petroleum"],
"sanctions": ["sanctions", "embargo", "tariff", "trade ban", "export control", "blacklist"],
"elections": ["election", "poll", "vote", "presidency", "referendum", "coup"],
"natural_disaster": ["earthquake", "hurricane", "flood", "drought", "wildfire", "tsunami", "volcano"],
"health_crisis": ["pandemic", "outbreak", "epidemic", "WHO", "virus", "quarantine", "lockdown"],
"resource_scarcity": ["shortage", "supply chain", "famine", "water crisis", "food security", "rare earth"],
"trade_war": ["trade war", "tariff", "WTO", "dumping", "protectionism", "trade deal"],
"political_speech": ["Trump", "Biden", "Xi Jinping", "Putin", "Macron", "Zelensky", "Fed", "ECB"],
}
def fetch_geo_news() -> List[Dict[str, Any]]:
news = []
for feed_info in GEO_RSS_FEEDS:
try:
feed = feedparser.parse(feed_info["url"])
for entry in feed.entries[:10]:
title = entry.get("title", "")
summary = entry.get("summary", entry.get("description", ""))
published = entry.get("published", "")
link = entry.get("link", "")
category = classify_news(title + " " + summary)
impact = estimate_impact(title + " " + summary, category)
news.append({
"id": link,
"title": title,
"summary": summary[:300],
"source": feed_info["name"],
"category": category,
"impact_score": impact,
"asset_impacts": compute_asset_impacts(category, impact),
"date": published,
"tags": extract_tags(title + " " + summary),
"url": link,
})
except Exception:
pass
return news[:50]
def classify_news(text: str) -> str:
text_lower = text.lower()
scores = {}
for cat, keywords in GEO_KEYWORDS.items():
scores[cat] = sum(1 for kw in keywords if kw.lower() in text_lower)
best = max(scores, key=scores.get)
return best if scores[best] > 0 else "general"
def estimate_impact(text: str, category: str) -> float:
high_impact = ["attack", "invasion", "collapse", "crisis", "war", "ban", "default", "Trump", "Fed",
"ceasefire", "truce", "nuclear", "coup", "massacre", "bombed", "strike"]
medium_impact = ["tension", "sanctions", "shortage", "election", "rate", "OPEC",
"peace", "deal", "agreement", "accord", "treaty", "negotiation"]
text_lower = text.lower()
score = 0.1
for word in high_impact:
if word.lower() in text_lower:
score += 0.2
for word in medium_impact:
if word.lower() in text_lower:
score += 0.1
return min(1.0, round(score, 2))
def compute_asset_impacts(category: str, impact: float) -> Dict[str, float]:
impact_map = {
"military": {"energy": 0.8, "metals": 0.6, "forex": 0.4, "indices": -0.5, "agriculture": 0.3},
"energy": {"energy": 0.9, "metals": 0.2, "forex": 0.3, "indices": -0.3},
"sanctions": {"forex": 0.6, "energy": 0.5, "metals": 0.3, "indices": -0.4},
"elections": {"forex": 0.7, "indices": 0.4, "equities": 0.3},
"natural_disaster": {"agriculture": 0.8, "energy": 0.4, "indices": -0.3},
"health_crisis": {"indices": -0.8, "agriculture": 0.5, "metals": 0.4},
"resource_scarcity": {"agriculture": 0.9, "metals": 0.7, "energy": 0.5},
"trade_war": {"indices": -0.6, "forex": 0.5, "agriculture": -0.3},
"political_speech": {"forex": 0.5, "indices": 0.4, "energy": 0.3},
}
base = impact_map.get(category, {})
return {k: round(v * impact, 3) for k, v in base.items()}
def extract_tags(text: str) -> List[str]:
all_tags = [
"Trump", "Russia", "Ukraine", "China", "Iran", "Israel", "Gaza", "NATO",
"OPEC", "Fed", "ECB", "Biden", "Xi", "Putin", "Zelensky", "Macron",
"oil", "gold", "wheat", "dollar", "yuan", "euro", "S&P", "VIX",
]
return [tag for tag in all_tags if tag.lower() in text.lower()]
# ── Economic calendar (using free Trading Economics RSS or static) ────────────
def get_economic_calendar() -> List[Dict[str, Any]]:
"""Return next 30 days of major economic events (static + scraped)."""
from datetime import date, timedelta
today = date.today()
events = [
{"title": "US Non-Farm Payrolls", "country": "US", "importance": "high",
"date": (today + timedelta(days=(4 - today.weekday()) % 7 + 7)).isoformat(),
"asset_impact": ["indices", "forex", "rates"]},
{"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high",
"date": (today + timedelta(days=12)).isoformat(),
"asset_impact": ["indices", "forex", "metals"]},
{"title": "FOMC Meeting / Fed Rate Decision", "country": "US", "importance": "high",
"date": (today + timedelta(days=18)).isoformat(),
"asset_impact": ["indices", "forex", "metals", "energy"]},
{"title": "ECB Rate Decision", "country": "EU", "importance": "high",
"date": (today + timedelta(days=20)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "US GDP (Preliminary)", "country": "US", "importance": "high",
"date": (today + timedelta(days=25)).isoformat(),
"asset_impact": ["indices", "forex"]},
{"title": "OPEC+ Meeting", "country": "Global", "importance": "high",
"date": (today + timedelta(days=14)).isoformat(),
"asset_impact": ["energy"]},
{"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium",
"date": (today + timedelta(days=3)).isoformat(),
"asset_impact": ["energy"]},
{"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium",
"date": (today + timedelta(days=8)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "China Trade Balance", "country": "CN", "importance": "medium",
"date": (today + timedelta(days=10)).isoformat(),
"asset_impact": ["metals", "agriculture", "forex"]},
{"title": "US Unemployment Claims", "country": "US", "importance": "medium",
"date": (today + timedelta(days=2)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "USDA Crop Report", "country": "US", "importance": "medium",
"date": (today + timedelta(days=6)).isoformat(),
"asset_impact": ["agriculture"]},
{"title": "G7 Summit", "country": "Global", "importance": "high",
"date": (today + timedelta(days=30)).isoformat(),
"asset_impact": ["forex", "indices", "metals"]},
]
return sorted(events, key=lambda x: x["date"])
# ── Macro Gauges & Scenario Scoring ──────────────────────────────────────────
MACRO_GAUGE_CONFIG = [
# (id, label, ticker, unit, bloc)
("dxy", "Dollar DXY", "DX-Y.NYB", "index", "liquidite"),
("us10y", "UST 10Y", "^TNX", "%", "liquidite"),
("us3m", "UST 3M", "^IRX", "%", "liquidite"),
("tips", "TIPS ETF", "TIP", "$", "liquidite"),
("vix", "VIX", "^VIX", "pts", "credit"),
("hyg", "HY Bonds (HYG)", "HYG", "$", "credit"),
("lqd", "IG Bonds (LQD)", "LQD", "$", "credit"),
("ief", "Trésor 7-10Y (IEF)", "IEF", "$", "credit"),
("brent", "Brent", "BZ=F", "$", "energie"),
("ng", "Gaz naturel", "NG=F", "$", "energie"),
("gold", "Or", "GC=F", "$", "metaux"),
("copper", "Cuivre", "HG=F", "$/lb", "metaux"),
("spx", "S&P 500", "^GSPC", "pts", "croissance"),
("iwm", "Russell 2000", "IWM", "$", "croissance"),
("xli", "Industriels XLI", "XLI", "$", "croissance"),
]
SCENARIO_META = {
"goldilocks": {"label": "Goldilocks", "color": "#10b981", "emoji": "🟢"},
"desinflation": {"label": "Désinflation / Baisse taux","color": "#3b82f6", "emoji": "🔵"},
"soft_landing": {"label": "Soft Landing", "color": "#06b6d4", "emoji": "🔷"},
"reflation": {"label": "Reflation", "color": "#f97316", "emoji": "🟠"},
"stagflation": {"label": "Stagflation", "color": "#f59e0b", "emoji": "🟡"},
"inflation_shock": {"label": "Choc Inflationniste", "color": "#dc2626", "emoji": "🔥"},
"recession": {"label": "Récession", "color": "#ef4444", "emoji": "🔴"},
"crise_liquidite": {"label": "Crise de liquidité", "color": "#7c3aed", "emoji": "🟣"},
}
SCENARIO_ASSET_BIAS = {
"goldilocks": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish+", "forex": "neutral", "agriculture": "neutral"},
"desinflation": {"energy": "bearish", "metals": "bullish+", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"},
"soft_landing": {"energy": "neutral", "metals": "bullish", "indices": "bullish+", "equities": "bullish", "forex": "neutral", "agriculture": "neutral"},
"reflation": {"energy": "bullish+", "metals": "bullish+", "indices": "bullish", "equities": "bullish+", "forex": "neutral", "agriculture": "bullish+"},
"stagflation": {"energy": "bullish+", "metals": "bullish", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish"},
"inflation_shock": {"energy": "bullish+", "metals": "bullish+", "indices": "bearish", "equities": "bearish", "forex": "defensive", "agriculture": "bullish+"},
"recession": {"energy": "bearish", "metals": "neutral", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"},
"crise_liquidite": {"energy": "neutral", "metals": "bullish+", "indices": "bearish+", "equities": "bearish+", "forex": "defensive", "agriculture": "neutral"},
}
def get_macro_gauges() -> Dict[str, Any]:
"""Fetch macro gauges from yfinance in parallel and compute derived metrics."""
from concurrent.futures import ThreadPoolExecutor, as_completed
raw: Dict[str, Any] = {}
with ThreadPoolExecutor(max_workers=min(len(MACRO_GAUGE_CONFIG), 12)) as exe:
futures = {
exe.submit(get_quote, ticker): (gid, label, ticker, unit, bloc)
for gid, label, ticker, unit, bloc in MACRO_GAUGE_CONFIG
}
for fut in as_completed(futures):
gid, label, ticker, unit, bloc = futures[fut]
try:
q = fut.result()
except Exception:
q = None
raw[gid] = {
"id": gid, "label": label, "ticker": ticker,
"value": q.get("price") if q else None,
"change_pct": q.get("change_pct") if q else None,
"unit": unit, "bloc": bloc,
}
# Normalize Treasury yields (yfinance sometimes returns 10x the actual %)
for yid in ("us10y", "us3m"):
v = raw[yid]["value"]
if v is not None and v > 20:
raw[yid]["value"] = round(v / 10, 3)
# Derived: yield curve slope 10Y 3M (% pts; negative = inverted)
v10 = raw["us10y"]["value"]
v3m = raw["us3m"]["value"]
slope = round(v10 - v3m, 3) if (v10 is not None and v3m is not None) else None
raw["slope_10y3m"] = {
"id": "slope_10y3m", "label": "Pente 10Y3M", "ticker": None,
"value": slope, "change_pct": None, "unit": "% pts", "bloc": "liquidite",
"note": ("inversée ⚠️" if slope is not None and slope < 0
else ("plate" if slope is not None and slope < 0.5 else "normale")),
}
# Derived: Gold / Copper ratio (oz gold / lb copper; >700 = fear, <500 = growth)
gv = raw["gold"]["value"]
cv = raw["copper"]["value"]
gcr = round(gv / cv, 1) if (gv and cv) else None
raw["gold_copper_ratio"] = {
"id": "gold_copper_ratio", "label": "Ratio Or/Cuivre",
"ticker": None, "value": gcr, "change_pct": None, "unit": "ratio", "bloc": "derive",
"note": ("peur/récession" if gcr and gcr > 700 else ("neutre" if gcr and gcr > 550 else "croissance")),
}
# Derived: S&P 500 % above/below 200-day MA
try:
spx_hist = get_historical("^GSPC", period="1y", interval="1d")
closes = [h["close"] for h in spx_hist if h.get("close")]
if len(closes) >= 50:
n = min(200, len(closes))
ma = sum(closes[-n:]) / n
vs200 = round((closes[-1] - ma) / ma * 100, 2)
else:
vs200 = None
except Exception:
vs200 = None
raw["spx_vs_200d"] = {
"id": "spx_vs_200d", "label": "S&P vs 200j MA",
"ticker": None, "value": vs200, "change_pct": None, "unit": "%", "bloc": "derive",
"note": ("bull market" if vs200 is not None and vs200 > 5
else ("au-dessus" if vs200 is not None and vs200 > 0
else ("en-dessous ⚠️" if vs200 is not None else None))),
}
# Derived: Russell 2000 vs S&P 500 relative daily performance
# Positive = small caps outperforming (risk-on breadth); negative = large cap defensiveness
iwm_c = raw.get("iwm", {}).get("change_pct") or 0.0
spx_c_val = raw.get("spx", {}).get("change_pct") or 0.0
rel_perf = round(iwm_c - spx_c_val, 2)
raw["iwm_spx_ratio"] = {
"id": "iwm_spx_ratio", "label": "Russell vs S&P (perf. rel.)",
"ticker": None, "value": rel_perf, "change_pct": None, "unit": "pts%", "bloc": "derive",
"note": ("small caps > large (risk-on)" if rel_perf > 0.2
else ("parité" if rel_perf > -0.2 else "large caps dominants (défensif)")),
}
return _sanitize_floats(raw)
def _sanitize_floats(obj: Any) -> Any:
"""Recursively replace NaN/Inf floats with None so json.dumps never crashes."""
import math
if isinstance(obj, dict):
return {k: _sanitize_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize_floats(v) for v in obj]
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
return None
return obj
def score_macro_scenarios(gauges: Dict[str, Any]) -> Dict[str, Any]:
"""Rule-based scoring of the 5 macro regimes (0-100 each) from live gauge values."""
def gv(k): return gauges.get(k, {}).get("value")
def gc(k): return gauges.get(k, {}).get("change_pct") or 0.0
vix = gv("vix") or 20.0
slope = gv("slope_10y3m")
gcr = gv("gold_copper_ratio")
vs200 = gv("spx_vs_200d")
brent_c = gc("brent")
ng_c = gc("ng")
gold_c = gc("gold")
copper_c = gc("copper")
hyg_c = gc("hyg")
lqd_c = gc("lqd")
ief_c = gc("ief")
dxy_c = gc("dxy")
iwm_c = gc("iwm")
xli_c = gc("xli")
rel_perf = gv("iwm_spx_ratio") or 0.0 # Russell vs S&P relative perf
scores: Dict[str, int] = {}
reasons: Dict[str, List[str]] = {}
# GOLDILOCKS — croissance + faible volatilité + crédit serré
s = 0; r: List[str] = []
if vix < 15: s += 30; r.append("VIX<15")
elif vix < 18: s += 20; r.append("VIX<18")
elif vix < 22: s += 10
if slope is not None:
if slope > 1.0: s += 20; r.append("Courbe +1%pt")
elif slope > 0.3: s += 10; r.append("Courbe légèrement positive")
if gcr is not None:
if gcr < 500: s += 20; r.append(f"Or/Cu {gcr} (croissance)")
elif gcr < 600: s += 10
if hyg_c > 0.2: s += 15; r.append("HYG↑ (crédit OK)")
elif hyg_c > 0: s += 5
if vs200 is not None:
if vs200 > 5: s += 15; r.append(f"S&P+{vs200}% vs 200j")
elif vs200 > 0: s += 7
if copper_c > 0.5: s += 10; r.append("Cuivre↑")
scores["goldilocks"] = min(100, s); reasons["goldilocks"] = r
# DÉSINFLATION / BAISSE DE TAUX
s = 0; r = []
if brent_c < -1.0: s += 25; r.append("Brent↓↓ (désinflationniste)")
elif brent_c < 0: s += 10
if ng_c < -1.0: s += 10; r.append("Gaz↓")
if ief_c > 0.2: s += 20; r.append("IEF↑ (taux longs baissent)")
elif ief_c > 0: s += 10
if vix < 20: s += 15; r.append("VIX<20")
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P au-dessus 200j")
if hyg_c > 0: s += 10; r.append("HYG↑")
if gold_c > 0 and brent_c < 0: s += 10; r.append("Or↑+Brent↓ (taux réels ↓)")
scores["desinflation"] = min(100, s); reasons["desinflation"] = r
# STAGFLATION — inflation + croissance faible
s = 0; r = []
if brent_c > 2.0: s += 30; r.append("Brent↑↑")
elif brent_c > 0.5: s += 15; r.append("Brent↑")
if ng_c > 2.0: s += 15; r.append("Gaz↑↑")
elif ng_c > 0.5: s += 7
if slope is not None:
if slope < 0: s += 20; r.append("Courbe inversée")
elif slope < 0.3: s += 10; r.append("Courbe plate")
if gold_c > 0.5: s += 15; r.append("Or↑ (protection inflation)")
if copper_c < 0: s += 15; r.append("Cuivre↓ (demande faible)")
if vix > 18: s += 10; r.append("VIX élevé")
scores["stagflation"] = min(100, s); reasons["stagflation"] = r
# RÉCESSION
s = 0; r = []
if slope is not None:
if slope < -0.5: s += 30; r.append("Courbe fortement inversée")
elif slope < 0: s += 15; r.append("Courbe inversée")
if gcr is not None:
if gcr > 750: s += 25; r.append(f"Or/Cu {gcr} (peur)")
elif gcr > 650: s += 10
if vix > 28: s += 25; r.append("VIX>28")
elif vix > 22: s += 12
if copper_c < -1.5: s += 20; r.append("Cuivre↓↓")
elif copper_c < -0.5: s += 8
if hyg_c < -0.5: s += 15; r.append("HYG↓ (spreads s'écartent)")
elif hyg_c < 0: s += 5
if gold_c > 0.3: s += 10; r.append("Or↑ (refuge)")
scores["recession"] = min(100, s); reasons["recession"] = r
# CRISE DE LIQUIDITÉ
s = 0; r = []
if vix > 35: s += 35; r.append("VIX>35 (panique)")
elif vix > 28: s += 20; r.append("VIX>28")
elif vix > 22: s += 8
if hyg_c < -1.5: s += 35; r.append("HYG↓↓ (crise crédit)")
elif hyg_c < -0.5: s += 15
if lqd_c < -0.5: s += 10; r.append("IG↓ (spreads s'écartent)")
if vs200 is not None:
if vs200 < -10: s += 25; r.append("S&P<200j -10%")
elif vs200 < -3: s += 10
if gold_c > 1.0 and copper_c < -1.0: s += 20; r.append("Or↑+Cuivre↓ (fuite sécurité)")
if dxy_c > 1.0: s += 15; r.append("Dollar↑↑")
if ief_c > 0.5: s += 10; r.append("Obligations souveraines↑↑")
scores["crise_liquidite"] = min(100, s); reasons["crise_liquidite"] = r
# REFLATION — croissance accélère + inflation remonte (cuivre, énergie, small caps explosent)
s = 0; r = []
if copper_c > 1.5: s += 25; r.append("Cuivre↑↑ (Dr Copper = croissance)")
elif copper_c > 0.5: s += 12; r.append("Cuivre↑")
if xli_c > 0.8: s += 20; r.append("Industriels↑↑ (activité mfg forte)")
elif xli_c > 0.2: s += 10; r.append("Industriels↑")
if brent_c > 1.5: s += 15; r.append("Brent↑ (reflation énergie)")
elif brent_c > 0.3: s += 6
if vs200 is not None and vs200 > 8: s += 20; r.append(f"S&P+{vs200}% vs 200j (bull fort)")
elif vs200 is not None and vs200 > 3: s += 10
if slope is not None and slope > 1.0: s += 15; r.append("Courbe pentue (anticipation croissance)")
elif slope is not None and slope > 0.3: s += 6
if rel_perf > 0.3: s += 10; r.append("Small caps > large (risk-on large)")
elif rel_perf > 0: s += 4
if vix < 18: s += 5
scores["reflation"] = min(100, s); reasons["reflation"] = r
# SOFT LANDING — croissance positive + inflation en repli, pas encore basse
# Intermédiaire entre Goldilocks (idéal) et Désinflation (taux baissent fortement)
s = 0; r = []
if vs200 is not None and vs200 > 0: s += 20; r.append("S&P > MA200 (croissance intacte)")
if brent_c < -0.5 and brent_c > -3: s += 20; r.append("Brent légèrement ↓ (désinflation graduelle)")
elif brent_c < 0: s += 8
if vix < 20: s += 15; r.append("VIX<20 (pas de stress)")
if hyg_c > 0: s += 12; r.append("HYG↑ (crédit solide)")
if lqd_c > 0: s += 8; r.append("IG↑ (spreads IG calmes)")
if slope is not None and slope > 0: s += 10; r.append("Courbe non-inversée")
if xli_c > 0: s += 8; r.append("Industriels positifs")
if copper_c > 0: s += 5; r.append("Cuivre stable")
if ief_c > 0 and brent_c < 0: s += 7; r.append("Taux baissent + énergie recule")
scores["soft_landing"] = min(100, s); reasons["soft_landing"] = r
# CHOC INFLATIONNISTE — spike énergie/supply soudain (guerre, OPEC, sécheresse)
# Différent de Stagflation : c'est un choc externe aigu, pas un régime durable
s = 0; r = []
if brent_c > 4.0: s += 40; r.append("Brent↑↑↑ (choc énergie majeur)")
elif brent_c > 2.0: s += 25; r.append("Brent↑↑")
elif brent_c > 0.8: s += 10
if ng_c > 4.0: s += 20; r.append("Gaz↑↑↑ (choc supply gaz)")
elif ng_c > 2.0: s += 12; r.append("Gaz↑↑")
if gold_c > 1.0: s += 20; r.append("Or↑↑ (refuge inflation/géo)")
elif gold_c > 0.3: s += 8; r.append("Or↑")
if vix > 22: s += 15; r.append("VIX↑ (stress montant)")
elif vix > 18: s += 5
if copper_c < -0.5: s += 8; r.append("Cuivre↓ (demand destruction)")
if ief_c < -0.2: s += 8; r.append("Trésor↓ (taux longs remontent)")
scores["inflation_shock"] = min(100, s); reasons["inflation_shock"] = r
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
dominant = ranked[0][0] if ranked[0][1] > 20 else "incertain"
return {
"scores": scores,
"ranked": [[k, v] for k, v in ranked],
"dominant": dominant,
"reasons": reasons,
"meta": SCENARIO_META,
"asset_bias": SCENARIO_ASSET_BIAS,
}

1405
backend/services/database.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,348 @@
"""
Geopolitical pattern engine.
Scores current events against historical templates and generates trade signals.
"""
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import json
# ── Historical geopolitical pattern library ───────────────────────────────────
GEO_PATTERNS = [
{
"id": "P001",
"name": "Middle East Military Escalation → Oil Spike",
"description": "Armed conflict or threat in Gulf region triggers Brent/WTI crude spike +10-20% within 2-4 weeks",
"triggers": ["military", "energy", "sanctions"],
"keywords": ["Iran", "Israel", "Saudi", "Gulf", "Strait of Hormuz", "OPEC"],
"historical_instances": [
{"date": "2019-09-14", "event": "Attack on Saudi Aramco facilities", "brent_move": +14.6, "days": 2},
{"date": "2020-01-03", "event": "Soleimani assassination", "brent_move": +4.4, "days": 1},
{"date": "2022-02-24", "event": "Russia invades Ukraine", "brent_move": +28.0, "days": 10},
],
"suggested_trades": [
{"strategy": "Bull Call Spread", "underlying": "USO", "rationale": "Oil ETF call spread, limited risk"},
{"strategy": "Long Call", "underlying": "CL=F", "rationale": "WTI crude direct exposure"},
],
"asset_class": "energy",
"expected_move_pct": 12.0,
"probability": 0.65,
"horizon_days": 30,
},
{
"id": "P002",
"name": "US Tariff Announcement → Agriculture Selloff",
"description": "Trump/US tariff threats on China cause immediate selloff in soy, corn, wheat (retaliatory risk)",
"triggers": ["trade_war", "political_speech"],
"keywords": ["tariff", "China", "trade", "soybean", "agriculture", "import duty"],
"historical_instances": [
{"date": "2018-07-06", "event": "US-China trade war tariffs", "zs_move": -10.2, "days": 30},
{"date": "2019-05-10", "event": "Trump tariff escalation tweet", "zs_move": -5.8, "days": 5},
{"date": "2025-02-01", "event": "Trump 25% tariff on Canada/Mexico", "zw_move": -3.4, "days": 3},
],
"suggested_trades": [
{"strategy": "Bear Put Spread", "underlying": "SOYB", "rationale": "Downside hedge on soy ETF"},
{"strategy": "Long Put", "underlying": "ZS=F", "rationale": "Soybean futures put"},
],
"asset_class": "agriculture",
"expected_move_pct": -8.0,
"probability": 0.70,
"horizon_days": 21,
},
{
"id": "P003",
"name": "Geopolitical Risk Flight → Gold Rally",
"description": "Major geopolitical uncertainty drives safe-haven demand for gold +5-15%",
"triggers": ["military", "health_crisis", "financial_crisis", "elections"],
"keywords": ["nuclear", "war", "crisis", "uncertainty", "safe haven", "debt ceiling"],
"historical_instances": [
{"date": "2022-02-24", "event": "Ukraine invasion", "gc_move": +6.8, "days": 14},
{"date": "2023-10-07", "event": "Hamas attack on Israel", "gc_move": +9.2, "days": 30},
{"date": "2020-03-01", "event": "COVID-19 fear peak", "gc_move": +12.1, "days": 45},
],
"suggested_trades": [
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold ETF call for safe-haven rally"},
{"strategy": "Bull Call Spread", "underlying": "GC=F", "rationale": "Gold futures spread, capped risk"},
],
"asset_class": "metals",
"expected_move_pct": 7.5,
"probability": 0.72,
"horizon_days": 30,
},
{
"id": "P004",
"name": "Fed Hawkish Pivot → Dollar Surge / EM Currency Crash",
"description": "Fed signals higher-for-longer rates → USD Index rallies, EUR/USD drops",
"triggers": ["political_speech"],
"keywords": ["Fed", "interest rate", "hike", "hawkish", "inflation", "FOMC", "Powell"],
"historical_instances": [
{"date": "2022-06-15", "event": "Fed 75bps hike", "dxy_move": +3.2, "days": 5},
{"date": "2023-03-22", "event": "Fed signals further hikes", "eurusd_move": -1.8, "days": 7},
],
"suggested_trades": [
{"strategy": "Bear Put Spread", "underlying": "FXE", "rationale": "EUR/USD put spread"},
{"strategy": "Long Call", "underlying": "UUP", "rationale": "Dollar index ETF call"},
],
"asset_class": "forex",
"expected_move_pct": 3.0,
"probability": 0.68,
"horizon_days": 14,
},
{
"id": "P005",
"name": "China Economic Slowdown → Copper/Metals Selloff",
"description": "Weak Chinese PMI or stimulus disappointment drives copper lower (China = 50%+ of global demand)",
"triggers": ["resource_scarcity", "trade_war"],
"keywords": ["China", "PMI", "slowdown", "recession", "property", "Evergrande", "copper demand"],
"historical_instances": [
{"date": "2015-08-24", "event": "China Black Monday", "hg_move": -8.4, "days": 5},
{"date": "2022-11-01", "event": "China PMI contraction", "hg_move": -5.2, "days": 10},
],
"suggested_trades": [
{"strategy": "Long Put", "underlying": "COPX", "rationale": "Copper miners ETF put"},
{"strategy": "Bear Put Spread", "underlying": "HG=F", "rationale": "Copper futures spread"},
],
"asset_class": "metals",
"expected_move_pct": -6.5,
"probability": 0.60,
"horizon_days": 21,
},
{
"id": "P006",
"name": "Ukraine/Russia War Escalation → Wheat Spike + Defense Rally",
"description": "New escalation in Russia-Ukraine conflict → wheat/fertilizer spike, defense stocks rally",
"triggers": ["military", "resource_scarcity"],
"keywords": ["Russia", "Ukraine", "Zelensky", "Kyiv", "grain corridor", "Black Sea", "NATO"],
"historical_instances": [
{"date": "2022-02-24", "event": "Full-scale invasion", "zw_move": +50.0, "days": 45},
{"date": "2022-07-22", "event": "Grain deal collapse threat", "zw_move": +6.3, "days": 3},
{"date": "2023-07-17", "event": "Russia exits grain deal", "zw_move": +8.5, "days": 2},
],
"suggested_trades": [
{"strategy": "Long Call", "underlying": "WEAT", "rationale": "Wheat ETF call on supply shock"},
{"strategy": "Bull Call Spread", "underlying": "LMT", "rationale": "Lockheed defense stock spread"},
],
"asset_class": "agriculture",
"expected_move_pct": 15.0,
"probability": 0.58,
"horizon_days": 45,
},
{
"id": "P007",
"name": "Natural Gas Supply Disruption → NG Price Spike",
"description": "Pipeline disruption, LNG strike, or extreme weather drives natural gas +20-40%",
"triggers": ["energy", "natural_disaster", "military"],
"keywords": ["pipeline", "LNG", "natural gas", "Nord Stream", "gas supply", "storage"],
"historical_instances": [
{"date": "2022-09-26", "event": "Nord Stream pipeline explosion", "ng_move": +18.0, "days": 5},
{"date": "2021-02-10", "event": "Texas winter storm Uri", "ng_move": +40.0, "days": 3},
],
"suggested_trades": [
{"strategy": "Long Call", "underlying": "UNG", "rationale": "Natural gas ETF call"},
{"strategy": "Bull Call Spread", "underlying": "NG=F", "rationale": "NG futures spread, capped risk"},
],
"asset_class": "energy",
"expected_move_pct": 25.0,
"probability": 0.55,
"horizon_days": 14,
},
{
"id": "P008",
"name": "Pandemic / Health Crisis → VIX Spike + Market Selloff",
"description": "New pandemic scare or major health crisis → VIX spike, equity selloff, gold bid",
"triggers": ["health_crisis"],
"keywords": ["pandemic", "virus", "outbreak", "WHO", "lockdown", "COVID", "mpox", "H5N1"],
"historical_instances": [
{"date": "2020-02-24", "event": "COVID-19 global spread fear", "spx_move": -34.0, "days": 30},
{"date": "2022-11-25", "event": "China COVID lockdowns", "spx_move": -3.5, "days": 3},
],
"suggested_trades": [
{"strategy": "Long Put", "underlying": "SPY", "rationale": "S&P 500 put for equity protection"},
{"strategy": "Long Call", "underlying": "^VIX", "rationale": "VIX call for volatility spike"},
{"strategy": "Long Call", "underlying": "GLD", "rationale": "Gold safe-haven call"},
],
"asset_class": "indices",
"expected_move_pct": -12.0,
"probability": 0.45,
"horizon_days": 30,
},
]
GEOPOLITICAL_RISK_WEIGHTS = {
"military": 0.25,
"energy": 0.20,
"trade_war": 0.15,
"political_speech": 0.15,
"natural_disaster": 0.10,
"health_crisis": 0.10,
"resource_scarcity": 0.05,
}
def compute_geo_risk_score(events: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Compute a global geopolitical risk score 0-100 from recent events."""
if not events:
return {"score": 35, "level": "medium", "breakdown": {}}
category_scores: Dict[str, float] = {}
for event in events[:30]:
cat = event.get("category", "general")
impact = event.get("impact_score", 0.1)
if cat in category_scores:
category_scores[cat] = max(category_scores[cat], impact)
else:
category_scores[cat] = impact
weighted = sum(
category_scores.get(cat, 0) * weight
for cat, weight in GEOPOLITICAL_RISK_WEIGHTS.items()
)
score = min(100, round(weighted * 100, 1))
if score < 25:
level = "low"
elif score < 50:
level = "medium"
elif score < 75:
level = "high"
else:
level = "extreme"
return {
"score": score,
"level": level,
"breakdown": {cat: round(v * 100, 1) for cat, v in category_scores.items()},
"top_risks": sorted(category_scores.items(), key=lambda x: x[1], reverse=True)[:3],
}
def match_patterns(events: List[Dict[str, Any]], patterns: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
"""Find which historical geo-patterns best match current event feed."""
if not events:
return []
if patterns is None:
patterns = GEO_PATTERNS
current_categories = set(e.get("category", "") for e in events)
current_tags = set()
for e in events:
current_tags.update(e.get("tags", []))
current_text = " ".join(e.get("title", "") + " " + e.get("summary", "") for e in events[:20]).lower()
matches = []
for pattern in patterns:
trigger_match = len(set(pattern["triggers"]) & current_categories) / len(pattern["triggers"])
keyword_match = sum(1 for kw in pattern["keywords"] if kw.lower() in current_text) / len(pattern["keywords"])
similarity = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1)
if similarity > 10:
matches.append({
"pattern_id": pattern["id"],
"name": pattern["name"],
"description": pattern["description"],
"similarity": similarity,
"suggested_trades": pattern["suggested_trades"],
"asset_class": pattern["asset_class"],
"expected_move_pct": pattern["expected_move_pct"],
"probability": pattern["probability"],
"horizon_days": pattern["horizon_days"],
"historical_instances": pattern["historical_instances"],
})
return sorted(matches, key=lambda x: x["similarity"], reverse=True)[:5]
def generate_trade_ideas(pattern_matches: List[Dict[str, Any]], geo_score: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Convert pattern matches into structured trade ideas with sizing for ~1000€."""
ideas = []
for pm in pattern_matches[:5]:
for i, trade in enumerate(pm["suggested_trades"]): # all suggested trades, not just first
move = pm["expected_move_pct"]
confidence = round(pm["probability"] * pm["similarity"] / 100 * 100)
# Use trade-level asset_class if provided, else fall back to pattern-level
asset_class = trade.get("asset_class") or pm["asset_class"]
ideas.append({
"id": f"IDEA-{pm['pattern_id']}-{i}-{trade['strategy'][:3].upper()}",
"title": f"{trade['strategy']} on {trade['underlying']}",
"rationale": f"[{pm['name']}] {trade['rationale']}. Expected move: {'+' if move > 0 else ''}{move}% in {pm['horizon_days']}d",
"pattern": pm["name"],
"asset_class": asset_class,
"underlying": trade["underlying"],
"strategy": trade["strategy"],
"expected_move_pct": move,
"confidence": min(95, confidence),
"horizon_days": pm["horizon_days"],
"capital_required": 1000,
"risk_level": "high" if abs(move) > 15 else "medium",
"pattern_similarity": pm["similarity"],
})
return ideas
def compute_pattern_relevance(
events: List[Dict[str, Any]],
patterns: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
"""Return ALL patterns with news-keyword relevance score + matching news snippets.
Unlike match_patterns(), no similarity threshold — every active pattern is returned.
"""
if patterns is None:
patterns = GEO_PATTERNS
current_categories = set(e.get("category", "") for e in events)
current_text = " ".join(
e.get("title", "") + " " + e.get("summary", "") for e in events[:30]
).lower()
result = []
for pattern in patterns:
triggers_list = pattern.get("triggers", []) or []
keywords_list = pattern.get("keywords", []) or []
trigger_match = (
len(set(triggers_list) & current_categories) / len(triggers_list)
if triggers_list else 0
)
kw_hits = [kw for kw in keywords_list if kw.lower() in current_text]
keyword_match = len(kw_hits) / len(keywords_list) if keywords_list else 0
relevance = round((trigger_match * 0.5 + keyword_match * 0.5) * 100, 1)
# Find matching news with which keywords triggered
matching_news = []
for e in events[:30]:
text = (e.get("title", "") + " " + e.get("summary", "")).lower()
hits = [kw for kw in keywords_list if kw.lower() in text]
if hits:
matching_news.append({
"title": e.get("title", ""),
"source": e.get("source", ""),
"date": str(e.get("date", ""))[:16],
"impact": round(e.get("impact_score", 0), 2),
"matched_keywords": hits,
"url": e.get("url", ""),
})
matching_news.sort(key=lambda x: x["impact"], reverse=True)
result.append({
"pattern_id": pattern.get("id", ""),
"name": pattern.get("name", ""),
"description": pattern.get("description", ""),
"asset_class": pattern.get("asset_class", ""),
"relevance": relevance,
"keyword_hits": len(kw_hits),
"keyword_total": len(keywords_list),
"matched_keywords": kw_hits,
"matching_news": matching_news[:5],
"suggested_trades": pattern.get("suggested_trades", []),
"expected_move_pct": pattern.get("expected_move_pct", 0),
"probability": pattern.get("probability", 0),
"horizon_days": pattern.get("horizon_days", 0),
})
result.sort(key=lambda x: x["relevance"], reverse=True)
return result
def get_all_patterns() -> List[Dict[str, Any]]:
return GEO_PATTERNS

View File

@@ -0,0 +1,126 @@
import numpy as np
from scipy.stats import norm
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
import math
def black_scholes(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> Dict[str, float]:
"""Black-Scholes pricing + Greeks."""
S = float(S or 100.0)
K = float(K or S)
T = float(T or 0.001)
sigma = float(sigma or 0.25)
if T <= 0 or sigma <= 0:
intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S)
return {"price": intrinsic, "delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0}
d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))
d2 = d1 - sigma * math.sqrt(T)
if option_type == "call":
price = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2)
delta = norm.cdf(d1)
rho = K * T * math.exp(-r * T) * norm.cdf(d2) / 100
else:
price = K * math.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
delta = norm.cdf(d1) - 1
rho = -K * T * math.exp(-r * T) * norm.cdf(-d2) / 100
gamma = norm.pdf(d1) / (S * sigma * math.sqrt(T))
theta = (-(S * norm.pdf(d1) * sigma) / (2 * math.sqrt(T)) - r * K * math.exp(-r * T) * norm.cdf(d2 if option_type == "call" else -d2)) / 365
vega = S * norm.pdf(d1) * math.sqrt(T) / 100
return {
"price": round(price, 4),
"delta": round(delta, 4),
"gamma": round(gamma, 6),
"theta": round(theta, 4),
"vega": round(vega, 4),
"rho": round(rho, 4),
}
def compute_pnl_curve(
S: float, K: float, T: float, r: float, sigma: float,
option_type: str, quantity: int, premium_paid: float
) -> List[Dict[str, float]]:
"""P&L at expiry across a range of underlying prices."""
prices = np.linspace(S * 0.5, S * 1.5, 100)
curve = []
for price in prices:
if option_type == "call":
intrinsic = max(0, price - K)
else:
intrinsic = max(0, K - price)
pnl = (intrinsic - premium_paid) * quantity * 100
curve.append({"underlying": round(float(price), 2), "pnl": round(float(pnl), 2)})
return curve
def bull_call_spread(S: float, K_low: float, K_high: float, T: float, r: float, sigma: float) -> Dict[str, Any]:
long_call = black_scholes(S, K_low, T, r, sigma, "call")
short_call = black_scholes(S, K_high, T, r, sigma, "call")
net_debit = long_call["price"] - short_call["price"]
max_gain = (K_high - K_low) - net_debit
return {
"strategy": "Bull Call Spread",
"net_debit": round(net_debit, 4),
"max_loss": round(net_debit * 100, 2),
"max_gain": round(max_gain * 100, 2),
"breakeven": round(K_low + net_debit, 2),
"legs": [
{"type": "long call", "strike": K_low, "premium": long_call["price"]},
{"type": "short call", "strike": K_high, "premium": short_call["price"]},
],
}
def bear_put_spread(S: float, K_high: float, K_low: float, T: float, r: float, sigma: float) -> Dict[str, Any]:
long_put = black_scholes(S, K_high, T, r, sigma, "put")
short_put = black_scholes(S, K_low, T, r, sigma, "put")
net_debit = long_put["price"] - short_put["price"]
max_gain = (K_high - K_low) - net_debit
return {
"strategy": "Bear Put Spread",
"net_debit": round(net_debit, 4),
"max_loss": round(net_debit * 100, 2),
"max_gain": round(max_gain * 100, 2),
"breakeven": round(K_high - net_debit, 2),
"legs": [
{"type": "long put", "strike": K_high, "premium": long_put["price"]},
{"type": "short put", "strike": K_low, "premium": short_put["price"]},
],
}
def long_straddle(S: float, K: float, T: float, r: float, sigma: float) -> Dict[str, Any]:
call = black_scholes(S, K, T, r, sigma, "call")
put = black_scholes(S, K, T, r, sigma, "put")
total_premium = call["price"] + put["price"]
return {
"strategy": "Long Straddle",
"net_debit": round(total_premium, 4),
"max_loss": round(total_premium * 100, 2),
"max_gain": None,
"breakevens": [round(K - total_premium, 2), round(K + total_premium, 2)],
"legs": [
{"type": "long call", "strike": K, "premium": call["price"]},
{"type": "long put", "strike": K, "premium": put["price"]},
],
}
def implied_vol_surface(S: float, strikes_pct: List[float], expiries_days: List[int], r: float, base_sigma: float) -> List[Dict]:
"""Generate a simplified IV surface (skew + term structure)."""
surface = []
for days in expiries_days:
T = days / 365
for pct in strikes_pct:
K = S * pct
moneyness = math.log(K / S)
skew_adj = -0.3 * moneyness # typical negative skew
term_adj = 0.02 * math.sqrt(30 / max(days, 1))
iv = max(0.05, base_sigma + skew_adj + term_adj)
surface.append({"expiry_days": days, "strike_pct": pct, "strike": round(K, 2), "iv": round(iv, 4)})
return surface