Initial commit — GeoOptions Intelligence Cockpit v2.0

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

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

20
backend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# System deps for yfinance / pandas / scipy
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc g++ libffi-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Persistent SQLite lives here (mounted as volume)
RUN mkdir -p data
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

70
backend/main.py Normal file
View File

@@ -0,0 +1,70 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router
from services.database import init_db, get_config
import os
import uvicorn
app = FastAPI(
title="GeoOptions Intelligence",
description="Geopolitical Options Trading Cockpit API",
version="2.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def startup():
init_db()
key = get_config("openai_api_key") or ""
if key:
os.environ["OPENAI_API_KEY"] = key
# Seed built-in patterns into DB (idempotent)
from services.geo_analyzer import GEO_PATTERNS
from services.database import seed_builtin_patterns
seed_builtin_patterns(GEO_PATTERNS)
# Start auto-cycle scheduler if enabled
from services.auto_cycle import start_scheduler
start_scheduler()
@app.on_event("shutdown")
def shutdown():
from services.auto_cycle import stop_scheduler
stop_scheduler()
app.include_router(market_data.router)
app.include_router(geopolitical.router)
app.include_router(options.router)
app.include_router(backtest.router)
app.include_router(ai.router)
app.include_router(portfolio.router)
app.include_router(config.router)
app.include_router(patterns.router)
app.include_router(journal.router)
app.include_router(cycle_router.router)
app.include_router(profiles_router.router)
app.include_router(reasoning_router.router)
app.include_router(knowledge_router.router)
@app.get("/")
def root():
return {"app": "GeoOptions Intelligence Cockpit", "version": "2.0.0", "docs": "/docs"}
@app.get("/api/health")
def health():
return {"status": "ok", "version": "2.0.0"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

155
backend/models/schemas.py Normal file
View File

@@ -0,0 +1,155 @@
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
class AssetClass(str, Enum):
ENERGY = "energy"
METALS = "metals"
AGRICULTURE = "agriculture"
EQUITIES = "equities"
INDICES = "indices"
FOREX = "forex"
CRYPTO = "crypto"
RATES = "rates"
class RiskLevel(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
EXTREME = "extreme"
class GeopoliticalCategory(str, Enum):
MILITARY = "military"
SANCTIONS = "sanctions"
ELECTIONS = "elections"
NATURAL_DISASTER = "natural_disaster"
HEALTH_CRISIS = "health_crisis"
RESOURCE_SCARCITY = "resource_scarcity"
TRADE_WAR = "trade_war"
ENERGY_CRISIS = "energy_crisis"
POLITICAL_SPEECH = "political_speech"
FINANCIAL_CRISIS = "financial_crisis"
class GeoEvent(BaseModel):
id: str
title: str
summary: str
category: GeopoliticalCategory
date: datetime
source: str
impact_score: float # -1.0 to 1.0
asset_impacts: Dict[AssetClass, float] # impact per asset class
tags: List[str]
is_processed: bool = False
class MarketQuote(BaseModel):
symbol: str
name: str
price: float
change: float
change_pct: float
volume: int
iv: Optional[float] = None # implied volatility
asset_class: AssetClass
timestamp: datetime
class OptionsContract(BaseModel):
symbol: str
underlying: str
expiry: str
strike: float
option_type: str # call / put
bid: float
ask: float
last: float
volume: int
open_interest: int
iv: float
delta: float
gamma: float
theta: float
vega: float
rho: float
class TradeIdea(BaseModel):
id: str
title: str
rationale: str
asset_class: AssetClass
underlying: str
strategy: str # e.g. "Bull Call Spread", "Long Put", "Straddle"
legs: List[Dict[str, Any]]
max_loss: float
max_gain: Optional[float]
breakeven: List[float]
horizon_days: int
confidence: float # 0-100
geo_trigger: Optional[str]
risk_level: RiskLevel
capital_required: float
created_at: datetime
class BacktestParams(BaseModel):
start_date: str
end_date: str
strategy: str
underlying: str
geo_filters: Optional[List[GeopoliticalCategory]] = None
capital: float = 1000.0
class BacktestResult(BaseModel):
params: BacktestParams
trades: List[Dict[str, Any]]
total_return: float
win_rate: float
max_drawdown: float
sharpe_ratio: float
profit_factor: float
equity_curve: List[Dict[str, Any]]
class EconomicEvent(BaseModel):
id: str
title: str
country: str
date: datetime
importance: str # low / medium / high
previous: Optional[str]
forecast: Optional[str]
actual: Optional[str]
asset_impact: List[AssetClass]
class PortfolioPosition(BaseModel):
id: str
trade_idea_id: Optional[str]
symbol: str
strategy: str
entry_date: datetime
expiry: str
legs: List[Dict[str, Any]]
capital_invested: float
current_value: float
pnl: float
pnl_pct: float
status: str # open / closed / expired
class GeoPatternMatch(BaseModel):
pattern_id: str
description: str
historical_date: datetime
current_similarity: float
historical_outcome: str
suggested_trades: List[str]
asset_class: AssetClass

20
backend/requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2
python-dotenv==1.0.1
yfinance>=1.4.1
pandas==2.2.3
numpy==2.1.2
scipy==1.14.1
httpx==0.27.2
aiohttp==3.10.10
sqlalchemy==2.0.36
aiosqlite==0.20.0
beautifulsoup4==4.12.3
feedparser==6.0.11
anthropic==0.36.2
openai>=1.30.0
python-multipart==0.0.12
apscheduler==3.10.4
pytz==2024.2
ta==0.11.0

View File

297
backend/routers/ai.py Normal file
View File

@@ -0,0 +1,297 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import json
from services.ai_analyzer import (
analyze_speech, evaluate_pattern, suggest_pattern_from_context,
rank_trade_ideas, analyze_news_item, get_client, score_patterns_with_context,
suggest_patterns_from_market_context, ai_score_news_batch,
DEFAULT_ANALYSIS_TEMPLATE, _chat,
)
from services.data_fetcher import fetch_geo_news, get_all_quotes
from services.geo_analyzer import compute_geo_risk_score, match_patterns
from services.database import get_config, get_custom_patterns, get_analysis_config, save_pattern_scores, get_pattern_scores, get_score_deltas, compute_pattern_similarity, log_geo_alert, log_trade_entries
router = APIRouter(prefix="/api/ai", tags=["ai"])
def require_ai():
key = get_config("openai_api_key") or ""
if not key:
raise HTTPException(400, "Clé OpenAI non configurée — aller dans Config")
import os
os.environ["OPENAI_API_KEY"] = key
class SpeechRequest(BaseModel):
text: str
speaker: Optional[str] = ""
class PatternEvalRequest(BaseModel):
pattern: Dict[str, Any]
class PatternSuggestRequest(BaseModel):
context: str
class NewsAnalyzeRequest(BaseModel):
title: str
summary: str
class ScorePatternsRequest(BaseModel):
top_n: Optional[int] = None # override config default
category_filter: Optional[str] = None # "all"|"energy"|"metals"|"agriculture"|"forex"|"indices"|"equities"
template: Optional[str] = None # override config template
@router.get("/status")
def ai_status():
key = get_config("openai_api_key") or ""
return {
"enabled": bool(key),
"key_configured": bool(key),
"model": "gpt-4o",
"fast_model": "gpt-4o-mini",
}
@router.post("/analyze-speech")
def analyze_speech_endpoint(req: SpeechRequest):
require_ai()
return analyze_speech(req.text, req.speaker)
@router.post("/analyze-news")
def analyze_news_endpoint(req: NewsAnalyzeRequest):
require_ai()
return analyze_news_item(req.title, req.summary)
@router.post("/evaluate-pattern")
def evaluate_pattern_endpoint(req: PatternEvalRequest):
require_ai()
return evaluate_pattern(req.pattern)
@router.post("/suggest-pattern")
def suggest_pattern_endpoint(req: PatternSuggestRequest):
require_ai()
return suggest_pattern_from_context(req.context)
@router.get("/top-ideas")
def top_ideas():
require_ai()
from routers.geopolitical import _news_cache
news = _news_cache.get("data") or fetch_geo_news()
matches = match_patterns(news)
geo_score = compute_geo_risk_score(news)
ideas = rank_trade_ideas(matches, geo_score, news, {})
return {"ideas": ideas, "count": len(ideas)}
@router.post("/score-patterns")
def score_patterns(req: ScorePatternsRequest):
"""Score all patterns with rich context (news + prices + IV) via GPT-4o."""
require_ai()
# Load config defaults
cfg = get_analysis_config()
top_n = req.top_n or cfg.get("top_n", 10)
category_filter = req.category_filter or cfg.get("category_filter", "all")
template = req.template or cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE
# Gather context
from routers.geopolitical import _news_cache
news = _news_cache.get("data") or fetch_geo_news()
# AI-rescore news: get accurate impact magnitudes + directional signals per asset class
# This enables contra-signal detection in pattern scoring (e.g. peace deal → oil bearish)
news = ai_score_news_batch(news)
_news_cache["data"] = news # propagate AI-enriched scores back to news feed
geo_score = compute_geo_risk_score(news)
quotes = get_all_quotes()
# Macro regime context (use cached value from /api/market/macro-regime if available)
from routers.market_data import _macro_cache
macro_regime = _macro_cache.get("data")
if not macro_regime:
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
gauges = get_macro_gauges()
macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)}
# All patterns from DB (builtin-seeded + custom)
all_patterns = get_custom_patterns()
# Score all active patterns
scored = score_patterns_with_context(
patterns=all_patterns,
recent_news=news,
quotes_by_class=quotes,
geo_score=geo_score,
template=template,
top_n=len(all_patterns),
category_filter=None,
macro_regime=macro_regime,
)
# Persist scores for later retrieval
run_id = save_pattern_scores(scored, meta={"geo_score": geo_score.get("score"), "total": len(scored)})
# Journal de Bord — log geo alert + trade entry prices at this moment
top_patterns_for_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=int(geo_score.get("score") or 0),
top_patterns=top_patterns_for_log,
news_count=len(news),
run_id=run_id,
)
log_trade_entries(run_id=run_id, scored_patterns=scored, quotes=quotes)
return {
"scored_patterns": scored,
"count": len(scored),
"top_n": top_n,
"category_filter": category_filter,
"geo_score": geo_score.get("score"),
}
@router.get("/last-scores")
def last_scores():
"""Return last persisted AI pattern scores with inter-run score deltas."""
data = get_pattern_scores()
deltas = get_score_deltas()
scored = data.get("scores", [])
for sp in scored:
pid = sp.get("pattern_id", "")
sp["score_trend"] = deltas.get(pid) # None = first run, int = change vs previous run
return {
"scored_patterns": scored,
"scored_at": data.get("scored_at"),
"meta": data.get("meta", {}),
"count": len(scored),
}
@router.get("/pattern-similarity")
def pattern_similarity():
"""Return pairs of patterns with high keyword overlap (Jaccard >= 0.25)."""
patterns = get_custom_patterns()
pairs = compute_pattern_similarity(patterns, threshold=0.25)
return {"pairs": pairs, "count": len(pairs)}
@router.post("/suggest-new-patterns")
def suggest_new_patterns():
"""Ask GPT-4o to propose new patterns from current geo/market context (no text input needed)."""
require_ai()
from routers.geopolitical import _news_cache
news = _news_cache.get("data") or fetch_geo_news()
quotes = get_all_quotes()
from services.data_fetcher import get_economic_calendar
calendar = get_economic_calendar()
# Macro regime context
from routers.market_data import _macro_cache
macro_regime = _macro_cache.get("data")
if not macro_regime:
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
gauges = get_macro_gauges()
macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)}
# Geo risk score for additional context
geo_score = compute_geo_risk_score(news)
patterns = suggest_patterns_from_market_context(news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score)
return {"suggested_patterns": patterns, "count": len(patterns)}
@router.get("/analysis-template")
def get_template():
cfg = get_analysis_config()
return {
"template": cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE,
"top_n": cfg.get("top_n", 10),
"category_filter": cfg.get("category_filter", "all"),
}
class MacroNarrationRequest(BaseModel):
macro_regime: Dict[str, Any]
@router.post("/macro-narration")
def macro_narration(req: MacroNarrationRequest):
"""Ask GPT-4o to narrate the current macro regime for the MacroRegime page."""
require_ai()
sc = req.macro_regime.get("scenarios", {})
gauges = req.macro_regime.get("gauges", {})
dom = sc.get("dominant", "incertain")
scores = sc.get("scores", {})
reasons = sc.get("reasons", {})
def gv(key: str) -> str:
v = gauges.get(key, {}).get("value")
return str(round(v, 3)) 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"
user = f"""Tu es un stratège macro senior utilisant un framework à 3 axes : Inflation / Croissance / Liquidité.
Scénario dominant: {dom.upper()}
Scores des 8 scénarios: {json.dumps(scores, ensure_ascii=False)}
Raisons du scénario dominant: {json.dumps(reasons.get(dom, []), ensure_ascii=False)}
AXE INFLATION (énergie + taux réels):
- Brent (var J-1): {gc('brent')}
- Gaz naturel (var J-1): {gc('ng')}
- TIPS ETF (var J-1): {gc('tips')}
AXE CROISSANCE (cycle réel):
- Cuivre (var J-1): {gc('copper')}"Dr Copper"
- S&P vs 200j MA: {gv('spx_vs_200d')}%
- Russell vs S&P (breadth): {gv('iwm_spx_ratio')} pts%
- Industriels XLI (proxy ISM, var J-1): {gc('xli')}
AXE LIQUIDITÉ / STRESS (conditions financières):
- VIX: {gv('vix')}
- Pente 10Y3M: {gv('slope_10y3m')}% (négatif = récession probable)
- HYG spreads HY (var J-1): {gc('hyg')}
- LQD spreads IG (var J-1): {gc('lqd')}
- Dollar DXY (var J-1): {gc('dxy')}
SIGNAUX DÉRIVÉS:
- Ratio Or/Cuivre: {gv('gold_copper_ratio')} (>700 = peur, <500 = expansion)
- Or (var J-1): {gc('gold')}
- IEF Trésor (var J-1): {gc('ief')}
Donne une analyse narrative COURTE (5-7 phrases) en français pour un trader options:
1. Confirme le régime dominant et ses 2-3 signaux les plus forts
2. Identifie 1-2 compteurs en contradiction ou tension (signal ambigu)
3. Évalue si la LIQUIDITÉ confirme ou contredit le régime inflation/croissance
4. Cite les 2-3 classes d'actifs les plus favorisées/défavorisées
5. Donne 1 biais tactique concret options pour les prochains jours
Réponds UNIQUEMENT en JSON: {{"narration": "<ton texte 5-7 phrases>"}}"""
result = _chat(
"Tu es un stratège macro senior. Analyse concise et actionnable pour traders options. JSON uniquement.",
user,
model="gpt-4o",
json_mode=True,
max_tokens=600,
)
if not result:
return {"narration": "IA non disponible — vérifier la clé OpenAI."}
return {"narration": result.get("narration", "")}

