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:
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
934
backend/services/ai_analyzer.py
Normal file
934
backend/services/ai_analyzer.py
Normal 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
|
||||
697
backend/services/auto_cycle.py
Normal file
697
backend/services/auto_cycle.py
Normal 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()),
|
||||
}
|
||||
593
backend/services/data_fetcher.py
Normal file
593
backend/services/data_fetcher.py
Normal 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 10Y–3M", "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
1405
backend/services/database.py
Normal file
File diff suppressed because it is too large
Load Diff
348
backend/services/geo_analyzer.py
Normal file
348
backend/services/geo_analyzer.py
Normal 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
|
||||
126
backend/services/options_pricer.py
Normal file
126
backend/services/options_pricer.py
Normal 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
|
||||
Reference in New Issue
Block a user