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>
349 lines
16 KiB
Python
349 lines
16 KiB
Python
"""
|
|
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
|