126
backend/routers/backtest.py Normal file
View File

@@ -0,0 +1,126 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
import yfinance as yf
import numpy as np
import pandas as pd
from datetime import datetime
from services.options_pricer import black_scholes
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
class BacktestRequest(BaseModel):
symbol: str
start_date: str
end_date: str
strategy: str # "long_call" | "long_put" | "bull_call_spread" | "bear_put_spread" | "straddle"
strike_offset_pct: float = 0.05 # e.g. 5% OTM
expiry_days: int = 90
capital: float = 1000.0
geo_filter: Optional[str] = None # optional pattern id to filter
@router.post("/run")
def run_backtest(req: BacktestRequest):
try:
ticker = yf.Ticker(req.symbol)
hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d")
if hist.empty or len(hist) < 20:
return {"error": "Insufficient data for the period"}
hist = hist.reset_index()
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
trades = []
equity = [req.capital]
capital = req.capital
r = 0.05
T_open = req.expiry_days / 365
step = max(1, req.expiry_days // 3)
for i in range(0, len(hist) - req.expiry_days, step):
row = hist.iloc[i]
S = float(row["Close"])
date_str = str(row["Date"])[:10]
sigma_window = returns.iloc[max(0, i - 30):i]
if len(sigma_window) < 5:
continue
sigma = float(sigma_window.std() * np.sqrt(252))
if sigma < 0.01:
sigma = 0.20
if req.strategy in ["long_call", "bull_call_spread"]:
K = S * (1 + req.strike_offset_pct)
else:
K = S * (1 - req.strike_offset_pct)
result = black_scholes(S, K, T_open, r, sigma, "call" if "call" in req.strategy else "put")
premium = result["price"]
contracts = max(1, int((capital * 0.1) / (premium * 100)))
cost = contracts * premium * 100
expiry_idx = min(i + req.expiry_days, len(hist) - 1)
S_expiry = float(hist.iloc[expiry_idx]["Close"])
date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10]
if req.strategy in ["long_call", "bull_call_spread"]:
intrinsic = max(0, S_expiry - K)
else:
intrinsic = max(0, K - S_expiry)
pnl = (intrinsic - premium) * contracts * 100
capital += pnl
equity.append(round(capital, 2))
trades.append({
"entry_date": date_str,
"exit_date": date_expiry,
"strategy": req.strategy,
"S_entry": round(S, 2),
"K": round(K, 2),
"premium": round(premium, 4),
"contracts": contracts,
"cost": round(cost, 2),
"S_expiry": round(S_expiry, 2),
"intrinsic": round(intrinsic, 4),
"pnl": round(pnl, 2),
"capital": round(capital, 2),
})
if not trades:
return {"error": "No trades generated"}
wins = [t for t in trades if t["pnl"] > 0]
losses = [t for t in trades if t["pnl"] <= 0]
total_pnl = sum(t["pnl"] for t in trades)
gross_profit = sum(t["pnl"] for t in wins) if wins else 0
gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 1
eq = np.array(equity)
peak = np.maximum.accumulate(eq)
drawdown = (eq - peak) / peak
max_dd = float(drawdown.min()) * 100
equity_curve = [{"index": i, "capital": v} for i, v in enumerate(equity)]
return {
"symbol": req.symbol,
"strategy": req.strategy,
"period": f"{req.start_date}{req.end_date}",
"total_trades": len(trades),
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / len(trades) * 100, 1) if trades else 0,
"total_pnl": round(total_pnl, 2),
"total_return_pct": round((capital - req.capital) / req.capital * 100, 2),
"max_drawdown_pct": round(max_dd, 2),
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss else 0,
"final_capital": round(capital, 2),
"equity_curve": equity_curve,
"trades": trades[-20:],
}
except Exception as e:
return {"error": str(e)}

91
backend/routers/config.py Normal file
View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Dict, Any, Optional
import os
from services.database import get_all_config, set_config, get_sources, update_sources, get_analysis_config, save_analysis_config
router = APIRouter(prefix="/api/config", tags=["config"])
class ApiKeysRequest(BaseModel):
openai_api_key: Optional[str] = None
newsapi_key: Optional[str] = None
eia_api_key: Optional[str] = None
fred_api_key: Optional[str] = None
class SourcesRequest(BaseModel):
sources: Dict[str, Any]
class SettingsRequest(BaseModel):
ai_enabled: Optional[str] = None
ai_auto_rescore: Optional[str] = None
class AnalysisConfigRequest(BaseModel):
top_n: Optional[int] = None
category_filter: Optional[str] = None
template: Optional[str] = None
@router.get("/")
def get_config_all():
return get_all_config()
@router.get("/sources")
def list_sources():
return get_sources()
@router.put("/sources")
def update_sources_endpoint(req: SourcesRequest):
update_sources(req.sources)
return {"status": "ok", "updated": len(req.sources)}
@router.put("/api-keys")
def update_api_keys(req: ApiKeysRequest):
updated = []
if req.openai_api_key is not None:
set_config("openai_api_key", req.openai_api_key)
os.environ["OPENAI_API_KEY"] = req.openai_api_key
updated.append("openai")
if req.newsapi_key is not None:
set_config("newsapi_key", req.newsapi_key)
updated.append("newsapi")
if req.eia_api_key is not None:
set_config("eia_api_key", req.eia_api_key)
updated.append("eia")
if req.fred_api_key is not None:
set_config("fred_api_key", req.fred_api_key)
updated.append("fred")
return {"status": "ok", "updated": updated}
@router.put("/settings")
def update_settings(req: SettingsRequest):
if req.ai_enabled is not None:
set_config("ai_enabled", req.ai_enabled)
if req.ai_auto_rescore is not None:
set_config("ai_auto_rescore", req.ai_auto_rescore)
return {"status": "ok"}
@router.get("/analysis")
def get_analysis_config_endpoint():
return get_analysis_config()
@router.put("/analysis")
def save_analysis_config_endpoint(req: AnalysisConfigRequest):
current = get_analysis_config()
if req.top_n is not None:
current["top_n"] = req.top_n
if req.category_filter is not None:
current["category_filter"] = req.category_filter
if req.template is not None:
current["template"] = req.template
save_analysis_config(current)
return {"status": "ok", "config": current}

74
backend/routers/cycle.py Normal file
View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
@router.get("/status")
def cycle_status():
"""Current scheduler state + last cycle summary."""
return get_status()
@router.get("/history")
def cycle_history(limit: int = 20):
"""List recent cycle runs."""
runs = get_cycle_runs(limit=limit)
# Parse commentary JSON for frontend
import json
for r in runs:
if r.get("commentary"):
try:
r["commentary_parsed"] = json.loads(r["commentary"])
except Exception:
r["commentary_parsed"] = {"commentary": r["commentary"]}
return {"runs": runs, "count": len(runs)}
@router.post("/trigger")
def trigger_cycle():
"""Manually trigger one cycle immediately (non-blocking)."""
key = get_config("openai_api_key") or ""
if not key:
raise HTTPException(400, "Clé OpenAI non configurée")
trigger_manual()
return {"triggered": True, "message": "Cycle lancé en arrière-plan"}
class CycleConfigRequest(BaseModel):
enabled: Optional[bool] = None
interval_hours: Optional[float] = None
similarity_threshold: Optional[float] = None
min_ev_threshold: Optional[float] = None
min_score_threshold: Optional[int] = None
@router.post("/config")
def update_cycle_config(req: CycleConfigRequest):
"""Update auto-cycle + filter configuration and restart scheduler."""
if req.enabled is not None:
set_config("auto_cycle_enabled", "true" if req.enabled else "false")
if req.interval_hours is not None:
if not (0.5 <= req.interval_hours <= 24):
raise HTTPException(400, "interval_hours must be between 0.5 and 24")
set_config("auto_cycle_hours", str(req.interval_hours))
if req.similarity_threshold is not None:
if not (0.0 <= req.similarity_threshold <= 1.0):
raise HTTPException(400, "similarity_threshold must be between 0 and 1")
set_config("auto_cycle_similarity_threshold", str(req.similarity_threshold))
if req.min_ev_threshold is not None:
if not (0.0 <= req.min_ev_threshold <= 1.0):
raise HTTPException(400, "min_ev_threshold must be between 0 and 1")
set_config("min_ev_threshold", str(req.min_ev_threshold))
if req.min_score_threshold is not None:
if not (0 <= req.min_score_threshold <= 100):
raise HTTPException(400, "min_score_threshold must be between 0 and 100")
set_config("min_score_threshold", str(req.min_score_threshold))
# Restart scheduler to pick up changes
restart_scheduler()
return get_status()

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Query
from typing import Optional, List
from services.data_fetcher import fetch_geo_news, get_economic_calendar
from services.geo_analyzer import compute_geo_risk_score, match_patterns, generate_trade_ideas, compute_pattern_relevance
from services.database import get_custom_patterns
from datetime import datetime, timezone, timedelta
router = APIRouter(prefix="/api/geo", tags=["geopolitical"])
_news_cache: dict = {"data": [], "ts": 0}
@router.get("/news")
def geo_news(force_refresh: bool = False):
import time
now = time.time()
if not force_refresh and _news_cache["data"] and (now - _news_cache["ts"]) < 3600:
return _news_cache["data"]
news = fetch_geo_news()
_news_cache["data"] = news
_news_cache["ts"] = now
return news
@router.get("/risk-score")
def risk_score():
news = _news_cache["data"] or fetch_geo_news()
return compute_geo_risk_score(news)
@router.get("/pattern-matches")
def pattern_matches():
news = _news_cache["data"] or fetch_geo_news()
all_patterns = get_custom_patterns()
return match_patterns(news, patterns=all_patterns)
@router.get("/pattern-relevance")
def pattern_relevance(days: int = 2):
"""Return ALL active patterns with news-keyword relevance over the last N days."""
all_news = _news_cache["data"] or fetch_geo_news()
# Filter news to last N days
if days > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
recent: list = []
for n in all_news:
try:
from email.utils import parsedate_to_datetime
d = parsedate_to_datetime(str(n.get("date", "")))
if d >= cutoff:
recent.append(n)
except Exception:
recent.append(n)
news = recent if recent else all_news
else:
news = all_news
all_patterns = get_custom_patterns()
return compute_pattern_relevance(news, patterns=all_patterns)
@router.get("/trade-ideas")
def trade_ideas():
news = _news_cache["data"] or fetch_geo_news()
all_patterns = get_custom_patterns()
matches = match_patterns(news, patterns=all_patterns)
geo_score = compute_geo_risk_score(news)
return generate_trade_ideas(matches, geo_score)
@router.get("/calendar")
def calendar():
return get_economic_calendar()

112
backend/routers/journal.py Normal file
View File

@@ -0,0 +1,112 @@
from fastapi import APIRouter
from typing import Any, Dict, List
import math
from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices
def _sanitize(obj: Any) -> Any:
"""Replace NaN/Inf with None recursively for JSON compliance."""
if isinstance(obj, dict):
return {k: _sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize(v) for v in obj]
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
return None
return obj
router = APIRouter(prefix="/api/journal", tags=["journal"])
# Bearish strategies — P&L is inverted (profit when price falls)
_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"}
def _is_bearish(strategy: str) -> bool:
s = (strategy or "").lower()
return any(kw in s for kw in _BEARISH_KEYWORDS)
@router.get("/macro-history")
def macro_history(days: int = 15):
"""Macro regime snapshots for the last N days."""
return _sanitize({"history": get_macro_regime_history(days), "days": days})
@router.get("/geo-history")
def geo_history(days: int = 30):
"""Geo alert score history for the last N days."""
return {"history": get_geo_alert_history(days), "days": days}
@router.get("/trade-mtm")
def trade_mtm(days: int = 30):
"""
Mark-to-market for all logged trade suggestions.
Enriches with live prices via shared _fetch_live_prices utility.
"""
entries = get_trade_entry_prices(days)
tickers_needed = list({(e.get("underlying") or "").upper() for e in entries if e.get("underlying")})
current_prices = _fetch_live_prices(tickers_needed, timeout=20)
from datetime import date as _date
result: List[Dict[str, Any]] = []
for e in entries:
ticker = (e.get("underlying") or "").upper()
entry_price = e.get("entry_price")
current_price = current_prices.get(ticker)
pnl_pct = None
if entry_price and current_price and entry_price > 0:
raw_pnl = (current_price - entry_price) / entry_price * 100
pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2)
days_held = None
try:
days_held = (_date.today() - _date.fromisoformat(e["entry_date"])).days
except Exception:
pass
result.append({
**e,
"current_price": current_price,
"pnl_pct": pnl_pct,
"days_held": days_held,
"direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish",
})
return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)})
@router.delete("/reset")
def reset_journal():
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""
reset_journal_history()
return {"reset": True, "message": "Journal de bord réinitialisé"}
@router.get("/summary")
def journal_summary():
"""Quick stats for the Journal de Bord header."""
macro = get_macro_regime_history(15)
geo = get_geo_alert_history(30)
trades = get_trade_entry_prices(30)
# Detect regime transitions (consecutive different dominants)
transitions = []
for i in range(1, len(macro)):
if macro[i - 1]["dominant"] != macro[i]["dominant"]:
transitions.append({
"from": macro[i]["dominant"],
"to": macro[i - 1]["dominant"],
"at": macro[i - 1]["timestamp"],
})
return {
"macro_snapshots": len(macro),
"regime_transitions": transitions[:5],
"current_dominant": macro[0]["dominant"] if macro else None,
"geo_alerts": len(geo),
"avg_geo_score": round(sum(g["geo_score"] for g in geo) / len(geo), 1) if geo else None,
"max_geo_score": max((g["geo_score"] for g in geo), default=None),
"trade_entries_logged": len(trades),
}

