Initial commit — GeoOptions Intelligence Cockpit v2.0
Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
297
backend/routers/ai.py
Normal file
297
backend/routers/ai.py
Normal 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 10Y–3M: {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
126
backend/routers/backtest.py
Normal 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
91
backend/routers/config.py
Normal 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
74
backend/routers/cycle.py
Normal 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()
|
||||
72
backend/routers/geopolitical.py
Normal file
72
backend/routers/geopolitical.py
Normal 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
112
backend/routers/journal.py
Normal 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),
|
||||
}
|
||||
309
backend/routers/knowledge.py
Normal file
309
backend/routers/knowledge.py
Normal 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"),
|
||||
},
|
||||
}
|
||||
77
backend/routers/market_data.py
Normal file
77
backend/routers/market_data.py
Normal 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
111
backend/routers/options.py
Normal 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}
|
||||
70
backend/routers/patterns.py
Normal file
70
backend/routers/patterns.py
Normal 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}
|
||||
298
backend/routers/portfolio.py
Normal file
298
backend/routers/portfolio.py
Normal 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
|
||||
88
backend/routers/profiles.py
Normal file
88
backend/routers/profiles.py
Normal 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,
|
||||
}
|
||||
332
backend/routers/reasoning.py
Normal file
332
backend/routers/reasoning.py
Normal 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
|
||||
Reference in New Issue
Block a user