Initial commit — GeoOptions Intelligence Cockpit v2.0

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

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

View File

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