View File

@@ -0,0 +1,309 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Any, Dict, List, Optional
import json
import os
from services.database import (
get_kb_entries, get_all_kb_entries, save_kb_entry, update_kb_entry_status,
get_latest_reasoning_state, get_reasoning_history, get_reasoning_state_by_id,
save_reasoning_state, list_ai_reports, get_mtm_trades_with_traces,
)
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries: List[Dict]):
"""Build the GPT-4o synthesis prompt from all accumulated data."""
now_str = __import__("datetime").datetime.utcnow().strftime("%Y-%m-%d %H:%M")
# Portfolio reports summary
reports_block = ""
for r in reports[:10]:
rpt = r.get("report") or {}
stats = r.get("stats") or {}
date = r.get("created_at", "")[:16]
headline = rpt.get("headline", "")
winners = rpt.get("winners_analysis", "")
losers = rpt.get("losers_analysis", "")
lessons = rpt.get("key_lessons", [])
blind = rpt.get("blind_spots", "")
next_p = rpt.get("next_cycle_priorities", "")
lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons)
reports_block += f"""
--- Rapport du {date} ---
Headline: {headline}
Stats: {stats}
Gagnants: {winners[:300]}
Perdants: {losers[:300]}
Leçons clés: {lessons_str[:400]}
Angles morts: {blind[:200]}
Priorités cycle suivant: {next_p[:200]}
"""
# Trade history
winners = [t for t in trades if (t.get("pnl_pct") or 0) > 0.5]
losers = [t for t in trades if (t.get("pnl_pct") or 0) < -0.5]
neutral = [t for t in trades if t not in winners and t not in losers]
def trade_line(t):
return (f"{t.get('underlying','?')} {t.get('strategy','?')} "
f"P&L={t.get('pnl_pct',0):.2f}% score={t.get('latest_score','?')} "
f"regime={t.get('macro_regime','?')}")
trades_block = f"""
Gagnants ({len(winners)}): {' | '.join(trade_line(t) for t in winners[:8])}
Perdants ({len(losers)}): {' | '.join(trade_line(t) for t in losers[:8])}
Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort
"""
# Existing KB
kb_block = ""
if kb_entries:
by_cat: Dict[str, List] = {}
for e in kb_entries:
cat = e.get("category", "général")
by_cat.setdefault(cat, []).append(e)
for cat, items in by_cat.items():
kb_block += f"\n[{cat.upper()}]\n"
for item in items[:5]:
kb_block += f" - [{item['confidence']}%] {item['title']}: {item['content'][:150]}\n"
system = """Tu es l'intelligence analytique centrale d'un système de trading d'options géopolitiques.
Tu dois synthétiser TOUT l'historique disponible pour produire un document de raisonnement évolutif.
Ce document sera utilisé comme contexte enrichi pour tous les prochains cycles d'analyse.
Réponds UNIQUEMENT en JSON valide selon le schéma spécifié."""
user = f"""Date: {now_str}
=== HISTORIQUE DES RAPPORTS DE PERFORMANCE ({len(reports)} rapports) ===
{reports_block}
=== HISTORIQUE DES TRADES ({len(trades)} trades) ===
{trades_block}
=== BASE DE CONNAISSANCES EXISTANTE ===
{kb_block if kb_block else "Aucune entrée existante — première synthèse."}
=== MISSION ===
Produis un JSON avec ces champs:
{{
"narrative": "Un texte narratif riche (500-800 mots) qui décrit l'état actuel du raisonnement du système, les patterns qui fonctionnent, les erreurs récurrentes, les corrélations géopolitiques/macro identifiées, les régimes qui favorisent nos stratégies, et les priorités d'amélioration. C'est le 'cerveau' du système.",
"regime_insights": [
{{"regime": "nom du régime macro", "observation": "ce qu'on sait de ce régime", "confidence": 0-100, "trade_count": N}}
],
"pattern_insights": [
{{"pattern": "nom du pattern", "observation": "performance et conditions", "confidence": 0-100, "win_rate_pct": 0-100}}
],
"macro_correlations": [
{{"trigger": "événement géopolitique/macro", "market_reaction": "réaction observée", "reliability": "haute/moyenne/faible"}}
],
"recurring_mistakes": [
{{"mistake": "description de l'erreur", "frequency": "souvent/parfois", "mitigation": "comment l'éviter"}}
],
"strengths": ["point fort 1", "point fort 2"],
"blind_spots": ["angle mort 1", "angle mort 2"],
"strategic_priorities": ["priorité 1", "priorité 2", "priorité 3"],
"risk_parameters": {{
"avoid_when": ["condition 1", "condition 2"],
"prefer_when": ["condition 1", "condition 2"]
}}
}}"""
return system, user
@router.get("/state")
def get_state():
"""Latest synthesized reasoning state."""
state = get_latest_reasoning_state()
return {"state": state}
@router.get("/history")
def get_history(limit: int = 10):
"""List of reasoning state versions."""
return {"history": get_reasoning_history(limit)}
@router.get("/history/{state_id}")
def get_state_version(state_id: int):
state = get_reasoning_state_by_id(state_id)
if not state:
raise HTTPException(404, "Version introuvable")
return {"state": state}
@router.get("/entries")
def list_entries(status: str = "all"):
if status == "all":
entries = get_all_kb_entries()
else:
entries = get_kb_entries(status)
by_cat: Dict[str, List] = {}
for e in entries:
by_cat.setdefault(e.get("category", "général"), []).append(e)
return {"entries": entries, "by_category": by_cat, "total": len(entries)}
class KbEntryIn(BaseModel):
category: str
title: str
content: str
confidence: int = 50
tags: str = ""
existing_id: Optional[int] = None
@router.post("/entries")
def add_entry(body: KbEntryIn):
entry_id = save_kb_entry(
category=body.category,
title=body.title,
content=body.content,
confidence=body.confidence,
tags=body.tags,
existing_id=body.existing_id,
)
return {"id": entry_id}
@router.patch("/entries/{entry_id}/status")
def patch_entry_status(entry_id: int, body: Dict[str, str]):
status = body.get("status", "active")
if status not in ("active", "tentative", "invalidated"):
raise HTTPException(400, "status must be active | tentative | invalidated")
update_kb_entry_status(entry_id, status)
return {"id": entry_id, "status": status}
@router.post("/synthesize")
async def synthesize():
"""Run GPT-4o synthesis over all historical data and save new reasoning state."""
ai_key = os.environ.get("OPENAI_API_KEY", "")
if not ai_key:
raise HTTPException(400, "OpenAI API key not configured")
import openai
client = openai.OpenAI(api_key=ai_key)
reports = list_ai_reports(limit=10)
mtm_data = get_mtm_trades_with_traces(days=90)
trades = mtm_data.get("all_trades", []) if isinstance(mtm_data, dict) else []
kb_entries = get_all_kb_entries()
system_msg, user_msg = _build_synthesis_prompt(reports, trades, kb_entries)
try:
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg},
],
temperature=0.3,
max_tokens=2500,
response_format={"type": "json_object"},
)
raw = resp.choices[0].message.content or "{}"
synthesis = json.loads(raw)
except Exception as e:
raise HTTPException(500, f"GPT-4o error: {e}")
narrative = synthesis.pop("narrative", "Synthèse non disponible.")
state_id = save_reasoning_state(
narrative=narrative,
synthesis=synthesis,
sources_count=len(reports) + len(trades),
reports_used=len(reports),
trades_analyzed=len(trades),
)
# Persist KB entries from synthesis
for regime in synthesis.get("regime_insights", []):
if regime.get("observation"):
save_kb_entry(
category="régimes",
title=f"Régime: {regime.get('regime', '?')}",
content=regime.get("observation", ""),
confidence=regime.get("confidence", 50),
tags="auto-synth",
)
for pattern in synthesis.get("pattern_insights", []):
if pattern.get("observation"):
save_kb_entry(
category="patterns",
title=f"Pattern: {pattern.get('pattern', '?')}",
content=pattern.get("observation", ""),
confidence=pattern.get("confidence", 50),
tags="auto-synth",
)
for mistake in synthesis.get("recurring_mistakes", []):
if mistake.get("mistake"):
save_kb_entry(
category="erreurs",
title=mistake.get("mistake", "")[:80],
content=f"{mistake.get('mistake','')}{mistake.get('mitigation','')}",
confidence=70,
tags="auto-synth",
)
return {
"state_id": state_id,
"narrative_preview": narrative[:200],
"kb_entries_added": (
len(synthesis.get("regime_insights", [])) +
len(synthesis.get("pattern_insights", [])) +
len(synthesis.get("recurring_mistakes", []))
),
"sources": {"reports": len(reports), "trades": len(trades)},
}
@router.get("/context-for-cycle")
def context_for_cycle():
"""Compact context to inject into AI cycle prompts."""
state = get_latest_reasoning_state()
if not state:
return {"available": False, "context": ""}
synthesis = state.get("synthesis") or {}
narrative = state.get("narrative", "")
priorities = synthesis.get("strategic_priorities", [])
avoid = synthesis.get("risk_parameters", {}).get("avoid_when", [])
prefer = synthesis.get("risk_parameters", {}).get("prefer_when", [])
mistakes = [m.get("mistake", "") for m in synthesis.get("recurring_mistakes", [])[:3]]
strengths = synthesis.get("strengths", [])
context = f"""=== SUPER CONTEXTE — BASE DE RAISONNEMENT ({state.get('created_at','')[:16]}) ===
{narrative[:600]}
PRIORITÉS STRATÉGIQUES: {' | '.join(priorities[:3])}
ERREURS À ÉVITER: {' | '.join(mistakes)}
PRÉFÉRER QUAND: {' | '.join(prefer[:2])}
ÉVITER QUAND: {' | '.join(avoid[:2])}
FORCES: {' | '.join(strengths[:2])}
"""
return {
"available": True,
"context": context,
"version": state.get("version"),
"created_at": state.get("created_at"),
"sources": {
"reports_used": state.get("reports_used"),
"trades_analyzed": state.get("trades_analyzed"),
},
}

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Query
from typing import Optional, Dict, Any
from datetime import datetime
from services.data_fetcher import get_all_quotes, get_historical, compute_historical_iv, WATCHLIST
_macro_cache: Dict[str, Any] = {}
router = APIRouter(prefix="/api/market", tags=["market"])
@router.get("/quotes")
def quotes_all():
return get_all_quotes()
@router.get("/quote/{symbol}")
def quote_single(symbol: str):
from services.data_fetcher import get_quote
return get_quote(symbol)
@router.get("/history/{symbol}")
def history(
symbol: str,
period: str = Query("1y", description="1d,5d,1mo,3mo,6mo,1y,2y,5y"),
interval: str = Query("1d", description="1m,5m,15m,1h,1d,1wk,1mo"),
):
return get_historical(symbol, period, interval)
@router.get("/iv/{symbol}")
def implied_vol(symbol: str, window: int = 30):
iv = compute_historical_iv(symbol, window)
return {"symbol": symbol, "iv": iv, "window": window}
@router.get("/watchlist")
def watchlist():
return WATCHLIST
@router.get("/macro-regime")
def macro_regime(force: bool = False):
"""Macro gauge values + 5-scenario scoring. Cached 15 min."""
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
now = datetime.utcnow()
if not force and _macro_cache.get("data") and _macro_cache.get("ts"):
age = (now - _macro_cache["ts"]).total_seconds()
if age < 900:
return {**_macro_cache["data"], "cached": True, "cache_age_sec": int(age)}
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
result: Dict[str, Any] = {
"gauges": gauges,
"scenarios": scenarios,
"fetched_at": now.isoformat(),
"cached": False,
}
_macro_cache["data"] = result
_macro_cache["ts"] = now
if force:
# Build a compact gauge summary (key → value + change_pct) for the journal
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
}
from services.database import log_macro_regime
log_macro_regime(
dominant=scenarios.get("dominant", "incertain"),
scores=scenarios.get("scores", {}),
reasons=scenarios.get("reasons", {}),
gauges_summary=gauges_summary,
)
return result

111
backend/routers/options.py Normal file
View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, Query
from typing import Optional
from services.options_pricer import (
black_scholes, compute_pnl_curve, bull_call_spread,
bear_put_spread, long_straddle, implied_vol_surface
)
from services.data_fetcher import get_quote, compute_historical_iv
router = APIRouter(prefix="/api/options", tags=["options"])
@router.get("/price")
def price_option(
symbol: str = Query(...),
strike: float = Query(...),
expiry_days: int = Query(90),
option_type: str = Query("call"),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else strike
sigma = compute_historical_iv(symbol)
T = expiry_days / 365
result = black_scholes(S, strike, T, rate, sigma, option_type)
result["underlying_price"] = S
result["sigma"] = sigma
return result
@router.get("/pnl-curve")
def pnl_curve(
symbol: str = Query(...),
strike: float = Query(...),
expiry_days: int = Query(90),
option_type: str = Query("call"),
quantity: int = Query(1),
premium_paid: float = Query(...),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else strike
sigma = compute_historical_iv(symbol)
T = expiry_days / 365
return compute_pnl_curve(S, strike, T, rate, sigma, option_type, quantity, premium_paid)
@router.get("/strategy/bull-call-spread")
def bull_spread(
symbol: str = Query(...),
strike_low: float = Query(...),
strike_high: float = Query(...),
expiry_days: int = Query(90),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else strike_low
sigma = compute_historical_iv(symbol)
T = expiry_days / 365
result = bull_call_spread(S, strike_low, strike_high, T, rate, sigma)
result["underlying_price"] = S
result["sigma"] = sigma
return result
@router.get("/strategy/bear-put-spread")
def bear_spread(
symbol: str = Query(...),
strike_high: float = Query(...),
strike_low: float = Query(...),
expiry_days: int = Query(90),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else strike_high
sigma = compute_historical_iv(symbol)
T = expiry_days / 365
result = bear_put_spread(S, strike_high, strike_low, T, rate, sigma)
result["underlying_price"] = S
result["sigma"] = sigma
return result
@router.get("/strategy/straddle")
def straddle(
symbol: str = Query(...),
strike: float = Query(...),
expiry_days: int = Query(90),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else strike
sigma = compute_historical_iv(symbol)
T = expiry_days / 365
result = long_straddle(S, strike, T, rate, sigma)
result["underlying_price"] = S
result["sigma"] = sigma
return result
@router.get("/iv-surface")
def iv_surface(
symbol: str = Query(...),
rate: float = Query(0.05),
):
q = get_quote(symbol)
S = q["price"] if q and "price" in q else 100.0
sigma = compute_historical_iv(symbol)
strikes_pct = [0.80, 0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15, 1.20]
expiries = [7, 14, 30, 60, 90, 180]
surface = implied_vol_surface(S, strikes_pct, expiries, rate, sigma)
return {"symbol": symbol, "spot": S, "surface": surface}

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from services.database import (
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active
)
router = APIRouter(prefix="/api/patterns", tags=["patterns"])
class PatternRequest(BaseModel):
id: Optional[str] = None
name: str
description: str
triggers: List[str]
keywords: List[str]
historical_instances: Optional[List[Dict[str, Any]]] = []
suggested_trades: Optional[List[Dict[str, Any]]] = []
asset_class: str
expected_move_pct: float
probability: float
horizon_days: int
ai_quality_score: Optional[int] = None
ai_evaluation: Optional[Dict[str, Any]] = None
source: Optional[str] = "custom"
@router.get("/all")
def list_all():
"""Return all active patterns from DB (builtin + custom)."""
return get_custom_patterns()
@router.get("/builtin")
def list_builtin():
return [p for p in get_custom_patterns() if p.get("source") == "builtin"]
@router.get("/custom")
def list_custom():
return [p for p in get_custom_patterns() if p.get("source") != "builtin"]
@router.post("/custom")
def create_pattern(req: PatternRequest):
data = req.model_dump()
data["source"] = "custom"
pat_id = save_custom_pattern(data)
return {"id": pat_id, "status": "saved"}
@router.put("/custom/{pat_id}")
def update_pattern(pat_id: str, req: PatternRequest):
data = req.model_dump()
data["id"] = pat_id
save_custom_pattern(data)
return {"id": pat_id, "status": "updated"}
@router.delete("/custom/{pat_id}")
def delete_pattern(pat_id: str):
delete_custom_pattern(pat_id)
return {"status": "deleted"}
@router.put("/toggle/{pat_id}")
def toggle_pattern(pat_id: str):
"""Enable or disable a pattern (works for builtin and custom)."""
new_state = toggle_pattern_active(pat_id)
return {"id": pat_id, "is_active": new_state}

View File

@@ -0,0 +1,298 @@
from fastapi import APIRouter, HTTPException
import traceback as tb_mod
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from datetime import datetime, date, timedelta
from services.database import (
add_position, get_positions, close_position,
update_position_notes, compute_ib_fees
)
from services.data_fetcher import get_quote
from services.options_pricer import black_scholes
from services.data_fetcher import compute_historical_iv
import math
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
class AddPositionRequest(BaseModel):
title: str
underlying: str
strategy: str
asset_class: Optional[str] = "indices"
entry_date: Optional[str] = None
expiry_date: Optional[str] = None
expiry_days: Optional[int] = 90
legs: List[Dict[str, Any]]
capital_invested: float
entry_underlying_price: Optional[float] = None
geo_trigger: Optional[str] = ""
rationale: Optional[str] = ""
notes: Optional[str] = ""
class ClosePositionRequest(BaseModel):
close_value: float
class NotesRequest(BaseModel):
notes: str
def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
"""Compute current value of a position using live prices + Black-Scholes."""
underlying = pos["underlying"]
q = get_quote(underlying)
S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0
legs = pos.get("legs", [])
if not legs:
return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0,
"current_underlying": S, "greeks": {}}
# Compute days to expiry
expiry_date = pos.get("expiry_date") or ""
if expiry_date:
try:
exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
T = max(0.001, (exp - date.today()).days / 365)
except Exception:
T = max(0.001, (pos.get("expiry_days", 90) - 30) / 365)
else:
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
days_elapsed = (date.today() - entry).days
T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365)
from services.data_fetcher import compute_historical_iv as get_iv
sigma = get_iv(underlying)
r = 0.05
total_current_value = 0.0
total_entry_value = 0.0
net_delta = 0.0
net_theta = 0.0
net_vega = 0.0
entry_from_legs = False
# T at entry (full original duration) — used to reprice legs at entry if premium_paid not stored
S_entry = float(pos.get("entry_underlying_price") or S)
entry_date_str = pos.get("entry_date", "")
if expiry_date and entry_date_str:
try:
exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
entry_dt = datetime.strptime(entry_date_str[:10], "%Y-%m-%d").date()
T_entry = max(0.001, (exp_dt - entry_dt).days / 365)
except Exception:
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
else:
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
for leg in legs:
K = leg.get("strike") or S
K_entry = leg.get("strike") or S_entry
opt_type = leg.get("option_type", "call")
qty = leg.get("quantity", 1)
sign = 1 if leg.get("position", "long") == "long" else -1
bs = black_scholes(S, K, T, r, sigma, opt_type)
leg_value = bs["price"] * qty * 100 * sign
total_current_value += leg_value
net_delta += bs["delta"] * qty * sign
net_theta += bs["theta"] * qty * sign
net_vega += bs["vega"] * qty * sign
if leg.get("premium_paid") is not None:
total_entry_value += leg["premium_paid"] * qty * 100 * sign
entry_from_legs = True
else:
# No stored premium: reprice at entry conditions for a consistent PnL baseline
bs_entry = black_scholes(S_entry, K_entry, T_entry, r, sigma, opt_type)
total_entry_value += bs_entry["price"] * qty * 100 * sign
# Entry reference: always from legs (either stored premium or BS at entry conditions)
ib_entry = pos.get("ib_fees_entry", 0)
entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"]
pnl = total_current_value - entry_ref - ib_entry
pnl_pct = (pnl / max(abs(entry_ref), 1) * 100) if entry_ref else 0
return {
**pos,
"current_underlying": round(S, 4),
"current_value": round(total_current_value, 2),
"entry_ref": round(entry_ref, 2),
"pnl": round(pnl, 2),
"pnl_pct": round(pnl_pct, 2),
"days_remaining": max(0, int(T * 365)),
"sigma_used": round(sigma, 4),
"greeks": {
"net_delta": round(net_delta, 4),
"net_theta": round(net_theta, 4),
"net_vega": round(net_vega, 4),
},
}
@router.get("/positions")
def list_positions(status: str = "open"):
positions = get_positions(status)
if status == "open":
return [mark_to_market(p) for p in positions]
return positions
@router.get("/summary")
def portfolio_summary():
open_pos = get_positions("open")
closed_pos = get_positions("closed")
marked = [mark_to_market(p) for p in open_pos]
total_invested = sum(p["capital_invested"] for p in open_pos)
total_current = sum(p.get("current_value", p["capital_invested"]) for p in marked)
total_pnl = sum(p.get("pnl", 0) for p in marked)
total_fees = sum(p.get("ib_fees_entry", 0) for p in open_pos)
realized_pnl = 0.0
for p in closed_pos:
if p.get("close_value") is not None:
realized_pnl += (p["close_value"] - p["capital_invested"]
- p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0))
return {
"open_positions": len(open_pos),
"closed_positions": len(closed_pos),
"total_invested": round(total_invested, 2),
"total_current_value": round(total_current, 2),
"unrealized_pnl": round(total_pnl, 2),
"unrealized_pnl_pct": round(total_pnl / total_invested * 100, 2) if total_invested else 0,
"realized_pnl": round(realized_pnl, 2),
"total_fees_paid": round(total_fees, 2),
"net_pnl": round(total_pnl + realized_pnl, 2),
}
TICKER_HINTS: Dict[str, str] = {
# Indices
"s&p 500": "^GSPC", "sp500": "^GSPC", "s&p500": "^GSPC", "spx": "^GSPC",
"nasdaq": "^NDX", "nasdaq 100": "^NDX", "ndx": "^NDX", "qqq": "QQQ",
"dow jones": "^DJI", "djia": "^DJI",
"vix": "^VIX",
"euro stoxx": "^STOXX50E", "stoxx50": "^STOXX50E",
"nikkei": "^N225",
# Metals
"gold": "GC=F", "or": "GC=F", "gold futures": "GC=F",
"silver": "SI=F", "argent": "SI=F",
"copper": "HG=F", "cuivre": "HG=F",
"platinum": "PL=F", "platine": "PL=F",
# Energy
"wti": "CL=F", "crude oil": "CL=F", "pétrole": "CL=F", "crude": "CL=F",
"brent": "BZ=F",
"natural gas": "NG=F", "gaz naturel": "NG=F", "natgas": "NG=F",
# Agriculture
"corn": "ZC=F", "maïs": "ZC=F",
"wheat": "ZW=F", "blé": "ZW=F",
"soybean": "ZS=F", "soja": "ZS=F",
"coffee": "KC=F", "café": "KC=F",
# Forex — yfinance format: {BASE}{QUOTE}=X
"eurusd": "EURUSD=X", "eur/usd": "EURUSD=X", "euro": "EURUSD=X",
"usdjpy": "USDJPY=X", "usd/jpy": "USDJPY=X",
"gbpusd": "GBPUSD=X", "gbp/usd": "GBPUSD=X",
"usdchf": "USDCHF=X", "usd/chf": "USDCHF=X",
"usdcnh": "USDCNH=X", "usd/cnh": "USDCNH=X",
"usdcny": "USDCNY=X", "usd/cny": "USDCNY=X", "cny": "USDCNY=X",
"usdrub": "USDRUB=X", "usd/rub": "USDRUB=X",
"usdtry": "USDTRY=X", "usd/try": "USDTRY=X",
"usdmxn": "USDMXN=X", "usd/mxn": "USDMXN=X",
"audusd": "AUDUSD=X", "aud/usd": "AUDUSD=X",
"dxy": "DX-Y.NYB", "dollar index": "DX-Y.NYB",
}
@router.post("/add")
def add_pos(req: AddPositionRequest):
import traceback
try:
data = req.model_dump()
# Normalize common names to yfinance tickers
raw = req.underlying.strip()
normalized = TICKER_HINTS.get(raw.lower(), raw)
data["underlying"] = normalized
# Fetch live underlying price
q = get_quote(normalized)
S = q.get("price") if q else None
if not S:
hint = TICKER_HINTS.get(raw.lower())
tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI)."
raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance.{tip}")
if not data.get("entry_underlying_price"):
data["entry_underlying_price"] = S
# Auto-fill entry date and expiry
if not data.get("entry_date"):
data["entry_date"] = datetime.utcnow().isoformat()[:10]
if not data.get("expiry_date") and data.get("expiry_days"):
data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat()
# Auto-price legs that have no premium_paid using BS at entry
# This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget)
if S and data.get("legs"):
sigma = compute_historical_iv(req.underlying)
T = max(0.001, data.get("expiry_days", 90) / 365)
r = 0.05
for leg in data["legs"]:
if leg.get("premium_paid") is None:
K = leg.get("strike") or S # ATM if no explicit strike
if not leg.get("strike"):
leg["strike"] = round(S, 2)
opt_type = leg.get("option_type", "call")
bs = black_scholes(S, K, T, r, sigma, opt_type)
leg["premium_paid"] = round(bs["price"], 4)
pos_id = add_position(data)
return {"id": pos_id, "status": "added"}
except HTTPException:
raise
except Exception as e:
tb = traceback.format_exc()
raise HTTPException(status_code=500, detail=f"{str(e)}\n\n{tb}")
@router.post("/close/{pos_id}")
def close_pos(pos_id: str, req: ClosePositionRequest):
return close_position(pos_id, req.close_value)
@router.delete("/{pos_id}")
def delete_pos(pos_id: str):
from services.database import get_conn
conn = get_conn()
conn.execute("DELETE FROM portfolio WHERE id=?", (pos_id,))
conn.commit()
conn.close()
return {"status": "deleted", "id": pos_id}
@router.patch("/notes/{pos_id}")
def update_notes(pos_id: str, req: NotesRequest):
update_position_notes(pos_id, req.notes)
return {"status": "ok"}
@router.get("/pnl-history")
def pnl_history():
"""Equity curve from closed positions."""
closed = get_positions("closed")
closed_sorted = sorted(closed, key=lambda p: p.get("close_date", ""))
curve = []
cumulative = 0.0
for p in closed_sorted:
if p.get("close_value") is not None:
pnl = (p["close_value"] - p["capital_invested"]
- p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0))
cumulative += pnl
curve.append({
"date": p.get("close_date", ""),
"pnl": round(pnl, 2),
"cumulative": round(cumulative, 2),
"title": p.get("title", p.get("underlying", "")),
})
return curve

View File

@@ -0,0 +1,88 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from services.database import get_risk_profiles, upsert_risk_profile, delete_risk_profile, _compute_trade_score
router = APIRouter(prefix="/api/profiles", tags=["profiles"])
class RiskProfileRequest(BaseModel):
id: Optional[int] = None
name: str
min_score: int
min_gain_pct: float
color: Optional[str] = "#3b82f6"
enabled: Optional[bool] = True
sort_order: Optional[int] = 0
@router.get("")
def list_profiles():
"""List all risk profiles ordered by sort_order."""
profiles = get_risk_profiles()
# Annotate each profile with the EV breakeven info
result = []
for p in profiles:
# At the exact frontier: score = min_score, gain = min_gain_pct
_, ev_net, trade_score = _compute_trade_score(p["min_score"], p["min_gain_pct"])
result.append({
**p,
"ev_net_at_frontier": round(ev_net, 3),
"trade_score_at_frontier": trade_score,
})
return {"profiles": result}
@router.post("")
def create_profile(req: RiskProfileRequest):
"""Create a new risk profile."""
if not (0 <= req.min_score <= 100):
raise HTTPException(400, "min_score must be between 0 and 100")
if req.min_gain_pct < 0:
raise HTTPException(400, "min_gain_pct must be >= 0")
pid = upsert_risk_profile(req.model_dump())
profiles = get_risk_profiles()
return {"id": pid, "profiles": profiles}
@router.put("/{profile_id}")
def update_profile(profile_id: int, req: RiskProfileRequest):
"""Update an existing risk profile."""
if not (0 <= req.min_score <= 100):
raise HTTPException(400, "min_score must be between 0 and 100")
data = req.model_dump()
data["id"] = profile_id
upsert_risk_profile(data)
return {"profiles": get_risk_profiles()}
@router.delete("/{profile_id}")
def remove_profile(profile_id: int):
"""Delete a risk profile."""
profiles = get_risk_profiles()
if len([p for p in profiles if p["enabled"]]) <= 1:
# Allow deletion but warn
pass
delete_risk_profile(profile_id)
return {"profiles": get_risk_profiles()}
@router.get("/preview")
def preview_score(score: int = 50, gain_pct: float = 100.0):
"""
Preview the trade metrics for a given (score, gain_pct) pair.
Useful for the Config UI slider simulation.
"""
ev_gross, ev_net, trade_score = _compute_trade_score(score, gain_pct)
profiles = get_risk_profiles(enabled_only=True)
from services.database import _matches_profile
matched = _matches_profile(score, gain_pct, profiles)
return {
"score": score,
"gain_pct": gain_pct,
"ev_gross": ev_gross,
"ev_net": ev_net,
"trade_score": trade_score,
"matched_profile": matched,
"accepted": matched is not None,
}

View File

@@ -0,0 +1,332 @@
"""
AI Reasoning Traces — store and query the full reasoning chain behind each trade.
Endpoints:
GET /api/reasoning/postmortem/{trade_id} — reasoning chain (no GPT call)
POST /api/reasoning/postmortem/{trade_id}/analyze — GPT-4o post-mortem analysis
"""
import json
import logging
import os
from fastapi import APIRouter, HTTPException
from services.database import (
get_config,
get_ai_report,
get_mtm_trades_with_traces,
get_pattern_scoring_history,
get_scoring_trace,
get_suggestion_trace,
get_trade_entry_by_id,
list_ai_reports,
save_ai_report,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/reasoning", tags=["reasoning"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _bucket_summary(buckets: list) -> str:
lines = []
for b in buckets:
pct = round(b.get("score", 0) / b.get("max", 1) * 100) if b.get("max") else 0
lines.append(f" {b.get('label', b.get('id'))}: {b.get('score')}/{b.get('max')} ({pct}%) — {(b.get('comment') or '')[:90]}")
return "\n".join(lines)
def _rankings_summary(rankings: list) -> str:
lines = []
for r in rankings:
delta = r.get("score_delta", 0)
sign = "+" if delta >= 0 else ""
lines.append(f" {r.get('underlying')} {r.get('strategy')} — delta {sign}{delta} | {(r.get('rationale') or '')[:80]}")
return "\n".join(lines)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/postmortem/{trade_id}")
def get_postmortem(trade_id: int):
"""
Return the full AI reasoning chain for a logged trade:
- why the pattern was suggested (suggestion trace)
- why it was scored at that level (scoring trace with pillar breakdown)
- score evolution across cycles (trend)
"""
trade = get_trade_entry_by_id(trade_id)
if not trade:
raise HTTPException(404, f"Trade {trade_id} not found")
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=8)
return {
"trade": trade,
"scoring_context": scoring_trace,
"suggestion_context": suggestion_trace,
"score_history": [
{
"run_id": t["run_id"],
"created_at": t["created_at"],
"score": t["output"].get("score"),
"key_catalyst": t["output"].get("key_catalyst"),
"macro_dominant": t["macro_dominant"],
"geo_score": t["geo_score"],
"summary": t["output"].get("summary"),
}
for t in score_history
],
}
@router.post("/postmortem/{trade_id}/analyze")
def analyze_postmortem(trade_id: int):
"""
Ask GPT-4o to explain why a trade did/didn't work based on the full reasoning chain.
Returns a structured analysis with diagnostic, lessons, and next-cycle recommendations.
"""
ai_key = get_config("openai_api_key") or ""
if not ai_key:
raise HTTPException(400, "Clé OpenAI non configurée")
os.environ["OPENAI_API_KEY"] = ai_key
from services.ai_analyzer import _chat
trade = get_trade_entry_by_id(trade_id)
if not trade:
raise HTTPException(404, f"Trade {trade_id} not found")
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=5)
scoring_out = scoring_trace["output"] if scoring_trace else {}
scoring_ctx = scoring_trace["input_context"] if scoring_trace else {}
suggestion_out = suggestion_trace["output"] if suggestion_trace else {}
buckets_text = _bucket_summary(scoring_out.get("buckets", []))
rankings_text = _rankings_summary(scoring_out.get("trade_rankings", []))
score_trend = "".join(
f"{t['output'].get('score', '?')}/100 ({t['macro_dominant'] or '?'} régime, géo {t['geo_score'] or '?'})"
for t in reversed(score_history)
)
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse le post-mortem d'un trade options.
═══ TRADE ANALYSÉ ═══
Pattern : {trade.get("pattern_name")}
Instrument : {trade.get("underlying")}{trade.get("strategy")}
Entrée : {trade.get("entry_date")} @ {trade.get("entry_price") or "N/A"}
Score entrée: {trade.get("score_at_entry")}/100 | Trade Score: {trade.get("trade_score") or "N/A"} | EV nette: {trade.get("ev_net") or "N/A"}
Profil : {trade.get("matched_profile")} | Gain prévu: {trade.get("expected_move_pct") or "N/A"}%
═══ CONTEXTE AU MOMENT DU SCORING ═══
Régime macro : {scoring_trace.get("macro_dominant") if scoring_trace else "N/A"} | Biais asset: {scoring_ctx.get("asset_bias", "N/A")}
Scores macro : {json.dumps(scoring_ctx.get("macro_scores", {}), ensure_ascii=False)}
Risque géo : {scoring_trace.get("geo_score") if scoring_trace else "N/A"}/100
Gain prévu : {scoring_ctx.get("expected_move_pct") or "N/A"}%
═══ POURQUOI CE PATTERN A ÉTÉ CRÉÉ ═══
{suggestion_out.get("macro_fit") or "N/A"}
{suggestion_out.get("description") or ""}
═══ SCORE DÉTAILLÉ PAR PILIER ═══
Score global : {scoring_out.get("score", 0)}/100 (confiance {scoring_out.get("confidence", 0)}%)
{buckets_text or "Non disponible"}
Catalyseur clé : {scoring_out.get("key_catalyst") or "N/A"}
Synthèse : {scoring_out.get("summary") or "N/A"}
Contra-signal fort : {"OUI" if scoring_out.get("has_strong_contra") else "non"}
═══ CLASSEMENT DES TRADES AU SCORING ═══
{rankings_text or "Non disponible"}
═══ ÉVOLUTION DU SCORE DANS LE TEMPS ═══
{score_trend or "Premier scoring — pas d'historique"}
Analyse ce trade en JSON :
{{
"diagnostic": "<2-3 phrases: qu'explique la performance (bonne ou mauvaise) de ce trade ?>",
"what_worked": "<ce qui était correct dans l'analyse initiale>",
"what_missed": "<ce que l'IA a sous/sur-estimé, ou n'a pas anticipé>",
"regime_alignment": "<le régime macro était-il vraiment favorable ? a-t-il évolué depuis ?>",
"contra_assessment": "<les contra-signals détectés étaient-ils le vrai risque ? ou un faux signal ?>",
"lesson": "<1 règle précise à retenir pour scorer ce type de pattern plus finement>",
"next_cycle": "<comment enrichir le contexte et les critères pour ce pattern dans les prochains cycles ?>"
}}"""
try:
result = _chat(
"Tu es un stratège macro-géopolitique senior. Post-mortem concis et actionnable. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=900,
)
except Exception as e:
logger.error(f"[Postmortem] GPT-4o call failed: {e}")
raise HTTPException(503, "GPT-4o indisponible")
if not result:
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
return {
"trade_id": trade_id,
"trade": trade,
"scoring_context": scoring_trace,
"suggestion_context": suggestion_trace,
"analysis": result,
}
# ── Portfolio AI Report ────────────────────────────────────────────────────────
def _trade_summary_block(label: str, trades: list) -> str:
if not trades:
return f"{label} : aucun trade pricé"
lines = [f"{label} :"]
for t in trades:
pnl = t.get("pnl_pct")
sc = t.get("scoring_context") or {}
sc_out = sc.get("output", {}) if isinstance(sc, dict) else {}
sg = t.get("suggestion_context") or {}
sg_out = sg.get("output", {}) if isinstance(sg, dict) else {}
macro = sc.get("macro_dominant") if isinstance(sc, dict) else "?"
geo = sc.get("geo_score") if isinstance(sc, dict) else "?"
catalyst = sc_out.get("key_catalyst") or "N/A"
macro_fit = sg_out.get("macro_fit") or sg_out.get("description") or "N/A"
trend = "".join(str(s) for s in (t.get("score_trend") or [])) or "N/A"
buckets = sc_out.get("buckets", [])
weak = [b.get("label", b.get("id", "")) for b in buckets if b.get("max") and b.get("score", 0) / b["max"] < 0.4]
lines.append(
f"{t.get('pattern_name')} | {t.get('underlying')} {t.get('strategy')}"
f" | P&L {'+' if (pnl or 0) >= 0 else ''}{(pnl or 0):.1f}%"
f" | Score entrée {t.get('score_at_entry')}/100 | Régime {macro} | Géo {geo}"
f"\n Thèse : {macro_fit[:120]}"
f"\n Catalyseur : {catalyst}"
f"\n Trend score : {trend}"
+ (f"\n Piliers faibles : {', '.join(weak)}" if weak else "")
)
return "\n".join(lines)
@router.get("/portfolio-report")
def get_portfolio_report_data(days: int = 90):
"""Return raw MTM + traces data (no GPT-4o call) for the report page."""
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
return data
@router.post("/portfolio-report/generate")
def generate_portfolio_report(days: int = 90):
"""
Generate a GPT-4o AI report: key highlights, explanations for top movers,
macro regime assessment, and actionable next-cycle recommendations.
"""
ai_key = get_config("openai_api_key") or ""
if not ai_key:
raise HTTPException(400, "Clé OpenAI non configurée")
os.environ["OPENAI_API_KEY"] = ai_key
from services.ai_analyzer import _chat
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
winners = data["winners"]
losers = data["losers"]
winners_block = _trade_summary_block("TOP GAINS", winners)
losers_block = _trade_summary_block("TOP PERTES", losers)
avg_pnl = data.get("avg_pnl_pct")
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. Génère un rapport synthétique sur notre portefeuille options.
═══ STATISTIQUES GLOBALES ═══
Période : {days} derniers jours
Trades total: {data['total_trades']} | Pricés: {data['priced_count']} | P&L moyen: {avg_str}
═══ {winners_block}
═══ {losers_block}
Génère un rapport JSON structuré :
{{
"headline": "<1 phrase résumant la performance de la période>",
"regime_assessment": "<le régime macro a-t-il bien servi nos thèses ? convergence ou divergence ?>",
"winners_analysis": "<pourquoi ces trades ont marché — pattern commun, catalyseur, régime ? 3-4 phrases>",
"losers_analysis": "<pourquoi ces trades ont déçu — mauvaise thèse, mauvais timing, contra-signal manqué ? 3-4 phrases>",
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
"blind_spots": "<ce que notre système de scoring n'a pas bien capturé cette période>",
"next_cycle_priorities": "<3 priorités concrètes pour améliorer les prochains cycles : patterns à surveiller, ajustements de scoring, régimes à anticiper>",
"risk_watch": "<1-2 risques macro-géopolitiques à surveiller de près qui pourraient impacter nos positions actuelles>"
}}"""
try:
result = _chat(
"Tu es un stratège macro senior. Rapport synthétique et actionnable. JSON uniquement.",
prompt,
model="gpt-4o",
json_mode=True,
max_tokens=1200,
)
except Exception as e:
logger.error(f"[PortfolioReport] GPT-4o call failed: {e}")
raise HTTPException(503, "GPT-4o indisponible")
if not result:
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
stats = {
"total_trades": data["total_trades"],
"priced_count": data["priced_count"],
"avg_pnl_pct": avg_pnl,
}
report_id = save_ai_report(
days=days,
stats=stats,
winners=winners,
losers=losers,
report=result,
)
return {
"id": report_id,
"days": days,
"stats": stats,
"winners": winners,
"losers": losers,
"report": result,
}
@router.get("/reports")
def list_reports(report_type: str = "portfolio", limit: int = 20):
"""List archived AI reports (newest first), summary only."""
reports = list_ai_reports(report_type=report_type, limit=limit)
return {
"reports": [
{
"id": r["id"],
"days": r["days"],
"created_at": r["created_at"],
"stats": r["stats"],
"headline": r["report"].get("headline", ""),
}
for r in reports
]
}
@router.get("/reports/{report_id}")
def get_report(report_id: int):
"""Retrieve a full archived AI report by ID."""
report = get_ai_report(report_id)
if not report:
raise HTTPException(404, f"Report {report_id} not found")
return report

View File

View File

@@ -0,0 +1,934 @@
"""
AI analysis engine using OpenAI GPT-4o.
Tasks: news scoring, speech analysis, pattern evaluation, trade idea ranking.
"""
from openai import OpenAI
from typing import Optional, List, Dict, Any
import json
import os
_client: Optional[OpenAI] = None
def get_client() -> Optional[OpenAI]:
global _client
key = os.environ.get("OPENAI_API_KEY", "")
if not key:
return None
if _client is None or _client.api_key != key:
_client = OpenAI(api_key=key)
return _client
def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = True, max_tokens: int = 1500) -> Optional[Dict]:
client = get_client()
if not client:
return None
kwargs: Dict[str, Any] = {
"model": model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"temperature": 0.2,
"max_tokens": max_tokens,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
if json_mode:
return json.loads(content)
return {"text": content}
# ── News / Article Analysis ───────────────────────────────────────────────────
SYSTEM_NEWS = """Tu es un analyste financier géopolitique senior spécialisé en options.
Tu analyses des actualités et identifies leur impact potentiel sur les marchés financiers.
Réponds UNIQUEMENT en JSON selon le schéma demandé. Sois précis et concis."""
def analyze_news_item(title: str, summary: str) -> Dict[str, Any]:
"""Classify and score a single news item with GPT."""
user = f"""Analyse cet article géopolitique/économique:
Titre: {title}
Résumé: {summary}
Retourne ce JSON:
{{
"category": "military|sanctions|elections|natural_disaster|health_crisis|resource_scarcity|trade_war|energy|political_speech|financial_crisis|general",
"impact_score": <float 0.0-1.0>,
"direction": "bullish|bearish|neutral|volatile",
"affected_assets": {{
"energy": <float -1.0 à 1.0 ou null>,
"metals": <float -1.0 à 1.0 ou null>,
"agriculture": <float -1.0 à 1.0 ou null>,
"indices": <float -1.0 à 1.0 ou null>,
"forex": <float -1.0 à 1.0 ou null>
}},
"key_entities": [<max 5 entités clés: pays, personnes, organisations>],
"horizon": "immediate|days|weeks|months",
"reasoning": "<1 phrase expliquant l'impact>"
}}"""
result = _chat(SYSTEM_NEWS, user)
if not result:
return {"category": "general", "impact_score": 0.1, "direction": "neutral",
"affected_assets": {}, "key_entities": [], "horizon": "days", "reasoning": "AI non disponible"}
return result
# ── Speech / Text Analysis (Trump, Powell, etc.) ─────────────────────────────
SYSTEM_SPEECH = """Tu es un analyste quantitatif géopolitique. Tu décodes les discours et déclarations
de personnalités politiques/économiques pour identifier des opportunités de trading en options.
Tu te spécialises dans: discours Trump (tarifs, énergie, dollar), Powell/Fed (taux),
leaders géopolitiques (sanctions, guerres, ressources). Réponds en JSON uniquement."""
def analyze_speech(text: str, speaker: str = "") -> Dict[str, Any]:
"""Deep analysis of a speech/statement for trading signals."""
user = f"""Analyse cette déclaration{'de ' + speaker if speaker else ''} pour des signaux de trading:
---
{text[:3000]}
---
Retourne ce JSON:
{{
"speaker_identified": "<nom si détecté>",
"tone": "hawkish|dovish|aggressive|conciliatory|ambiguous",
"key_statements": [<liste des 3-5 phrases/points les plus impactants>],
"market_signals": [
{{
"asset": "<symbole ou classe>",
"direction": "up|down|volatile",
"magnitude": "low|medium|high|extreme",
"reasoning": "<pourquoi>",
"timeframe": "<immédiat|1 semaine|1 mois|3 mois>"
}}
],
"options_opportunities": [
{{
"underlying": "<symbole ETF ou futur>",
"strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle",
"strike_guidance": "<ATM|5% OTM|10% OTM>",
"expiry_guidance": "<30j|60j|90j>",
"rationale": "<pourquoi cette stratégie>",
"confidence": <int 0-100>,
"capital_1000eur": "<comment allouer 1000€>"
}}
],
"risk_level": "low|medium|high|extreme",
"geo_pattern_triggered": "<nom du pattern si applicable ou null>",
"summary": "<2-3 phrases de synthèse pour un trader>"
}}"""
result = _chat(SYSTEM_SPEECH, user, model="gpt-4o")
if not result:
return {"error": "OpenAI non disponible — vérifier la clé API"}
return result
# ── Pattern Evaluation & Creation ────────────────────────────────────────────
SYSTEM_PATTERN = """Tu es un expert en analyse géopolitique quantitative et en trading d'options.
Tu évalues et améliores des patterns géopolitiques pour un système de trading algorithmique.
Tes évaluations se basent sur des faits historiques vérifiables. Réponds en JSON."""
def evaluate_pattern(pattern: Dict[str, Any]) -> Dict[str, Any]:
"""AI evaluation of a user-defined pattern."""
user = f"""Évalue ce pattern géopolitique de trading:
{json.dumps(pattern, ensure_ascii=False, indent=2)}
Retourne ce JSON:
{{
"quality_score": <int 0-100>,
"validity": "excellent|good|fair|poor",
"strengths": [<liste des points forts>],
"weaknesses": [<liste des faiblesses ou lacunes>],
"suggested_improvements": {{
"additional_keywords": [<mots-clés manquants pertinents>],
"additional_triggers": [<catégories manquantes>],
"probability_estimate": <float 0.0-1.0, ton estimation>,
"expected_move_revision": <float % ou null si ok>,
"horizon_revision": <int jours ou null si ok>
}},
"historical_validation": [
{{
"date": "<YYYY-MM-DD>",
"event": "<événement réel qui confirme le pattern>",
"outcome": "<ce qui s'est passé sur les marchés>"
}}
],
"counter_scenarios": [<2-3 scénarios qui invalideraient ce pattern>],
"overall_recommendation": "<conseil général en 2-3 phrases>",
"risk_warnings": [<risques spécifiques à ce pattern>]
}}"""
result = _chat(SYSTEM_PATTERN, user, model="gpt-4o")
if not result:
return {"error": "OpenAI non disponible", "quality_score": 0}
return result
def suggest_pattern_from_context(context: str) -> Dict[str, Any]:
"""AI creates a pattern structure from a free-text context description."""
user = f"""Un trader décrit ce contexte géopolitique et veut créer un pattern de trading:
"{context}"
Génère un pattern complet en JSON:
{{
"id": "P_USER_<3 lettres aléatoires>",
"name": "<nom concis du pattern>",
"description": "<description précise du mécanisme>",
"triggers": [<catégories parmi: military, sanctions, elections, natural_disaster, health_crisis, resource_scarcity, trade_war, energy, political_speech, financial_crisis>],
"keywords": [<10-15 mots-clés anglais pour détecter ce pattern dans les news>],
"historical_instances": [
{{"date": "<YYYY-MM-DD>", "event": "<événement réel>", "outcome": "<mouvement de marché observé>"}}
],
"suggested_trades": [
{{"strategy": "<stratégie>", "underlying": "<symbole>", "rationale": "<pourquoi>"}}
],
"asset_class": "<classe principale>",
"expected_move_pct": <float>,
"probability": <float 0.0-1.0>,
"horizon_days": <int>,
"confidence_in_pattern": <int 0-100>,
"caveats": [<mises en garde importantes>]
}}"""
result = _chat(SYSTEM_PATTERN, user, model="gpt-4o")
if not result:
return {"error": "OpenAI non disponible"}
return result
# ── Top 10 Trade Ideas Ranking ────────────────────────────────────────────────
SYSTEM_RANKING = """Tu es un gestionnaire de portefeuille spécialisé en options.
Tu dois sélectionner et classer les 10 meilleures opportunités de trading options
pour un capital de ~1000€ avec horizon 3 mois, en intégrant le contexte géopolitique actuel.
Privilégie: risque/rendement optimal, liquidité des options, clarté du catalyseur. Réponds en JSON."""
def rank_trade_ideas(
pattern_matches: List[Dict],
geo_score: Dict,
recent_news: List[Dict],
market_quotes: Dict,
) -> List[Dict[str, Any]]:
"""Generate and rank top 10 trade ideas using GPT-4o."""
context = {
"geo_risk_score": geo_score.get("score", 50),
"geo_risk_level": geo_score.get("level", "medium"),
"top_risks": geo_score.get("top_risks", []),
"active_patterns": [
{"name": p["name"], "similarity": p["similarity"],
"asset_class": p["asset_class"], "expected_move": p["expected_move_pct"]}
for p in pattern_matches[:5]
],
"top_news": [
{"title": n["title"], "category": n["category"], "impact": n["impact_score"]}
for n in recent_news[:10]
],
}
user = f"""Contexte géopolitique et marché actuel:
{json.dumps(context, ensure_ascii=False, indent=2)}
Génère les 10 meilleures idées de trades en options pour 1000€ / horizon 3 mois.
Diversifie les classes d'actifs. Inclus au moins: 2 énergie, 1 métal, 1 agri, 2 indices/actions, 1 forex.
Retourne ce JSON:
{{
"ideas": [
{{
"rank": <1-10>,
"title": "<titre court>",
"underlying": "<symbole ETF/futur liquide>",
"strategy": "Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle|Long Strangle",
"asset_class": "<classe>",
"rationale": "<raisonnement géopolitique en 2 phrases>",
"geo_trigger": "<pattern ou événement déclencheur>",
"strike_guidance": "<ATM|5% OTM|10% OTM>",
"expiry_days": <int>,
"expected_move_pct": <float>,
"max_loss_eur": <float, max 1000>,
"target_gain_eur": <float>,
"confidence": <int 0-100>,
"risk_level": "low|medium|high|extreme",
"timing": "<entrer maintenant|attendre catalyseur|après date X>",
"invalidation": "<condition qui invalide le trade>"
}}
],
"portfolio_note": "<note générale sur l'allocation des 1000€ entre ces idées>",
"current_bias": "bullish|bearish|neutral|volatile",
"key_risk": "<risque principal à surveiller>"
}}"""
result = _chat(SYSTEM_RANKING, user, model="gpt-4o")
if not result:
return []
return result.get("ideas", [])
# ── Pattern Scoring with Rich Context ────────────────────────────────────────
DEFAULT_ANALYSIS_TEMPLATE = """Pour chaque pattern, note chaque sous-pilier ET fournis un commentaire 1-2 phrases en français.
Score total = somme exacte des 4 piliers (0-100).
PILIER 1 — ACTUALITÉS & GÉO-CONTEXTE (30 pts max)
1a. News géopolitiques (0-12): pertinence des événements récents vs keywords/triggers du pattern
1b. News macro/économiques (0-10): données macro, publications éco, politiques monétaires/fiscales
1c. Volume & récence signal (0-8) : nb de sources indépendantes, fraîcheur (<48h = max), cohérence
PILIER 2 — CALENDRIER ÉCONOMIQUE (20 pts max)
2a. Banques centrales (0-10): décisions FOMC/BCE/BoJ/BoE à venir, minutes, discours membres
2b. Publications macro (0-10): CPI, NFP, PIB, PMI, rapport OPEC — alignement avec le pattern
PILIER 3 — SIGNAUX DE PRIX (35 pts max)
3a. Taux & Obligations (0-7): mouvements yields, courbe de taux, spreads crédit
3b. Énergie & Matières prem. (0-7): or, pétrole, gaz, cuivre, blé — direction et momentum
3c. Forex (0-7): USD index, EUR/USD, paires émergentes — cohérence avec pattern
3d. Actions & Indices (0-7): SPX, NDX, rotation sectorielle, breadth, sentiment
3e. Volatilité (VIX/IV) (0-7): régime de vol, coût options, skew — favorable à la stratégie ?
PILIER 4 — RISQUE / RÉCOMPENSE (15 pts max)
4a. Asymétrie R/R (0-10): ratio gain potentiel / prime payée / perte max pour ~1000€
4b. Timing d'entrée (0-5) : qualité du point d'entrée vs analogues historiques du pattern
Règles: score total = 1a+1b+1c+2a+2b+3a+3b+3c+3d+3e+4a+4b; ne pas dépasser les max; commenter chaque sous-pilier.
⚠️ RÈGLE ANTI-BIAIS DIRECTIONNELLE (IMPÉRATIVE):
- "expected_direction" indique si le pattern attend une hausse ou une baisse.
- "contra_signals" liste les news AI-scorées qui CONTREDISENT cette direction.
- "has_strong_contra": true = le contexte actuel ANNULE ou INVERSE la thèse du pattern.
→ Si has_strong_contra=true: score total ≤ 40/100 ; sous-pilier 1a geo ≤ 3/12.
→ Si resolution=true dans contra_signals (accord/cessez-le-feu résolvant le conflit trigger): 1a geo = 0-2/12.
→ Indique toujours dans "summary" si le signal est [SUPPORTING], [NEUTRAL] ou [CONTRA]."""
SYSTEM_SCORER = """Tu es un gestionnaire de portefeuille senior spécialisé en options géopolitiques.
Tu analyses des patterns géopolitiques avec leur contexte marché enrichi (news, prix, IV) pour identifier
les meilleures opportunités de trading options (~1000€, horizon 3 mois).
Tu es rigoureux, quantitatif et pragmatique. Réponds UNIQUEMENT en JSON valide."""
def score_patterns_with_context(
patterns: List[Dict],
recent_news: List[Dict],
quotes_by_class: Dict,
geo_score: Dict,
template: str = None,
top_n: int = 10,
category_filter: str = None,
macro_regime: Optional[Dict] = None,
portfolio_lessons: Optional[Dict] = None,
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV) using GPT-4o."""
if not get_client():
return []
scoring_template = template or DEFAULT_ANALYSIS_TEMPLATE
# Flatten quotes to symbol -> data dict for fast lookup
quotes_flat: Dict[str, Dict] = {}
for cls_quotes in quotes_by_class.values():
for q in cls_quotes:
quotes_flat[q.get("symbol", "")] = q
# Build per-pattern context blocks
pattern_blocks = []
for pat in patterns:
if category_filter and category_filter != "all":
if pat.get("asset_class") != category_filter:
# also check suggested trades
trade_classes = [t.get("asset_class", "") for t in pat.get("suggested_trades", [])]
if category_filter not in trade_classes:
continue
# Filter news relevant to this pattern
keywords = [kw.lower() for kw in pat.get("keywords", [])]
relevant_news = []
for n in recent_news[:50]:
text = (n.get("title", "") + " " + n.get("summary", "")).lower()
if any(kw in text for kw in keywords):
relevant_news.append({
"title": n.get("title", ""),
"date": n.get("published", "")[:10],
"source": n.get("source", ""),
"impact": n.get("impact_score", 0),
})
if len(relevant_news) >= 4:
break
# Market data for each suggested underlying
market_data = {}
for trade in pat.get("suggested_trades", []):
sym = trade.get("underlying", "")
if sym and sym in quotes_flat:
q = quotes_flat[sym]
from services.data_fetcher import compute_historical_iv
try:
iv = compute_historical_iv(sym)
except Exception:
iv = None
market_data[sym] = {
"price": q.get("price"),
"change_1d_pct": q.get("change_pct"),
"iv_pct": round(iv * 100, 1) if iv else None,
"name": q.get("name", sym),
}
# Detect contra-signals: AI-scored news that contradicts this pattern's direction
expected_up = pat.get("expected_move_pct", 0) > 0
asset_cls = pat.get("asset_class", "")
_dir_field = {"energy": "ai_dir_energy", "metals": "ai_dir_metals"}.get(asset_cls, "ai_dir_indices")
contra_signals = []
for n in recent_news[:25]:
if not n.get("ai_scored"):
continue
ai_dir = n.get(_dir_field, "neutral")
impact = float(n.get("impact_score") or 0)
is_contra = (expected_up and ai_dir == "bearish") or (not expected_up and ai_dir == "bullish")
if is_contra and impact >= 0.35:
contra_signals.append({
"title": (n.get("title") or "")[:100],
"impact": round(impact, 2),
"direction": ai_dir,
"resolution": n.get("ai_resolution", False),
"insight": n.get("ai_insight", ""),
})
has_strong_contra = any(c["impact"] >= 0.55 or c.get("resolution") for c in contra_signals)
# Macro regime context for this pattern
macro_ctx = None
if macro_regime:
scenarios = macro_regime.get("scenarios", {})
gauges = macro_regime.get("gauges", {})
dominant = scenarios.get("dominant", "incertain")
asset_bias = scenarios.get("asset_bias", {})
pat_cls = pat.get("asset_class", "")
bias_for_class = asset_bias.get(dominant, {}).get(pat_cls, "neutral") if dominant != "incertain" else "neutral"
macro_ctx = {
"dominant_scenario": dominant,
"scenario_scores": scenarios.get("scores", {}),
"asset_class_bias": bias_for_class,
"vix": gauges.get("vix", {}).get("value"),
"yield_slope_pct": gauges.get("slope_10y3m", {}).get("value"),
"gold_copper_ratio": gauges.get("gold_copper_ratio", {}).get("value"),
"brent_1d_pct": gauges.get("brent", {}).get("change_pct"),
"spx_vs_200d_pct": gauges.get("spx_vs_200d", {}).get("value"),
}
pattern_blocks.append({
"id": pat.get("id", pat.get("pattern_id", "")),
"name": pat.get("name", ""),
"description": pat.get("description", ""),
"asset_class": pat.get("asset_class", ""),
"triggers": pat.get("triggers", []),
"historical_instances": pat.get("historical_instances", [])[:2],
"suggested_trades": pat.get("suggested_trades", []),
"expected_move_pct": pat.get("expected_move_pct", 0),
"expected_direction": "hausse" if expected_up else "baisse",
"horizon_days": pat.get("horizon_days", 90),
"relevant_news_count": len(relevant_news),
"relevant_news": relevant_news,
"market_data": market_data,
"contra_signals": contra_signals[:3],
"has_strong_contra": has_strong_contra,
"macro_regime": macro_ctx,
})
if not pattern_blocks:
return []
macro_section = ""
if macro_regime:
sc = macro_regime.get("scenarios", {})
dom = sc.get("dominant", "incertain")
sc_scores = sc.get("scores", {})
macro_section = f"""
RÉGIME MACRO ACTUEL (30 compteurs agrégés):
- Scénario dominant: {dom.upper()} | Scores: {json.dumps(sc_scores, ensure_ascii=False)}
- Instruction: Intègre ce régime dans les piliers prix (3a taux, 3b énergie, 3d indices, 3e VIX).
Chaque pattern reçoit un champ "macro_regime.asset_class_bias" indiquant la compatibilité
(bullish+/bullish/neutral/bearish/bearish+/defensive) de sa classe d'actif avec le scénario dominant.
"bullish+" = conditions très favorables pour ce pattern → majore 3b ou 3d selon la classe
"bearish" ou "bearish+" = conditions défavorables → minore 3b ou 3d
Indique dans "summary": [GOLDILOCKS|STAGFLATION|RÉCESSION|DÉSINFLATION|CRISE] + [SUPPORTING|NEUTRAL|CONTRA]
"""
user = f"""CONTEXTE GLOBAL:
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
- Top risques: {geo_score.get('top_risks', [])}
{macro_section}
TEMPLATE DE NOTATION:
{scoring_template}
PATTERNS À SCORER ({len(pattern_blocks)} patterns):
{json.dumps(pattern_blocks, ensure_ascii=False, indent=2)}
Pour chacun des {len(pattern_blocks)} patterns, score chaque sous-pilier + commente en français.
Le champ "score" = somme exacte de tous les sous-piliers.
⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE).
Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis "trade_rankings" en:
- classant les trades du meilleur (rank 1) au moins bon
- assignant un "score_delta" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon)
- expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern
- la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern)
Retourne UNIQUEMENT ce JSON valide:
{{
"scored_patterns": [
{{
"pattern_id": "<id>",
"score": <int 0-100, somme exacte des 4 piliers>,
"confidence": <int 0-100>,
"buckets": [
{{
"id": "actualites",
"label": "Actualités & Géo-contexte",
"score": <0-30>,
"max": 30,
"comment": "<synthèse 1-2 phrases>",
"subs": [
{{"id": "geo", "label": "News géopolitiques", "score": <0-12>, "max": 12, "comment": "<1-2 phrases>"}},
{{"id": "eco", "label": "News macro/éco", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}},
{{"id": "flux", "label": "Volume & récence", "score": <0-8>, "max": 8, "comment": "<1-2 phrases>"}}
]
}},
{{
"id": "calendrier",
"label": "Calendrier économique",
"score": <0-20>,
"max": 20,
"comment": "<synthèse>",
"subs": [
{{"id": "banques", "label": "Banques centrales", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}},
{{"id": "macro_cal", "label": "Publications macro", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}}
]
}},
{{
"id": "prix",
"label": "Signaux de prix",
"score": <0-35>,
"max": 35,
"comment": "<synthèse>",
"subs": [
{{"id": "taux", "label": "Taux & Obligations", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}},
{{"id": "energie", "label": "Énergie & Matières", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}},
{{"id": "forex_sig", "label": "Forex", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}},
{{"id": "actions", "label": "Actions & Indices", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}},
{{"id": "vix", "label": "Volatilité (VIX/IV)", "score": <0-7>, "max": 7, "comment": "<1-2 phrases>"}}
]
}},
{{
"id": "rr",
"label": "Risque / Récompense",
"score": <0-15>,
"max": 15,
"comment": "<synthèse>",
"subs": [
{{"id": "asymetrie", "label": "Asymétrie R/R", "score": <0-10>, "max": 10, "comment": "<1-2 phrases>"}},
{{"id": "timing_rr", "label": "Timing d'entrée", "score": <0-5>, "max": 5, "comment": "<1-2 phrases>"}}
]
}}
],
"key_catalyst": "<catalyseur principal en 1 phrase>",
"recommended_trade": {{
"underlying": "<symbole>",
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>",
"strike_guidance": "<ATM|5% OTM|...>",
"expiry_days": <int>,
"rationale": "<pourquoi ce trade maintenant, 2 phrases max>",
"target_gain_eur": <float>,
"max_loss_eur": <float max 1000>,
"timing_note": "<entrer maintenant|attendre X|surveiller Y>",
"invalidation": "<condition qui invalide>"
}},
"asset_class": "<classe>",
"geo_trigger": "<pattern name>",
"summary": "<synthèse 1 phrase>",
"trade_rankings": [
{{
"underlying": "<ticker>",
"strategy": "<stratégie>",
"rank": <1-N>,
"score_delta": <int -20 à +20, positif si ce trade est supérieur à la moyenne du pattern>,
"rationale": "<1 phrase: pourquoi ce trade mérite plus/moins que les autres du même pattern>",
"expected_move_pct": <float, RENDEMENT OPTION ATTENDU en % si thèse confirmée, levier inclus. Long Call ATM: 80-200%, Spread: 40-120%, Straddle: 60-180%. Réévalue par rapport au contexte actuel.>
}}
]
}}
],
"analysis_meta": {{
"patterns_analyzed": <int>,
"top_bias": "bullish|bearish|neutral|volatile",
"key_risk": "<risque principal>"
}}
}}"""
# Extract the return-schema portion from `user` so batches use the identical full schema
# (includes bucket id/label/max definitions that GPT-4o needs to populate correctly)
_return_schema = user.split("Retourne UNIQUEMENT ce JSON valide:\n", 1)[1]
# Build lessons feedback block for the scorer
lessons_header = ""
if portfolio_lessons:
lessons = portfolio_lessons.get("key_lessons") or []
super_ctx = portfolio_lessons.get("super_context", "")
priorities = portfolio_lessons.get("strategic_priorities", [])
mistakes = portfolio_lessons.get("recurring_mistakes", [])
super_scoring_block = ""
if super_ctx:
super_scoring_block = f"""
🧠 SUPER CONTEXTE (base de raisonnement accumulée) :
{super_ctx[:400]}
Priorités: {' | '.join(str(p) for p in priorities[:2])}
Erreurs à éviter: {' | '.join(str(m) for m in mistakes[:2])}
"""
lessons_header = f"""
{super_scoring_block}
RETOUR DE PERFORMANCE (rapport du {portfolio_lessons.get('created_at','?')[:10]}) :
Bilan global : {portfolio_lessons.get('headline', '')[:150]}
Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]}
Priorités : {portfolio_lessons.get('next_cycle_priorities', '')[:150]}
Leçons : {' | '.join(str(l)[:80] for l in lessons[:3])}
⚠️ Tiens compte du Super Contexte et de ces leçons pour ajuster les scores et les commentaires par pilier.
"""
# Build the per-batch prompt template (static parts)
prompt_header = f"""CONTEXTE GLOBAL:
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
- Top risques: {geo_score.get('top_risks', [])}
{macro_section}{lessons_header}
TEMPLATE DE NOTATION:
{scoring_template}
"""
import logging as _logging
_scorer_log = _logging.getLogger(__name__)
def _score_batch(batch: list) -> list:
ids = [p.get("id", "?") for p in batch]
_scorer_log.info(f"[Scorer] Batch of {len(batch)} patterns: {ids}")
batch_user = (
prompt_header
+ f"PATTERNS À SCORER ({len(batch)} patterns):\n"
+ json.dumps(batch, ensure_ascii=False, indent=2)
+ f"\n\n⚠️ OBLIGATOIRE: Tu dois retourner EXACTEMENT {len(batch)} objets dans scored_patterns — un pour CHAQUE pattern de la liste, SANS EXCEPTION. Même si un pattern a score=0 (non pertinent actuellement), il doit figurer dans la liste.\n\n"
+ f"Pour chacun des {len(batch)} patterns, score chaque sous-pilier + commente en français.\n"
+ "Le champ \"score\" = somme exacte de tous les sous-piliers.\n\n"
+ "⚠️ TRADE RANKINGS (OBLIGATOIRE): Un pattern peut avoir plusieurs suggested_trades (ex: Long Call WTI + Bull Spread XLE).\n"
+ "Ces trades ne méritent PAS tous le même score. Pour chaque pattern, remplis \"trade_rankings\" en:\n"
+ "- classant les trades du meilleur (rank 1) au moins bon\n"
+ "- assignant un \"score_delta\" entre -20 et +20 (ex: +10 pour le meilleur, 0 pour la moyenne, -8 pour le moins bon)\n"
+ "- expliquant en 1 phrase pourquoi chaque trade est au-dessus/en-dessous de la moyenne du pattern\n"
+ "- la somme des score_delta doit être ≈ 0 (les trades se compensent par rapport au score pattern)\n\n"
+ "Retourne UNIQUEMENT ce JSON valide:\n"
+ _return_schema
)
try:
res = _chat(SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000)
except Exception as e:
_scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}")
res = None
scored = res.get("scored_patterns", []) if res else []
_scorer_log.info(f"[Scorer] Batch returned {len(scored)} scored_patterns (expected {len(batch)})")
# Guarantee every pattern in the batch has an entry — prevents silent drops on truncation
scored_ids = {str(s.get("pattern_id", "")) for s in scored}
for p in batch:
if str(p.get("id", "")) not in scored_ids:
_scorer_log.warning(f"[Scorer] Pattern id='{p.get('id')}' name='{p.get('name')}' missing from GPT-4o response — adding stub score=0")
scored.append({
"pattern_id": p["id"],
"score": 0,
"confidence": 0,
"buckets": [],
"key_catalyst": "Non pertinent dans le contexte actuel",
"recommended_trade": {},
"asset_class": p.get("asset_class", ""),
"geo_trigger": p.get("name", ""),
"summary": "[CONTRA] Pattern non pertinent dans le contexte actuel.",
"trade_rankings": [],
})
return scored
BATCH_SIZE = 4 # 4 patterns × ~800 tokens output = ~3200 tokens, safely within gpt-4o limits
# Score all batches in parallel
from concurrent.futures import ThreadPoolExecutor, as_completed
batches = [pattern_blocks[i:i+BATCH_SIZE] for i in range(0, len(pattern_blocks), BATCH_SIZE)]
_scorer_log.info(f"[Scorer] Scoring {len(pattern_blocks)} patterns in {len(batches)} batches of max {BATCH_SIZE}")
all_scored = []
with ThreadPoolExecutor(max_workers=min(len(batches), 4)) as executor:
futures = [executor.submit(_score_batch, b) for b in batches]
for future in as_completed(futures):
try:
results = future.result()
all_scored.extend(results)
except Exception as e:
_scorer_log.error(f"[Scorer] Batch future raised: {e}")
# Hardcoded max values per bucket id — used as fallback if GPT-4o omits the max field
_BUCKET_MAX = {"actualites": 30, "calendrier": 20, "prix": 35, "rr": 15}
_SUB_MAX = {
"geo": 12, "eco": 10, "flux": 8,
"banques": 10, "macro_cal": 10,
"taux": 7, "energie": 7, "forex_sig": 7, "actions": 7, "vix": 7,
"asymetrie": 10, "timing_rr": 5,
}
# Normalize bucket scores and recompute total from sub-buckets
for p in all_scored:
if p.get("buckets"):
total = 0
for b in p["buckets"]:
bid = b.get("id", "")
b_max = int(b.get("max") or _BUCKET_MAX.get(bid, 30))
sub_sum = 0
for sub in b.get("subs", []):
sid = sub.get("id", "")
s_max = int(sub.get("max") or _SUB_MAX.get(sid, 10))
sub["score"] = max(0, min(int(sub.get("score") or 0), s_max))
sub["max"] = s_max # ensure max is always set for frontend display
sub_sum += sub["score"]
b["score"] = max(0, min(int(b.get("score") or sub_sum), b_max))
b["max"] = b_max # ensure max is always set for frontend display
total += b["score"]
p["score"] = min(total, 100)
all_scored.sort(key=lambda x: x.get("score", 0), reverse=True)
return all_scored[:top_n]
# ── Suggest new patterns from live market context ─────────────────────────────
def suggest_patterns_from_market_context(
news: List[Dict],
quotes_by_class: Dict[str, List[Dict]],
calendar: List[Dict],
macro_regime: Optional[Dict] = None,
geo_score: Optional[Dict] = None,
portfolio_lessons: Optional[Dict] = None,
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
top_news = sorted(news, key=lambda x: x.get("impact_score", 0), reverse=True)[:12]
news_block = "\n".join([
f"- [{n.get('source','')}] {n.get('title','')} (impact {n.get('impact_score',0):.2f})"
for n in top_news
])
market_lines = []
for cls, qs in quotes_by_class.items():
for q in qs[:3]:
if q.get("price"):
market_lines.append(f" {cls} | {q.get('name', q['symbol'])}: {q['price']} ({q.get('change_pct', 0):+.1f}%)")
market_block = "\n".join(market_lines)
cal_block = "\n".join([
f"- {e.get('date','')} [{e.get('importance','')}] {e.get('title','')}"
for e in (calendar or [])[:8]
])
# Macro regime block
macro_block = ""
if macro_regime:
sc = macro_regime.get("scenarios", {})
gauges = macro_regime.get("gauges", {})
dominant = sc.get("dominant", "incertain")
scores = sc.get("scores", {})
asset_bias = sc.get("asset_bias", {}).get(dominant, {})
reasons = sc.get("reasons", {}).get(dominant, [])
vix = gauges.get("vix", {}).get("value")
slope = gauges.get("slope_10y3m", {}).get("value")
gold_cu = gauges.get("gold_copper_ratio", {}).get("value")
spx_200 = gauges.get("spx_vs_200d", {}).get("value")
brent_chg = gauges.get("brent", {}).get("change_pct")
bias_lines = "\n".join([f" - {cls}: {b}" for cls, b in asset_bias.items()])
brent_str = f"{brent_chg:+.2f}%" if brent_chg is not None else "N/A"
macro_block = f"""
## Régime macro actuel (30 compteurs institutionnels)
- Scénario dominant: {dominant.upper()} | Scores: {json.dumps(scores, ensure_ascii=False)}
- Signaux clés: {', '.join(reasons[:4])}
- Compteurs: VIX={vix} | Pente 10Y-3M={slope}% | Or/Cuivre={gold_cu} | SPX vs 200j={spx_200}% | Brent J-1={brent_str}
- Biais par classe d'actif (scénario {dominant.upper()}):
{bias_lines}
⚠️ CONTRAINTE: Les patterns proposés doivent être COHÉRENTS avec ce régime macro.
- Favorise les patterns dont l'asset_class a un biais "bullish" ou "bullish+" dans le régime actuel.
- Évite les patterns haussiers sur des classes "bearish" ou "bearish+" sauf si un catalyseur géopolitique exceptionnel le justifie.
- Chaque pattern doit expliquer dans "macro_fit" pourquoi il est compatible (ou en tension) avec le régime {dominant.upper()}.
"""
geo_block = ""
if geo_score:
geo_block = f"\n## Risque géopolitique global\n- Score: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})\n- Top risques: {', '.join(str(r) for r in geo_score.get('top_risks', [])[:3])}\n"
lessons_block = ""
if portfolio_lessons:
super_ctx = portfolio_lessons.get("super_context", "")
priorities = portfolio_lessons.get("strategic_priorities", [])
mistakes = portfolio_lessons.get("recurring_mistakes", [])
lessons = portfolio_lessons.get("key_lessons") or []
super_block = ""
if super_ctx:
super_block = f"""
## 🧠 SUPER CONTEXTE — Base de raisonnement accumulée
{super_ctx[:600]}
Priorités stratégiques: {' | '.join(str(p) for p in priorities[:3])}
Erreurs récurrentes à éviter: {' | '.join(str(m) for m in mistakes[:3])}
"""
lessons_block = f"""
{super_block}
## ⚡ RETOUR DE PERFORMANCE — cycles précédents (rapport du {portfolio_lessons.get('created_at','?')[:10]})
Performance globale : {portfolio_lessons.get('headline', '')}
Pourquoi les gains : {portfolio_lessons.get('winners_analysis', '')[:200]}
Pourquoi les pertes : {portfolio_lessons.get('losers_analysis', '')[:200]}
Angles morts détectés : {portfolio_lessons.get('blind_spots', '')[:150]}
Priorités identifiées : {portfolio_lessons.get('next_cycle_priorities', '')[:200]}
Leçons clés :
{chr(10).join(f' - {l}' for l in lessons[:4])}
⚠️ CONSIGNE : Tiens compte de ce retour de performance et du Super Contexte pour proposer des patterns MIEUX CIBLÉS.
Évite les erreurs identifiées dans les pertes. Privilégie les types de thèses qui ont fonctionné.
"""
user = f"""Tu es un stratège géopolitique et financier senior.
{macro_block}{geo_block}{lessons_block}
## Actualités géopolitiques du moment (triées par impact)
{news_block}
## Prix des marchés (variation J-1)
{market_block}
## Calendrier économique à venir
{cal_block}
En analysant ce panorama, propose 4 à 6 NOUVEAUX patterns géopolitiques qui sont en train d'émerger RIGHT NOW et qui méritent d'être surveillés pour des opportunités d'options.
Ne reprend pas les patterns classiques connus (Middle East Oil Spike, Gold Flight to Safety, etc.) — propose des patterns SPÉCIFIQUES au contexte actuel, cohérents avec le régime macro.
IMPORTANT — CHAMP expected_move_pct:
Ce champ représente le RENDEMENT OPTION ATTENDU en % (levier inclus), PAS le mouvement du sous-jacent.
Raisonne: si le sous-jacent bouge de X% dans la direction attendue, combien gagne l'option en %?
- Long Call ATM (delta ~0.5, 30-90j): sous-jacent +5% → option +60 à +150%
- Long Call OTM (delta ~0.25): sous-jacent +8% → option +100 à +300%
- Bull Call Spread: sous-jacent +5% → spread +50 à +120% (plafonné)
- Long Straddle: mouvement ±10% → option +80 à +200%
Exemples réalistes: Long Call énergie sur catalyseur fort → 80-200%. Spread défensif → 40-100%.
Retourne UNIQUEMENT ce JSON:
{{
"patterns": [
{{
"name": "<nom court et percutant>",
"description": "<mécanisme géopolitique → impact marché, 2-3 phrases>",
"macro_fit": "<1-2 phrases: pourquoi ce pattern est cohérent ou en tension avec le régime macro actuel, et quel catalyseur géopolitique le justifie>",
"triggers": ["<trigger1>", "<trigger2>"],
"keywords": ["<kw1>", "<kw2>", "<kw3>"],
"asset_class": "<energy|metals|agriculture|indices|equities|forex>",
"expected_move_pct": <float, RENDEMENT OPTION MOYEN en % pour ce pattern, levier inclus. Typiquement 50-300%.>,
"probability": <float 0-1>,
"horizon_days": <int>,
"suggested_trades": [
{{
"strategy": "<Long Call|Long Put|Bull Call Spread|Bear Put Spread|Long Straddle>",
"underlying": "<ticker Yahoo Finance>",
"rationale": "<pourquoi ce trade dans ce contexte macro+géo>",
"asset_class": "<classe>",
"expected_move_pct": <float, RENDEMENT OPTION en % pour CE trade si thèse confirmée. Long Call: 80-250%, Spread: 40-120%, Straddle: 60-180%.>
}},
{{
"strategy": "<autre stratégie>",
"underlying": "<ticker Yahoo Finance>",
"rationale": "<rationale>",
"asset_class": "<classe>",
"expected_move_pct": <float, rendement option attendu en % pour ce trade spécifique>
}}
]
}}
]
}}"""
result = _chat(SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000)
if not result:
return []
return result.get("patterns", [])
# ── AI news batch scoring: impact magnitude + directional signals ─────────────
def ai_score_news_batch(news_items: List[Dict]) -> List[Dict]:
"""Score news items with AI: accurate impact + per-asset directional signal.
Adds ai_dir_energy/metals/indices, ai_resolution, ai_insight, ai_scored fields.
Called before pattern scoring so contra-signals can be detected.
"""
if not get_client() or not news_items:
return news_items
to_score = [n for n in news_items[:20] if not n.get("ai_scored")]
if not to_score:
return news_items
compact = [
{"i": idx, "t": n.get("title", ""), "s": (n.get("summary", "") or "")[:150]}
for idx, n in enumerate(to_score)
]
user = f"""Score these geopolitical news items for TRUE market impact.
CRITICAL: Resolution events (peace deals, ceasefires, truces, agreements ending conflicts)
have HIGH impact (0.7-0.9) but are BEARISH for oil/energy and BEARISH for safe-haven patterns.
Items: {json.dumps(compact, ensure_ascii=False)}
For each item return:
- impact_score: 0.0-1.0 real magnitude (resolution = high, sports/culture = low)
- dir_energy: "bullish"|"bearish"|"neutral" (for oil/gas/energy)
- dir_metals: "bullish"|"bearish"|"neutral" (for gold/silver/copper)
- dir_indices: "bullish"|"bearish"|"neutral" (risk-on vs risk-off)
- resolution: true if this is a de-escalation/peace/deal that REDUCES a prior conflict
- insight: "<1 short French sentence on main market effect>"
JSON: {{"items": [{{"i":<int>,"impact_score":<float>,"dir_energy":"...","dir_metals":"...","dir_indices":"...","resolution":<bool>,"insight":"..."}}]}}"""
result = _chat(SYSTEM_NEWS, user, model="gpt-4o-mini", json_mode=True, max_tokens=2000)
if not result:
return news_items
scored_map = {s["i"]: s for s in result.get("items", [])}
for idx, n in enumerate(to_score):
s = scored_map.get(idx)
if s:
n["impact_score"] = max(0.0, min(1.0, float(s.get("impact_score") or n.get("impact_score", 0.1))))
n["ai_dir_energy"] = s.get("dir_energy", "neutral")
n["ai_dir_metals"] = s.get("dir_metals", "neutral")
n["ai_dir_indices"] = s.get("dir_indices", "neutral")
n["ai_resolution"] = bool(s.get("resolution", False))
n["ai_insight"] = s.get("insight", "")
n["ai_scored"] = True
return news_items
# ── Re-score news batch with AI ───────────────────────────────────────────────
def ai_rescore_news(news_items: List[Dict]) -> List[Dict]:
"""Batch re-score news items using AI for better classification."""
if not get_client() or not news_items:
return news_items
rescored = []
for item in news_items[:20]:
try:
ai = analyze_news_item(item.get("title", ""), item.get("summary", ""))
item["ai_category"] = ai.get("category", item.get("category"))
item["ai_impact"] = ai.get("impact_score", item.get("impact_score"))
item["ai_direction"] = ai.get("direction", "neutral")
item["ai_reasoning"] = ai.get("reasoning", "")
item["ai_entities"] = ai.get("key_entities", [])
if ai.get("affected_assets"):
item["asset_impacts"] = ai["affected_assets"]
except Exception:
pass
rescored.append(item)
return rescored

View File

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

View File

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

1405
backend/services/database.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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