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

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
*.egg
*.egg-info/
dist/
build/
.eggs/
venv/
.venv/
env/
.env
*.env
# Database — ne pas versionner les données de production
backend/data/*.db
backend/data/*.db-shm
backend/data/*.db-wal
# Node / frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
.npm/
# IDE
.vscode/settings.json
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
desktop.ini
# Claude Code settings (locaux, non partagés)
.claude/
# Fichiers de travail locaux
*.docx
docx_extract.txt
# Logs
*.log
logs/
# Deploy secrets
deploy/.env
deploy/nginx/certs/
deploy/nginx/certbot-webroot/

179
GUIDE.md Normal file
View File

@@ -0,0 +1,179 @@
# GeoOptions Intelligence — Guide d'utilisation
## Démarrage
```powershell
powershell -ExecutionPolicy Bypass -File c:\DataS\OpenFin\start.ps1
```
Le script tue les anciens processus, démarre le backend (port 8000) et le frontend (port 5173), puis ouvre automatiquement **http://localhost:5173** dans le navigateur.
---
## Les 9 pages
### 1. Cockpit (page d'accueil)
Vue centrale de pilotage. S'actualise toutes les minutes.
| Zone | Ce qu'elle montre |
|---|---|
| **Risque Géopolitique** | Score 0100 calculé depuis les flux RSS en temps réel |
| **Cartographie risques** | Radar par catégorie (énergie, métaux, agriculture…) |
| **Patterns actifs** | Top 3 patterns déclenchés avec leur score de similarité |
| **Catalyseurs** | Prochains événements économiques importants |
| **Top 10 idées** | Idées de trades générées automatiquement (≈1000€, horizon 3 mois) |
| **Marchés** | Prix en direct : énergie, métaux, indices, forex |
**Ajouter un trade depuis une idée** : cliquer sur **"+ Ajouter au portefeuille"** sur une carte idée. La position est créée automatiquement avec le prix Black-Scholes du moment comme référence d'entrée.
**Top 10 IA** (bouton bleu, requiert clé OpenAI) : envoie le contexte géopolitique live à GPT-4o qui génère et classe les 10 meilleures opportunités du moment.
---
### 2. Radar Géopolitique
Analyse des flux d'information géopolitique.
- **Flux RSS** : actualités classées par catégorie (conflit, énergie, santé, politique)
- **Patterns déclenchés** : liste complète avec score, actif ciblé et direction suggérée
- **Score de risque détaillé** : décomposition par sous-catégorie
---
### 3. Marchés & Prix
Cotations temps réel (délai 15 min via Yahoo Finance) pour les 28 instruments du watchlist :
- Énergie : USO, XLE, UNG, BNO, OIL
- Métaux : GLD, SLV, COPX, PPLT, GDX
- Agriculture : WEAT, CORN, SOYB, JO, NIB
- Indices : SPY, QQQ, IWM, EFA, EEM, VIX
- Actions : XOM, CVX, LMT, MOS, AA
- Forex : UUP, FXE, FXY, FXF, CYB
Graphiques historiques disponibles par instrument (1j à 5 ans).
---
### 4. Options Lab
Pricer Black-Scholes interactif.
1. Choisir un **sous-jacent** (ou le taper manuellement)
2. Sélectionner une **stratégie** : Long Call, Long Put, Bull Call Spread, Bear Put Spread, Long Straddle
3. Régler **strike**, **type**, **durée** et **quantité**
4. Cliquer **"Calculer"** → prix de l'option, Greeks (Δ Θ ν ρ), breakeven
5. **Courbe P&L** : visualisation du gain/perte selon le prix du sous-jacent à expiration
La volatilité implicite (IV) est calculée automatiquement depuis l'historique de prix.
---
### 5. Patterns
Moteurs de détection géopolitique → signal de trade.
**Patterns intégrés (8)** : Middle East → Oil, US Tariffs → Agriculture, Geo Risk → Gold, Fed Hawkish → USD, China → Copper, Ukraine → Wheat, NG Disruption, Pandemic → VIX.
**Créer un pattern personnalisé** :
1. Cliquer **"Nouveau pattern"**
2. Remplir nom, description, mots-clés déclencheurs, actif ciblé, direction
3. **"Générer depuis un contexte"** (IA) : décrire librement la situation géopolitique → GPT-4o-mini génère le pattern complet
4. **"Évaluer avec IA"** : GPT-4o note le pattern sur 100 et indique ses forces/faiblesses
5. Sauvegarder → le pattern est actif immédiatement dans le Cockpit
---
### 6. Portefeuille
Suivi mark-to-market en temps réel de toutes les positions.
**Ajouter une position manuellement** :
1. Cliquer **"Nouvelle position"**
2. Remplir : sous-jacent, stratégie, strike, type (call/put), prime payée, capital, durée
3. Si la prime est renseignée → le P&L est calculé par rapport à cette prime réelle
4. Si non → le prix Black-Scholes au moment de l'ajout sert de référence (P&L démarre à ~0€)
**Lire une carte de position** :
| Champ | Signification |
|---|---|
| **Prime payée / Capital investi** | Référence d'entrée (BS au moment de l'achat si pas de prime réelle) |
| **Valeur BS actuelle** | Prix Black-Scholes calculé avec le spot live |
| **Spot sous-jacent** | Prix du sous-jacent récupéré en temps réel |
| **Jours restants** | Temps avant expiration (rouge < 14j, orange < 30j) |
| **Δ Θ ν** | Greeks nets de la position |
| **Frais IB** | $0.65/contrat (min $1.00) simulés à l'entrée |
| **P&L €/%** | (Valeur actuelle) (Référence entrée) (Frais IB entrée) |
**Clôturer une position** : cliquer "Clôturer la position", entrer la valeur de revente → le P&L net (frais sortie inclus) est calculé et la position passe dans l'historique.
**Supprimer une position** : icône poubelle (positions ouvertes) ou hover sur la ligne (positions clôturées).
**Courbe P&L** : onglet "Courbe P&L" → equity curve cumulée de tous les trades clôturés.
---
### 7. Backtest
Simulation historique d'une stratégie sur une période passée.
1. Choisir un sous-jacent, une stratégie, un strike et une durée
2. Sélectionner la fenêtre historique (1 an, 2 ans…)
3. Lancer le backtest → résultats : win rate, P&L moyen, max drawdown, sharpe ratio
---
### 8. Calendrier
Événements économiques à surveiller : Fed, BCE, NFP, CPI, OPEC, résultats trimestriels.
Classés par importance (! moyen, !! élevé, !!! critique).
---
### 9. Configuration
**Sources d'information** : activer/désactiver les flux RSS et APIs externes (Reuters, AP, Al Jazeera, FT, Bloomberg, EIA, FRED, USDA, WHO…).
**Clés API** :
- **OpenAI** : nécessaire pour le Top 10 IA, l'évaluation de patterns et l'analyse de discours
- **NewsAPI, EIA, FRED** : sources optionnelles pour enrichir les données
Après avoir entré la clé OpenAI et cliqué Sauvegarder, le badge "IA GPT-4o active" apparaît dans la sidebar.
---
## Workflow typique
```
1. Lancer start.ps1
2. Cockpit → lire le score de risque géopolitique
3. Patterns actifs → comprendre quels signaux sont déclenchés
4. Top 10 IA → générer les meilleures idées du moment
5. Options Lab → affiner le strike et visualiser la courbe P&L
6. "+ Ajouter au portefeuille" → position créée avec référence BS
7. Portefeuille → suivre l'évolution quotidienne
8. À terme → clôturer la position et voir la courbe P&L cumulée
```
---
## Frais IB simulés
| Nb contrats | Frais entrée | Frais sortie | Total |
|---|---|---|---|
| 1 | $1.00 | $1.00 | $2.00 |
| 3 | $1.95 | $1.95 | $3.90 |
| 5 | $3.25 | $3.25 | $6.50 |
| 10 | $6.50 | $6.50 | $13.00 |
1 contrat = 100 actions sous-jacentes. La prime s'exprime en $/share, la valeur totale en $/share × 100.
---
## Notes importantes
- Les prix sont en **temps différé de 15 minutes** (Yahoo Finance gratuit)
- La volatilité implicite est calculée depuis **l'historique de prix** (pas le marché des options)
- Les P&L sont en **euros** par convention d'affichage mais les sous-jacents sont cotés en **dollars**
- Ce cockpit est un **outil de simulation et d'aide à la décision** — pas une interface de trading réel

7
_start_backend.bat Normal file
View File

@@ -0,0 +1,7 @@
@echo off
title GeoOptions Backend
cd /d "C:\DataS\OpenFin\backend"
if not exist venv python -m venv venv
call venv\Scripts\activate.bat
pip install -r requirements.txt -q
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload

5
_start_frontend.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
title GeoOptions Frontend
cd /d "C:\DataS\OpenFin\frontend"
if not exist node_modules npm install
npm run dev

20
backend/Dockerfile Normal file
View File

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

70
backend/main.py Normal file
View File

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

View File

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

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

20
backend/requirements.txt Normal file
View File

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

View File

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

1405
backend/services/database.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

8
deploy/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Copier ce fichier en .env et remplir les valeurs
# cp .env.example .env
# Clé OpenAI pour GPT-4o (obligatoire pour les fonctionnalités IA)
OPENAI_API_KEY=sk-...
# (Optionnel) Forcer la timezone du backend
# TZ=Europe/Paris

49
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
backend:
build:
context: ../backend
dockerfile: Dockerfile
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
volumes:
- db_data:/app/data
restart: unless-stopped
networks:
- internal
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
restart: unless-stopped
networks:
- internal
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/certs:/etc/letsencrypt:ro
- ./nginx/certbot-webroot:/var/www/certbot:ro
depends_on:
- backend
- frontend
restart: unless-stopped
networks:
- internal
volumes:
db_data:
networks:
internal:
driver: bridge

View File

@@ -0,0 +1,25 @@
# Config temporaire HTTP-only — utilisée uniquement lors du premier setup
# pour permettre à Certbot d'obtenir le certificat SSL.
# Remplacée automatiquement par setup-vps.sh après obtention du cert.
server {
listen 80;
server_name openfin.open-squared.tech;
# Challenge ACME pour Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
}
}

View File

@@ -0,0 +1,61 @@
# Config HTTPS définitive avec SSL Let's Encrypt
# Copiée vers nginx.conf par setup-vps.sh après obtention du certificat.
server {
listen 80;
server_name openfin.open-squared.tech;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name openfin.open-squared.tech;
ssl_certificate /etc/letsencrypt/live/openfin.open-squared.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openfin.open-squared.tech/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Sécurité headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
# API FastAPI
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_buffering off;
}
# Swagger docs FastAPI
location /docs {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
location /openapi.json {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
# Frontend React SPA
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_read_timeout 30s;
}
}

93
deploy/setup-vps.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# setup-vps.sh — Premier déploiement complet sur VPS Ubuntu/Debian
# Usage : bash setup-vps.sh
set -euo pipefail
DOMAIN="openfin.open-squared.tech"
EMAIL="opensquaredgeneva@gmail.com"
REPO="https://gitea.open-squared.tech/admin/OpenFin.git"
DEPLOY_DIR="/opt/openfin"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
abort() { echo -e "${RED}[ERR]${NC} $*"; exit 1; }
# ── 1. Docker ─────────────────────────────────────────────────────────────────
info "Vérification de Docker..."
if ! command -v docker &>/dev/null; then
info "Installation de Docker..."
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
fi
if ! docker compose version &>/dev/null; then
info "Installation du plugin docker compose..."
apt-get install -y docker-compose-plugin
fi
info "Docker $(docker --version | cut -d' ' -f3) OK"
# ── 2. Clone du repo ──────────────────────────────────────────────────────────
if [ -d "$DEPLOY_DIR" ]; then
warn "Le dossier $DEPLOY_DIR existe — pull de la dernière version..."
git -C "$DEPLOY_DIR" pull
else
info "Clone du repo dans $DEPLOY_DIR..."
git clone "$REPO" "$DEPLOY_DIR"
fi
cd "$DEPLOY_DIR/deploy"
# ── 3. Fichier .env ───────────────────────────────────────────────────────────
if [ ! -f .env ]; then
cp .env.example .env
warn "Fichier .env créé. Remplis ta clé OpenAI :"
warn " nano $DEPLOY_DIR/deploy/.env"
warn "Puis relance ce script."
exit 0
fi
if grep -q "sk-\.\.\." .env; then
abort "La clé OPENAI_API_KEY n'est pas configurée dans .env"
fi
# ── 4. Créer les dossiers nécessaires ─────────────────────────────────────────
mkdir -p nginx/certs nginx/certbot-webroot
# ── 5. Premier démarrage HTTP pour obtenir le cert SSL ────────────────────────
info "Démarrage initial en HTTP pour la vérification Let's Encrypt..."
cp nginx/nginx-http-init.conf nginx/nginx.conf
docker compose up -d --build
info "Attente que le frontend soit prêt..."
sleep 15
# ── 6. Certificat SSL Let's Encrypt ──────────────────────────────────────────
info "Obtention du certificat SSL pour $DOMAIN..."
docker run --rm \
-v "$DEPLOY_DIR/deploy/nginx/certs:/etc/letsencrypt" \
-v "$DEPLOY_DIR/deploy/nginx/certbot-webroot:/var/www/certbot" \
certbot/certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d "$DOMAIN" \
--email "$EMAIL" \
--agree-tos \
--non-interactive \
--expand
# ── 7. Passage en HTTPS ───────────────────────────────────────────────────────
info "Activation de la config HTTPS..."
cp nginx/nginx-https.conf nginx/nginx.conf
docker compose restart nginx
# ── 8. Renouvellement automatique du cert (cron mensuel) ─────────────────────
CRON_CMD="0 3 1 * * docker run --rm -v $DEPLOY_DIR/deploy/nginx/certs:/etc/letsencrypt -v $DEPLOY_DIR/deploy/nginx/certbot-webroot:/var/www/certbot certbot/certbot renew --quiet && docker compose -f $DEPLOY_DIR/deploy/docker-compose.yml restart nginx"
(crontab -l 2>/dev/null | grep -v certbot; echo "$CRON_CMD") | crontab -
info "Renouvellement SSL automatique configuré (1er de chaque mois à 3h)"
# ── 9. Résumé ─────────────────────────────────────────────────────────────────
echo ""
info "=== Déploiement terminé ==="
info "Cockpit disponible sur : https://$DOMAIN"
info "Statut des containers :"
docker compose ps

21
deploy/update.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# update.sh — Mise à jour du cockpit depuis le repo Gitea
# Usage : bash update.sh
set -euo pipefail
DEPLOY_DIR="/opt/openfin"
cd "$DEPLOY_DIR"
echo "[UPDATE] Pull de la dernière version..."
git pull
cd deploy
echo "[UPDATE] Rebuild et redémarrage des containers..."
docker compose up -d --build
echo "[UPDATE] Nettoyage des images orphelines..."
docker image prune -f
echo "[UPDATE] Done. Statut :"
docker compose ps

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ── Étape 1 : build React ─────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline
COPY . .
RUN npm run build
# ── Étape 2 : servir le build avec nginx ──────────────────────────────────────
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeoOptions Intelligence</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #0a0e17; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

16
frontend/nginx-spa.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing — toujours renvoyer index.html pour les routes React
location / {
try_files $uri $uri/ /index.html;
}
# Cache assets statiques (immutables car Vite les hash)
location ~* \.(js|css|png|jpg|jpeg|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

3476
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "geooptions-cockpit",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.13.0",
"@tanstack/react-query": "^5.59.0",
"zustand": "^5.0.0",
"axios": "^1.7.7",
"date-fns": "^4.1.0",
"lucide-react": "^0.447.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.9"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

49
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Sidebar from './components/layout/Sidebar'
import Dashboard from './pages/Dashboard'
import GeoRadar from './pages/GeoRadar'
import Markets from './pages/Markets'
import MacroRegime from './pages/MacroRegime'
import OptionsLab from './pages/OptionsLab'
import Backtest from './pages/Backtest'
import CalendarPage from './pages/CalendarPage'
import Portfolio from './pages/Portfolio'
import PatternEditor from './pages/PatternEditor'
import JournalDeBord from './pages/JournalDeBord'
import RapportIA from './pages/RapportIA'
import SuperContexte from './pages/SuperContexte'
import Config from './pages/Config'
import { useCycleWatcher } from './hooks/useApi'
function GlobalWatcher() {
useCycleWatcher()
return null
}
export default function App() {
return (
<BrowserRouter>
<div className="flex min-h-screen">
<GlobalWatcher />
<Sidebar />
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/geo" element={<GeoRadar />} />
<Route path="/markets" element={<Markets />} />
<Route path="/macro" element={<MacroRegime />} />
<Route path="/options" element={<OptionsLab />} />
<Route path="/patterns" element={<PatternEditor />} />
<Route path="/portfolio" element={<Portfolio />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/journal" element={<JournalDeBord />} />
<Route path="/rapport" element={<RapportIA />} />
<Route path="/super-contexte" element={<SuperContexte />} />
<Route path="/config" element={<Config />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,105 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Cockpit' },
{ to: '/geo', icon: Globe, label: 'Radar Géopolitique' },
{ to: '/markets', icon: BarChart2, label: 'Marchés & Prix' },
{ to: '/macro', icon: Activity, label: 'Régime Macro' },
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
{ to: '/patterns', icon: Zap, label: 'Patterns' },
{ to: '/portfolio', icon: DollarSign, label: 'Portefeuille' },
{ to: '/journal', icon: BookOpen, label: 'Journal de Bord' },
{ to: '/rapport', icon: FileBarChart, label: 'Rapport IA' },
{ to: '/super-contexte', icon: Brain, label: 'Super Contexte' },
{ to: '/backtest', icon: History, label: 'Backtest' },
{ to: '/calendar', icon: Calendar, label: 'Calendrier' },
{ to: '/config', icon: Settings, label: 'Configuration' },
]
const riskColors: Record<string, string> = {
low: 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40',
medium: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/40',
high: 'text-orange-400 bg-orange-900/30 border-orange-700/40',
extreme: 'text-red-400 bg-red-900/30 border-red-700/40 animate-pulse',
}
export default function Sidebar() {
const { data: riskScore } = useGeoRiskScore()
const { data: aiStatus } = useAiStatus()
const { data: summary } = usePortfolioSummary()
return (
<aside className="w-56 bg-dark-800 border-r border-slate-700/40 flex flex-col h-screen sticky top-0">
{/* Logo */}
<div className="p-4 border-b border-slate-700/40">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" />
<div>
<div className="text-sm font-bold text-white tracking-wide">GeoOptions</div>
<div className="text-xs text-slate-500">Intelligence v2.0</div>
</div>
</div>
</div>
{/* Geo risk indicator */}
{riskScore && (
<div className={clsx('mx-3 mt-3 px-3 py-2 rounded border text-xs', riskColors[riskScore.level])}>
<div className="font-semibold uppercase tracking-wider">Risque Géo</div>
<div className="text-lg font-bold">{riskScore.score}/100</div>
<div className="capitalize">{riskScore.level}</div>
</div>
)}
{/* Portfolio mini summary */}
{summary && (summary.open_positions > 0 || summary.unrealized_pnl !== 0) && (
<div className="mx-3 mt-2 px-3 py-2 rounded border border-slate-700/30 bg-dark-700/50 text-xs">
<div className="text-slate-500 uppercase tracking-wider text-xs mb-1">Portefeuille</div>
<div className="flex justify-between">
<span className="text-slate-400">{summary.open_positions} positions</span>
<span className={clsx('font-bold', summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{summary.unrealized_pnl >= 0 ? '+' : ''}{summary.unrealized_pnl?.toFixed(0)}
</span>
</div>
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-2 mt-2 space-y-0.5 overflow-y-auto">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) => clsx('nav-link', isActive && 'active')}
>
<Icon className="w-4 h-4 shrink-0" />
<span>{label}</span>
</NavLink>
))}
</nav>
{/* AI status badge */}
<div className="px-3 py-2 border-t border-slate-700/40">
<div className={clsx('flex items-center gap-2 text-xs px-2 py-1.5 rounded', {
'bg-blue-900/30 text-blue-400': aiStatus?.enabled,
'bg-dark-700 text-slate-600': !aiStatus?.enabled,
})}>
<BrainCircuit className="w-3.5 h-3.5" />
<span>{aiStatus?.enabled ? 'IA GPT-4o active' : 'IA non configurée'}</span>
</div>
</div>
{/* Footer */}
<div className="p-3 border-t border-slate-700/40 text-xs text-slate-600">
<div>© 2026 GeoOptions</div>
<div className="text-slate-700 mt-0.5">Local · IBKR ready</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,545 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import axios from 'axios'
import type {
Quote, GeoNews, GeoRiskScore, PatternMatch, TradeIdea,
EconomicEvent, HistoricalCandle, BacktestResult
} from '../types'
export const api = axios.create({ baseURL: '/api' })
// ── Market ────────────────────────────────────────────────────────────────────
export const useAllQuotes = () =>
useQuery<Record<string, Quote[]>>({
queryKey: ['quotes'],
queryFn: () => api.get('/market/quotes').then(r => r.data),
refetchInterval: 60_000,
})
export const useHistory = (symbol: string, period = '1y', interval = '1d') =>
useQuery<HistoricalCandle[]>({
queryKey: ['history', symbol, period, interval],
queryFn: () =>
api.get(`/market/history/${encodeURIComponent(symbol)}`, { params: { period, interval } }).then(r => r.data),
enabled: !!symbol,
})
// ── Geo ───────────────────────────────────────────────────────────────────────
export const useGeoNews = () =>
useQuery<GeoNews[]>({
queryKey: ['geo-news'],
queryFn: () => api.get('/geo/news').then(r => r.data),
refetchInterval: 3_600_000,
})
export const useGeoRiskScore = () =>
useQuery<GeoRiskScore>({
queryKey: ['geo-risk-score'],
queryFn: () => api.get('/geo/risk-score').then(r => r.data),
refetchInterval: 3_600_000,
})
export const usePatternMatches = () =>
useQuery<PatternMatch[]>({
queryKey: ['pattern-matches'],
queryFn: () => api.get('/geo/pattern-matches').then(r => r.data),
})
export const usePatternRelevance = (days: number) =>
useQuery({
queryKey: ['pattern-relevance', days],
queryFn: () => api.get('/geo/pattern-relevance', { params: { days } }).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useTradeIdeas = () =>
useQuery<TradeIdea[]>({
queryKey: ['trade-ideas'],
queryFn: () => api.get('/geo/trade-ideas').then(r => r.data),
})
export const useCalendar = () =>
useQuery<EconomicEvent[]>({
queryKey: ['calendar'],
queryFn: () => api.get('/geo/calendar').then(r => r.data),
})
// ── Options ───────────────────────────────────────────────────────────────────
export const useIvSurface = (symbol: string) =>
useQuery({
queryKey: ['iv-surface', symbol],
queryFn: () => api.get('/options/iv-surface', { params: { symbol } }).then(r => r.data),
enabled: !!symbol,
})
export const usePnlCurve = (params: {
symbol: string; strike: number; expiry_days: number
option_type: string; quantity: number; premium_paid: number
}) =>
useQuery({
queryKey: ['pnl-curve', params],
queryFn: () => api.get('/options/pnl-curve', { params }).then(r => r.data),
enabled: !!params.symbol && !!params.strike && !!params.premium_paid,
})
// ── Backtest ──────────────────────────────────────────────────────────────────
export const useBacktest = () =>
useMutation<BacktestResult, Error, Record<string, unknown>>({
mutationFn: (data) => api.post('/backtest/run', data).then(r => r.data),
})
// ── AI ────────────────────────────────────────────────────────────────────────
export const useAiStatus = () =>
useQuery({
queryKey: ['ai-status'],
queryFn: () => api.get('/ai/status').then(r => r.data),
refetchInterval: 30_000,
})
export const useAiTopIdeas = () =>
useQuery({
queryKey: ['ai-top-ideas'],
queryFn: () => api.get('/ai/top-ideas').then(r => r.data),
enabled: false,
retry: false,
})
export const useAnalyzeSpeech = () =>
useMutation({
mutationFn: (data: { text: string; speaker?: string }) =>
api.post('/ai/analyze-speech', data).then(r => r.data),
})
export const useEvaluatePattern = () =>
useMutation({
mutationFn: (pattern: Record<string, unknown>) =>
api.post('/ai/evaluate-pattern', { pattern }).then(r => r.data),
})
export const useSuggestPattern = () =>
useMutation({
mutationFn: (context: string) =>
api.post('/ai/suggest-pattern', { context }).then(r => r.data),
})
export const useAiTopIdeasRefetch = () =>
useMutation({
mutationFn: () => api.get('/ai/top-ideas').then(r => r.data),
})
// ── Portfolio ─────────────────────────────────────────────────────────────────
export const usePortfolioPositions = (status = 'open') =>
useQuery({
queryKey: ['portfolio', status],
queryFn: () => api.get('/portfolio/positions', { params: { status } }).then(r => r.data),
refetchInterval: status === 'open' ? 60_000 : false,
})
export const usePortfolioSummary = () =>
useQuery({
queryKey: ['portfolio-summary'],
queryFn: () => api.get('/portfolio/summary').then(r => r.data),
refetchInterval: 60_000,
})
export const usePnlHistory = () =>
useQuery({
queryKey: ['pnl-history'],
queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data),
})
export const useAddPosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.post('/portfolio/add', data).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
},
})
}
export const useClosePosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, close_value }: { id: string; close_value: number }) =>
api.post(`/portfolio/close/${id}`, { close_value }).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
qc.invalidateQueries({ queryKey: ['pnl-history'] })
},
})
}
// ── Config ────────────────────────────────────────────────────────────────────
export const useConfig = () =>
useQuery({
queryKey: ['config'],
queryFn: () => api.get('/config/').then(r => r.data),
})
export const useSources = () =>
useQuery({
queryKey: ['sources'],
queryFn: () => api.get('/config/sources').then(r => r.data),
})
export const useUpdateSources = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (sources: Record<string, unknown>) =>
api.put('/config/sources', { sources }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['sources'] }),
})
}
export const useUpdateApiKeys = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (keys: Record<string, string>) =>
api.put('/config/api-keys', keys).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['config'] })
qc.invalidateQueries({ queryKey: ['ai-status'] })
},
})
}
// ── AI Pattern Scoring ────────────────────────────────────────────────────────
export const useScorePatterns = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (params: { top_n?: number; category_filter?: string; template?: string }) =>
api.post('/ai/score-patterns', params).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['last-scores'] }),
})
}
export const useLastScores = () =>
useQuery({
queryKey: ['last-scores'],
queryFn: () => api.get('/ai/last-scores').then(r => r.data),
staleTime: Infinity,
})
export const useSuggestNewPatterns = () =>
useMutation({
mutationFn: () => api.post('/ai/suggest-new-patterns').then(r => r.data),
})
export const useAnalysisConfig = () =>
useQuery({
queryKey: ['analysis-config'],
queryFn: () => api.get('/config/analysis').then(r => r.data),
})
export const useSaveAnalysisConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (cfg: { top_n?: number; category_filter?: string; template?: string }) =>
api.put('/config/analysis', cfg).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['analysis-config'] }),
})
}
// ── Patterns ──────────────────────────────────────────────────────────────────
export const useAllPatterns = () =>
useQuery({
queryKey: ['all-patterns'],
queryFn: () => api.get('/patterns/all').then(r => r.data),
})
export const usePatternSimilarity = () =>
useQuery({
queryKey: ['pattern-similarity'],
queryFn: () => api.get('/ai/pattern-similarity').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useMacroRegime = () => {
const qc = useQueryClient()
const query = useQuery({
queryKey: ['macro-regime'],
queryFn: () => api.get('/market/macro-regime').then(r => r.data),
staleTime: 10 * 60 * 1000,
refetchInterval: 15 * 60 * 1000,
})
const forceRefetch = async () => {
const fresh = await api.get('/market/macro-regime?force=true').then(r => r.data)
qc.setQueryData(['macro-regime'], fresh)
return fresh
}
return { ...query, forceRefetch }
}
export const useSavePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (pattern: Record<string, unknown>) =>
api.post('/patterns/custom', pattern).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
export const useDeletePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
api.delete(`/patterns/custom/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
export const useTogglePattern = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
api.put(`/patterns/toggle/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['all-patterns'] }),
})
}
// ── Auto-Cycle ───────────────────────────────────────────────────────────────
export const useCycleStatus = () =>
useQuery({
queryKey: ['cycle-status'],
queryFn: () => api.get('/cycle/status').then(r => r.data),
staleTime: 5_000,
// Poll every 5s while a cycle is running, every 30s otherwise
refetchInterval: (query) => ((query.state.data as any)?.running ? 5_000 : 30_000),
})
export const useCycleHistory = (limit = 20) =>
useQuery({
queryKey: ['cycle-history', limit],
queryFn: () => api.get(`/cycle/history?limit=${limit}`).then(r => r.data),
staleTime: 60_000,
})
export const useUpdateCycleConfig = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (cfg: { enabled?: boolean; interval_hours?: number; similarity_threshold?: number; min_ev_threshold?: number; min_score_threshold?: number }) =>
api.post('/cycle/config', cfg).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cycle-status'] }),
})
}
/** Keys to refresh once a cycle finishes — covers cockpit, macro, journal */
export const CYCLE_REFRESH_KEYS = [
['macro-regime'],
['last-scores'],
['all-patterns'],
['cycle-history'],
['cycle-status'],
['journal-summary'],
['journal-mtm'],
['journal-geo'],
['journal-macro'],
['geo-risk-score'],
['pattern-matches'],
]
export const useTriggerCycle = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/cycle/trigger').then(r => r.data),
onSuccess: () => {
// Immediate status refresh so the spinner shows
qc.invalidateQueries({ queryKey: ['cycle-status'] })
},
})
}
/**
* Mount this hook once at a high level (e.g. CyclesSection or App).
* It watches cycle-status and, when a running cycle finishes, refreshes
* all cockpit + journal queries so the UI reflects the new scores/regime.
*/
export const useCycleWatcher = () => {
const qc = useQueryClient()
const { data: statusData } = useCycleStatus()
const wasRunning = useRef(false)
useEffect(() => {
const running = (statusData as any)?.running ?? false
if (running) {
wasRunning.current = true
} else if (wasRunning.current) {
// Transition: was running → now done → refresh everything
wasRunning.current = false
CYCLE_REFRESH_KEYS.forEach(key => qc.invalidateQueries({ queryKey: key }))
}
}, [(statusData as any)?.running])
}
// ── Journal de Bord ──────────────────────────────────────────────────────────
export const useJournalSummary = () =>
useQuery({
queryKey: ['journal-summary'],
queryFn: () => api.get('/journal/summary').then(r => r.data),
staleTime: 2 * 60_000,
})
export const useMacroHistory = (days = 15) =>
useQuery({
queryKey: ['journal-macro', days],
queryFn: () => api.get(`/journal/macro-history?days=${days}`).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useGeoHistory = (days = 30) =>
useQuery({
queryKey: ['journal-geo', days],
queryFn: () => api.get(`/journal/geo-history?days=${days}`).then(r => r.data),
staleTime: 5 * 60_000,
})
export const useTradeMtm = (days = 30) =>
useQuery({
queryKey: ['journal-mtm', days],
queryFn: () => api.get(`/journal/trade-mtm?days=${days}`).then(r => r.data),
staleTime: 0,
refetchInterval: 5 * 60_000, // re-fetch live prices every 5 minutes
refetchIntervalInBackground: false,
})
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>
useQuery({
queryKey: ['risk-profiles'],
queryFn: () => api.get('/profiles').then(r => r.data),
staleTime: 30_000,
})
export const useUpsertProfile = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (profile: {
id?: number; name: string; min_score: number; min_gain_pct: number;
color?: string; enabled?: boolean; sort_order?: number
}) => {
if (profile.id) {
return api.put(`/profiles/${profile.id}`, profile).then(r => r.data)
}
return api.post('/profiles', profile).then(r => r.data)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }),
})
}
export const useDeleteProfile = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/profiles/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['risk-profiles'] }),
})
}
export const usePreviewTradeScore = (score: number, gainPct: number, enabled = true) =>
useQuery({
queryKey: ['profile-preview', score, gainPct],
queryFn: () => api.get(`/profiles/preview?score=${score}&gain_pct=${gainPct}`).then(r => r.data),
enabled,
staleTime: 0,
})
// ── Reasoning Traces / Post-mortem ────────────────────────────────────────────
export const useTradePostmortem = (tradeId: number | null) =>
useQuery({
queryKey: ['postmortem', tradeId],
queryFn: () => api.get(`/reasoning/postmortem/${tradeId}`).then(r => r.data),
enabled: tradeId !== null,
staleTime: 60_000,
})
export const useAnalyzePostmortem = () =>
useMutation({
mutationFn: (tradeId: number) =>
api.post(`/reasoning/postmortem/${tradeId}/analyze`).then(r => r.data),
})
export const usePortfolioReportData = (days: number) =>
useQuery({
queryKey: ['portfolio-report-data', days],
queryFn: () => api.get(`/reasoning/portfolio-report?days=${days}`).then(r => r.data),
staleTime: 120_000,
})
export const useGeneratePortfolioReport = () =>
useMutation({
mutationFn: (days: number) =>
api.post(`/reasoning/portfolio-report/generate?days=${days}`).then(r => r.data),
})
export const useAiReportsList = () =>
useQuery({
queryKey: ['ai-reports-list'],
queryFn: () => api.get('/reasoning/reports').then(r => r.data),
staleTime: 30_000,
})
export const useAiReport = (reportId: number | null) =>
useQuery({
queryKey: ['ai-report', reportId],
queryFn: () => api.get(`/reasoning/reports/${reportId}`).then(r => r.data),
enabled: reportId !== null,
staleTime: Infinity,
})
// ── Super Contexte / Knowledge Base ──────────────────────────────────────────
export const useKnowledgeState = () =>
useQuery({
queryKey: ['knowledge-state'],
queryFn: () => api.get('/knowledge/state').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useKnowledgeHistory = () =>
useQuery({
queryKey: ['knowledge-history'],
queryFn: () => api.get('/knowledge/history').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useKnowledgeStateVersion = (stateId: number | null) =>
useQuery({
queryKey: ['knowledge-state-version', stateId],
queryFn: () => api.get(`/knowledge/history/${stateId}`).then(r => r.data),
enabled: stateId !== null,
staleTime: Infinity,
})
export const useKnowledgeEntries = () =>
useQuery({
queryKey: ['knowledge-entries'],
queryFn: () => api.get('/knowledge/entries').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useSynthesizeKnowledge = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post('/knowledge/synthesize').then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['knowledge-state'] })
qc.invalidateQueries({ queryKey: ['knowledge-history'] })
qc.invalidateQueries({ queryKey: ['knowledge-entries'] })
},
})
}
export const usePatchKbEntryStatus = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) =>
api.patch(`/knowledge/entries/${id}/status`, { status }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge-entries'] }),
})
}

52
frontend/src/index.css Normal file
View File

@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-dark-900 text-slate-200 font-mono;
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-dark-800; }
::-webkit-scrollbar-thumb { @apply bg-dark-500 rounded; }
::-webkit-scrollbar-thumb:hover { @apply bg-slate-600; }
}
@layer components {
.card {
@apply bg-dark-800 border border-slate-700/40 rounded-lg p-4;
}
.card-sm {
@apply bg-dark-700 border border-slate-700/30 rounded p-3;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-green { @apply badge bg-emerald-900/50 text-emerald-400 border border-emerald-700/30; }
.badge-red { @apply badge bg-red-900/50 text-red-400 border border-red-700/30; }
.badge-yellow { @apply badge bg-yellow-900/50 text-yellow-400 border border-yellow-700/30; }
.badge-blue { @apply badge bg-blue-900/50 text-blue-400 border border-blue-700/30; }
.badge-orange { @apply badge bg-orange-900/50 text-orange-400 border border-orange-700/30; }
.badge-purple { @apply badge bg-purple-900/50 text-purple-400 border border-purple-700/30; }
.stat-value { @apply text-2xl font-bold text-white; }
.stat-label { @apply text-xs text-slate-500 uppercase tracking-wider; }
.nav-link {
@apply flex items-center gap-2 px-3 py-2 rounded text-sm text-slate-400
hover:bg-dark-600 hover:text-slate-200 transition-colors;
}
.nav-link.active {
@apply bg-dark-600 text-blue-400 border-l-2 border-blue-400;
}
.positive { @apply text-emerald-400; }
.negative { @apply text-red-400; }
.neutral { @apply text-slate-400; }
.section-title {
@apply text-xs font-semibold text-slate-500 uppercase tracking-widest mb-3;
}
.risk-low { @apply text-emerald-400; }
.risk-medium { @apply text-yellow-400; }
.risk-high { @apply text-orange-400; }
.risk-extreme { @apply text-red-400; }
}

19
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, retry: 1 },
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)

View File

@@ -0,0 +1,310 @@
import { useState } from 'react'
import { useBacktest } from '../hooks/useApi'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine,
} from 'recharts'
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
import type { BacktestResult } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call' },
{ key: 'long_put', label: 'Long Put' },
{ key: 'bull_call_spread', label: 'Bull Call Spread' },
{ key: 'bear_put_spread', label: 'Bear Put Spread' },
]
const SYMBOLS = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX',
]
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
return (
<div className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-xl font-bold mt-1', {
'text-emerald-400': positive === true,
'text-red-400': positive === false,
'text-white': positive === undefined,
})}>
{value}
</div>
{sub && <div className="text-xs text-slate-600 mt-0.5">{sub}</div>}
</div>
)
}
export default function Backtest() {
const { mutate: runBacktest, data: result, isPending } = useBacktest()
const [form, setForm] = useState({
symbol: 'GLD',
start_date: '2022-01-01',
end_date: '2024-12-31',
strategy: 'long_call',
strike_offset_pct: 0.05,
expiry_days: 90,
capital: 1000,
})
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const run = () => runBacktest(form as Record<string, unknown>)
const typed = result as BacktestResult | undefined
const hasResult = typed && !typed.error
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-blue-400" /> Backtest & Simulation
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Testez vos stratégies options sur des données historiques réelles
</p>
</div>
<div className="grid grid-cols-4 gap-5">
{/* Config panel */}
<div className="col-span-1 space-y-4">
<div className="card">
<div className="section-title">Configuration</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</label>
<div className="flex flex-wrap gap-1 mb-1">
{SYMBOLS.slice(0, 7).map(s => (
<button
key={s}
onClick={() => set('symbol', s)}
className={clsx('px-1.5 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.symbol === s,
'border-slate-700 text-slate-500': form.symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={form.symbol}
onChange={e => set('symbol', e.target.value.toUpperCase())}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Stratégie</label>
{STRATEGIES.map(s => (
<button
key={s.key}
onClick={() => set('strategy', s.key)}
className={clsx('w-full text-left px-2 py-1.5 rounded mb-1 text-xs border transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': form.strategy === s.key,
'border-slate-700/40 text-slate-400': form.strategy !== s.key,
})}
>
{s.label}
</button>
))}
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Période</label>
<input
type="date"
value={form.start_date}
onChange={e => set('start_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white mb-1 focus:outline-none focus:border-blue-500"
/>
<input
type="date"
value={form.end_date}
onChange={e => set('end_date', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Strike OTM: {(form.strike_offset_pct * 100).toFixed(0)}%
</label>
<input
type="range" min={0} max={0.20} step={0.01}
value={form.strike_offset_pct}
onChange={e => set('strike_offset_pct', Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Expiration: {form.expiry_days}j</label>
<div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => (
<button
key={d}
onClick={() => set('expiry_days', d)}
className={clsx('flex-1 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.expiry_days === d,
'border-slate-700 text-slate-500': form.expiry_days !== d,
})}
>
{d}j
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Capital initial ()</label>
<input
type="number"
value={form.capital}
onChange={e => set('capital', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
<button
onClick={run}
disabled={isPending}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold flex items-center justify-center gap-2 transition-colors"
>
<Play className="w-4 h-4" />
{isPending ? 'Calcul...' : 'Lancer le backtest'}
</button>
</div>
</div>
</div>
{/* Results panel */}
<div className="col-span-3 space-y-4">
{typed?.error && (
<div className="card border-red-700/40 bg-red-900/10">
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4" /> {typed.error}
</div>
</div>
)}
{hasResult && (
<>
{/* KPIs */}
<div className="grid grid-cols-4 gap-3">
<StatCard
label="Retour total"
value={`${typed.total_return_pct >= 0 ? '+' : ''}${typed.total_return_pct.toFixed(2)}%`}
positive={typed.total_return_pct >= 0}
/>
<StatCard
label="Taux de succès"
value={`${typed.win_rate.toFixed(1)}%`}
sub={`${typed.wins}W / ${typed.losses}L`}
positive={typed.win_rate >= 50}
/>
<StatCard
label="Max Drawdown"
value={`${typed.max_drawdown_pct.toFixed(2)}%`}
positive={false}
/>
<StatCard
label="Profit Factor"
value={typed.profit_factor.toFixed(2)}
sub={`${typed.total_trades} trades`}
positive={typed.profit_factor >= 1}
/>
</div>
{/* Equity curve */}
<div className="card">
<div className="section-title">Courbe d'équité — {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}</div>
<div className="text-xs text-slate-500 mb-3">
Capital final: <span className="text-white font-bold">{typed.final_capital.toFixed(2)}€</span>
{' '}(initial: {form.capital}€ · P&L: {typed.total_pnl >= 0 ? '+' : ''}{typed.total_pnl.toFixed(2)}€)
</div>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={typed.equity_curve}>
<defs>
<linearGradient id="equity-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
<stop offset="95%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="index" tick={{ fill: '#475569', fontSize: 9 }} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(0)}€`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`${v.toFixed(2)}€`, 'Capital']}
/>
<ReferenceLine y={form.capital} stroke="#475569" strokeDasharray="4 4" label={{ value: 'Initial', fill: '#475569', fontSize: 9 }} />
<Area
type="monotone" dataKey="capital"
stroke={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'}
fill="url(#equity-grad)" strokeWidth={2} dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Last trades */}
<div className="card">
<div className="section-title">Derniers trades exécutés</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Entrée</th>
<th className="text-left pb-2">Sortie</th>
<th className="text-right pb-2">Prix entrée</th>
<th className="text-right pb-2">Strike</th>
<th className="text-right pb-2">Prime</th>
<th className="text-right pb-2">Prix sortie</th>
<th className="text-right pb-2">P&L</th>
<th className="text-right pb-2">Capital</th>
</tr>
</thead>
<tbody>
{typed.trades.map((t, i) => {
const pnl = t.pnl as number
return (
<tr key={i} className="border-b border-slate-700/20 hover:bg-dark-700/50">
<td className="py-1">{t.entry_date as string}</td>
<td className="py-1">{t.exit_date as string}</td>
<td className="py-1 text-right font-mono">${(t.S_entry as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.K as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.premium as number).toFixed(4)}</td>
<td className="py-1 text-right font-mono">${(t.S_expiry as number).toFixed(2)}</td>
<td className={clsx('py-1 text-right font-mono font-bold', pnl >= 0 ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}
</td>
<td className="py-1 text-right font-mono text-slate-300">{(t.capital as number).toFixed(2)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</>
)}
{!hasResult && !isPending && !typed?.error && (
<div className="card h-80 flex items-center justify-center text-slate-600">
<div className="text-center">
<History className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">Configurer et lancer un backtest</div>
<div className="text-xs mt-1">Données yfinance historique complet disponible</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
import { useCalendar, useGeoNews } from '../hooks/useApi'
import clsx from 'clsx'
import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react'
import type { EconomicEvent, AssetClass } from '../types'
import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns'
import { fr } from 'date-fns/locale'
const ASSET_EMOJIS: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', rates: '🏦',
}
const COUNTRY_FLAGS: Record<string, string> = {
US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧',
DE: '🇩🇪', FR: '🇫🇷', Global: '🌍',
}
const IMPORTANCE_CONFIG: Record<string, { color: string; label: string; dots: number }> = {
high: { color: 'text-red-400 border-red-700/40', label: 'Majeur', dots: 3 },
medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Modéré', dots: 2 },
low: { color: 'text-slate-400 border-slate-700/40', label: 'Mineur', dots: 1 },
}
function EventCard({ ev }: { ev: EconomicEvent }) {
const cfg = IMPORTANCE_CONFIG[ev.importance]
const isPast = ev.actual !== undefined && ev.actual !== null
const today = new Date()
const evDate = parseISO(ev.date)
const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3))
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-yellow-500/40 bg-yellow-900/5': isToday,
'border-blue-500/30': isSoon && !isToday,
'opacity-60': isPast,
})}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-base">{COUNTRY_FLAGS[ev.country] ?? '🌍'}</span>
<span className={clsx('text-xs font-bold uppercase tracking-wider', cfg.color.split(' ')[0])}>
{'●'.repeat(cfg.dots)}
</span>
{isToday && <span className="badge badge-yellow text-xs">AUJOURD'HUI</span>}
{isSoon && !isToday && <span className="badge badge-blue text-xs">BIENTÔT</span>}
</div>
<div className="text-sm text-white font-semibold">{ev.title}</div>
<div className="text-xs text-slate-500 mt-0.5">
{format(evDate, "EEEE d MMM yyyy", { locale: fr })} · {ev.country}
</div>
</div>
<div className="text-right shrink-0">
<div className={clsx('text-xs font-semibold', cfg.color.split(' ')[0])}>{cfg.label}</div>
{ev.previous && <div className="text-xs text-slate-600 mt-0.5">Préc: {ev.previous}</div>}
{ev.forecast && <div className="text-xs text-slate-500">Prév: {ev.forecast}</div>}
{ev.actual && <div className="text-xs text-emerald-400 font-bold">Réel: {ev.actual}</div>}
</div>
</div>
{ev.asset_impact && ev.asset_impact.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{ev.asset_impact.map(cls => (
<span key={cls} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700/40">
{ASSET_EMOJIS[cls] ?? ''} {cls}
</span>
))}
</div>
)}
</div>
)
}
export default function CalendarPage() {
const { data: calendar, isLoading } = useCalendar()
const { data: news } = useGeoNews()
const today = new Date()
const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? []
const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? []
const highImpactNews = news?.filter(n => n.impact_score > 0.4).slice(0, 5) ?? []
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-400" /> Calendrier Économique & Géopolitique
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Événements macro, catalyseurs géopolitiques, dates clés
</p>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<div className="flex items-center gap-1"><span className="text-red-400">●●●</span> Majeur (forte volatilité attendue)</div>
<div className="flex items-center gap-1"><span className="text-yellow-400">●●</span> Modéré</div>
<div className="flex items-center gap-1"><span className="text-slate-400">●</span> Mineur</div>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Economic calendar */}
<div className="col-span-2 space-y-3">
<div className="section-title flex items-center gap-1">
<Clock className="w-3 h-3" /> Événements à venir ({upcoming.length})
</div>
{isLoading ? (
[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700"></div>)
) : upcoming.length > 0 ? (
upcoming.map((ev, i) => <EventCard key={i} ev={ev} />)
) : (
<div className="card text-center py-8 text-slate-500 text-sm">
Démarrer le backend pour charger le calendrier
</div>
)}
{past.length > 0 && (
<>
<div className="section-title mt-6 flex items-center gap-1 opacity-60">
Événements passés ({past.length})
</div>
{past.map((ev, i) => <EventCard key={i} ev={ev} />)}
</>
)}
</div>
{/* Right: geo alerts + timeline */}
<div className="col-span-1 space-y-4">
<div className="card">
<div className="section-title flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" /> Alertes géopolitiques
</div>
{highImpactNews.length > 0 ? (
<div className="space-y-2">
{highImpactNews.map((n, i) => (
<div key={i} className="card-sm">
<div className="flex items-start justify-between gap-1">
<div className="text-xs text-white line-clamp-2">{n.title}</div>
<span className="text-xs text-orange-400 font-bold shrink-0 ml-1">
{Math.round(n.impact_score * 100)}
</span>
</div>
<div className="text-xs text-slate-600 mt-1">{n.source}</div>
</div>
))}
</div>
) : (
<div className="text-xs text-slate-600 text-center py-4">Charger les actualités géopolitiques</div>
)}
</div>
{/* Trade opportunity windows */}
<div className="card">
<div className="section-title">Fenêtres d'opportunité</div>
<div className="space-y-2 text-xs">
{[
{ window: 'Pré-FOMC (-3j)', strategy: 'Straddle sur SPY', rationale: 'IV monte avant décision' },
{ window: 'Pré-NFP (-2j)', strategy: 'Straddle sur indices', rationale: 'Directional uncertainty' },
{ window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike probable' },
{ window: 'Pré-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' },
{ window: 'Élections US approche', strategy: 'Long VIX Call', rationale: 'Vol expansion garantie' },
].map((op, i) => (
<div key={i} className="card-sm">
<div className="font-semibold text-blue-400">{op.window}</div>
<div className="text-white mt-0.5">{op.strategy}</div>
<div className="text-slate-500">{op.rationale}</div>
</div>
))}
</div>
</div>
{/* Geo-event impact guide */}
<div className="card">
<div className="section-title flex items-center gap-1">
<Globe className="w-3 h-3" /> Guide d'impact
</div>
<div className="space-y-1.5 text-xs">
{[
{ event: 'Conflit Moyen-Orient', impact: 'Oil +10-20%', cls: 'energy' },
{ event: 'Sanctions Russie', impact: 'Gaz +15-40%', cls: 'energy' },
{ event: 'Tarifs US-Chine', impact: 'Soja -8%', cls: 'agriculture' },
{ event: 'Crise sanitaire', impact: 'Or +7-12%', cls: 'metals' },
{ event: 'Hausses Fed', impact: 'USD +2-4%', cls: 'forex' },
{ event: 'Guerre Ukraine', impact: 'Blé +15-50%', cls: 'agriculture' },
].map((g, i) => (
<div key={i} className="flex justify-between items-center py-1 border-b border-slate-700/20 last:border-0">
<span className="text-slate-400">{g.event}</span>
<span className={clsx('font-bold', g.impact.includes('+') ? 'positive' : 'negative')}>
{g.impact}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,733 @@
import { useState, useEffect } from 'react'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X } from 'lucide-react'
import clsx from 'clsx'
const SOURCE_CATEGORIES = {
'Flux RSS actifs': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'],
'Données économiques': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'],
'Santé & Catastrophes': ['who', 'emdat'],
'Réseaux sociaux': ['twitter_trump'],
}
const SOURCE_DOCS: Record<string, { description: string; link?: string; cost: string }> = {
reuters_world: { description: 'Actualités mondiales Reuters', cost: 'Gratuit' },
reuters_business: { description: 'Business et marchés Reuters', cost: 'Gratuit' },
reuters_energy: { description: 'Énergie et commodités Reuters', cost: 'Gratuit' },
ap_top: { description: 'Associated Press — Top Stories', cost: 'Gratuit' },
aljazeera: { description: 'Al Jazeera — couverture Moyen-Orient/Monde', cost: 'Gratuit' },
ft: { description: 'Financial Times — finance internationale', cost: 'Gratuit' },
bloomberg: { description: 'Bloomberg Markets RSS', cost: 'Gratuit' },
newsapi: { description: '100 req/jour gratuit — actualités mondiales multi-sources', link: 'https://newsapi.org', cost: '100 req/j gratuit' },
gdelt: { description: 'Base de données géopolitique mondiale — 300K events/jour', link: 'https://gdeltproject.org', cost: 'Gratuit total' },
eia: { description: 'US Energy Information Administration — données pétrole/gaz hebdo', link: 'https://www.eia.gov/opendata', cost: 'Gratuit avec clé' },
fred: { description: 'Federal Reserve St. Louis — macro US (PIB, inflation, emploi)', link: 'https://fred.stlouisfed.org/docs/api/fred/', cost: 'Gratuit avec clé' },
usda: { description: 'USDA — rapports agricoles officiels US', cost: 'Gratuit' },
who: { description: 'OMS — alertes sanitaires mondiales RSS', cost: 'Gratuit' },
emdat: { description: 'EM-DAT — base de données catastrophes naturelles', link: 'https://www.emdat.be', cost: 'Inscription gratuite' },
twitter_trump: { description: 'Flux X/Twitter Trump — discours, annonces tarifs', cost: 'API payante ($100/mois+)' },
}
// ── Risk Profiles Component ───────────────────────────────────────────────────
const PROFILE_COLORS = [
{ value: '#22c55e', label: 'Vert' },
{ value: '#3b82f6', label: 'Bleu' },
{ value: '#f97316', label: 'Orange' },
{ value: '#ef4444', label: 'Rouge' },
{ value: '#8b5cf6', label: 'Violet' },
{ value: '#eab308', label: 'Jaune' },
]
function evNetLabel(evNet: number): { text: string; cls: string } {
if (evNet > 0.05) return { text: `EV nette +${(evNet * 100).toFixed(0)}%`, cls: 'text-emerald-400' }
if (evNet >= -0.01) return { text: 'EV nette ≈ 0', cls: 'text-yellow-400' }
return { text: `EV nette ${(evNet * 100).toFixed(0)}%`, cls: 'text-slate-600' }
}
function ProfileRow({
profile,
onSave,
onDelete,
}: {
profile: any
onSave: (p: any) => void
onDelete: (id: number) => void
}) {
const [editing, setEditing] = useState(false)
const [name, setName] = useState(profile.name)
const [score, setScore] = useState(profile.min_score)
const [gain, setGain] = useState(profile.min_gain_pct)
const [color, setColor] = useState(profile.color ?? '#3b82f6')
const [enabled, setEnabled] = useState(profile.enabled !== 0)
// Live EV computation
const p = score / 100
const G = gain / 100
const denom = p * G + (1 - p)
const ev_net = p * G - (1 - p)
const trade_score = denom > 0 ? (p * G / denom * 100) : 0
const { text: evText, cls: evCls } = evNetLabel(ev_net)
const save = () => {
onSave({ id: profile.id, name, min_score: score, min_gain_pct: gain, color, enabled, sort_order: profile.sort_order ?? 0 })
setEditing(false)
}
if (!editing) {
const { text: fev, cls: fevc } = evNetLabel(profile.ev_net_at_frontier ?? 0)
return (
<div className="flex items-center gap-3 py-2 px-3 rounded hover:bg-dark-700/40 group">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: profile.color ?? '#3b82f6' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx('text-sm font-semibold', enabled ? 'text-slate-200' : 'text-slate-600')}>{profile.name}</span>
{!enabled && <span className="text-[10px] text-slate-700 italic">désactivé</span>}
<span className="text-xs text-slate-500">Score <span className="text-slate-300 font-mono">{profile.min_score}</span></span>
<span className="text-xs text-slate-500">Gain <span className="text-slate-300 font-mono">{profile.min_gain_pct}%</span></span>
<span className={clsx('text-[11px] font-mono', fevc)}>{fev}</span>
<span className="text-[11px] text-slate-600">Trade Score {(profile.trade_score_at_frontier ?? 0).toFixed(0)}</span>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => setEditing(true)}
className="text-slate-600 hover:text-slate-300 p-1 rounded">
<Pencil className="w-3 h-3" />
</button>
<button onClick={() => onDelete(profile.id)}
className="text-slate-600 hover:text-red-400 p-1 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
}
return (
<div className="bg-dark-700/60 rounded-lg p-3 border border-slate-600/40 space-y-3">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Nom du profil</label>
<input value={name} onChange={e => setName(e.target.value)}
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{score}</span>/100</label>
<input type="range" min="0" max="90" step="5" value={score}
onChange={e => setScore(parseInt(e.target.value))}
className="w-full accent-blue-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{gain}%</span></label>
<input type="range" min="10" max="2000" step="10" value={gain}
onChange={e => setGain(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
</div>
</div>
{/* Live EV preview */}
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
<span className="text-slate-500">À la frontière (score={score}, gain={gain}%)</span>
<span className={clsx('font-mono font-bold', evCls)}>{evText}</span>
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{trade_score.toFixed(1)}</span>/100</span>
<span className="text-slate-600">
Score min EV=0 : <span className="text-slate-400 font-mono">{Math.ceil(100 / (G + 1))}</span>/100
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
{PROFILE_COLORS.map(c => (
<button key={c.value} onClick={() => setColor(c.value)}
title={c.label}
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
color === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
style={{ background: c.value }} />
))}
</div>
<button onClick={() => setEnabled(!enabled)}
className={clsx('text-xs px-2 py-0.5 rounded border transition-all',
enabled ? 'border-emerald-600/40 text-emerald-400 bg-emerald-900/20' : 'border-slate-700 text-slate-600')}>
{enabled ? '✓ Activé' : '○ Désactivé'}
</button>
<div className="ml-auto flex gap-2">
<button onClick={() => setEditing(false)}
className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Annuler</button>
<button onClick={save}
className="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded font-semibold">
Sauvegarder
</button>
</div>
</div>
</div>
)
}
function RiskProfilesCard() {
const { data: profileData, isLoading } = useRiskProfiles()
const { mutate: upsert } = useUpsertProfile()
const { mutate: del } = useDeleteProfile()
const [showNew, setShowNew] = useState(false)
const [newName, setNewName] = useState('')
const [newScore, setNewScore] = useState(30)
const [newGain, setNewGain] = useState(200)
const [newColor, setNewColor] = useState('#3b82f6')
const profiles: any[] = (profileData as any)?.profiles ?? []
// Live preview for new profile
const np = newScore / 100
const nG = newGain / 100
const nDenom = np * nG + (1 - np)
const nEvNet = np * nG - (1 - np)
const nTradeScore = nDenom > 0 ? (np * nG / nDenom * 100) : 0
const nMinScore = Math.ceil(100 / (nG + 1))
const saveNew = () => {
if (!newName.trim()) return
upsert({ name: newName, min_score: newScore, min_gain_pct: newGain, color: newColor, enabled: true, sort_order: profiles.length })
setShowNew(false)
setNewName('')
setNewScore(30)
setNewGain(200)
}
return (
<div className="card bg-dark-700/20 mb-4">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm font-semibold text-slate-300">Profils de risque</div>
<div className="text-[10px] text-slate-600 mt-0.5">
Un trade est loggé dans le journal s'il passe <span className="text-slate-500">au moins un</span> profil activé
· Formule : EV nette = (score/100 × gain/100) (1 score/100)
</div>
</div>
<button onClick={() => setShowNew(true)}
className="flex items-center gap-1 text-xs border border-blue-500/40 text-blue-400 hover:bg-blue-900/20 px-2.5 py-1 rounded">
<Plus className="w-3 h-3" /> Nouveau profil
</button>
</div>
{isLoading ? (
<div className="h-16 animate-pulse bg-dark-700 rounded" />
) : profiles.length === 0 ? (
<div className="text-center py-6 text-slate-600 text-sm">Aucun profil — tous les trades seront ignorés</div>
) : (
<div className="space-y-1">
{profiles.map((p: any) => (
<ProfileRow key={p.id} profile={p}
onSave={data => upsert(data)}
onDelete={id => del(id)} />
))}
</div>
)}
{/* New profile form */}
{showNew && (
<div className="bg-dark-700/60 rounded-lg p-3 border border-blue-700/30 space-y-3 mt-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-blue-400">Nouveau profil</span>
<button onClick={() => setShowNew(false)} className="text-slate-600 hover:text-slate-400"><X className="w-3.5 h-3.5" /></button>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Nom</label>
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="ex: Ultra-risqué"
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white placeholder:text-slate-700" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{newScore}</span></label>
<input type="range" min="0" max="90" step="5" value={newScore}
onChange={e => setNewScore(parseInt(e.target.value))} className="w-full accent-blue-500" />
</div>
<div>
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{newGain}%</span></label>
<input type="range" min="10" max="2000" step="10" value={newGain}
onChange={e => setNewGain(parseFloat(e.target.value))} className="w-full accent-blue-500" />
</div>
</div>
{/* Live math */}
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
<span className={clsx('font-mono font-bold', evNetLabel(nEvNet).cls)}>{evNetLabel(nEvNet).text}</span>
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{nTradeScore.toFixed(1)}</span>/100</span>
<span className="text-slate-700">Score min pour EV=0 avec gain {newGain}% : <span className="text-slate-500 font-mono">{nMinScore}</span>/100</span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
{PROFILE_COLORS.map(c => (
<button key={c.value} onClick={() => setNewColor(c.value)} title={c.label}
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
newColor === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
style={{ background: c.value }} />
))}
</div>
<div className="ml-auto flex gap-2">
<button onClick={() => setShowNew(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Annuler</button>
<button onClick={saveNew} disabled={!newName.trim()}
className="text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 py-1 rounded font-semibold">
Créer
</button>
</div>
</div>
</div>
)}
{/* Frontier visualization */}
{profiles.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="text-[10px] text-slate-600 mb-2">Frontière d'acceptation chaque point représente le minimum requis par profil</div>
<div className="flex flex-wrap gap-2">
{profiles.filter((p: any) => p.enabled).map((p: any) => {
const { text: ev, cls } = evNetLabel(p.ev_net_at_frontier ?? 0)
return (
<div key={p.id} className="flex items-center gap-2 bg-dark-700 rounded px-2.5 py-1.5 text-xs">
<div className="w-2 h-2 rounded-full" style={{ background: p.color }} />
<span className="text-slate-400 font-semibold">{p.name}</span>
<span className="font-mono text-slate-500">{p.min_score}pts</span>
<span className="text-slate-600">×</span>
<span className="font-mono text-slate-500">{p.min_gain_pct}%</span>
<span className={clsx('font-mono text-[10px]', cls)}> {ev}</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export default function Config() {
const { data: sources, isLoading } = useSources()
const { data: config } = useConfig()
const { data: aiStatus } = useAiStatus()
const { data: analysisCfg } = useAnalysisConfig()
const { mutate: updateSources, isPending: savingSources } = useUpdateSources()
const { mutate: updateApiKeys, isPending: savingKeys } = useUpdateApiKeys()
const { mutate: saveAnalysis, isPending: savingAnalysis } = useSaveAnalysisConfig()
const { data: cycleStatus, refetch: refetchCycle } = useCycleStatus()
const { mutate: updateCycleConfig, isPending: savingCycle } = useUpdateCycleConfig()
const { mutate: triggerCycle, isPending: triggeringCycle } = useTriggerCycle()
const [localSources, setLocalSources] = useState<Record<string, any> | null>(null)
const [openaiKey, setOpenaiKey] = useState('')
const [newsapiKey, setNewsapiKey] = useState('')
const [eiaKey, setEiaKey] = useState('')
const [fredKey, setFredKey] = useState('')
const [showKeys, setShowKeys] = useState(false)
const [savedMsg, setSavedMsg] = useState('')
// Analysis config local state
const [analysisTopN, setAnalysisTopN] = useState(10)
const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all')
const [analysisTemplate, setAnalysisTemplate] = useState('')
// Auto-cycle local state
const cs = cycleStatus as any
const [cycleEnabled, setCycleEnabled] = useState(false)
const [cycleHours, setCycleHours] = useState(3)
const [cycleSimilarity, setCycleSimilarity] = useState(0.30)
const [minEv, setMinEv] = useState(0.0)
const [minScore, setMinScore] = useState(0)
useEffect(() => {
if (cs) {
setCycleEnabled(cs.enabled ?? false)
setCycleHours(cs.interval_hours ?? 3)
setCycleSimilarity(cs.similarity_threshold ?? 0.30)
setMinEv(cs.min_ev_threshold ?? 0.0)
setMinScore(cs.min_score_threshold ?? 0)
}
}, [cs])
useEffect(() => {
if (analysisCfg) {
setAnalysisTopN(analysisCfg.top_n ?? 10)
setAnalysisCategoryDefault(analysisCfg.category_filter ?? 'all')
setAnalysisTemplate(analysisCfg.template ?? '')
}
}, [analysisCfg])
const displaySources = localSources ?? sources ?? {}
const toggleSource = (key: string) => {
setLocalSources(prev => {
const base = prev ?? sources ?? {}
return { ...base, [key]: { ...base[key], enabled: !base[key]?.enabled } }
})
}
const saveSources = () => {
updateSources(displaySources, {
onSuccess: () => { setSavedMsg('Sources sauvegardées'); setTimeout(() => setSavedMsg(''), 2000) }
})
}
const saveKeys = () => {
const keys: Record<string, string> = {}
if (openaiKey) keys.openai_api_key = openaiKey
if (newsapiKey) keys.newsapi_key = newsapiKey
if (eiaKey) keys.eia_api_key = eiaKey
if (fredKey) keys.fred_api_key = fredKey
updateApiKeys(keys, {
onSuccess: () => {
setSavedMsg('Clés API sauvegardées')
setTimeout(() => setSavedMsg(''), 2000)
setOpenaiKey(''); setNewsapiKey(''); setEiaKey(''); setFredKey('')
}
})
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-blue-400" /> Configuration
</h1>
<p className="text-xs text-slate-500 mt-0.5">Clés API, sources d'information, paramètres IA</p>
</div>
{savedMsg && (
<div className="card border-emerald-700/40 bg-emerald-900/10 text-emerald-400 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> {savedMsg}
</div>
)}
<div className="grid grid-cols-3 gap-6">
{/* Left col: API Keys + AI status */}
<div className="col-span-1 space-y-4">
{/* AI Status */}
<div className={clsx('card', aiStatus?.enabled ? 'border-emerald-700/40' : 'border-slate-700/40')}>
<div className="section-title flex items-center gap-1"><Key className="w-3 h-3" /> Statut IA</div>
<div className="flex items-center gap-3 mb-3">
{aiStatus?.enabled ? (
<><CheckCircle className="w-5 h-5 text-emerald-400" />
<div><div className="text-sm text-emerald-400 font-semibold">OpenAI Connecté</div>
<div className="text-xs text-slate-500">GPT-4o + GPT-4o-mini</div></div></>
) : (
<><XCircle className="w-5 h-5 text-red-400" />
<div><div className="text-sm text-red-400 font-semibold">OpenAI Non configuré</div>
<div className="text-xs text-slate-500">Entrer la clé ci-dessous</div></div></>
)}
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>• Analyse de discours (Trump, Powell...)</div>
<div>• Classification IA des actualités</div>
<div>• Évaluation de patterns</div>
<div>• Top 10 idées contextualisées</div>
</div>
</div>
{/* API Keys */}
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="section-title mb-0 flex items-center gap-1"><Key className="w-3 h-3" /> Clés API</div>
<button onClick={() => setShowKeys(!showKeys)} className="text-slate-500 hover:text-slate-300">
{showKeys ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
<div className="space-y-3">
{[
{ label: 'OpenAI API Key', value: openaiKey, setter: setOpenaiKey, placeholder: 'sk-proj-...', status: aiStatus?.enabled },
{ label: 'NewsAPI Key', value: newsapiKey, setter: setNewsapiKey, placeholder: 'Obtenir sur newsapi.org', status: false },
{ label: 'EIA API Key', value: eiaKey, setter: setEiaKey, placeholder: 'Obtenir sur eia.gov', status: false },
{ label: 'FRED API Key', value: fredKey, setter: setFredKey, placeholder: 'Obtenir sur fred.stlouisfed.org', status: false },
].map(({ label, value, setter, placeholder, status }) => (
<div key={label}>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-slate-500">{label}</label>
{status !== undefined && (
<span className={clsx('text-xs', status ? 'text-emerald-400' : 'text-slate-600')}>
{status ? ' Actif' : ' Inactif'}
</span>
)}
</div>
<input
type={showKeys ? 'text' : 'password'}
value={value}
onChange={e => setter(e.target.value)}
placeholder={placeholder}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono"
/>
</div>
))}
<button
onClick={saveKeys}
disabled={savingKeys || (!openaiKey && !newsapiKey && !eiaKey && !fredKey)}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-1.5 text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
{savingKeys ? 'Sauvegarde...' : 'Sauvegarder les clés'}
</button>
</div>
</div>
</div>
{/* Right col: Sources */}
<div className="col-span-2 space-y-4">
{isLoading ? (
<div className="card animate-pulse h-40 bg-dark-700" />
) : (
<>
{Object.entries(SOURCE_CATEGORIES).map(([catName, keys]) => (
<div key={catName} className="card">
<div className="section-title flex items-center gap-1">
<Globe className="w-3 h-3" /> {catName}
</div>
<div className="space-y-2">
{keys.map(key => {
const src = displaySources[key] ?? {}
const enabled = src.enabled ?? false
const requiresKey = src.requires_key
const doc = SOURCE_DOCS[key]
const keySet = requiresKey ? !!config?.[requiresKey + '_set'] : true
return (
<div
key={key}
className={clsx(
'flex items-start justify-between p-3 rounded border transition-all',
enabled
? 'border-blue-500/40 bg-blue-900/5'
: 'border-slate-700/30 bg-dark-700/30'
)}
>
<div className="flex-1 min-w-0 mr-3">
<div className="flex items-center gap-2">
<span className="text-xs text-white font-semibold">{src.name || key}</span>
<span className={clsx('badge text-xs', {
'badge-green': doc?.cost === 'Gratuit' || doc?.cost === 'Gratuit total',
'badge-blue': doc?.cost?.includes('gratuit') || doc?.cost?.includes('Inscription'),
'badge-yellow': doc?.cost?.includes('clé'),
'badge-red': doc?.cost?.includes('payante'),
})}>
{doc?.cost}
</span>
{requiresKey && !keySet && (
<span className="badge badge-orange text-xs">Clé requise</span>
)}
</div>
<div className="text-xs text-slate-500 mt-0.5">{doc?.description}</div>
</div>
<button
onClick={() => toggleSource(key)}
disabled={requiresKey && !keySet}
className={clsx(
'shrink-0 w-10 h-5 rounded-full border transition-all relative',
enabled
? 'bg-blue-600 border-blue-500'
: 'bg-dark-600 border-slate-600',
requiresKey && !keySet && 'opacity-40 cursor-not-allowed'
)}
>
<div className={clsx(
'absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all',
enabled ? 'left-5' : 'left-0.5'
)} />
</button>
</div>
)
})}
</div>
</div>
))}
<div className="flex justify-end">
<button
onClick={saveSources}
disabled={savingSources || !localSources}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold"
>
<Save className="w-4 h-4" />
{savingSources ? 'Sauvegarde...' : 'Appliquer les changements'}
</button>
</div>
</>
)}
</div>
</div>
{/* ── Paramètres d'analyse IA */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
<SlidersHorizontal className="w-4 h-4 text-blue-400" /> Paramètres d'analyse IA
</h2>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs text-slate-500 mb-1 block">Nombre de résultats par défaut (Top N)</label>
<div className="flex gap-1">
{[5, 10, 15, 20].map(n => (
<button key={n} onClick={() => setAnalysisTopN(n)}
className={clsx('flex-1 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': analysisTopN === n,
'bg-dark-700 text-slate-400 hover:text-slate-200': analysisTopN !== n,
})}>
Top {n}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Catégorie par défaut</label>
<select value={analysisCategoryDefault} onChange={e => setAnalysisCategoryDefault(e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="all">Toutes les catégories</option>
<option value="energy">Énergie</option>
<option value="metals">Métaux</option>
<option value="agriculture">Agriculture</option>
<option value="indices">Indices</option>
<option value="equities">Actions</option>
<option value="forex">Forex</option>
</select>
</div>
</div>
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-slate-500">Template d'analyse (prompt envoyé à GPT-4o)</label>
<button onClick={() => setAnalysisTemplate('')}
className="text-xs text-slate-600 hover:text-slate-400">
Remettre par défaut
</button>
</div>
<textarea
value={analysisTemplate}
onChange={e => setAnalysisTemplate(e.target.value)}
rows={10}
placeholder="Laisser vide pour utiliser le template par défaut..."
className="w-full bg-dark-700 border border-slate-700 rounded px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-blue-500 resize-y"
/>
<div className="text-xs text-slate-600 mt-1">
Variables disponibles dans le template : le contexte marché (prix, IV, variation 1j) et les news filtrées par keywords sont toujours injectées automatiquement.
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => saveAnalysis(
{ top_n: analysisTopN, category_filter: analysisCategoryDefault, template: analysisTemplate || undefined },
{ onSuccess: () => { setSavedMsg('Paramètres d\'analyse sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
disabled={savingAnalysis}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
<Save className="w-4 h-4" />
{savingAnalysis ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
{savedMsg && <span className="text-xs text-emerald-400">{savedMsg}</span>}
</div>
</div>
{/* ── Auto-Cycle ── */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Zap className="w-4 h-4 text-blue-400" /> Auto-Cycle Intelligence
</h2>
{cs && (
<div className="flex items-center gap-2 text-xs">
{cs.running ? (
<span className="flex items-center gap-1 text-blue-400 bg-blue-900/30 border border-blue-700/30 px-2 py-0.5 rounded animate-pulse">
<RefreshCw className="w-3 h-3 animate-spin" /> En cours...
</span>
) : cs.scheduler_alive ? (
<span className="text-emerald-400 bg-emerald-900/30 border border-emerald-700/30 px-2 py-0.5 rounded">
Scheduler actif
</span>
) : (
<span className="text-slate-600 bg-dark-700 border border-slate-700/30 px-2 py-0.5 rounded">
Scheduler inactif
</span>
)}
</div>
)}
</div>
<p className="text-xs text-slate-500 mb-4">
Toutes les N heures : suggère de nouveaux patterns, filtre les doublons, score tout, log les prix et génère un commentaire IA sur les performances.
</p>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">Activer l'auto-cycle</label>
<button
onClick={() => setCycleEnabled(!cycleEnabled)}
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
'bg-blue-600 border-blue-500 text-white': cycleEnabled,
'bg-dark-700 border-slate-700 text-slate-400 hover:border-slate-500': !cycleEnabled,
})}>
{cycleEnabled ? ' Activé' : ' Désactivé'}
</button>
</div>
<div>
<label className="text-xs text-slate-500 mb-2 block">Intervalle (heures)</label>
<div className="flex gap-1">
{[1, 3, 6, 12].map(h => (
<button key={h} onClick={() => setCycleHours(h)}
className={clsx('flex-1 py-2 rounded text-sm transition-colors', {
'bg-blue-600 text-white': cycleHours === h,
'bg-dark-700 text-slate-400 hover:text-slate-200': cycleHours !== h,
})}>
{h}h
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-2 block">
Similarité max ({Math.round(cycleSimilarity * 100)}% — nouveaux patterns en-dessous)
</label>
<input type="range" min="0.1" max="0.8" step="0.05"
value={cycleSimilarity}
onChange={e => setCycleSimilarity(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
<span>10% (strict)</span><span>80% (permissif)</span>
</div>
</div>
</div>
{/* Risk Profiles */}
<RiskProfilesCard />
{cs?.last_cycle && (
<div className="card bg-dark-700/50 mb-4 text-xs">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-slate-500">Dernier cycle :</span>
<span className="text-slate-300">{cs.last_cycle.started_at?.slice(0, 16)} UTC</span>
<span className={clsx('badge', cs.last_cycle.status === 'completed' ? 'badge-green' : 'badge-red')}>
{cs.last_cycle.status}
</span>
<span className="text-slate-500">+{cs.last_cycle.patterns_added} patterns</span>
<span className="text-slate-500">{cs.last_cycle.patterns_scored} scorés</span>
<span className="text-slate-500">Géo: {cs.last_cycle.geo_score}</span>
<span className="text-slate-500 capitalize">{cs.last_cycle.dominant_regime}</span>
</div>
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={() => updateCycleConfig(
{ enabled: cycleEnabled, interval_hours: cycleHours, similarity_threshold: cycleSimilarity, min_ev_threshold: minEv, min_score_threshold: minScore },
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configuré'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
disabled={savingCycle}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
<Save className="w-4 h-4" />
{savingCycle ? 'Sauvegarde...' : 'Appliquer'}
</button>
<button
onClick={() => triggerCycle(undefined, { onSuccess: () => { refetchCycle(); setSavedMsg('Cycle lancé !'); setTimeout(() => setSavedMsg(''), 3000) } })}
disabled={triggeringCycle || !aiStatus?.enabled}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
<RefreshCw className={clsx('w-4 h-4', triggeringCycle && 'animate-spin')} />
Lancer maintenant
</button>
{savedMsg && <span className="text-xs text-emerald-400">{savedMsg}</span>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,867 @@
import { useState, useMemo, useEffect } from 'react'
import {
useGeoRiskScore, useAllQuotes,
useCalendar, useAiStatus, usePortfolioSummary, useAddPosition,
useScorePatterns, useLastScores, useAllPatterns, useMacroRegime,
usePortfolioPositions, useTradeMtm, useRiskProfiles,
} from '../hooks/useApi'
import { Target, Clock, Brain, Globe, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2 } from 'lucide-react'
import clsx from 'clsx'
import type { Quote } from '../types'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer } from 'recharts'
const riskGauge = (score: number) => {
if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'FAIBLE' }
if (score < 50) return { color: 'text-yellow-400', bg: 'bg-yellow-500', label: 'MODÉRÉ' }
if (score < 75) return { color: 'text-orange-400', bg: 'bg-orange-500', label: 'ÉLEVÉ' }
return { color: 'text-red-400', bg: 'bg-red-500', label: 'EXTRÊME' }
}
const assetEmoji: Record<string, string> = {
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
indices: '📊', forex: '💱', crypto: '₿', rates: '📉',
}
const scoreColor = (s: number) => {
if (s >= 70) return 'text-emerald-400'
if (s >= 50) return 'text-yellow-400'
return 'text-slate-400'
}
const scoreBg = (s: number) => {
if (s >= 70) return 'bg-emerald-500'
if (s >= 50) return 'bg-yellow-500'
return 'bg-slate-600'
}
const BUCKET_ICONS: Record<string, string> = {
actualites: '📰', calendrier: '📅', prix: '📈', rr: '⚖️',
geo: '🌍', eco: '🌐', flux: '📡',
banques: '🏦', macro_cal: '📋',
taux: '📉', energie: '⛽', forex_sig: '💱', actions: '📊', vix: '⚡',
asymetrie: '⚖️', timing_rr: '⏱️',
}
function BucketBar({ score, max }: { score: number; max: number }) {
const pct = max > 0 ? (score / max) * 100 : 0
const color = pct >= 75 ? 'bg-emerald-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500/70'
return (
<div className="w-12 bg-dark-600 rounded-full h-1 shrink-0">
<div className={clsx('h-1 rounded-full transition-all', color)} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
)
}
function BucketBreakdown({ buckets }: { buckets: any[] }) {
const [openBucket, setOpenBucket] = useState<string | null>(null)
return (
<div className="space-y-1 text-xs">
{buckets.map((b: any) => {
const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0
const isOpen = openBucket === b.id
return (
<div key={b.id} className="bg-dark-700/60 rounded overflow-hidden">
<button
className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-dark-600/60 transition-colors text-left"
onClick={() => setOpenBucket(isOpen ? null : b.id)}
>
<span>{BUCKET_ICONS[b.id] ?? '•'}</span>
<span className="text-slate-400 flex-1 truncate">{b.label}</span>
<BucketBar score={b.score} max={b.max} />
<span className={clsx('font-mono w-9 text-right shrink-0', pct >= 75 ? 'text-emerald-400' : pct >= 50 ? 'text-yellow-400' : 'text-red-400')}>
{b.score}/{b.max}
</span>
{isOpen ? <ChevronUp className="w-2.5 h-2.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-2.5 h-2.5 text-slate-600 shrink-0" />}
</button>
{isOpen && (
<div className="px-2 pb-2 border-t border-slate-700/30 space-y-2 pt-1.5">
{b.comment && (
<p className="text-slate-500 italic">{b.comment}</p>
)}
{b.subs?.map((sub: any) => {
const subPct = sub.max > 0 ? Math.round((sub.score / sub.max) * 100) : 0
return (
<div key={sub.id} className="pl-1 space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="text-xs">{BUCKET_ICONS[sub.id] ?? ''}</span>
<span className="text-slate-500 flex-1 truncate">{sub.label}</span>
<BucketBar score={sub.score} max={sub.max} />
<span className={clsx('font-mono w-7 text-right shrink-0', subPct >= 75 ? 'text-emerald-400' : subPct >= 50 ? 'text-yellow-400' : 'text-slate-600')}>
{sub.score}/{sub.max}
</span>
</div>
{sub.comment && (
<p className="text-slate-600 italic ml-4">{sub.comment}</p>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}
const CATEGORIES = [
{ key: 'all', label: 'Tous' },
{ key: 'energy', label: '⛽ Énergie' },
{ key: 'metals', label: '🥇 Métaux' },
{ key: 'agriculture', label: '🌾 Agri' },
{ key: 'indices', label: '📊 Indices' },
{ key: 'equities', label: '📈 Actions' },
{ key: 'forex', label: '💱 Forex' },
]
interface TradeItem {
trade: any
patternName: string
patternId: string
assetClass: string
score: number | null
scoreInfo: any | null
scoreDelta: number | null
rankRationale: string | null
expectedMovePct: number // from original pattern definition
}
const BIAS_DISPLAY: Record<string, { label: string; color: string }> = {
'bullish+': { label: '★★ Compatible', color: '#10b981' },
'bullish': { label: '★ Compatible', color: '#34d399' },
'neutral': { label: '→ Neutre', color: '#64748b' },
'bearish': { label: '✗ Défavorable', color: '#f97316' },
'bearish+': { label: '✗✗ Contra', color: '#ef4444' },
'defensive':{ label: '⚠ Défensif', color: '#f59e0b' },
}
function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
item: TradeItem
onAdd: (item: TradeItem) => void
macroInfo?: { dominant: string; label: string; color: string; emoji: string; assetBias: Record<string, string> } | null
addedInfo?: { entry_date: string } | null
profiles?: any[]
}) {
const [expanded, setExpanded] = useState(false)
const { trade, patternName, assetClass, score, scoreInfo, scoreDelta, rankRationale, expectedMovePct } = item
const effectiveScore = score !== null
? Math.max(0, Math.min(100, score + (scoreDelta ?? 0)))
: null
// EV calculation: ev_net = p×G - (1-p) where p=score/100, G=gain/100
const gainPct = expectedMovePct ?? 0
const evNet = effectiveScore !== null && gainPct > 0
? (effectiveScore / 100) * (gainPct / 100) - (1 - effectiveScore / 100)
: null
// Which profile matches this trade (if any)
const matchedProfile = useMemo(() => {
if (effectiveScore === null || !profiles || profiles.length === 0) return undefined
return profiles.find(prof =>
prof.enabled && effectiveScore >= prof.min_score && gainPct >= prof.min_gain_pct
) ?? null
}, [effectiveScore, gainPct, profiles])
const breakdown = scoreInfo?.score_breakdown ?? {}
const rationale = scoreInfo?.summary ?? trade.rationale ?? scoreInfo?.key_catalyst ?? ''
const maxLoss = trade.max_loss_eur ?? scoreInfo?.recommended_trade?.max_loss_eur
// Cible dérivée de la formule (cohérent avec Gain%) — fallback sur estimation GPT-4o
const target = maxLoss != null && gainPct > 0
? Math.round(Math.abs(maxLoss) * gainPct / 100)
: (trade.target_gain_eur ?? scoreInfo?.recommended_trade?.target_gain_eur)
const timing = trade.timing_note ?? scoreInfo?.recommended_trade?.timing_note
return (
<div className={clsx('card transition-all', {
'border-emerald-700/50': effectiveScore !== null && effectiveScore >= 70,
'border-yellow-700/30': effectiveScore !== null && effectiveScore >= 50 && effectiveScore < 70,
'border-slate-700/20': effectiveScore === null,
})}>
{/* Pattern name (tiny, above) */}
<div className="text-xs text-slate-600 line-clamp-1 mb-1 font-mono">{patternName}</div>
{/* Trade + score */}
<div className="flex items-start justify-between mb-1.5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 flex-wrap">
<span className="badge badge-blue text-xs">{assetClass}</span>
{trade.underlying && (
<span className="text-sm text-white font-semibold font-mono">{trade.underlying}</span>
)}
{trade.isRecommended && effectiveScore !== null && (
<span className="text-xs text-yellow-400 bg-yellow-400/10 border border-yellow-400/30 rounded px-1"> IA</span>
)}
</div>
{trade.strategy && (
<span className="badge badge-green text-xs mt-0.5">{trade.strategy}</span>
)}
</div>
{effectiveScore !== null ? (
<div className="ml-2 shrink-0 text-center min-w-[48px]">
<div className={clsx('text-2xl font-bold leading-none', scoreColor(effectiveScore))}>{effectiveScore}</div>
<div className="text-xs text-slate-600 flex items-center justify-center gap-0.5">
<span>/100</span>
{scoreDelta !== null && scoreDelta !== 0 && (
<span className={clsx('text-[10px] font-mono font-bold', scoreDelta > 0 ? 'text-emerald-400' : 'text-red-400')}>
{scoreDelta > 0 ? '+' : ''}{scoreDelta}
</span>
)}
</div>
{scoreInfo?.score_trend != null && (
<div className={clsx('text-[10px] font-mono font-bold mt-0.5', scoreInfo.score_trend > 0 ? 'text-emerald-400' : scoreInfo.score_trend < 0 ? 'text-red-400' : 'text-slate-600')}>
{scoreInfo.score_trend > 0 ? '↑+' : scoreInfo.score_trend < 0 ? '↓' : '→'}{scoreInfo.score_trend !== 0 ? Math.abs(scoreInfo.score_trend) : ''}
</div>
)}
</div>
) : (
<span className="ml-2 shrink-0 text-xs text-slate-500 bg-dark-600 border border-slate-700/40 rounded px-1.5 py-0.5 whitespace-nowrap">
à scorer
</span>
)}
</div>
{/* Score bar */}
{effectiveScore !== null && (
<div className="bg-dark-600 rounded-full h-1.5 mb-2">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</div>
)}
{/* EV / Profile match row — always shown when scored, helps understand why trade is/isn't logged */}
{effectiveScore !== null && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] flex-wrap">
<span className="text-slate-600">
Gain:{' '}
<span className={gainPct > 0 ? 'text-slate-300' : 'text-orange-400/80'}>
{gainPct > 0 ? `${gainPct}%` : '?'}
</span>
</span>
{evNet !== null ? (
<>
<span className="text-slate-700">·</span>
<span className={clsx('font-mono font-semibold', evNet >= 0 ? 'text-emerald-400' : 'text-orange-400')}>
EV {evNet >= 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
</span>
</>
) : gainPct === 0 ? (
<>
<span className="text-slate-700">·</span>
<span className="text-orange-400/70">EV ?</span>
</>
) : null}
{matchedProfile !== undefined && (
<>
<span className="text-slate-700">·</span>
{matchedProfile ? (
<span className="font-semibold" style={{ color: matchedProfile.color }}>
{matchedProfile.name}
</span>
) : (
<span className="text-red-400/80">
{gainPct === 0 ? 'Gain non défini' : 'Aucun profil'}
</span>
)}
</>
)}
</div>
)}
{/* Rank rationale (why this trade differs from pattern average) */}
{rankRationale && (
<div className="text-[10px] text-slate-600 italic mb-1.5 line-clamp-1">{rankRationale}</div>
)}
{/* Macro scenario compatibility */}
{macroInfo && macroInfo.dominant !== 'incertain' && (() => {
const bias = macroInfo.assetBias[assetClass] ?? 'neutral'
const bd = BIAS_DISPLAY[bias] ?? BIAS_DISPLAY['neutral']
return (
<div className="flex items-center gap-1.5 mb-2 text-[10px]">
<span style={{ color: macroInfo.color }}>{macroInfo.emoji} {macroInfo.label}</span>
<span className="text-slate-700">·</span>
<span style={{ color: bd.color }}>{bd.label}</span>
</div>
)
})()}
{/* Rationale */}
{rationale && <p className="text-xs text-slate-400 line-clamp-2 mb-2">{rationale}</p>}
{/* timing */}
{timing && <div className="text-xs text-yellow-400/80 mb-2"> {timing}</div>}
{/* max/target */}
{(maxLoss != null || target != null) && (
<div className="flex gap-2 text-xs mb-2">
{maxLoss != null && <span className="text-red-400">Max -{Math.abs(maxLoss)}</span>}
{target != null && <span className="text-emerald-400">Cible +{target}</span>}
{scoreInfo?.confidence && <span className="text-slate-600 ml-auto">conf. {scoreInfo.confidence}%</span>}
</div>
)}
{/* Expandable score breakdown */}
{effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && (
<>
<button onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-1.5">
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail du score par pilier
</button>
{expanded && (
<div className="mb-2">
{scoreInfo?.buckets?.length > 0 ? (
<BucketBreakdown buckets={scoreInfo.buckets} />
) : (
<div className="space-y-1 bg-dark-700/50 rounded p-2">
{Object.entries(breakdown).map(([k, v]: [string, any]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-slate-500 capitalize">{k.replace(/_/g, ' ')}</span>
<div className="flex items-center gap-1">
<div className="w-14 bg-dark-600 rounded-full h-1">
<div className="bg-blue-500 h-1 rounded-full" style={{ width: `${(v / 25) * 100}%` }} />
</div>
<span className="text-slate-300 w-5 text-right text-xs">{v}/25</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{addedInfo && (
<div className="flex items-center gap-1.5 mb-2 text-[10px] text-emerald-400 bg-emerald-900/20 border border-emerald-700/30 rounded px-2 py-1">
<CheckCircle2 className="w-3 h-3 shrink-0" />
<span>Ajouté le {format(new Date(addedInfo.entry_date), "d MMM yyyy", { locale: fr })}</span>
</div>
)}
<button onClick={() => onAdd(item)}
className={clsx(
'w-full flex items-center justify-center gap-1 text-xs rounded py-1 transition-all border',
addedInfo
? 'text-slate-500 border-slate-700/30 hover:text-slate-300 hover:border-slate-600'
: 'text-blue-400 hover:text-white hover:bg-blue-600 border-blue-500/30 hover:border-blue-500'
)}>
<Plus className="w-3 h-3" />
{addedInfo ? 'Ajouter à nouveau' : 'Ajouter au portefeuille'}
</button>
</div>
)
}
function QuoteRow({ q }: { q: Quote }) {
if (!q.price) return null
const pos = q.change_pct >= 0
return (
<div className="flex items-center justify-between py-1.5 border-b border-slate-700/20 last:border-0">
<span className="text-xs text-white truncate max-w-[140px]">{q.name || q.symbol}</span>
<div className="text-right ml-2">
<div className="text-xs text-white font-mono">{q.price.toFixed(2)}</div>
<div className={clsx('text-xs font-mono', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</div>
</div>
</div>
)
}
export default function Dashboard() {
const { data: riskScore, isLoading: riskLoading } = useGeoRiskScore()
const { data: allQuotes } = useAllQuotes()
const { data: calendar } = useCalendar()
const { data: aiStatus } = useAiStatus()
const { data: portfolio } = usePortfolioSummary()
const { data: lastScoresData } = useLastScores()
const { data: allPatternsData } = useAllPatterns()
const { data: macroData } = useMacroRegime()
const { data: positions, refetch: refetchPositions } = usePortfolioPositions('open')
const { data: tradeMtmData } = useTradeMtm(30)
const { data: riskProfilesData } = useRiskProfiles()
const { mutate: scorePatterns, isPending: scoring } = useScorePatterns()
const { mutate: addPos } = useAddPosition()
const riskProfiles: any[] = (riskProfilesData as any)?.profiles ?? []
const [categoryFilter, setCategoryFilter] = useState('all')
const [topN, setTopN] = useState(10)
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null)
useEffect(() => {
if (!toast) return
const t = setTimeout(() => setToast(null), 3500)
return () => clearTimeout(t)
}, [toast])
// Build two keys per position so we match regardless of ticker normalization:
// key1 = geo_trigger (pattern name) + strategy → survives underlying normalization
// key2 = underlying + strategy → direct ticker match fallback
const addedMap = useMemo(() => {
const map: Record<string, { entry_date: string }> = {}
const upsert = (key: string, entry_date: string) => {
if (!map[key] || entry_date > map[key].entry_date) map[key] = { entry_date }
}
for (const pos of (positions as any[] ?? [])) {
const strategy = (pos.strategy ?? '').toLowerCase()
const trigger = (pos.geo_trigger ?? '').toLowerCase()
const underly = (pos.underlying ?? '').toLowerCase()
if (trigger) upsert(`trigger:${trigger}:${strategy}`, pos.entry_date ?? '')
if (underly) upsert(`ticker:${underly}:${strategy}`, pos.entry_date ?? '')
}
return map
}, [positions])
const macroInfo = useMemo(() => {
if (!macroData?.scenarios) return null
const sc = macroData.scenarios
const dom = sc.dominant ?? 'incertain'
const m = sc.meta?.[dom] ?? { label: dom, color: '#94a3b8', emoji: '?' }
const assetBias: Record<string, string> = sc.asset_bias?.[dom] ?? {}
return { dominant: dom, label: m.label, color: m.color, emoji: m.emoji, assetBias }
}, [macroData])
const gauge = riskScore ? riskGauge(riskScore.score) : null
const radarData = riskScore?.breakdown
? Object.entries(riskScore.breakdown).map(([k, v]) => ({ subject: k.replace('_', ' '), score: v }))
: []
// Map of last AI scores by pattern_id
const scoreMap = useMemo(() => {
const map: Record<string, any> = {}
for (const sp of (lastScoresData?.scored_patterns ?? [])) {
if (sp.pattern_id) map[sp.pattern_id] = sp
}
return map
}, [lastScoresData])
const allPatterns: any[] = allPatternsData ?? []
const scoredAt: string | null = lastScoresData?.scored_at ?? null
// Build flat list of TradeItems
const { topScored, allUnscored } = useMemo(() => {
const filtered = allPatterns.filter(p =>
categoryFilter === 'all' || p.asset_class === categoryFilter
)
const scored: TradeItem[] = []
const unscored: TradeItem[] = []
for (const p of filtered) {
const sp = scoreMap[p.id]
if (sp) {
// Scored: show ALL suggested_trades from pattern, annotated with score
const recUnderlying = sp.recommended_trade?.underlying
const trades: any[] = p.suggested_trades ?? []
const tradesOrFallback = trades.length > 0 ? trades : [sp.recommended_trade ?? {}]
const rankings: any[] = sp.trade_rankings ?? []
for (const t of tradesOrFallback) {
const isRecommended = recUnderlying && t.underlying === recUnderlying
// Match this trade in rankings by underlying (+ strategy if available)
const ranking = rankings.find(r =>
r.underlying === t.underlying &&
(!r.strategy || !t.strategy || r.strategy === t.strategy)
)
scored.push({
trade: { ...t, isRecommended },
patternName: p.name,
patternId: p.id,
assetClass: t.asset_class ?? p.asset_class,
score: sp.score,
scoreInfo: sp,
scoreDelta: ranking?.score_delta ?? null,
rankRationale: ranking?.rationale ?? null,
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
})
}
} else {
// Unscored: one card per suggested trade
const trades: any[] = p.suggested_trades ?? []
if (trades.length === 0) {
unscored.push({ trade: {}, patternName: p.name, patternId: p.id, assetClass: p.asset_class, score: null, scoreInfo: null, scoreDelta: null, rankRationale: null, expectedMovePct: p.expected_move_pct ?? 0 })
} else {
for (const t of trades) {
unscored.push({
trade: t,
patternName: p.name,
patternId: p.id,
assetClass: t.asset_class ?? p.asset_class,
score: null,
scoreInfo: null,
scoreDelta: null,
rankRationale: null,
expectedMovePct: t.expected_move_pct ?? p.expected_move_pct ?? 0,
})
}
}
}
}
const effScore = (item: TradeItem) =>
Math.max(0, Math.min(100, (item.score ?? 0) + (item.scoreDelta ?? 0)))
scored.sort((a, b) => effScore(b) - effScore(a))
return { topScored: scored.slice(0, topN), allUnscored: unscored }
}, [allPatterns, scoreMap, categoryFilter, topN])
const handleAdd = (item: TradeItem) => {
const t = item.trade
const sp = item.scoreInfo
addPos({
title: `${t.strategy ?? ''} ${t.underlying ?? ''}${item.patternName}`.trim(),
underlying: t.underlying ?? item.patternName,
strategy: t.strategy ?? '',
asset_class: item.assetClass,
expiry_days: t.expiry_days ?? sp?.recommended_trade?.expiry_days ?? 90,
capital_invested: Math.abs(t.max_loss_eur ?? sp?.recommended_trade?.max_loss_eur ?? 1000),
geo_trigger: item.patternName,
rationale: t.rationale ?? sp?.key_catalyst ?? '',
legs: [{
option_type: (t.strategy ?? '').toLowerCase().includes('put') ? 'put' : 'call',
quantity: 1,
position: 'long',
}],
}, {
onSuccess: () => {
refetchPositions()
setToast({
title: 'Ajouté au portefeuille',
sub: `${t.strategy ?? ''} ${t.underlying ?? ''} · ${item.patternName}`.trim(),
})
},
})
}
const getAddedInfo = (item: TradeItem) => {
const strategy = (item.trade.strategy ?? '').toLowerCase()
const trigger = (item.patternName ?? '').toLowerCase()
const underly = (item.trade.underlying ?? '').toLowerCase()
return (
addedMap[`trigger:${trigger}:${strategy}`] ??
addedMap[`ticker:${underly}:${strategy}`] ??
null
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">Cockpit GeoOptions</h1>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(), "EEEE d MMMM yyyy · HH:mm", { locale: fr })}
</p>
</div>
<div className="flex items-center gap-3">
{portfolio && portfolio.open_positions > 0 && (
<div className={clsx('text-sm font-bold', portfolio.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
Portfolio: {portfolio.unrealized_pnl >= 0 ? '+' : ''}{portfolio.unrealized_pnl?.toFixed(0)}
</div>
)}
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Live
</div>
</div>
</div>
{/* Top row */}
<div className="grid grid-cols-4 gap-4">
{/* Geo Risk */}
<div className="card col-span-1">
<div className="section-title flex items-center gap-1"><Globe className="w-3 h-3" /> Risque Géopolitique</div>
{riskLoading ? (
<div className="animate-pulse h-16 bg-dark-600 rounded"></div>
) : riskScore && gauge ? (
<>
<div className={clsx('text-5xl font-bold', gauge.color)}>{riskScore.score}</div>
<div className={clsx('text-sm font-semibold mt-1', gauge.color)}>{gauge.label}</div>
<div className="mt-3 bg-dark-700 rounded-full h-2">
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
</div>
<div className="mt-2 space-y-1">
{riskScore.top_risks?.map(([cat, val]) => (
<div key={cat} className="flex justify-between text-xs">
<span className="text-slate-500 capitalize">{(cat as string).replace('_', ' ')}</span>
<span className="text-slate-300">{Math.round((val as number) * 100)}%</span>
</div>
))}
</div>
</>
) : <div className="text-slate-500 text-xs">Backend requis</div>}
</div>
{/* Radar */}
<div className="card col-span-1">
<div className="section-title">Cartographie risques</div>
{radarData.length > 0 ? (
<ResponsiveContainer width="100%" height={160}>
<RadarChart data={radarData}>
<PolarGrid stroke="#1e2d4d" />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
<Radar dataKey="score" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
</RadarChart>
</ResponsiveContainer>
) : <div className="h-40 flex items-center justify-center text-slate-600 text-xs">Chargement...</div>}
</div>
{/* Patterns — AI scores */}
<div className="card col-span-1 overflow-y-auto max-h-64">
<div className="section-title flex items-center gap-1">
<Brain className="w-3 h-3" /> Scores patterns
{scoredAt && <span className="text-slate-600 text-xs ml-auto font-normal">{allPatterns.length} patterns</span>}
</div>
<div className="space-y-1.5 mt-1">
{allPatterns.length === 0 ? (
<div className="text-slate-600 text-xs">Backend requis</div>
) : (
[...allPatterns]
.sort((a, b) => {
const spA = scoreMap[a.id], spB = scoreMap[b.id]
const effA = spA ? Math.max(...[0, ...(spA.trade_rankings ?? []).map((r: any) => (spA.score ?? 0) + (r.score_delta ?? 0))]) : -1
const effB = spB ? Math.max(...[0, ...(spB.trade_rankings ?? []).map((r: any) => (spB.score ?? 0) + (r.score_delta ?? 0))]) : -1
return effB - effA
})
.map(p => {
const sp = scoreMap[p.id]
const rawScore = sp?.score ?? null
// Show best effective score across all trades (consistent with card display)
const bestEff = sp
? Math.max(rawScore ?? 0, ...(sp.trade_rankings ?? []).map((r: any) =>
Math.max(0, Math.min(100, (rawScore ?? 0) + (r.score_delta ?? 0)))))
: null
return (
<div key={p.id} className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-300 truncate flex-1 min-w-0">{p.name}</span>
{bestEff !== null ? (
<span className={clsx('text-xs font-bold shrink-0', scoreColor(bestEff))}>{bestEff}</span>
) : (
<span className="text-xs text-slate-600 shrink-0"></span>
)}
</div>
)
})
)}
</div>
</div>
{/* Calendrier */}
<div className="card col-span-1 space-y-3">
<div className="section-title flex items-center gap-1"><Clock className="w-3 h-3" /> Prochains catalyseurs</div>
<div className="space-y-1.5">
{calendar?.slice(0, 4).map((ev, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={clsx('badge', { 'badge-red': ev.importance === 'high', 'badge-yellow': ev.importance === 'medium', 'badge-blue': ev.importance === 'low' })}>
{'!'.repeat(ev.importance === 'high' ? 3 : ev.importance === 'medium' ? 2 : 1)}
</span>
<div className="min-w-0">
<div className="text-white line-clamp-1">{ev.title}</div>
<div className="text-slate-600">{ev.date?.slice(0, 10)}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* ── Trade ideas scorées par IA ── */}
<div>
{/* Toolbar */}
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<h2 className="section-title flex items-center gap-1 mb-0">
<Target className="w-3 h-3" /> Idées de trade
</h2>
{scoredAt && (
<span className="text-xs text-slate-600">
scoré le {format(new Date(scoredAt), "d MMM à HH:mm", { locale: fr })}
</span>
)}
{topScored.length > 0 && (
<span className="text-xs text-emerald-500">
{topScored.length} scoré{topScored.length > 1 ? 's' : ''} · {allUnscored.length} à scorer
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Category filter */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{CATEGORIES.map(c => (
<button key={c.key} onClick={() => setCategoryFilter(c.key)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-blue-600 text-white': categoryFilter === c.key,
'text-slate-400 hover:text-slate-200': categoryFilter !== c.key,
})}>
{c.label}
</button>
))}
</div>
{/* Top N */}
<div className="flex items-center gap-0.5 bg-dark-700 rounded p-0.5">
{[5, 10, 20].map(n => (
<button key={n} onClick={() => setTopN(n)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': topN === n,
'text-slate-400 hover:text-slate-200': topN !== n,
})}>
Top {n}
</button>
))}
</div>
{/* Score button */}
{aiStatus?.enabled ? (
<button onClick={() => scorePatterns({})}
disabled={scoring}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded text-xs font-semibold transition-all">
{scoring
? <><RefreshCw className="w-3 h-3 animate-spin" /> Scoring GPT-4o...</>
: <><Brain className="w-3 h-3" /> Scorer les patterns</>}
</button>
) : (
<span className="text-xs text-slate-600 border border-slate-700/30 rounded px-2 py-1">Clé OpenAI requise</span>
)}
</div>
</div>
{/* Scored trade cards */}
{topScored.length > 0 && (
<div className="grid grid-cols-5 gap-3 mb-5">
{topScored.map((item, i) => (
<TradeCard key={`${item.patternId}-scored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
)}
{/* Unscored trade cards */}
{allUnscored.length > 0 && (
<>
{topScored.length > 0 && (
<div className="flex items-center gap-2 text-xs text-slate-600 mb-3">
<span className="border-b border-slate-700/30 flex-1"></span>
{allUnscored.length} trade{allUnscored.length > 1 ? 's' : ''} à scorer
<span className="border-b border-slate-700/30 flex-1"></span>
</div>
)}
<div className="grid grid-cols-5 gap-3">
{allUnscored.map((item, i) => (
<TradeCard key={`${item.patternId}-unscored-${i}`} item={item} onAdd={handleAdd} macroInfo={macroInfo} addedInfo={getAddedInfo(item)} profiles={riskProfiles} />
))}
</div>
</>
)}
{allPatterns.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Démarrer le backend patterns en cours de chargement
</div>
)}
</div>
{/* ── Suivi M2M des trades logués par le système ── */}
{(() => {
const mtmTrades: any[] = (tradeMtmData as any)?.trades ?? []
const withPnl = mtmTrades.filter(t => t.pnl_pct != null)
if (mtmTrades.length === 0) return null
const winners = withPnl.filter(t => t.pnl_pct > 0).length
const losers = withPnl.filter(t => t.pnl_pct < 0).length
const avgPnl = withPnl.length ? withPnl.reduce((s, t) => s + t.pnl_pct, 0) / withPnl.length : null
return (
<div className="card">
<div className="flex items-center justify-between mb-3">
<div className="section-title flex items-center gap-1 mb-0">
<Brain className="w-3 h-3 text-blue-400" /> Suivi M2M système ({mtmTrades.length} trades)
</div>
<div className="flex items-center gap-3 text-xs">
{avgPnl != null && (
<span className={clsx('font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
)}
<span className="text-emerald-400">{winners}</span>
<span className="text-red-400">{losers}</span>
<span className="text-slate-600">{withPnl.length - winners - losers} flat</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-800">
<th className="text-left py-1.5 pr-3">Pattern</th>
<th className="text-left py-1.5 pr-3">Stratégie</th>
<th className="text-left py-1.5 pr-3 font-mono">Ticker</th>
<th className="text-right py-1.5 pr-3">Score</th>
<th className="text-right py-1.5 pr-3">Entrée</th>
<th className="text-right py-1.5 pr-3">Actuel</th>
<th className="text-right py-1.5 pr-3">J</th>
<th className="text-right py-1.5">P&L th.</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/40">
{mtmTrades.slice(0, 15).map((t: any) => {
const pnl: number | null = t.pnl_pct
return (
<tr key={t.id} className="hover:bg-dark-700/30">
<td className="py-1.5 pr-3 text-slate-300 max-w-[140px] truncate">{t.pattern_name || '—'}</td>
<td className="py-1.5 pr-3">
<span className={clsx('badge text-[10px]', t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
</span>
</td>
<td className="py-1.5 pr-3 font-mono text-slate-400">{t.underlying}</td>
<td className={clsx('py-1.5 pr-3 text-right font-bold font-mono',
t.score_at_entry >= 50 ? 'text-emerald-400' : t.score_at_entry >= 25 ? 'text-yellow-400' : 'text-slate-600')}>
{t.score_at_entry}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-slate-500">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-slate-300">
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
</td>
<td className="py-1.5 pr-3 text-right text-slate-600">{t.days_held ?? '—'}</td>
<td className="py-1.5 text-right">
{pnl != null ? (
<span className={clsx('font-bold font-mono', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(1)}%
</span>
) : <span className="text-slate-600"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})()}
{/* Markets mini overview */}
<div className="grid grid-cols-4 gap-3">
{['energy', 'metals', 'indices', 'forex'].map(cls => (
<div key={cls} className="card">
<div className="section-title">{assetEmoji[cls]} {cls}</div>
{allQuotes?.[cls]?.map(q => <QuoteRow key={q.symbol} q={q} />) ?? (
<div className="text-xs text-slate-600">Chargement...</div>
)}
</div>
))}
</div>
{/* Toast notification */}
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-start gap-3 bg-emerald-950 border border-emerald-600/50 text-emerald-200 rounded-xl px-4 py-3 shadow-2xl animate-fade-in min-w-[260px]">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-semibold text-emerald-300">{toast.title}</div>
<div className="text-xs text-emerald-500 mt-0.5 line-clamp-2">{toast.sub}</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,345 @@
import { useState } from 'react'
import { useGeoNews, usePatternRelevance, useGeoRiskScore } from '../hooks/useApi'
import clsx from 'clsx'
import type { GeoNews } from '../types'
import { Globe, ExternalLink, Search } from 'lucide-react'
const CATEGORY_COLORS: Record<string, string> = {
military: 'badge-red', sanctions: 'badge-orange', elections: 'badge-purple',
natural_disaster: 'badge-yellow', health_crisis: 'badge-orange',
resource_scarcity: 'badge-yellow', trade_war: 'badge-blue',
energy: 'badge-orange', political_speech: 'badge-blue',
financial_crisis: 'badge-red', general: 'badge-blue',
}
const CATEGORY_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
resource_scarcity: '⚠️ Ressources', trade_war: '🤝 Commerce',
energy: '⚡ Énergie', political_speech: '🎙️ Discours',
financial_crisis: '💸 Finance', general: '📰 Général',
}
const ASSET_LABELS: Record<string, string> = {
energy: '⛽ Énergie', metals: '🥇 Métaux', agriculture: '🌾 Agri',
equities: '📈 Actions', indices: '📊 Indices', forex: '💱 Forex',
}
function ImpactBar({ value, label }: { value: number; label: string }) {
const abs = Math.abs(value)
const positive = value > 0
return (
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500 w-16 shrink-0">{label}</span>
<div className="flex-1 flex items-center gap-1">
{positive ? (
<><div className="w-1/2"></div><div className="flex-1 bg-dark-600 rounded-full h-1.5"><div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${abs * 100}%` }}></div></div></>
) : (
<><div className="flex-1 bg-dark-600 rounded-full h-1.5 flex justify-end"><div className="bg-red-500 h-1.5 rounded-full" style={{ width: `${abs * 100}%` }}></div></div><div className="w-1/2"></div></>
)}
</div>
<span className={clsx('w-10 text-right', positive ? 'positive' : 'negative')}>
{positive ? '+' : ''}{(value * 100).toFixed(0)}
</span>
</div>
)
}
function NewsCard({ news }: { news: GeoNews }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="card hover:border-slate-600/50 transition-colors cursor-pointer" onClick={() => setExpanded(!expanded)}>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium line-clamp-2 leading-tight">{news.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className={clsx('badge', CATEGORY_COLORS[news.category] ?? 'badge-blue')}>
{CATEGORY_LABELS[news.category] ?? news.category}
</span>
<span className="text-xs text-slate-600">{news.source}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-xs font-bold text-orange-400">{Math.round(news.impact_score * 100)}</div>
<div className="text-xs text-slate-600">impact</div>
</div>
</div>
{expanded && (
<>
<p className="text-xs text-slate-400 leading-relaxed mb-3">{news.summary}</p>
{Object.keys(news.asset_impacts).length > 0 && (
<div className="mb-3">
<div className="text-xs text-slate-600 mb-1">Impact par classe d'actif</div>
{Object.entries(news.asset_impacts).map(([cls, val]) => (
<ImpactBar key={cls} label={ASSET_LABELS[cls] ?? cls} value={val} />
))}
</div>
)}
{news.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{news.tags.map(tag => (
<span key={tag} className="text-xs text-slate-600 bg-dark-700 px-1.5 py-0.5 rounded">#{tag}</span>
))}
</div>
)}
{news.url && (
<a href={news.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
onClick={e => e.stopPropagation()}>
<ExternalLink className="w-3 h-3" /> Lire l'article
</a>
)}
</>
)}
<div className="flex items-center justify-between mt-2 text-xs text-slate-600">
<span>{news.date?.slice(0, 16)}</span>
<span>{expanded ? '▲ Réduire' : '▼ Détail'}</span>
</div>
</div>
)
}
function RelevanceBar({ pct }: { pct: number }) {
const color = pct >= 60 ? 'bg-emerald-500' : pct >= 30 ? 'bg-yellow-500' : 'bg-slate-600'
return (
<div className="flex items-center gap-2">
<div className="w-24 bg-dark-600 rounded-full h-1.5">
<div className={clsx('h-1.5 rounded-full', color)} style={{ width: `${pct}%` }} />
</div>
<span className={clsx('text-sm font-bold', pct >= 60 ? 'text-emerald-400' : pct >= 30 ? 'text-yellow-400' : 'text-slate-500')}>
{pct}%
</span>
</div>
)
}
function PatternRelevanceCard({ p }: { p: any }) {
const [expanded, setExpanded] = useState(false)
const hasNews = p.matching_news?.length > 0
return (
<div className={clsx('card transition-colors', {
'border-emerald-700/40': p.relevance >= 60,
'border-yellow-700/30': p.relevance >= 30 && p.relevance < 60,
'border-slate-700/20 opacity-60': p.relevance === 0,
})}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 mr-3">
<div className="text-sm font-semibold text-white line-clamp-1">{p.name}</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span className="badge badge-blue text-xs">{p.asset_class}</span>
<span className="text-xs text-slate-500">{p.keyword_hits}/{p.keyword_total} mots-clés</span>
{hasNews && (
<span className="text-xs text-emerald-400 flex items-center gap-0.5">
<Search className="w-2.5 h-2.5" />{p.matching_news.length} news
</span>
)}
</div>
</div>
<RelevanceBar pct={p.relevance} />
</div>
{/* Matched keywords */}
{p.matched_keywords?.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{p.matched_keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-emerald-900/30 border border-emerald-700/30 text-emerald-400 px-1.5 py-0.5 rounded">
#{kw}
</span>
))}
</div>
)}
{/* Toggle matching news */}
{hasNews && (
<>
<button onClick={() => setExpanded(!expanded)}
className="text-xs text-slate-600 hover:text-slate-400 mb-2">
{expanded ? '▲ Masquer les news' : `▼ Voir les ${p.matching_news.length} news correspondantes`}
</button>
{expanded && (
<div className="space-y-1.5">
{p.matching_news.map((n: any, i: number) => (
<div key={i} className="card-sm border-slate-700/20">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs text-white line-clamp-2">{n.title}</div>
<div className="text-xs text-slate-600 mt-0.5">{n.source} · {n.date}</div>
</div>
<div className="shrink-0 text-right">
<div className="text-xs text-orange-400 font-mono">{Math.round(n.impact * 100)}</div>
</div>
</div>
<div className="flex flex-wrap gap-1 mt-1">
{n.matched_keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-blue-900/20 text-blue-400 px-1 rounded">#{kw}</span>
))}
</div>
{n.url && (
<a href={n.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 mt-1"
onClick={e => e.stopPropagation()}>
<ExternalLink className="w-2.5 h-2.5" /> Lire
</a>
)}
</div>
))}
</div>
)}
</>
)}
{!hasNews && p.relevance === 0 && (
<div className="text-xs text-slate-600">Aucune news correspondante sur la période</div>
)}
</div>
)
}
export default function GeoRadar() {
const { data: news, isLoading } = useGeoNews()
const { data: riskScore } = useGeoRiskScore()
const [filterCat, setFilterCat] = useState<string>('all')
const [tab, setTab] = useState<'news' | 'patterns'>('news')
const [days, setDays] = useState(2)
const { data: patternRelevance, isLoading: relLoading } = usePatternRelevance(days)
const categories = ['all', ...Array.from(new Set(news?.map(n => n.category) ?? []))]
const filtered = filterCat === 'all' ? news : news?.filter(n => n.category === filterCat)
const patternsWithNews = (patternRelevance ?? []).filter((p: any) => p.matching_news?.length > 0).length
const totalPatterns = patternRelevance?.length ?? 0
return (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-400" /> Radar Géopolitique
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Flux d'actualités · Correspondance avec les patterns de trading
</p>
</div>
{riskScore && (
<div className={clsx('card-sm text-center', {
'border-emerald-700/40': riskScore.level === 'low',
'border-yellow-700/40': riskScore.level === 'medium',
'border-orange-700/40': riskScore.level === 'high',
'border-red-700/40 animate-pulse': riskScore.level === 'extreme',
})}>
<div className={clsx('text-2xl font-bold', {
'text-emerald-400': riskScore.level === 'low',
'text-yellow-400': riskScore.level === 'medium',
'text-orange-400': riskScore.level === 'high',
'text-red-400': riskScore.level === 'extreme',
})}>{riskScore.score}/100</div>
<div className="text-xs text-slate-500 uppercase">{riskScore.level}</div>
</div>
)}
</div>
{/* Tabs */}
<div className="flex items-center gap-3">
<div className="flex gap-1 bg-dark-700 p-1 rounded">
{(['news', 'patterns'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={clsx('px-4 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === t,
'text-slate-400 hover:text-slate-200': tab !== t,
})}>
{t === 'news' ? `📰 Actualités (${news?.length ?? 0})` : `🧩 Patterns (${patternsWithNews}/${totalPatterns} avec news)`}
</button>
))}
</div>
{/* Day selector — only shown on patterns tab */}
{tab === 'patterns' && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Fenêtre :</span>
<div className="flex gap-0.5 bg-dark-700 p-0.5 rounded">
{[1, 2, 7, 30].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-2 py-1 rounded text-xs transition-colors', {
'bg-slate-600 text-white': days === d,
'text-slate-400 hover:text-slate-200': days !== d,
})}>
{d}j
</button>
))}
</div>
<span className="text-xs text-slate-600">derniers jours</span>
</div>
)}
</div>
{/* News tab */}
{tab === 'news' && (
<>
<div className="flex flex-wrap gap-2">
{categories.slice(0, 8).map(cat => (
<button key={cat} onClick={() => setFilterCat(cat)}
className={clsx('px-3 py-1 rounded text-xs border transition-colors', {
'bg-blue-600 border-blue-500 text-white': filterCat === cat,
'border-slate-700 text-slate-400 hover:border-slate-500': filterCat !== cat,
})}>
{cat === 'all' ? 'Tous' : CATEGORY_LABELS[cat] ?? cat}
</button>
))}
</div>
{isLoading ? (
<div className="grid grid-cols-2 gap-4">
{[1,2,3,4].map(i => <div key={i} className="card animate-pulse h-32 bg-dark-700"></div>)}
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{filtered?.map((n, i) => <NewsCard key={n.id || i} news={n} />)}
{(!filtered || filtered.length === 0) && (
<div className="card col-span-2 text-center py-8 text-slate-500">
Démarrer le backend pour charger les actualités
</div>
)}
</div>
)}
</>
)}
{/* Patterns relevance tab */}
{tab === 'patterns' && (
<>
<div className="flex items-center gap-3 text-xs text-slate-500 bg-dark-700/40 rounded px-3 py-2">
<Search className="w-3.5 h-3.5 shrink-0 text-blue-400" />
<span>
Correspondance entre les mots-clés de chaque pattern et les news des {days} derniers jours.
Ce signal sert à enrichir le contexte donné à l'IA lors du scoring.
{patternsWithNews > 0 && (
<span className="text-emerald-400 ml-1">
{patternsWithNews} patterns ont des news correspondantes.
</span>
)}
</span>
</div>
{relLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}
</div>
) : (
<div className="space-y-3">
{(patternRelevance ?? []).map((p: any) => (
<PatternRelevanceCard key={p.pattern_id} p={p} />
))}
{(!patternRelevance || patternRelevance.length === 0) && (
<div className="card text-center py-8 text-slate-500">
Démarrer le backend pour calculer la correspondance news-patterns
</div>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,846 @@
import { useState, useEffect, useRef } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
desinflation: { label: 'Désinflation', color: '#06b6d4', emoji: '❄️' },
soft_landing: { label: 'Soft Landing', color: '#3b82f6', emoji: '🛬' },
reflation: { label: 'Reflation', color: '#f97316', emoji: '🔥' },
stagflation: { label: 'Stagflation', color: '#eab308', emoji: '⚡' },
inflation_shock: { label: 'Inflation Shock', color: '#ef4444', emoji: '💥' },
recession: { label: 'Récession', color: '#8b5cf6', emoji: '📉' },
crise_liquidite: { label: 'Crise Liquidité', color: '#ec4899', emoji: '🚨' },
incertain: { label: 'Incertain', color: '#64748b', emoji: '❓' },
}
function ScenarioBadge({ dominant, size = 'sm' }: { dominant: string; size?: 'sm' | 'lg' }) {
const m = SCENARIO_META[dominant] ?? SCENARIO_META.incertain
return (
<span
className={clsx('inline-flex items-center gap-1 rounded font-semibold',
size === 'lg' ? 'px-2.5 py-1 text-sm' : 'px-1.5 py-0.5 text-[11px]')}
style={{ background: `${m.color}22`, color: m.color, border: `1px solid ${m.color}44` }}
>
{m.emoji} {m.label}
</span>
)
}
function PnlBadge({ pnl }: { pnl: number | null | undefined }) {
if (pnl == null) return <span className="text-slate-600 text-xs"></span>
const pos = pnl >= 0
return (
<span className={clsx('inline-flex items-center gap-0.5 text-xs font-bold font-mono',
pos ? 'text-emerald-400' : 'text-red-400')}>
{pos ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{pos ? '+' : ''}{pnl.toFixed(2)}%
</span>
)
}
function ScoreDelta({ entry, latest }: { entry: number | null; latest: number | null }) {
if (entry == null || latest == null) return <span className="text-slate-600 text-[10px]"></span>
const delta = latest - entry
return (
<span className="flex flex-col items-end gap-0.5">
<span className={clsx('font-bold font-mono text-xs',
latest >= 50 ? 'text-emerald-400' : latest >= 25 ? 'text-yellow-400' : 'text-slate-500')}>
{latest}
</span>
{delta !== 0 && (
<span className={clsx('text-[10px] font-mono', delta > 0 ? 'text-emerald-600' : 'text-red-600')}>
{delta > 0 ? '+' : ''}{delta}
</span>
)}
</span>
)
}
// ── Section 1 : Historique des régimes macro ──────────────────────────────────
function MacroHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
const history: any[] = (data as any)?.history ?? []
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{history.length} snapshots · {days} derniers jours
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-14 animate-pulse bg-dark-700" />)}</div>
) : history.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<Activity className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucun snapshot cliquer "Ré-analyser" dans Régime Macro pour commencer à logger
</div>
) : (
<div className="space-y-2">
{history.map((entry: any, i: number) => {
const scores: Record<string, number> = entry.scores ?? {}
const maxScore = Math.max(...Object.values(scores), 1)
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
const reasons: string[] = entry.reasons?.[entry.dominant] ?? []
const isTransition = i > 0 && history[i - 1].dominant !== entry.dominant
return (
<div key={entry.id} className={clsx('card', isTransition && 'border-amber-700/30')}>
<div className="flex items-center gap-3 mb-2">
{isTransition && (
<span className="text-[10px] bg-amber-900/30 text-amber-400 border border-amber-700/30 rounded px-1.5 py-0.5 shrink-0">
Transition
</span>
)}
<ScenarioBadge dominant={entry.dominant} size="lg" />
<span className="text-xs text-slate-600 ml-auto shrink-0">{ts} UTC</span>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-1 mb-2">
{Object.entries(scores)
.sort(([, a], [, b]) => (b as number) - (a as number))
.slice(0, 8)
.map(([key, score]) => {
const m = SCENARIO_META[key] ?? SCENARIO_META.incertain
return (
<div key={key} className="flex items-center gap-1">
<div className="w-16 text-[10px] text-slate-600 truncate">{m.label}</div>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all"
style={{ width: `${((score as number) / maxScore) * 100}%`, background: m.color }} />
</div>
<span className="text-[10px] font-mono text-slate-500 w-6 text-right">{score}</span>
</div>
)
})}
</div>
{reasons.length > 0 && (
<div className="flex flex-wrap gap-1">
{reasons.slice(0, 4).map((r: string, ri: number) => (
<span key={ri} className="text-[10px] text-slate-500 bg-dark-700 px-1.5 py-0.5 rounded">{r}</span>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Post-mortem panel ─────────────────────────────────────────────────────────
function PostmortemPanel({ tradeId, onClose }: { tradeId: number; onClose: () => void }) {
const { data, isLoading } = useTradePostmortem(tradeId)
const { mutate: analyze, isPending: analyzing, data: analysisData } = useAnalyzePostmortem()
const [showScoring, setShowScoring] = useState(true)
const [showSuggestion, setShowSuggestion] = useState(false)
if (isLoading) {
return (
<div className="card mt-3 p-4 border border-blue-700/30 animate-pulse">
<div className="h-4 bg-dark-700 rounded w-48 mb-3" />
<div className="h-3 bg-dark-700 rounded w-full mb-2" />
<div className="h-3 bg-dark-700 rounded w-3/4" />
</div>
)
}
if (!data) return null
const { trade, scoring_context: sc, suggestion_context: sg, score_history } = data as any
const scOut = sc?.output ?? {}
const sgOut = sg?.output ?? {}
const scCtx = sc?.input_context ?? {}
const buckets: any[] = scOut.buckets ?? []
const analysis: any = analysisData?.analysis ?? null
return (
<div className="mt-3 rounded-lg border border-blue-700/30 bg-dark-900/80 p-4 space-y-4 text-xs">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-blue-400" />
<span className="font-semibold text-blue-300">Post-mortem {trade?.pattern_name}</span>
<span className="text-slate-500">|</span>
<span className="font-mono text-slate-400">{trade?.underlying} {trade?.strategy}</span>
</div>
<button onClick={onClose} className="text-slate-600 hover:text-slate-400">
<X className="w-4 h-4" />
</button>
</div>
{/* Score history sparkline */}
{score_history?.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-slate-600 shrink-0">Historique :</span>
<div className="flex items-center gap-1.5 flex-wrap">
{[...score_history].reverse().map((h: any, i: number) => (
<div key={i} className="flex flex-col items-center gap-0.5">
<div
className="w-6 rounded-sm"
style={{
height: `${Math.max(4, (h.score ?? 0) / 2)}px`,
background: (h.score ?? 0) >= 60 ? '#22c55e' : (h.score ?? 0) >= 40 ? '#eab308' : '#64748b',
}}
/>
<span className="text-[9px] font-mono text-slate-600">{h.score ?? '?'}</span>
</div>
))}
</div>
{score_history.length > 0 && (
<span className="text-slate-600 ml-auto shrink-0">
{score_history[0]?.macro_dominant ?? ''} géo {score_history[0]?.geo_score ?? '?'}
</span>
)}
</div>
)}
{/* Scoring context */}
<div>
<button
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
onClick={() => setShowScoring(v => !v)}
>
{showScoring ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Pourquoi noté {scOut.score ?? '?'}/100
{scOut.key_catalyst && <span className="text-slate-600 font-normal ml-1">· {scOut.key_catalyst}</span>}
</button>
{showScoring && (
<div className="pl-5 space-y-2">
{/* Regime / bias */}
<div className="flex flex-wrap gap-3 text-[11px] text-slate-500">
{sc?.macro_dominant && (
<span>Régime : <ScenarioBadge dominant={sc.macro_dominant} /></span>
)}
{scCtx.asset_bias && (
<span>Biais asset : <span className="text-slate-300">{scCtx.asset_bias}</span></span>
)}
{sc?.geo_score != null && (
<span>Géo : <span className="font-mono text-slate-300">{sc.geo_score}/100</span></span>
)}
</div>
{/* Buckets */}
{buckets.length > 0 && (
<div className="grid grid-cols-2 gap-1.5">
{buckets.map((b: any, i: number) => {
const pct = b.max ? Math.round((b.score / b.max) * 100) : 0
const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444'
return (
<div key={i} className="flex flex-col gap-0.5 bg-dark-800 rounded p-2">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-medium truncate mr-2">{b.label ?? b.id}</span>
<span className="font-mono shrink-0" style={{ color }}>{b.score}/{b.max}</span>
</div>
<div className="h-1 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
{b.comment && (
<span className="text-[10px] text-slate-600 line-clamp-2">{b.comment}</span>
)}
</div>
)
})}
</div>
)}
{scOut.summary && (
<p className="text-slate-500 italic">{scOut.summary}</p>
)}
</div>
)}
</div>
{/* Suggestion context */}
{sg && (
<div>
<button
className="flex items-center gap-1.5 text-slate-400 hover:text-slate-200 font-semibold mb-2"
onClick={() => setShowSuggestion(v => !v)}
>
{showSuggestion ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
Pourquoi ce pattern a é créé
</button>
{showSuggestion && (
<div className="pl-5 space-y-1.5 text-[11px] text-slate-400">
{sgOut.macro_fit && <p className="italic">{sgOut.macro_fit}</p>}
{sgOut.description && <p className="text-slate-500">{sgOut.description}</p>}
{sg.macro_dominant && (
<div className="flex gap-3 mt-1">
<span>Créé sous : <ScenarioBadge dominant={sg.macro_dominant} /></span>
{sg.geo_score != null && <span>Géo : <span className="font-mono text-slate-300">{sg.geo_score}/100</span></span>}
</div>
)}
</div>
)}
</div>
)}
{/* GPT-4o analysis */}
<div className="border-t border-slate-800 pt-3">
{!analysis ? (
<button
onClick={() => analyze(tradeId)}
disabled={analyzing}
className="flex items-center gap-2 px-3 py-1.5 rounded bg-blue-900/30 border border-blue-700/40 text-blue-300 hover:bg-blue-900/50 disabled:opacity-50 text-xs font-medium"
>
<Brain className={clsx('w-3.5 h-3.5', analyzing && 'animate-pulse')} />
{analyzing ? 'Analyse GPT-4o en cours…' : 'Analyser avec GPT-4o'}
</button>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 text-blue-400 font-semibold">
<Brain className="w-3.5 h-3.5" />
Analyse GPT-4o
</div>
{[
{ key: 'diagnostic', label: 'Diagnostic', color: 'text-slate-200' },
{ key: 'what_worked', label: 'Ce qui a marché', color: 'text-emerald-400' },
{ key: 'what_missed', label: 'Ce qui a manqué', color: 'text-red-400' },
{ key: 'regime_alignment', label: 'Alignement régime', color: 'text-yellow-400' },
{ key: 'contra_assessment', label: 'Contra-signals', color: 'text-orange-400' },
{ key: 'lesson', label: 'Leçon', color: 'text-blue-300' },
{ key: 'next_cycle', label: 'Prochain cycle', color: 'text-purple-400' },
].map(({ key, label, color }) =>
analysis[key] ? (
<div key={key}>
<span className={clsx('font-semibold', color)}>{label} : </span>
<span className="text-slate-400">{analysis[key]}</span>
</div>
) : null
)}
</div>
)}
</div>
</div>
)
}
// ── Section 2 : Mark-to-Market des trades ─────────────────────────────────────
function TradeMtmSection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useTradeMtm(days)
const [minScoreFilter, setMinScoreFilter] = useState(0)
const [selectedTradeId, setSelectedTradeId] = useState<number | null>(null)
const allTrades: any[] = (data as any)?.trades ?? []
const trades = allTrades.filter((t: any) => (t.latest_score ?? t.score_at_entry ?? 0) >= minScoreFilter)
const withPnl = trades.filter((t: any) => t.pnl_pct != null)
const avgPnl = withPnl.length
? withPnl.reduce((s: number, t: any) => s + t.pnl_pct, 0) / withPnl.length
: null
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{trades.length}/{allTrades.length} trades · {withPnl.length} pricés
{avgPnl != null && (
<span className={clsx('ml-2 font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
· moy {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Score filter */}
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="shrink-0">Score </span>
<input type="range" min="0" max="80" step="5"
value={minScoreFilter}
onChange={e => setMinScoreFilter(parseInt(e.target.value))}
className="w-24 accent-blue-500" />
<span className="w-5 font-mono text-blue-400">{minScoreFilter}</span>
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
</div>
{isLoading ? (
<div className="card h-32 animate-pulse bg-dark-700" />
) : trades.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<TrendingUp className="w-8 h-8 mx-auto mb-2 opacity-20" />
{allTrades.length === 0
? 'Aucun trade logué — scorer des patterns pour commencer le suivi'
: `Aucun trade avec score ≥ ${minScoreFilter}`}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-slate-700/40">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left px-3 py-2 font-medium">Pattern</th>
<th className="text-left px-3 py-2 font-medium">Profil</th>
<th className="text-left px-3 py-2 font-medium">Stratégie</th>
<th className="text-left px-3 py-2 font-medium">Ticker</th>
<th className="text-right px-3 py-2 font-medium">Score</th>
<th className="text-right px-3 py-2 font-medium">Trade Score</th>
<th className="text-right px-3 py-2 font-medium">EV nette</th>
<th className="text-right px-3 py-2 font-medium">Gain prévu</th>
<th className="text-right px-3 py-2 font-medium">Date</th>
<th className="text-right px-3 py-2 font-medium">Prix entrée</th>
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
<th className="text-right px-3 py-2 font-medium">J</th>
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
<th className="px-3 py-2 font-medium w-8"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{trades.map((t: any) => {
const evNet = t.ev_net ?? null
const tradeScore = t.trade_score ?? null
return (
<tr key={t.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-3 py-2 text-slate-300 max-w-[110px] truncate">{t.pattern_name || t.pattern_id}</td>
<td className="px-3 py-2">
{t.matched_profile ? (
<span className="text-[10px] font-semibold text-slate-400 bg-dark-700 px-1.5 py-0.5 rounded border border-slate-700/50">
{t.matched_profile}
</span>
) : <span className="text-slate-700 text-[10px]"></span>}
</td>
<td className="px-3 py-2">
<span className={clsx('badge text-[10px]',
t.direction === 'bearish' ? 'badge-red' : 'badge-green')}>
{t.direction === 'bearish' ? '🐻' : '🐂'} {t.strategy || '—'}
</span>
</td>
<td className="px-3 py-2 font-mono text-slate-300">{t.underlying}</td>
<td className="px-3 py-2 text-right">
<ScoreDelta entry={t.score_at_entry} latest={t.latest_score ?? t.score_at_entry} />
</td>
<td className="px-3 py-2 text-right">
{tradeScore != null ? (
<span className={clsx('font-bold font-mono text-xs',
tradeScore >= 55 ? 'text-emerald-400' : tradeScore >= 45 ? 'text-yellow-400' : 'text-slate-500')}>
{tradeScore.toFixed(1)}
</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right">
{evNet != null ? (
<span className={clsx('font-mono text-[11px]',
evNet > 0.05 ? 'text-emerald-400' : evNet >= -0.01 ? 'text-yellow-400' : 'text-slate-600')}>
{evNet > 0 ? '+' : ''}{(evNet * 100).toFixed(0)}%
</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right">
{t.expected_move_pct != null ? (
<span className="font-mono text-[11px] text-slate-500">{t.expected_move_pct.toFixed(0)}%</span>
) : <span className="text-slate-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-right text-slate-600 whitespace-nowrap text-[11px]">{t.entry_date}</td>
<td className="px-3 py-2 text-right font-mono text-slate-400 text-[11px]">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right font-mono text-slate-300 text-[11px]">
{t.current_price != null ? t.current_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">
{t.days_held != null ? t.days_held : '—'}
</td>
<td className="px-3 py-2 text-right">
<PnlBadge pnl={t.pnl_pct} />
</td>
<td className="px-2 py-2">
<button
onClick={() => setSelectedTradeId(selectedTradeId === t.id ? null : t.id)}
className={clsx(
'p-1 rounded transition-colors',
selectedTradeId === t.id
? 'text-blue-400 bg-blue-900/30'
: 'text-slate-600 hover:text-blue-400 hover:bg-blue-900/20'
)}
title="Post-mortem IA"
>
<Search className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{selectedTradeId !== null && (
<PostmortemPanel
tradeId={selectedTradeId}
onClose={() => setSelectedTradeId(null)}
/>
)}
</div>
)
}
// ── Section 3 : Historique du score géopolitique ──────────────────────────────
function GeoHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useGeoHistory(days)
const history: any[] = (data as any)?.history ?? []
const maxScore = Math.max(...history.map((h: any) => h.geo_score), 1)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{history.length} alertes · {days} derniers jours
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2].map(i => <div key={i} className="card h-12 animate-pulse bg-dark-700" />)}</div>
) : history.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucune alerte scorer des patterns pour commencer le suivi
</div>
) : (
<div className="space-y-2">
{history.map((entry: any) => {
const score = entry.geo_score ?? 0
const level = score >= 70 ? 'extreme' : score >= 50 ? 'high' : score >= 30 ? 'medium' : 'low'
const barColor = { low: '#22c55e', medium: '#eab308', high: '#f97316', extreme: '#ef4444' }[level]
const ts = entry.timestamp?.slice(0, 16).replace('T', ' ') ?? ''
const patterns: any[] = entry.top_patterns ?? []
return (
<div key={entry.id} className="card">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2 shrink-0">
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold"
style={{ background: `${barColor}22`, color: barColor, border: `1px solid ${barColor}44` }}>
{score}
</div>
<div>
<div className="text-[10px] text-slate-600 uppercase">{level}</div>
<div className="text-[10px] text-slate-700">{ts}</div>
</div>
</div>
<div className="flex-1">
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${(score / 100) * 100}%`, background: barColor }} />
</div>
</div>
<span className="text-[10px] text-slate-700 shrink-0">{entry.news_count} news</span>
</div>
{patterns.length > 0 && (
<div className="flex flex-wrap gap-1">
{patterns.slice(0, 5).map((p: any, i: number) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-dark-700 text-slate-400 rounded px-1.5 py-0.5">
<span className="font-mono text-slate-500">{p.score}</span>
<span className="max-w-[120px] truncate">{p.name || p.pattern_id}</span>
</span>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Section 4 : Cycles d'intelligence ────────────────────────────────────────
function CyclesSection() {
const { data: histData, isLoading, refetch, isFetching } = useCycleHistory(20)
const { data: statusData } = useCycleStatus()
const { mutate: triggerCycle, isPending: triggering } = useTriggerCycle()
const runs: any[] = (histData as any)?.runs ?? []
const cs = statusData as any
// Auto-refetch history list when the running cycle finishes
const wasRunning = useRef(false)
useEffect(() => {
if (cs?.running) { wasRunning.current = true }
else if (wasRunning.current) { wasRunning.current = false; refetch() }
}, [cs?.running])
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{runs.length} cycles · {cs?.enabled ? `auto ${cs.interval_hours}h` : 'manuel uniquement'}
</div>
<div className="flex gap-2">
<button
onClick={() => triggerCycle(undefined, { onSuccess: () => setTimeout(() => refetch(), 3000) })}
disabled={triggering}
className="flex items-center gap-1 text-xs border border-blue-500/40 text-blue-400 hover:bg-blue-900/20 px-2.5 py-1 rounded disabled:opacity-40">
<Zap className={clsx('w-3 h-3', triggering && 'animate-pulse')} />
{triggering ? 'Lancement...' : 'Lancer un cycle'}
</button>
<button onClick={() => refetch()} disabled={isFetching}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</button>
</div>
</div>
{isLoading ? (
<div className="space-y-2">{[1,2,3].map(i => <div key={i} className="card h-20 animate-pulse bg-dark-700" />)}</div>
) : runs.length === 0 ? (
<div className="card text-center py-10 text-slate-600 text-sm">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucun cycle activer l'auto-cycle dans Configuration ou lancer manuellement
</div>
) : (
<div className="space-y-3">
{runs.map((run: any) => {
const commentary = run.commentary_parsed
const ok = run.status === 'completed'
const ts = run.started_at?.slice(0, 16).replace('T', ' ') ?? ''
const duration = run.completed_at
? Math.round((new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000)
: null
return (
<div key={run.id} className={clsx('card', ok ? 'border-slate-700/40' : 'border-red-800/30')}>
<div className="flex items-start gap-3 mb-2">
{ok
? <CheckCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-300 font-semibold">{ts} UTC</span>
<span className={clsx('badge text-[10px]', run.trigger === 'manual' ? 'badge-blue' : 'badge-purple')}>
{run.trigger === 'manual' ? 'Manuel' : `Auto ${cs?.interval_hours ?? ''}h`}
</span>
{run.dominant_regime && <ScenarioBadge dominant={run.dominant_regime} />}
{duration != null && <span className="text-[10px] text-slate-600">{duration}s</span>}
</div>
<div className="flex gap-3 mt-1 text-[11px] text-slate-500 flex-wrap">
<span>💡 {run.patterns_suggested} suggérés</span>
<span>✚ {run.patterns_added} ajoutés</span>
<span>🎯 {run.patterns_scored} scorés</span>
{run.geo_score != null && <span>🌐 Géo {run.geo_score}/100</span>}
</div>
</div>
</div>
{commentary && (
<div className="bg-blue-900/10 border border-blue-700/20 rounded p-3 space-y-2">
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 font-semibold uppercase tracking-wide">
<Brain className="w-3 h-3" /> Commentaire IA
</div>
<p className="text-xs text-slate-300 leading-relaxed">{commentary.commentary}</p>
{commentary.key_risk && (
<div className="flex items-start gap-1.5 text-[11px] text-orange-400/80 bg-orange-900/10 border border-orange-700/20 rounded px-2 py-1">
<span className="shrink-0">⚠</span> {commentary.key_risk}
</div>
)}
{commentary.top_pattern && (
<div className="text-[11px] text-emerald-400/80">
🎯 Pattern clé : <span className="font-semibold">{commentary.top_pattern}</span>
</div>
)}
{commentary.lessons_from_report ? (
<div className="flex items-center gap-1.5 text-[10px] text-purple-400/80 bg-purple-900/10 border border-purple-700/20 rounded px-2 py-1">
<BookOpen className="w-3 h-3 shrink-0" />
Leçons du rapport du {commentary.lessons_from_report} intégrées dans ce cycle
{commentary.lessons_headline && (
<span className="text-slate-600 ml-1">· {commentary.lessons_headline}</span>
)}
</div>
) : (
<div className="flex items-center gap-1.5 text-[10px] text-slate-700 italic">
<BookOpen className="w-3 h-3 shrink-0" />
Aucun rapport de performance disponible — générer un rapport dans "Rapport IA"
</div>
)}
</div>
)}
{!commentary && ok && (
<div className="text-[11px] text-slate-700 italic">Aucun commentaire IA généré pour ce cycle</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
// ── Page principale ────────────────────────────────────────────────────────────
const TABS = [
{ key: 'cycles', label: 'Cycles IA', icon: Zap },
{ key: 'macro', label: 'Régimes Macro', icon: Activity },
{ key: 'mtm', label: 'Mark-to-Market', icon: TrendingUp },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [resetMsg, setResetMsg] = useState('')
const { data: summary, refetch: refetchSummary } = useJournalSummary()
const qc = useQueryClient()
const s = summary as any
const handleReset = async () => {
if (!confirmReset) {
setConfirmReset(true)
return
}
setResetting(true)
try {
await api.delete('/journal/reset')
setResetMsg('Journal réinitialisé')
setConfirmReset(false)
// Invalidate all journal queries
await qc.invalidateQueries({ queryKey: ['journal-summary'] })
await qc.invalidateQueries({ queryKey: ['macro-history'] })
await qc.invalidateQueries({ queryKey: ['geo-history'] })
await qc.invalidateQueries({ queryKey: ['trade-mtm'] })
await qc.invalidateQueries({ queryKey: ['cycle-history'] })
setTimeout(() => setResetMsg(''), 3000)
} catch {
setResetMsg('Erreur lors du reset')
setTimeout(() => setResetMsg(''), 3000)
} finally {
setResetting(false)
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" /> Journal de Bord
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Historique des régimes macro · Mark-to-market des trades · Évolution du risque géopolitique
</p>
</div>
<div className="flex items-center gap-3">
{/* Reset button */}
<div className="flex items-center gap-2">
{resetMsg && (
<span className={clsx('text-xs', resetMsg.includes('Erreur') ? 'text-red-400' : 'text-emerald-400')}>
{resetMsg}
</span>
)}
{confirmReset && !resetting && (
<span className="text-xs text-amber-400">Confirmer ?</span>
)}
<button
onClick={confirmReset ? handleReset : () => setConfirmReset(true)}
disabled={resetting}
onBlur={() => setTimeout(() => setConfirmReset(false), 300)}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold border transition-all disabled:opacity-40',
confirmReset
? 'bg-red-900/30 border-red-600/50 text-red-400 hover:bg-red-900/50'
: 'border-slate-700/40 text-slate-600 hover:text-slate-400 hover:border-slate-600'
)}>
<Trash2 className="w-3.5 h-3.5" />
{resetting ? 'Réinit...' : confirmReset ? 'Effacer tout' : 'Reset journal'}
</button>
</div>
{/* Period selector */}
<div className="flex gap-1 bg-dark-700 p-1 rounded text-xs">
{[7, 15, 30].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-2.5 py-1 rounded transition-colors', {
'bg-blue-600 text-white': days === d,
'text-slate-400 hover:text-slate-200': days !== d,
})}>
{d}j
</button>
))}
</div>
</div>
</div>
{/* Summary cards */}
{s && (
<div className="grid grid-cols-4 gap-3">
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.macro_snapshots ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">snapshots macro</div>
{s.current_dominant && (
<div className="mt-1"><ScenarioBadge dominant={s.current_dominant} /></div>
)}
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.regime_transitions?.length ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">transitions de régime</div>
{s.regime_transitions?.length > 0 && (
<div className="text-[10px] text-amber-400 mt-1">
↕ {s.regime_transitions[0].from} → {s.regime_transitions[0].to}
</div>
)}
</div>
<div className="card text-center">
<div className={clsx('text-2xl font-bold',
(s.avg_geo_score ?? 0) >= 70 ? 'text-red-400' : (s.avg_geo_score ?? 0) >= 40 ? 'text-orange-400' : 'text-emerald-400')}>
{s.avg_geo_score != null ? s.avg_geo_score.toFixed(0) : ''}
</div>
<div className="text-xs text-slate-600 mt-0.5">score géo moyen</div>
{s.max_geo_score != null && (
<div className="text-[10px] text-slate-600 mt-1">max {s.max_geo_score}</div>
)}
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-white">{s.trade_entries_logged ?? 0}</div>
<div className="text-xs text-slate-600 mt-0.5">trades logués</div>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{TABS.map(({ key, label, icon: Icon }) => (
<button key={key} onClick={() => setTab(key)}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
{/* Content */}
{tab === 'cycles' && <CyclesSection />}
{tab === 'macro' && <MacroHistorySection days={days} />}
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}
</div>
)
}

View File

@@ -0,0 +1,353 @@
import { useState, useEffect } from 'react'
import { useMacroRegime, useAiStatus } from '../hooks/useApi'
import { RefreshCw, Activity, ChevronDown, ChevronUp, Brain } from 'lucide-react'
import clsx from 'clsx'
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
interface MacroGauge {
id: string; label: string; ticker?: string | null
value: number | null; change_pct: number | null
unit: string; bloc: string; note?: string | null
}
const SCENARIO_ORDER = [
'goldilocks', 'desinflation', 'soft_landing', 'reflation',
'stagflation', 'inflation_shock', 'recession', 'crise_liquidite',
] as const
const BLOC_INFO: Record<string, { label: string; emoji: string; description: string }> = {
liquidite: { label: 'Liquidité mondiale', emoji: '💧', description: 'Carburant des marchés — DXY↓ = conditions mondiales détendues ; pente = signal récession/reprise' },
credit: { label: 'Crédit & Stress', emoji: '⚡', description: 'Thermomètre systémique — HYG (HY, obligataires détectent avant les actions), LQD (IG), VIX' },
energie: { label: 'Énergie', emoji: '⛽', description: 'Premier moteur d\'inflation — Brent↑↑ = stagflation/choc ; Brent↓ = désinflationniste' },
metaux: { label: 'Métaux stratégiques', emoji: '🥇', description: 'Cuivre = "Dr Copper" (croissance mondiale) ; Or = refuge/taux réels ; ratio Or/Cu = biais craint vs expansion' },
croissance: { label: 'Croissance mondiale', emoji: '📊', description: 'Cycle réel — S&P, Russell 2000 (breadth risk-on), XLI (proxy ISM mfg), Baltic Dry (secondaire)' },
derive: { label: 'Indicateurs dérivés', emoji: '🔗', description: 'Pente 10Y3M (récession) · Or/Cu (peur vs expansion) · S&P vs 200j · Russell vs S&P (breadth)' },
}
const ASSET_BIAS_COLORS: Record<string, string> = {
'bullish+': '#10b981', 'bullish': '#34d399', 'neutral': '#64748b',
'bearish': '#f97316', 'bearish+': '#ef4444', 'defensive': '#f59e0b',
}
const ASSET_BIAS_LABELS: Record<string, string> = {
'bullish+': '★★ Très favorable', 'bullish': '★ Favorable', 'neutral': '→ Neutre',
'bearish': '✗ Défavorable', 'bearish+': '✗✗ Contre', 'defensive': '⚠ Défensif',
}
const ASSET_CLASS_LABELS: Record<string, string> = {
energy: 'Énergie ⛽', metals: 'Métaux 🥇', agriculture: 'Agriculture 🌾',
indices: 'Indices 📊', equities: 'Actions 📈', forex: 'Forex 💱',
}
function GaugeRow({ g }: { g: MacroGauge }) {
const val = g.value
const chg = g.change_pct
const up = chg != null && chg > 0
const dn = chg != null && chg < 0
const fmt = (v: number) => {
if (v > 10000) return `${(v / 1000).toFixed(1)}k`
if (v > 1000) return v.toFixed(0)
if (v > 100) return v.toFixed(2)
return v.toFixed(3)
}
return (
<div className="flex items-center justify-between py-1 border-b border-slate-800/50 last:border-0 group">
<div className="flex-1 min-w-0 mr-3">
<span className="text-xs text-slate-300 group-hover:text-white transition-colors">{g.label}</span>
{g.ticker && <span className="text-[10px] text-slate-700 ml-1.5">{g.ticker}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
{val != null ? (
<span className="text-sm font-mono font-semibold text-white">
{fmt(val)}<span className="text-slate-600 text-xs ml-0.5">{g.unit}</span>
</span>
) : (
<span className="text-xs text-slate-700">N/A</span>
)}
{chg != null && (
<span className={clsx('text-xs font-mono w-14 text-right', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-600')}>
{up ? '↑' : dn ? '↓' : '→'} {Math.abs(chg).toFixed(2)}%
</span>
)}
{g.note && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-dark-600 text-slate-500 w-28 text-center truncate">
{g.note}
</span>
)}
</div>
</div>
)
}
function BlocCard({ id, gauges }: { id: string; gauges: MacroGauge[] }) {
const [open, setOpen] = useState(true)
const info = BLOC_INFO[id] ?? { label: id, emoji: '📌', description: '' }
if (!gauges.length) return null
return (
<div className="card p-0 overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-dark-700/50 transition-colors"
>
<div>
<div className="text-sm font-semibold text-white text-left">
{info.emoji} {info.label}
</div>
<div className="text-[10px] text-slate-600 text-left mt-0.5">{info.description}</div>
</div>
{open ? <ChevronUp className="w-3.5 h-3.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-slate-600 shrink-0" />}
</button>
{open && (
<div className="px-3 pb-2 border-t border-slate-800/50">
{gauges.map(g => <GaugeRow key={g.id} g={g} />)}
</div>
)}
</div>
)
}
export default function MacroRegime() {
const { data, isLoading, refetch, isFetching, forceRefetch } = useMacroRegime()
const { data: aiStatus } = useAiStatus()
const [savedNarration, setSavedNarration] = useState<{ text: string; at: string } | null>(null)
const [loadingNarration, setLoadingNarration] = useState(false)
useEffect(() => {
try {
const raw = localStorage.getItem('geo-options:macro-narration')
if (raw) setSavedNarration(JSON.parse(raw))
} catch {}
}, [])
const gauges: Record<string, MacroGauge> = data?.gauges ?? {}
const scenarios = data?.scenarios ?? {}
const dominant: string = scenarios.dominant ?? 'incertain'
const scores: Record<string, number> = scenarios.scores ?? {}
const meta: Record<string, { label: string; color: string; emoji: string }> = scenarios.meta ?? {}
const ranked: [string, number][] = scenarios.ranked ?? []
const reasons: Record<string, string[]> = scenarios.reasons ?? {}
const assetBias: Record<string, Record<string, string>> = scenarios.asset_bias ?? {}
const dominantMeta = meta[dominant] ?? { label: dominant, color: '#94a3b8', emoji: '?' }
const dominantBias: Record<string, string> = assetBias[dominant] ?? {}
// Group gauges by bloc
const byBloc: Record<string, MacroGauge[]> = {}
for (const g of Object.values(gauges)) {
if (!byBloc[g.bloc]) byBloc[g.bloc] = []
byBloc[g.bloc].push(g)
}
// Add derived gauges into their own bloc
const derivedGauges = ([gauges.slope_10y3m, gauges.gold_copper_ratio, gauges.spx_vs_200d] as (MacroGauge | undefined)[])
.filter((g): g is MacroGauge => !!g)
if (derivedGauges.length) byBloc['derive'] = derivedGauges
const handleAiNarration = async () => {
setLoadingNarration(true)
try {
const freshData = await forceRefetch()
const res = await api.post('/ai/macro-narration', { macro_regime: freshData })
const text = res.data.narration ?? res.data.text ?? JSON.stringify(res.data)
const entry = { text, at: new Date().toISOString() }
setSavedNarration(entry)
localStorage.setItem('geo-options:macro-narration', JSON.stringify(entry))
} catch {
const entry = { text: 'Erreur lors de la génération — vérifier la clé OpenAI.', at: new Date().toISOString() }
setSavedNarration(entry)
} finally {
setLoadingNarration(false)
}
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-400" /> Régime Macro
</h1>
<p className="text-xs text-slate-500 mt-0.5">
30 compteurs institutionnels · Détection de régime · Compatibilité scénarios
</p>
</div>
<div className="flex items-center gap-3">
{data?.fetched_at && (
<span className="text-xs text-slate-600">
{data.cached ? `cache ${data.cache_age_sec}s` : 'live'} · {data.fetched_at.slice(11, 16)} UTC
</span>
)}
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white border border-slate-700 rounded px-2.5 py-1.5 transition-colors hover:border-slate-500 disabled:opacity-40"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
Actualiser
</button>
</div>
</div>
{isLoading ? (
<div className="card flex items-center justify-center py-12 text-slate-600 text-sm">
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Chargement des compteurs macro...
</div>
) : (
<>
{/* Dominant scenario banner */}
{dominant !== 'incertain' && (
<div className="rounded-xl border p-4 flex items-center gap-4"
style={{ borderColor: `${dominantMeta.color}44`, background: `${dominantMeta.color}0d` }}>
<div className="text-4xl">{dominantMeta.emoji}</div>
<div className="flex-1">
<div className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Scénario dominant</div>
<div className="text-xl font-bold" style={{ color: dominantMeta.color }}>{dominantMeta.label}</div>
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-1">
{(reasons[dominant] ?? []).map((r, i) => (
<span key={i} className="px-1.5 py-0.5 rounded text-[10px]"
style={{ background: `${dominantMeta.color}22`, color: dominantMeta.color }}>
{r}
</span>
))}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-3xl font-bold font-mono" style={{ color: dominantMeta.color }}>
{scores[dominant]}%
</div>
<div className="text-xs text-slate-600">score de régime</div>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-5">
{/* Left: Gauge blocs */}
<div className="col-span-2 space-y-3">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Compteurs par bloc ({Object.values(gauges).length} indicateurs)
</div>
{['liquidite', 'credit', 'energie', 'metaux', 'croissance', 'derive'].map(bloc => (
<BlocCard key={bloc} id={bloc} gauges={byBloc[bloc] ?? []} />
))}
</div>
{/* Right: Scenarios + asset compatibility */}
<div className="space-y-4">
{/* Scenario ranking */}
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classement des scénarios
</div>
<div className="space-y-3">
{ranked.map(([key, score]) => {
const m = meta[key] ?? { label: key, color: '#94a3b8', emoji: '?' }
const isDom = key === dominant
const rl = reasons[key] ?? []
return (
<div key={key}>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm w-5 text-center">{m.emoji}</span>
<span className="text-xs flex-1 font-medium"
style={{ color: isDom ? m.color : '#94a3b8', fontWeight: isDom ? 700 : 400 }}>
{m.label}
</span>
<span className="text-xs font-mono font-bold w-8 text-right"
style={{ color: isDom ? m.color : '#475569' }}>
{score}%
</span>
</div>
<div className="bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div className="h-full rounded-full transition-all duration-700"
style={{ width: `${score}%`, background: m.color, opacity: isDom ? 1 : 0.45 }} />
</div>
{isDom && rl.length > 0 && (
<div className="mt-1 space-y-0.5">
{rl.slice(0, 3).map((r, i) => (
<div key={i} className="text-[10px] text-slate-600 flex items-center gap-1">
<span style={{ color: m.color }}></span> {r}
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Asset class compatibility for dominant scenario */}
{dominant !== 'incertain' && Object.keys(dominantBias).length > 0 && (
<div className="card">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">
Classes d'actifs · {dominantMeta.emoji} {dominantMeta.label}
</div>
<div className="space-y-1.5">
{Object.entries(dominantBias).map(([cls, bias]) => {
const col = ASSET_BIAS_COLORS[bias] ?? '#64748b'
const lbl = ASSET_BIAS_LABELS[bias] ?? bias
return (
<div key={cls} className="flex items-center justify-between">
<span className="text-xs text-slate-400">{ASSET_CLASS_LABELS[cls] ?? cls}</span>
<span className="text-xs font-semibold" style={{ color: col }}>{lbl}</span>
</div>
)
})}
</div>
</div>
)}
{/* AI narration block */}
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5 text-blue-400" /> Analyse IA
</div>
{savedNarration?.at && (
<span className="text-[10px] text-slate-700">
{new Date(savedNarration.at).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
})}
</span>
)}
</div>
{savedNarration ? (
<p className="text-xs text-slate-300 leading-relaxed mb-3">{savedNarration.text}</p>
) : (
<p className="text-xs text-slate-600 leading-relaxed mb-3">
GPT-4o peut interpréter le régime actuel, identifier les incohérences entre compteurs et proposer des biais de trading.
</p>
)}
{aiStatus?.enabled ? (
<button
onClick={handleAiNarration}
disabled={loadingNarration}
className="w-full text-xs bg-blue-600/20 hover:bg-blue-600/40 border border-blue-500/30 hover:border-blue-500/60 text-blue-300 rounded py-1.5 transition-all disabled:opacity-40 flex items-center justify-center gap-1.5"
>
{loadingNarration ? (
<><RefreshCw className="w-3 h-3 animate-spin" /> Analyse en cours...</>
) : (
<><Brain className="w-3 h-3" /> {savedNarration ? '-analyser' : 'Demander à l\'IA'}</>
)}
</button>
) : (
<div className="text-xs text-slate-700 text-center">OpenAI non configuré</div>
)}
</div>
{/* Reading order reminder */}
<div className="card bg-dark-700/30">
<div className="text-xs font-semibold text-slate-600 mb-2">Ordre de lecture (5 min)</div>
<div className="space-y-0.5 text-[10px] text-slate-600">
<div>1. <span className="text-slate-500">Liquidité</span> Bilan Fed, DXY, taux réels</div>
<div>2. <span className="text-slate-500">Crédit</span> HY spreads, VIX, MOVE</div>
<div>3. <span className="text-slate-500">Énergie</span> Brent, gaz naturel</div>
<div>4. <span className="text-slate-500">Métaux</span> Or/Cuivre ratio</div>
<div>5. <span className="text-slate-500">Synthèse</span> Scénario dominant</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,353 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useAllQuotes, useHistory } from '../hooks/useApi'
import clsx from 'clsx'
import type { Quote, AssetClass } from '../types'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { TrendingUp, TrendingDown, BarChart2, RefreshCw, Search } from 'lucide-react'
const ASSET_CLASSES: { key: AssetClass; label: string; emoji: string }[] = [
{ key: 'energy', label: 'Énergie', emoji: '⛽' },
{ key: 'metals', label: 'Métaux', emoji: '🥇' },
{ key: 'agriculture', label: 'Agriculture', emoji: '🌾' },
{ key: 'indices', label: 'Indices', emoji: '📊' },
{ key: 'equities', label: 'Actions', emoji: '📈' },
{ key: 'forex', label: 'Forex', emoji: '💱' },
]
function QuoteCard({ q, selected, onClick }: { q: Quote; selected: boolean; onClick: () => void }) {
if (!q.price) return null
const pos = q.change_pct >= 0
return (
<div
onClick={onClick}
className={clsx(
'card-sm cursor-pointer hover:border-slate-500/60 transition-all',
selected && 'border-blue-500/60 bg-dark-600'
)}
>
<div className="flex justify-between items-start">
<div className="min-w-0">
<div className="text-xs text-white font-semibold truncate">{q.name || q.symbol}</div>
<div className="text-xs text-slate-600">{q.symbol}</div>
</div>
{pos ? (
<TrendingUp className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
) : (
<TrendingDown className="w-3.5 h-3.5 text-red-400 shrink-0" />
)}
</div>
<div className="mt-2 flex justify-between items-end">
<span className="text-sm font-bold text-white font-mono">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
<span className={clsx('text-xs font-mono font-bold', pos ? 'positive' : 'negative')}>
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
</span>
</div>
{q.iv !== undefined && (
<div className="text-xs text-slate-600 mt-1">IV: {(q.iv * 100).toFixed(1)}%</div>
)}
</div>
)
}
const PERIOD_OPTIONS = [
{ label: '5D', value: '5d' },
{ label: '1M', value: '1mo' },
{ label: '3M', value: '3mo' },
{ label: '6M', value: '6mo' },
{ label: '1A', value: '1y' },
{ label: '2A', value: '2y' },
]
function PriceChart({ symbol, name }: { symbol: string; name: string }) {
const [period, setPeriod] = useState('3mo')
const { data: hist, isLoading } = useHistory(symbol, period)
const chartData = hist?.map(h => ({
date: h.date.slice(0, 10),
close: h.close,
open: h.open,
high: h.high,
low: h.low,
})) ?? []
const first = chartData[0]?.close ?? 0
const last = chartData[chartData.length - 1]?.close ?? 0
const isUp = last >= first
const chg = first > 0 ? ((last - first) / first * 100).toFixed(2) : '0'
return (
<div className="card h-full">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm font-bold text-white">{name}</div>
<div className="text-xs text-slate-500">{symbol}</div>
</div>
<div className="flex items-center gap-3">
<span className={clsx('text-sm font-bold', isUp ? 'positive' : 'negative')}>
{isUp ? '+' : ''}{chg}%
</span>
<div className="flex gap-1">
{PERIOD_OPTIONS.map(p => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={clsx('px-2 py-0.5 rounded text-xs transition-colors', {
'bg-blue-600 text-white': period === p.value,
'text-slate-500 hover:text-slate-300': period !== p.value,
})}
>
{p.label}
</button>
))}
</div>
</div>
</div>
{isLoading ? (
<div className="h-48 flex items-center justify-center">
<RefreshCw className="w-4 h-4 animate-spin text-slate-600" />
</div>
) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData}>
<defs>
<linearGradient id={`grad-${symbol}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={isUp ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
<stop offset="95%" stopColor={isUp ? '#10b981' : '#ef4444'} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} tickLine={false}
tickFormatter={v => v.slice(5)} interval="preserveStartEnd" />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
domain={['auto', 'auto']} width={55}
tickFormatter={v => v > 1000 ? `${(v/1000).toFixed(1)}k` : v.toFixed(2)} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
labelStyle={{ color: '#94a3b8' }}
formatter={(v: number) => [v.toFixed(4), 'Prix']}
/>
<Area
type="monotone" dataKey="close"
stroke={isUp ? '#10b981' : '#ef4444'}
fill={`url(#grad-${symbol})`}
strokeWidth={1.5} dot={false}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-600 text-xs">
Aucune donnée disponible
</div>
)}
</div>
)
}
export default function Markets() {
const { data: allQuotes, isLoading, refetch } = useAllQuotes()
const [searchParams, setSearchParams] = useSearchParams()
const [activeClass, setActiveClass] = useState<AssetClass>('energy')
const [selectedSymbol, setSelectedSymbol] = useState<{ symbol: string; name: string } | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const initialSymbol = useRef(searchParams.get('symbol'))
// Auto-select symbol coming from another page (e.g. Portfolio ticker link)
useEffect(() => {
if (!initialSymbol.current || !allQuotes) return
const sym = initialSymbol.current
initialSymbol.current = null
for (const [cls, quotes] of Object.entries(allQuotes)) {
const match = (quotes as Quote[]).find(q => q.symbol.toUpperCase() === sym.toUpperCase())
if (match) {
setActiveClass(cls as AssetClass)
setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol })
break
}
}
setSearchParams({}, { replace: true })
}, [allQuotes, setSearchParams])
const allQuotesFlat = useMemo(() => {
if (!allQuotes) return []
return Object.entries(allQuotes).flatMap(([cls, quotes]) =>
(quotes as Quote[]).filter(q => q.price).map(q => ({ ...q, assetClass: cls as AssetClass }))
)
}, [allQuotes])
const searchResults = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return []
return allQuotesFlat
.filter(quote => quote.symbol.toLowerCase().includes(q) || (quote.name ?? '').toLowerCase().includes(q))
.slice(0, 8)
}, [searchQuery, allQuotesFlat])
const currentQuotes = allQuotes?.[activeClass] ?? []
return (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<BarChart2 className="w-5 h-5 text-blue-400" /> Marchés & Prix
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Données en temps réel · Volatilité implicite · Opportunités options
</p>
</div>
<button
onClick={() => refetch()}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Actualiser
</button>
</div>
{/* Asset class tabs + ticker search */}
<div className="flex flex-wrap items-center gap-2">
{ASSET_CLASSES.map(({ key, label, emoji }) => (
<button
key={key}
onClick={() => { setActiveClass(key); setSelectedSymbol(null); setSearchQuery('') }}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm transition-all', {
'bg-blue-600 border-blue-500 text-white': activeClass === key,
'border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200': activeClass !== key,
})}
>
<span>{emoji}</span> {label}
</button>
))}
<div className="relative ml-auto">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setSearchQuery('') }}
placeholder="Rechercher un ticker..."
className="bg-dark-700 border border-slate-700 rounded pl-8 pr-3 py-1.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 w-52"
/>
{searchResults.length > 0 && (
<div className="absolute top-full right-0 w-72 bg-dark-700 border border-slate-600 rounded shadow-xl z-20 mt-1 overflow-hidden">
{searchResults.map(q => (
<button
key={q.symbol}
onMouseDown={e => e.preventDefault()}
onClick={() => {
setActiveClass(q.assetClass)
setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })
setSearchQuery('')
}}
className="w-full text-left px-3 py-2 hover:bg-dark-600 border-b border-slate-700/30 last:border-0 flex items-center justify-between transition-colors"
>
<div className="min-w-0 flex-1">
<span className="text-white font-mono text-sm">{q.symbol}</span>
{q.name && <span className="text-slate-500 ml-2 text-xs truncate">{q.name}</span>}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{q.price != null && (
<span className="text-slate-300 font-mono text-xs">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
)}
<span className={clsx('text-xs font-mono', q.change_pct >= 0 ? 'positive' : 'negative')}>
{q.change_pct >= 0 ? '+' : ''}{q.change_pct.toFixed(2)}%
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* Quote grid */}
<div className="col-span-1 space-y-2">
<div className="section-title">
{ASSET_CLASSES.find(c => c.key === activeClass)?.emoji}{' '}
{ASSET_CLASSES.find(c => c.key === activeClass)?.label}
</div>
{isLoading ? (
[1,2,3,4].map(i => <div key={i} className="card-sm animate-pulse h-16 bg-dark-700"></div>)
) : currentQuotes.length > 0 ? (
currentQuotes.map(q => (
<QuoteCard
key={q.symbol}
q={q}
selected={selectedSymbol?.symbol === q.symbol}
onClick={() => setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })}
/>
))
) : (
<div className="card text-slate-500 text-xs text-center py-6">
Démarrer le backend pour charger les prix
</div>
)}
</div>
{/* Chart */}
<div className="col-span-2">
{selectedSymbol ? (
<PriceChart symbol={selectedSymbol.symbol} name={selectedSymbol.name} />
) : (
<div className="card h-full flex items-center justify-center text-slate-600 text-sm">
<div className="text-center">
<BarChart2 className="w-8 h-8 mx-auto mb-2 opacity-30" />
<div>Sélectionner un instrument</div>
<div className="text-xs mt-1">pour voir le graphique de prix</div>
</div>
</div>
)}
</div>
</div>
{/* Market overview table */}
<div className="card">
<div className="section-title">Vue d'ensemble — Tous marchés</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Instrument</th>
<th className="text-left pb-2">Classe</th>
<th className="text-right pb-2">Prix</th>
<th className="text-right pb-2">Var. 1j</th>
<th className="text-right pb-2">Vol. IV (est.)</th>
<th className="text-right pb-2">Signal</th>
</tr>
</thead>
<tbody>
{allQuotes && Object.entries(allQuotes).flatMap(([cls, quotes]) =>
quotes.map(q => q.price && (
<tr key={q.symbol} className="border-b border-slate-700/20 hover:bg-dark-700/50 transition-colors">
<td className="py-1.5">
<div className="text-white">{q.name || q.symbol}</div>
<div className="text-slate-600">{q.symbol}</div>
</td>
<td className="py-1.5 text-slate-500 capitalize">{cls}</td>
<td className="py-1.5 text-right font-mono text-white">
{q.price.toFixed(q.price > 100 ? 2 : 4)}
</td>
<td className={clsx('py-1.5 text-right font-mono font-bold', q.change_pct >= 0 ? 'positive' : 'negative')}>
{q.change_pct >= 0 ? '+' : ''}{q.change_pct.toFixed(2)}%
</td>
<td className="py-1.5 text-right text-slate-400">
{q.iv ? `${(q.iv * 100).toFixed(1)}%` : ''}
</td>
<td className="py-1.5 text-right">
{q.change_pct > 1.5 ? <span className="badge-green badge"> Haussier</span>
: q.change_pct < -1.5 ? <span className="badge-red badge"> Baissier</span>
: <span className="badge badge-blue"> Neutre</span>}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,341 @@
import { useState } from 'react'
import { usePnlCurve } from '../hooks/useApi'
import axios from 'axios'
import clsx from 'clsx'
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, Legend,
} from 'recharts'
import { TrendingUp, FlaskConical, Calculator } from 'lucide-react'
import type { PnLPoint } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call', desc: 'Pari haussier, gain illimité, perte limitée à la prime' },
{ key: 'long_put', label: 'Long Put', desc: 'Pari baissier, gain important, perte limitée à la prime' },
{ key: 'bull_call_spread', label: 'Bull Call Spread', desc: 'Haussier modéré, coût réduit, gain plafonné' },
{ key: 'bear_put_spread', label: 'Bear Put Spread', desc: 'Baissier modéré, coût réduit, gain plafonné' },
{ key: 'straddle', label: 'Long Straddle', desc: 'Pari sur la volatilité, direction neutre' },
]
const WATCHLIST_QUICK = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'UUP', 'XOM', 'LMT',
]
interface Greeks {
price: number; delta: number; gamma: number; theta: number; vega: number; rho: number
underlying_price: number; sigma: number
}
interface SpreadResult {
strategy: string; net_debit: number; max_loss: number; max_gain: number | null
breakeven?: number; breakevens?: number[]; legs: Array<{type: string; strike: number; premium: number}>
underlying_price: number; sigma: number
}
export default function OptionsLab() {
const [strategy, setStrategy] = useState('long_call')
const [symbol, setSymbol] = useState('GLD')
const [strike, setStrike] = useState(200)
const [strikeHigh, setStrikeHigh] = useState(210)
const [expiry, setExpiry] = useState(90)
const [optionType, setOptionType] = useState('call')
const [quantity, setQuantity] = useState(1)
const [result, setResult] = useState<Greeks | SpreadResult | null>(null)
const [pnlData, setPnlData] = useState<PnLPoint[]>([])
const [loading, setLoading] = useState(false)
const compute = async () => {
setLoading(true)
try {
let res: Greeks | SpreadResult
if (strategy === 'long_call' || strategy === 'long_put') {
const type = strategy === 'long_call' ? 'call' : 'put'
const r = await axios.get('/api/options/price', {
params: { symbol, strike, expiry_days: expiry, option_type: type }
})
res = r.data as Greeks
const pnl = await axios.get('/api/options/pnl-curve', {
params: { symbol, strike, expiry_days: expiry, option_type: type, quantity, premium_paid: res.price }
})
setPnlData(pnl.data as PnLPoint[])
} else if (strategy === 'bull_call_spread') {
const r = await axios.get('/api/options/strategy/bull-call-spread', {
params: { symbol, strike_low: strike, strike_high: strikeHigh, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else if (strategy === 'bear_put_spread') {
const r = await axios.get('/api/options/strategy/bear-put-spread', {
params: { symbol, strike_high: strikeHigh, strike_low: strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else {
const r = await axios.get('/api/options/strategy/straddle', {
params: { symbol, strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
}
setResult(res)
} catch (e) {
console.error(e)
}
setLoading(false)
}
const isGreeks = result && 'delta' in result
const isSpread = result && 'net_debit' in result
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<FlaskConical className="w-5 h-5 text-blue-400" /> Options Lab
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Pricer Black-Scholes · Greeks · Stratégies · Courbe P&L
</p>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Left: strategy builder */}
<div className="col-span-1 space-y-4">
{/* Strategy select */}
<div className="card">
<div className="section-title">Stratégie</div>
<div className="space-y-1.5">
{STRATEGIES.map(s => (
<button
key={s.key}
onClick={() => setStrategy(s.key)}
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': strategy === s.key,
'border-slate-700/40 text-slate-400 hover:border-slate-600': strategy !== s.key,
})}
>
<div className="font-semibold">{s.label}</div>
<div className="text-slate-500 mt-0.5">{s.desc}</div>
</button>
))}
</div>
</div>
{/* Parameters */}
<div className="card">
<div className="section-title flex items-center gap-1"><Calculator className="w-3 h-3" /> Paramètres</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</label>
<div className="flex gap-1 flex-wrap mb-1">
{WATCHLIST_QUICK.map(s => (
<button
key={s}
onClick={() => setSymbol(s)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': symbol === s,
'border-slate-700 text-slate-500 hover:border-slate-500': symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
placeholder="Ex: GLD, USO, SPY"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Strike {strategy === 'bull_call_spread' || strategy === 'bear_put_spread' ? 'bas' : ''}
</label>
<input
type="number"
value={strike}
onChange={e => setStrike(Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
{(strategy === 'bull_call_spread' || strategy === 'bear_put_spread') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike haut</label>
<input
type="number"
value={strikeHigh}
onChange={e => setStrikeHigh(Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
)}
<div>
<label className="text-xs text-slate-500 mb-1 block">Expiration (jours)</label>
<div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => (
<button
key={d}
onClick={() => setExpiry(d)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': expiry === d,
'border-slate-700 text-slate-500 hover:border-slate-500': expiry !== d,
})}
>
{d}j
</button>
))}
</div>
<input
type="number"
value={expiry}
onChange={e => setExpiry(Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
{(strategy === 'long_call' || strategy === 'long_put') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Nb contrats</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
min={1}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
</div>
)}
<button
onClick={compute}
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold transition-colors"
>
{loading ? 'Calcul...' : '⚡ Calculer'}
</button>
</div>
</div>
</div>
{/* Right: results */}
<div className="col-span-2 space-y-4">
{/* Greeks / Spread summary */}
{isGreeks && (
<div className="card">
<div className="section-title">Prix & Greeks {symbol} {strike} {strategy === 'long_call' ? 'Call' : 'Put'} {expiry}j</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Prime', value: `$${result.price.toFixed(4)}`, highlight: true },
{ label: 'Coût (1 contrat)', value: `$${(result.price * 100).toFixed(2)}` },
{ label: 'Spot sous-jacent', value: `$${result.underlying_price?.toFixed(2)}` },
{ label: 'Vol. Réalisée', value: `${((result.sigma ?? 0) * 100).toFixed(1)}%` },
].map(({ label, value, highlight }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('stat-value text-lg mt-1', highlight && 'text-blue-400')}>{value}</div>
</div>
))}
</div>
<div className="grid grid-cols-5 gap-2">
{[
{ label: 'Delta (Δ)', value: result.delta, desc: 'Sensibilité au prix' },
{ label: 'Gamma (Γ)', value: result.gamma, desc: 'Variation du delta' },
{ label: 'Theta (Θ)', value: result.theta, desc: 'Déclin temporel/j' },
{ label: 'Vega (ν)', value: result.vega, desc: 'Sensibilité à IV' },
{ label: 'Rho (ρ)', value: result.rho, desc: 'Sensibilité aux taux' },
].map(({ label, value, desc }) => (
<div key={label} className="card-sm text-center">
<div className="text-xs text-slate-500">{label}</div>
<div className={clsx('text-base font-bold mt-0.5', {
'text-emerald-400': value > 0,
'text-red-400': value < 0,
'text-slate-400': value === 0,
})}>
{value?.toFixed(4)}
</div>
<div className="text-xs text-slate-600 mt-0.5">{desc}</div>
</div>
))}
</div>
</div>
)}
{isSpread && (
<div className="card">
<div className="section-title">Résumé stratégie {result.strategy}</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Débit net', value: `$${result.net_debit.toFixed(4)}`, color: 'text-red-400' },
{ label: 'Perte max', value: `$${result.max_loss.toFixed(2)}`, color: 'text-red-400' },
{ label: 'Gain max', value: result.max_gain != null ? `$${result.max_gain.toFixed(2)}` : '∞', color: 'text-emerald-400' },
{ label: 'Seuil renta.', value: `$${(result.breakeven ?? (result.breakevens?.[0]) ?? 0).toFixed(2)}`, color: 'text-yellow-400' },
].map(({ label, value, color }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-lg font-bold mt-1', color)}>{value}</div>
</div>
))}
</div>
<div>
<div className="text-xs text-slate-500 mb-2">Jambes de la stratégie</div>
{result.legs.map((leg, i) => (
<div key={i} className="flex items-center gap-3 text-xs py-1.5 border-b border-slate-700/30 last:border-0">
<span className={clsx('badge', leg.type.includes('long') ? 'badge-green' : 'badge-red')}>
{leg.type.toUpperCase()}
</span>
<span className="text-white">Strike: ${leg.strike}</span>
<span className="text-slate-400">Prime: ${leg.premium.toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
{/* P&L Chart */}
{pnlData.length > 0 && (
<div className="card">
<div className="section-title">Courbe P&L à l'expiration</div>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={pnlData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" tick={{ fill: '#475569', fontSize: 9 }}
tickFormatter={v => `$${v.toFixed(0)}`} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `$${v.toFixed(0)}`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`$${v.toFixed(2)}`, 'P&L']}
labelFormatter={v => `Prix sous-jacent: $${Number(v).toFixed(2)}`}
/>
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={strike} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: 'Strike', fill: '#f59e0b', fontSize: 9 }} />
<Line
type="monotone" dataKey="pnl"
stroke="#3b82f6" strokeWidth={2} dot={false}
activeDot={{ r: 4, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{!result && !loading && (
<div className="card h-80 flex items-center justify-center text-slate-600 text-sm">
<div className="text-center">
<FlaskConical className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>Configurer et calculer une stratégie</div>
<div className="text-xs mt-1">Les résultats apparaîtront ici</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,690 @@
import { useState, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
const setA = new Set(a.map(x => x.toLowerCase()))
const setB = new Set(b.map(x => x.toLowerCase()))
let intersection = 0
for (const x of setA) if (setB.has(x)) intersection++
const union = setA.size + setB.size - intersection
return union === 0 ? 0 : intersection / union
}
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
resource_scarcity: '⚠️ Ressources', trade_war: '🤝 Commerce',
energy: '⚡ Énergie', political_speech: '🎙️ Discours', financial_crisis: '💸 Finance',
}
const EMPTY_PATTERN = {
name: '', description: '', triggers: [] as string[], keywords: [] as string[],
historical_instances: [] as any[], suggested_trades: [] as any[],
asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30,
}
function QualityBadge({ score }: { score: number }) {
const color = score >= 75 ? 'badge-green' : score >= 50 ? 'badge-yellow' : 'badge-red'
const label = score >= 75 ? 'Excellent' : score >= 50 ? 'Bon' : 'Faible'
return <span className={clsx('badge', color)}>{score}/100 {label}</span>
}
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
similarTo?: Array<{ name: string; similarity: number }>
aiScore?: number | null
}) {
const [expanded, setExpanded] = useState(false)
const isCustom = p.source === 'custom'
const isActive = p.is_active !== 0
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-blue-500/30': isCustom,
'opacity-50': !isActive,
})}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<div className="text-sm font-semibold text-white">{p.name}</div>
{isCustom && <span className="badge badge-purple text-xs">Custom</span>}
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
</div>
<div className="text-xs text-slate-500">{p.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<div className={clsx('text-sm font-bold', p.expected_move_pct > 0 ? 'positive' : 'negative')}>
{p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%
</div>
<div className="text-xs text-slate-600">{p.horizon_days}j</div>
</div>
<button onClick={onEdit} title="Modifier" className="text-slate-500 hover:text-blue-400 transition-colors">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={onToggle} title={isActive ? 'Désactiver' : 'Activer'} className={clsx('transition-colors', isActive ? 'text-slate-500 hover:text-yellow-400' : 'text-yellow-500 hover:text-yellow-300')}>
{isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={onDelete} title="Supprimer" className="text-slate-500 hover:text-red-400 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{similarTo && similarTo.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similarTo.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mb-2">
{(p.triggers || []).map((t: string) => (
<span key={t} className="badge badge-orange text-xs">{TRIGGER_LABELS[t] ?? t}</span>
))}
<span className="badge badge-blue text-xs">{p.asset_class}</span>
{aiScore != null ? (
<span className={clsx('badge text-xs font-bold', aiScore >= 50 ? 'badge-green' : aiScore >= 25 ? 'badge-yellow' : 'badge-red')}>
{aiScore}/100 IA
</span>
) : (
<span className="badge badge-purple text-xs">{Math.round(p.probability * 100)}% prob.</span>
)}
</div>
<button onClick={() => setExpanded(!expanded)} className="text-xs text-slate-600 hover:text-slate-400">
{expanded ? '▲ Réduire' : '▼ Voir détails'}
</button>
{expanded && (
<div className="mt-3 space-y-2">
{p.keywords?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Mots-clés de détection</div>
<div className="flex flex-wrap gap-1">
{p.keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded">#{kw}</span>
))}
</div>
</div>
)}
{p.historical_instances?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Instances historiques</div>
{p.historical_instances.map((h: any, i: number) => (
<div key={i} className="card-sm mb-1">
<span className="text-xs text-slate-500">{h.date}</span>
<span className="text-xs text-slate-300 ml-2">{h.event || h.outcome}</span>
</div>
))}
</div>
)}
{p.suggested_trades?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Trades suggérés</div>
{p.suggested_trades.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs mb-1 flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
{t.asset_class && <span className="badge badge-blue">{t.asset_class}</span>}
<span className="text-slate-500"> {t.rationale}</span>
</div>
))}
</div>
)}
{p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
<div className="card-sm border-blue-700/30">
<div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div>
{p.ai_evaluation.strengths?.length > 0 && (
<div className="text-xs text-emerald-400 mb-1"> {p.ai_evaluation.strengths[0]}</div>
)}
{p.ai_evaluation.weaknesses?.length > 0 && (
<div className="text-xs text-red-400"> {p.ai_evaluation.weaknesses[0]}</div>
)}
{p.ai_evaluation.overall_recommendation && (
<div className="text-xs text-slate-400 mt-1">{p.ai_evaluation.overall_recommendation}</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => void; onSaveAll: (patterns: any[]) => void; allPatterns: any[] }) {
const { mutate: suggest, isPending, data } = useSuggestNewPatterns()
const [saved, setSaved] = useState<Set<number>>(new Set())
const { mutate: savePattern } = useSavePattern()
const suggestions: any[] = (data as any)?.suggested_patterns ?? []
const handleSave = (p: any, idx: number) => {
savePattern(p, { onSuccess: () => setSaved(prev => new Set(prev).add(idx)) })
}
// For each suggestion, find existing patterns with keyword overlap >= 25%
const suggestionSimilarities = useMemo(() =>
suggestions.map(s => {
const kws = s.keywords ?? []
return allPatterns
.map(ep => ({ name: ep.name, similarity: jaccard(kws, ep.keywords ?? []) }))
.filter(x => x.similarity >= 0.25)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
}),
[suggestions, allPatterns]
)
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/30">
<div>
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-400" /> Suggérer des patterns par l'IA
</h2>
<p className="text-xs text-slate-500 mt-0.5">
GPT-4o analyse l'actualité géo, les prix, le calendrier et le <span className="text-blue-400">régime macro actuel</span> pour proposer des patterns cohérents avec le scénario dominant
</p>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{!data && !isPending && (
<div className="text-center py-12">
<Sparkles className="w-10 h-10 text-blue-400/40 mx-auto mb-3" />
<p className="text-sm text-slate-400 mb-4">
L'IA va analyser les news géopolitiques actuelles, les mouvements de prix et le calendrier économique pour proposer des patterns pertinents <em>aujourd'hui</em>.
</p>
<button onClick={() => suggest()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded text-sm font-semibold">
Lancer l'analyse (GPT-4o)
</button>
</div>
)}
{isPending && (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-slate-400">GPT-4o analyse le contexte du moment…</p>
</div>
)}
{suggestions.map((p: any, idx: number) => {
const score = Math.round((p.probability ?? 0.5) * 100)
const similars = suggestionSimilarities[idx] ?? []
return (
<div key={idx} className={clsx('card', saved.has(idx) ? 'border-emerald-700/40 opacity-60' : similars.length > 0 ? 'border-amber-700/30' : 'border-blue-700/20')}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{p.name}</div>
<div className="flex gap-1 mt-0.5 flex-wrap">
<span className="badge badge-blue text-xs">{p.asset_class}</span>
<span className={clsx('badge text-xs font-bold', score >= 50 ? 'badge-green' : score >= 25 ? 'badge-yellow' : 'badge-red')}>
{score}/100 IA
</span>
<span className="text-xs text-slate-500">{p.horizon_days}j · {p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%</span>
</div>
</div>
<button
onClick={() => handleSave(p, idx)}
disabled={saved.has(idx)}
className={clsx('shrink-0 ml-3 flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all', {
'bg-emerald-700/30 border border-emerald-700/40 text-emerald-400 cursor-default': saved.has(idx),
'bg-blue-600 hover:bg-blue-500 text-white': !saved.has(idx),
})}>
{saved.has(idx) ? <><Check className="w-3 h-3" /> Sauvegardé</> : <><Save className="w-3 h-3" /> Sauvegarder</>}
</button>
</div>
{similars.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similars.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
⚠ Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<p className="text-xs text-slate-400 mb-2">{p.description}</p>
{p.macro_fit && (
<div className="flex items-start gap-1.5 mb-2 text-[11px] text-blue-300/80 bg-blue-900/20 border border-blue-700/20 rounded px-2 py-1.5">
<span className="shrink-0 mt-px">🌐</span>
<span>{p.macro_fit}</span>
</div>
)}
{p.suggested_trades?.length > 0 && (
<div className="space-y-1">
{p.suggested_trades.map((t: any, ti: number) => (
<div key={ti} className="flex items-center gap-2 text-xs flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
<span className="text-slate-500">— {t.rationale}</span>
</div>
))}
</div>
)}
</div>
)
})}
{data && suggestions.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Aucun pattern suggéré. Réessayer.
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-700/30 flex justify-between items-center">
{suggestions.length > 0 && (
<span className="text-xs text-slate-500">
{saved.size}/{suggestions.length} sauvegardé{saved.size > 1 ? 's' : ''}
</span>
)}
<div className="flex gap-2 ml-auto">
{data && suggestions.length > 0 && (
<button onClick={() => suggest()} disabled={isPending}
className="flex items-center gap-1 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-xs">
<RotateCcw className="w-3 h-3" /> Régénérer
</button>
)}
<button onClick={onClose} className="border border-slate-700 text-slate-400 hover:text-slate-200 px-4 py-1.5 rounded text-xs">
Fermer
</button>
</div>
</div>
</div>
</div>
)
}
function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p: any) => void; onCancel: () => void }) {
const [form, setForm] = useState(initial ? {
...initial,
triggers: Array.isArray(initial.triggers) ? initial.triggers : [],
keywords: Array.isArray(initial.keywords) ? initial.keywords : [],
} : { ...EMPTY_PATTERN })
const [kwInput, setKwInput] = useState('')
const [aiResult, setAiResult] = useState<any>(null)
const [aiLoading, setAiLoading] = useState(false)
const [suggestInput, setSuggestInput] = useState('')
const [suggestLoading, setSuggestLoading] = useState(false)
const { mutateAsync: evaluate } = useEvaluatePattern()
const { mutateAsync: suggest } = useSuggestPattern()
const { data: aiStatus } = useAiStatus()
const set = (k: string, v: unknown) => setForm((f: any) => ({ ...f, [k]: v }))
const toggleTrigger = (t: string) => {
set('triggers', form.triggers.includes(t) ? form.triggers.filter((x: string) => x !== t) : [...form.triggers, t])
}
const addKeyword = () => {
if (kwInput.trim() && !form.keywords.includes(kwInput.trim())) {
set('keywords', [...form.keywords, kwInput.trim()])
setKwInput('')
}
}
const evaluateWithAI = async () => {
setAiLoading(true)
try {
const result = await evaluate(form)
setAiResult(result)
if (result.quality_score) set('ai_quality_score', result.quality_score)
set('ai_evaluation', result)
} catch (e) { console.error(e) }
setAiLoading(false)
}
const suggestFromContext = async () => {
setSuggestLoading(true)
try {
const result = await suggest(suggestInput)
if (result && !result.error) {
setForm((f: any) => ({
...f,
name: result.name || f.name,
description: result.description || f.description,
triggers: result.triggers || f.triggers,
keywords: result.keywords || f.keywords,
historical_instances: result.historical_instances || f.historical_instances,
suggested_trades: result.suggested_trades || f.suggested_trades,
asset_class: result.asset_class || f.asset_class,
expected_move_pct: result.expected_move_pct ?? f.expected_move_pct,
probability: result.probability ?? f.probability,
horizon_days: result.horizon_days ?? f.horizon_days,
}))
}
} catch (e) { console.error(e) }
setSuggestLoading(false)
}
return (
<div className="space-y-4">
{/* AI Suggest from context */}
{aiStatus?.enabled && (
<div className="card border-blue-500/30">
<div className="section-title flex items-center gap-1"><Brain className="w-3 h-3 text-blue-400" /> Générer depuis un contexte (IA)</div>
<div className="flex gap-2">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Décris le contexte géopolitique... ex: 'Quand la Chine impose des restrictions sur les terres rares, que se passe-t-il sur le marché des semi-conducteurs et des métaux ?'"
rows={2}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none"
/>
<button onClick={suggestFromContext} disabled={suggestLoading || !suggestInput}
className="shrink-0 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 rounded text-xs">
{suggestLoading ? '...' : ' Générer'}
</button>
</div>
</div>
)}
{/* Form fields */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Nom du pattern</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="Ex: Chine tensions Taiwan → Semi-conducteurs spike"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
rows={2} placeholder="Mécanisme géopolitique → impact marché..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none" />
</div>
</div>
{/* Triggers */}
<div>
<label className="text-xs text-slate-500 mb-2 block">Déclencheurs</label>
<div className="flex flex-wrap gap-1.5">
{TRIGGERS.map(t => (
<button key={t} onClick={() => toggleTrigger(t)}
className={clsx('px-2 py-1 rounded border text-xs transition-all', {
'bg-orange-900/30 border-orange-700/50 text-orange-300': form.triggers.includes(t),
'border-slate-700/40 text-slate-500 hover:border-slate-500': !form.triggers.includes(t),
})}>
{TRIGGER_LABELS[t]}
</button>
))}
</div>
</div>
{/* Keywords */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Mots-clés de détection (anglais)</label>
<div className="flex gap-2 mb-2">
<input value={kwInput} onChange={e => setKwInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addKeyword()}
placeholder="Ajouter un mot-clé..."
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<button onClick={addKeyword} className="bg-dark-600 border border-slate-700 hover:border-slate-500 text-slate-300 px-3 rounded text-xs">+</button>
</div>
<div className="flex flex-wrap gap-1">
{form.keywords.map((kw: string) => (
<span key={kw} className="flex items-center gap-1 text-xs bg-dark-700 text-slate-300 px-2 py-0.5 rounded border border-slate-700/40">
#{kw}
<button onClick={() => set('keywords', form.keywords.filter((k: string) => k !== kw))}
className="text-slate-600 hover:text-red-400 ml-0.5">×</button>
</span>
))}
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{ASSET_CLASSES.map(a => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Move attendu (%)</label>
<input type="number" value={form.expected_move_pct} onChange={e => set('expected_move_pct', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Probabilité (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.probability} onChange={e => set('probability', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Horizon (jours)</label>
<input type="number" value={form.horizon_days} onChange={e => set('horizon_days', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
{/* AI Evaluation result */}
{aiResult && (
<div className="card border-blue-700/40">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-blue-400 flex items-center gap-1">
<Brain className="w-4 h-4" /> Évaluation IA
</div>
<QualityBadge score={aiResult.quality_score || 0} />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-slate-500 mb-1 font-semibold"> Points forts</div>
{aiResult.strengths?.map((s: string, i: number) => (
<div key={i} className="text-emerald-400 mb-0.5"> {s}</div>
))}
</div>
<div>
<div className="text-slate-500 mb-1 font-semibold"> Faiblesses</div>
{aiResult.weaknesses?.map((w: string, i: number) => (
<div key={i} className="text-orange-400 mb-0.5"> {w}</div>
))}
</div>
</div>
{aiResult.suggested_improvements?.additional_keywords?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Mots-clés suggérés par l'IA</div>
<div className="flex flex-wrap gap-1">
{aiResult.suggested_improvements.additional_keywords.map((kw: string) => (
<button key={kw} onClick={() => set('keywords', [...form.keywords, kw])}
className="text-xs bg-blue-900/30 border border-blue-700/40 text-blue-300 px-1.5 py-0.5 rounded hover:bg-blue-800/40">
+{kw}
</button>
))}
</div>
</div>
)}
{aiResult.overall_recommendation && (
<div className="mt-2 text-xs text-slate-400 border-t border-slate-700/40 pt-2">
{aiResult.overall_recommendation}
</div>
)}
{aiResult.counter_scenarios?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Scénarios d'invalidation</div>
{aiResult.counter_scenarios.map((s: string, i: number) => (
<div key={i} className="text-xs text-red-400 mb-0.5"> {s}</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={evaluateWithAI} disabled={aiLoading || !form.name}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm transition-colors disabled:opacity-40">
<Brain className="w-3.5 h-3.5" />
{aiLoading ? 'Évaluation IA...' : 'Évaluer avec IA'}
</button>
)}
<button onClick={onCancel} className="flex items-center gap-1.5 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-sm">
<RotateCcw className="w-3.5 h-3.5" /> Annuler
</button>
<button onClick={() => onSave(form)} disabled={!form.name || form.triggers.length === 0}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-1.5 rounded text-sm font-semibold ml-auto">
<Save className="w-3.5 h-3.5" /> Sauvegarder le pattern
</button>
</div>
</div>
)
}
export default function PatternEditor() {
const { data: patterns, isLoading } = useAllPatterns()
const { mutate: savePattern } = useSavePattern()
const { mutate: deletePattern } = useDeletePattern()
const { mutate: togglePattern } = useTogglePattern()
const { data: aiStatus } = useAiStatus()
const { data: simData } = usePatternSimilarity()
const { data: lastScoresData } = useLastScores()
const [editing, setEditing] = useState<any>(null)
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
const map: Record<string, number> = {}
for (const sp of (lastScoresData as any)?.scored_patterns ?? []) {
const pid = sp.pattern_id
if (!pid) continue
const base = sp.score ?? 0
const best = Math.max(base, ...((sp.trade_rankings ?? []) as any[]).map((r: any) =>
Math.max(0, Math.min(100, base + (r.score_delta ?? 0)))))
map[pid] = best
}
return map
}, [lastScoresData])
// Build a map: patternId → [{name, similarity}] for each side of a similar pair
const similarityMap = useMemo(() => {
const pairs: any[] = (simData as any)?.pairs ?? []
const map: Record<string, Array<{ name: string; similarity: number }>> = {}
for (const pair of pairs) {
if (!map[pair.id_a]) map[pair.id_a] = []
map[pair.id_a].push({ name: pair.name_b, similarity: pair.similarity })
if (!map[pair.id_b]) map[pair.id_b] = []
map[pair.id_b].push({ name: pair.name_a, similarity: pair.similarity })
}
return map
}, [simData])
const displayPatterns = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
return (
<div className="p-6 space-y-5">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
onSaveAll={() => setShowAiSuggest(false)}
allPatterns={patterns ?? []}
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns actifs ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={() => setShowAiSuggest(true)}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm font-semibold transition-colors">
<Sparkles className="w-4 h-4" /> Suggérer par l'IA
</button>
)}
<button onClick={() => { setCreating(true); setEditing(null) }}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Créer un pattern
</button>
</div>
</div>
{(creating || editing) && (
<div className="card border-blue-500/30">
<div className="text-sm font-semibold text-white mb-4">
{editing ? `Modifier: ${editing.name}` : 'Nouveau pattern géopolitique'}
</div>
<PatternForm
initial={editing}
onSave={handleSave}
onCancel={() => { setEditing(null); setCreating(false) }}
/>
</div>
)}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{isLoading ? (
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
) : (
<div className="space-y-3">
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
onEdit={() => { setEditing(p); setCreating(false) }}
onDelete={() => deletePattern(p.id)}
onToggle={() => togglePattern(p.id)}
similarTo={similarityMap[p.id]}
aiScore={aiScoreMap[p.id] ?? null}
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucun pattern personnalisé</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,627 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition
} from '../hooks/useApi'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import axios from 'axios'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, BarChart, Bar
} from 'recharts'
import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react'
import type { TradeIdea } from '../types'
const useDeletePosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => axios.delete(`/api/portfolio/${id}`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
},
})
}
const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex']
interface AddModalProps {
prefill?: Partial<TradeIdea>
onClose: () => void
}
const STRADDLE_STRATEGIES = ['Long Straddle', 'Short Straddle', 'Long Strangle', 'Short Strangle']
function AddPositionModal({ prefill, onClose }: AddModalProps) {
const { mutate: addPos, isPending } = useAddPosition()
const [addError, setAddError] = useState<string | null>(null)
const [form, setForm] = useState({
title: prefill?.title || '',
underlying: prefill?.underlying || '',
strategy: prefill?.strategy || 'Long Call',
asset_class: prefill?.asset_class || 'indices',
expiry_days: prefill?.horizon_days || 90,
capital_invested: prefill?.capital_required || 1000,
geo_trigger: prefill?.geo_trigger || '',
rationale: prefill?.rationale || '',
strike: '',
option_type: 'call',
quantity: 1,
premium: '',
})
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const isStraddle = STRADDLE_STRATEGIES.includes(form.strategy)
const isShort = form.strategy.startsWith('Short')
const submit = () => {
setAddError(null)
const strikeVal = parseFloat(form.strike) || undefined
const premiumVal = parseFloat(form.premium) || undefined
const legs = isStraddle
? [
{ strike: strikeVal, option_type: 'call', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
{ strike: strikeVal, option_type: 'put', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
]
: [{
strike: strikeVal,
option_type: form.option_type,
quantity: form.quantity,
position: form.strategy.startsWith('Short') ? 'short' : 'long',
premium_paid: premiumVal,
}]
addPos({
title: form.title || `${form.strategy} ${form.underlying}`,
underlying: form.underlying,
strategy: form.strategy,
asset_class: form.asset_class,
expiry_days: form.expiry_days,
capital_invested: form.capital_invested,
geo_trigger: form.geo_trigger,
rationale: form.rationale,
legs,
}, {
onSuccess: onClose,
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setAddError(detail || 'Erreur lors de l\'ajout de la position')
}
})
}
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="card w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-blue-400" /> Ajouter au portefeuille
</h2>
<button onClick={onClose} className="text-slate-500 hover:text-slate-200"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Titre</label>
<input value={form.title} onChange={e => set('title', e.target.value)}
placeholder="Ex: Long Call GLD Q3 2026"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Sous-jacent <span className="text-slate-600">(ticker Yahoo Finance)</span>
</label>
<input value={form.underlying} onChange={e => { set('underlying', e.target.value.toUpperCase()); setAddError(null) }}
placeholder="^GSPC, GLD, CL=F..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<div className="text-xs text-slate-600 mt-0.5">S&amp;P 500: ^GSPC · Or: GC=F · WTI: CL=F · GLD · USO</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Stratégie</label>
<select value={form.strategy} onChange={e => set('strategy', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{STRATEGIES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{ASSET_CLASSES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike</label>
<input type="number" value={form.strike} onChange={e => set('strike', e.target.value)}
placeholder="Prix d'exercice"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Type</label>
<select value={form.option_type} onChange={e => set('option_type', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="call">Call</option>
<option value="put">Put</option>
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Prime payée</label>
<input type="number" value={form.premium} onChange={e => set('premium', e.target.value)}
placeholder="Prix option"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Capital investi (€)</label>
<input type="number" value={form.capital_invested} onChange={e => set('capital_invested', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Durée (jours)</label>
<input type="number" value={form.expiry_days} onChange={e => set('expiry_days', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Déclencheur géopolitique</label>
<input value={form.geo_trigger} onChange={e => set('geo_trigger', e.target.value)}
placeholder="Ex: Middle East escalation oil spike"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Raisonnement</label>
<textarea value={form.rationale} onChange={e => set('rationale', e.target.value)}
rows={2} placeholder="Pourquoi ce trade..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none" />
</div>
{isStraddle && (
<div className="bg-blue-900/30 border border-blue-700/30 rounded p-2 text-xs text-blue-300">
Straddle : 2 legs seront créés automatiquement (CALL + PUT au même strike)
</div>
)}
<div className="bg-dark-700 rounded p-2 text-xs text-slate-500">
<div className="font-semibold text-slate-400 mb-1">Frais IB simulés</div>
<div>Options: $0.65/contrat (min $1.00) à l'entrée + sortie</div>
<div>1 contrat = <span className="text-white">$0.65</span> · 5 contrats = <span className="text-white">$3.25</span></div>
</div>
{addError && (
<div className="bg-red-900/40 border border-red-700/40 rounded p-2 text-xs text-red-300">
{addError}
</div>
)}
<button onClick={submit} disabled={isPending || !form.underlying}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-2 text-sm font-semibold">
{isPending ? 'Ajout...' : '+ Ajouter au portefeuille'}
</button>
</div>
</div>
</div>
)
}
function PositionCard({ pos }: { pos: Record<string, any> }) {
const navigate = useNavigate()
const [showClose, setShowClose] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [closeVal, setCloseVal] = useState('')
const { mutate: closePos, isPending: closing } = useClosePosition()
const { mutate: deletePos, isPending: deleting } = useDeletePosition()
const pnl = pos.pnl ?? 0
const pnlPct = pos.pnl_pct ?? 0
const isProfit = pnl >= 0
const entryRef = pos.entry_ref ?? pos.capital_invested
const hasPremium = pos.entry_ref != null && pos.entry_ref !== pos.capital_invested
return (
<div className={clsx('card transition-all', {
'border-emerald-700/40': isProfit && pnl !== 0,
'border-red-700/40': !isProfit && pnl !== 0,
})}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{pos.title}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="badge badge-blue">{pos.strategy}</span>
<button
onClick={() => navigate(`/markets?symbol=${pos.underlying}`)}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-0.5 transition-colors"
title="Voir dans les marchés"
>
{pos.underlying} <ExternalLink className="w-2.5 h-2.5" />
</button>
<span className="text-xs text-slate-600">{pos.entry_date?.slice(0, 10)}</span>
{pos.sigma_used && (
<span className="text-xs text-slate-600">IV {(pos.sigma_used * 100).toFixed(1)}%</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
<div className="text-right">
<div className={clsx('text-lg font-bold', isProfit ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€
</div>
<div className={clsx('text-xs font-mono', isProfit ? 'positive' : 'negative')}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
</div>
</div>
<button onClick={() => deletePos(pos.id)} disabled={deleting}
className="text-slate-600 hover:text-red-400 transition-colors" title="Supprimer">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* KPI grid */}
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="card-sm text-center">
<div className="text-xs text-slate-600">{hasPremium ? 'Prime payée' : 'Capital investi'}</div>
<div className="text-xs text-white font-mono mt-0.5">{entryRef.toFixed(2)}€</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Valeur BS actuelle</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_value != null ? `${pos.current_value.toFixed(2)}€` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Spot sous-jacent</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_underlying != null ? `$${pos.current_underlying}` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Jours restants</div>
<div className={clsx('text-xs font-mono mt-0.5', {
'text-red-400': (pos.days_remaining ?? 99) < 14,
'text-yellow-400': (pos.days_remaining ?? 99) < 30,
'text-white': (pos.days_remaining ?? 99) >= 30,
})}>
{pos.days_remaining != null ? `${pos.days_remaining}j` : '—'}
</div>
</div>
</div>
{/* Greeks + fees */}
{pos.greeks && (
<div className="flex items-center gap-4 text-xs mb-2">
<span className="text-slate-600">Greeks:</span>
<span>Δ <span className="text-slate-300 font-mono">{pos.greeks.net_delta?.toFixed(3)}</span></span>
<span>Θ <span className="text-red-400 font-mono">{pos.greeks.net_theta?.toFixed(3)}</span>/j</span>
<span>ν <span className="text-blue-400 font-mono">{pos.greeks.net_vega?.toFixed(3)}</span></span>
<span className="ml-auto text-slate-600">Frais IB: <span className="text-orange-400">{(pos.ib_fees_entry || 0).toFixed(2)}€</span></span>
</div>
)}
{/* Contract detail toggle */}
{pos.legs && pos.legs.length > 0 && (
<button
onClick={() => setShowDetails(s => !s)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail de la simulation Black-Scholes
</button>
)}
{/* Contract details panel */}
{showDetails && pos.legs && pos.legs.length > 0 && (
<div className="mb-3 bg-dark-700/40 rounded p-2.5 text-xs border border-slate-700/30">
<div className="flex flex-wrap items-center gap-3 mb-2 text-slate-500 border-b border-slate-700/30 pb-1.5">
{pos.entry_underlying_price != null && (
<span>Spot entrée: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
)}
{pos.sigma_used != null && (
<span>σ (IV histo): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
)}
<span>r: <span className="text-slate-300">5%</span></span>
{pos.expiry_days != null && (
<span>Durée initiale: <span className="text-slate-300">{pos.expiry_days}j</span></span>
)}
</div>
<table className="w-full mb-2">
<thead>
<tr className="text-slate-600">
<th className="text-left pb-1 font-normal">Leg</th>
<th className="text-left pb-1 font-normal">Strike</th>
<th className="text-right pb-1 font-normal">Qté</th>
<th className="text-right pb-1 font-normal">Prime/contrat</th>
<th className="text-right pb-1 font-normal">Coût total</th>
</tr>
</thead>
<tbody>
{pos.legs.map((leg: any, i: number) => {
const legTotal = leg.premium_paid != null
? leg.premium_paid * (leg.quantity ?? 1) * 100
: null
return (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1">
<span className={clsx(
'badge text-xs',
(leg.option_type ?? 'call') === 'call' ? 'badge-green' : 'badge-red'
)}>
{(leg.option_type ?? 'call').toUpperCase()}
</span>
<span className="ml-1 text-slate-500">
{leg.position === 'long' ? '▲' : '▼'} {leg.position ?? 'long'}
</span>
</td>
<td className="py-1 font-mono text-slate-300">
{leg.strike != null ? `$${Number(leg.strike).toFixed(2)}` : 'ATM'}
</td>
<td className="py-1 text-right text-slate-300">{leg.quantity ?? 1}</td>
<td className="py-1 text-right font-mono text-slate-300">
{leg.premium_paid != null ? `$${Number(leg.premium_paid).toFixed(4)}` : '—'}
</td>
<td className="py-1 text-right font-mono text-white">
{legTotal != null ? `$${legTotal.toFixed(2)}` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="text-slate-600 italic">
Black-Scholes · 1 contrat = 100 actions · Prix calculé au moment de l'ajout (spot, IV historique, r=5%)
</div>
</div>
)}
{/* Geo trigger */}
{pos.geo_trigger && (
<div className="text-xs text-slate-500 mb-2">
<span className="text-slate-600">⚡ Trigger: </span>{pos.geo_trigger}
</div>
)}
{/* P&L bar */}
<div className="mb-3">
<div className="bg-dark-600 rounded-full h-1.5 relative overflow-hidden">
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-500 z-10"></div>
<div
className={clsx('h-1.5 rounded-full transition-all absolute top-0', isProfit ? 'bg-emerald-500' : 'bg-red-500')}
style={{
width: `${Math.min(50, Math.abs(pnlPct) / 4)}%`,
left: isProfit ? '50%' : `${50 - Math.min(50, Math.abs(pnlPct) / 4)}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-slate-700 mt-0.5">
<span>-100%</span><span>0</span><span>+100%</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button onClick={() => setShowClose(!showClose)}
className="flex-1 text-xs border border-slate-700 hover:border-emerald-600 text-slate-400 hover:text-emerald-400 rounded py-1.5 transition-colors">
{showClose ? 'Annuler' : '💰 Clôturer la position'}
</button>
</div>
{showClose && (
<div className="mt-2 space-y-1">
<div className="text-xs text-slate-500">Valeur de revente (€) — ex: {pos.current_value?.toFixed(2)}</div>
<div className="flex gap-2">
<input type="number" value={closeVal} onChange={e => setCloseVal(e.target.value)}
placeholder={`Valeur actuelle: ~${pos.current_value?.toFixed(2)}€`}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-blue-500" />
<button
onClick={() => closePos({ id: pos.id, close_value: parseFloat(closeVal) || 0 },
{ onSuccess: () => setShowClose(false) })}
disabled={closing || !closeVal}
className="bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 text-white px-3 rounded text-xs font-semibold">
{closing ? '...' : 'Confirmer'}
</button>
</div>
{closeVal && (
<div className={clsx('text-xs', parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1 >= 0 ? 'positive' : 'negative')}>
P&L net IB: {(parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1).toFixed(2)}€
(frais sortie: ~$1.00)
</div>
)}
</div>
)}
</div>
)
}
function ClosedRow({ pos, fees, pnl }: { pos: Record<string, any>; fees: number; pnl: number | null }) {
const { mutate: deletePos, isPending } = useDeletePosition()
return (
<tr className="border-b border-slate-700/20 group">
<td className="py-1.5 text-white">{pos.title}</td>
<td className="py-1.5 text-slate-400">{pos.strategy}</td>
<td className="py-1.5 text-right font-mono">{pos.capital_invested}€</td>
<td className="py-1.5 text-right font-mono">{pos.close_value?.toFixed(2) ?? '—'}€</td>
<td className="py-1.5 text-right font-mono text-orange-400">{fees.toFixed(2)}€</td>
<td className={clsx('py-1.5 text-right font-mono font-bold', pnl != null ? (pnl >= 0 ? 'positive' : 'negative') : 'text-slate-500')}>
{pnl != null ? `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}€` : '—'}
</td>
<td className="py-1.5 text-slate-500">{pos.close_date?.slice(0, 10)}</td>
<td className="py-1.5 pl-2">
<button onClick={() => deletePos(pos.id)} disabled={isPending}
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all" title="Supprimer">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
}
export default function Portfolio() {
const { data: positions, isLoading, refetch } = usePortfolioPositions('open')
const { data: closed } = usePortfolioPositions('closed')
const { data: summary } = usePortfolioSummary()
const { data: pnlHistory } = usePnlHistory()
const [showModal, setShowModal] = useState(false)
const [tab, setTab] = useState<'open' | 'closed' | 'history'>('open')
return (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<DollarSign className="w-5 h-5 text-blue-400" /> Portefeuille
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Suivi temps réel · Mark-to-market Black-Scholes · Frais IB simulés
</p>
</div>
<div className="flex gap-2">
<button onClick={() => refetch()} className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200">
<RefreshCw className="w-3.5 h-3.5" /> Actualiser
</button>
<button onClick={() => setShowModal(true)}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Nouvelle position
</button>
</div>
</div>
{/* Summary KPIs */}
{summary && (
<div className="grid grid-cols-5 gap-3">
{[
{ label: 'Positions ouvertes', value: summary.open_positions, color: 'text-white' },
{ label: 'Capital engagé', value: `${summary.total_invested}€`, color: 'text-white' },
{ label: 'P&L latent', value: `${summary.unrealized_pnl >= 0 ? '+' : ''}${summary.unrealized_pnl}€`, color: summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'P&L réalisé', value: `${summary.realized_pnl >= 0 ? '+' : ''}${summary.realized_pnl}€`, color: summary.realized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Frais IB total', value: `${summary.total_fees_paid}€`, color: 'text-orange-400' },
].map(({ label, value, color }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-lg font-bold mt-1', color)}>{value}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'open', label: `Positions ouvertes (${positions?.length ?? 0})` },
{ key: 'closed', label: `Clôturées (${closed?.length ?? 0})` },
{ key: 'history', label: 'Courbe P&L' },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{/* Open positions */}
{tab === 'open' && (
<>
{isLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => <div key={i} className="card animate-pulse h-32 bg-dark-700" />)}
</div>
) : positions && positions.length > 0 ? (
<div className="space-y-3">
{(positions as Record<string, any>[]).map(pos => (
<PositionCard key={pos.id} pos={pos} />
))}
</div>
) : (
<div className="card text-center py-12 text-slate-500">
<DollarSign className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>Aucune position ouverte</div>
<div className="text-xs mt-1">Ajouter un trade depuis les idées ou manuellement</div>
</div>
)}
</>
)}
{/* Closed positions */}
{tab === 'closed' && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Position</th>
<th className="text-left pb-2">Stratégie</th>
<th className="text-right pb-2">Investi</th>
<th className="text-right pb-2">Clôture</th>
<th className="text-right pb-2">Frais IB</th>
<th className="text-right pb-2">P&L net</th>
<th className="text-left pb-2">Date clôture</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{(closed as Record<string, any>[] || []).map(pos => {
const fees = (pos.ib_fees_entry || 0) + (pos.ib_fees_exit || 0)
const pnl = pos.close_value != null ? pos.close_value - pos.capital_invested - fees : null
return (
<ClosedRow key={pos.id} pos={pos} fees={fees} pnl={pnl} />
)
})}
{(!closed || closed.length === 0) && (
<tr><td colSpan={8} className="py-8 text-center text-slate-600">Aucun trade clôturé</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* P&L history */}
{tab === 'history' && (
<div className="card">
<div className="section-title">Courbe de P&L cumulé (trades réalisés)</div>
{pnlHistory && pnlHistory.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={pnlHistory}>
<defs>
<linearGradient id="pnl-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(0)}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`${v.toFixed(2)}€`, 'P&L cumulé']} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<Area type="monotone" dataKey="cumulative" stroke="#10b981" fill="url(#pnl-grad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={120} className="mt-2">
<BarChart data={pnlHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 8 }} />
<YAxis tick={{ fill: '#475569', fontSize: 8 }} tickFormatter={v => `${v}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 10 }}
formatter={(v: number) => [`${v >= 0 ? '+' : ''}${v.toFixed(2)}€`, 'P&L']} />
<Bar dataKey="pnl" fill="#3b82f6" radius={[2,2,0,0]}
label={false} />
</BarChart>
</ResponsiveContainer>
</>
) : (
<div className="h-40 flex items-center justify-center text-slate-600 text-sm">
Clôturer des positions pour voir la courbe P&L
</div>
)}
</div>
)}
{showModal && <AddPositionModal onClose={() => setShowModal(false)} />}
</div>
)
}

View File

@@ -0,0 +1,509 @@
import { useState } from 'react'
import { Brain, TrendingUp, TrendingDown, RefreshCw, Zap, AlertTriangle, BookOpen, Target, Eye, Clock, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import { usePortfolioReportData, useGeneratePortfolioReport, useAiReportsList, useAiReport } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' },
desinflation: { label: 'Désinflation', color: '#06b6d4', emoji: '❄️' },
soft_landing: { label: 'Soft Landing', color: '#3b82f6', emoji: '🛬' },
reflation: { label: 'Reflation', color: '#f97316', emoji: '🔥' },
stagflation: { label: 'Stagflation', color: '#eab308', emoji: '⚡' },
inflation_shock: { label: 'Inflation Shock', color: '#ef4444', emoji: '💥' },
recession: { label: 'Récession', color: '#8b5cf6', emoji: '📉' },
crise_liquidite: { label: 'Crise Liquidité', color: '#ec4899', emoji: '🚨' },
incertain: { label: 'Incertain', color: '#64748b', emoji: '❓' },
}
function ScenarioBadge({ dominant }: { dominant: string }) {
const m = SCENARIO_META[dominant] ?? SCENARIO_META.incertain
return (
<span
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-semibold"
style={{ background: `${m.color}22`, color: m.color, border: `1px solid ${m.color}44` }}
>
{m.emoji} {m.label}
</span>
)
}
function PnlBar({ pnl }: { pnl: number }) {
const pos = pnl >= 0
const width = Math.min(Math.abs(pnl), 200) / 2 // cap at 100% width
return (
<div className="flex items-center gap-2">
<span className={clsx('font-bold font-mono text-sm w-16 text-right', pos ? 'text-emerald-400' : 'text-red-400')}>
{pos ? '+' : ''}{pnl.toFixed(1)}%
</span>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${width}%`, background: pos ? '#22c55e' : '#ef4444' }}
/>
</div>
</div>
)
}
function ScoreTrend({ trend }: { trend: number[] }) {
if (!trend || trend.length === 0) return <span className="text-slate-700 text-xs"></span>
return (
<div className="flex items-end gap-0.5 h-6">
{trend.map((s, i) => (
<div
key={i}
className="w-2 rounded-sm"
style={{
height: `${Math.max(3, (s ?? 0) / 100 * 24)}px`,
background: (s ?? 0) >= 60 ? '#22c55e' : (s ?? 0) >= 40 ? '#eab308' : '#64748b',
}}
title={`${s}/100`}
/>
))}
</div>
)
}
function MoverCard({ trade, rank, type }: { trade: any; rank: number; type: 'winner' | 'loser' }) {
const [expanded, setExpanded] = useState(false)
const pnl = trade.pnl_pct ?? 0
const sc = trade.scoring_context ?? {}
const scOut = sc.output ?? {}
const sg = trade.suggestion_context ?? {}
const sgOut = sg.output ?? {}
const buckets: any[] = scOut.buckets ?? []
const isWinner = type === 'winner'
return (
<div className={clsx(
'card border',
isWinner ? 'border-emerald-700/20' : 'border-red-700/20'
)}>
<div className="flex items-start gap-3">
{/* Rank */}
<div className={clsx(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0',
isWinner ? 'bg-emerald-900/40 text-emerald-400' : 'bg-red-900/40 text-red-400'
)}>
{rank}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-slate-200 truncate">{trade.pattern_name || trade.pattern_id}</span>
<span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded',
trade.direction === 'bearish' ? 'bg-red-900/30 text-red-400' : 'bg-emerald-900/30 text-emerald-400')}>
{trade.direction === 'bearish' ? '🐻' : '🐂'} {trade.strategy}
</span>
<span className="font-mono text-slate-400 text-xs">{trade.underlying}</span>
</div>
<button
onClick={() => setExpanded(v => !v)}
className="text-slate-600 hover:text-slate-400 shrink-0"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<PnlBar pnl={pnl} />
<div className="flex flex-wrap items-center gap-3 mt-2 text-[11px] text-slate-500">
<span>Score entrée : <span className="font-mono text-slate-300">{trade.score_at_entry ?? '?'}/100</span></span>
<span>EV : <span className={clsx('font-mono', (trade.ev_net ?? 0) > 0 ? 'text-emerald-400' : 'text-red-400')}>
{trade.ev_net != null ? `${(trade.ev_net * 100).toFixed(0)}%` : '—'}
</span></span>
{sc.macro_dominant && <ScenarioBadge dominant={sc.macro_dominant} />}
{sc.geo_score != null && <span>Géo <span className="font-mono text-slate-300">{sc.geo_score}/100</span></span>}
<span className="ml-auto text-slate-700">{trade.entry_date}</span>
</div>
{scOut.key_catalyst && (
<p className="mt-1.5 text-[11px] text-slate-500 italic">
Catalyseur : {scOut.key_catalyst}
</p>
)}
{trade.score_trend?.length > 0 && (
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-slate-600">Score trend :</span>
<ScoreTrend trend={trade.score_trend} />
</div>
)}
{expanded && (
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs">
{/* Thèse d'origine */}
{(sgOut.macro_fit || sgOut.description) && (
<div>
<span className="text-slate-600 font-medium">Thèse initiale : </span>
<span className="text-slate-400">{sgOut.macro_fit || sgOut.description}</span>
</div>
)}
{/* Piliers */}
{buckets.length > 0 && (
<div className="grid grid-cols-2 gap-1.5 mt-2">
{buckets.map((b: any, i: number) => {
const pct = b.max ? Math.round((b.score / b.max) * 100) : 0
const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444'
return (
<div key={i} className="flex items-center gap-2 bg-dark-800 rounded px-2 py-1">
<div className="w-10 h-1 bg-dark-700 rounded-full overflow-hidden shrink-0">
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="text-slate-500 truncate">{b.label ?? b.id}</span>
<span className="font-mono ml-auto shrink-0" style={{ color }}>{b.score}/{b.max}</span>
</div>
)
})}
</div>
)}
{scOut.summary && (
<p className="text-slate-600 italic mt-1">{scOut.summary}</p>
)}
</div>
)}
</div>
</div>
</div>
)
}
function ReportSection({ icon: Icon, title, content, color = 'text-slate-300' }: {
icon: any; title: string; content: string | string[]; color?: string
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-400">
<Icon className="w-4 h-4" />
{title}
</div>
{Array.isArray(content) ? (
<ul className="space-y-1 pl-4">
{content.map((item, i) => (
<li key={i} className={clsx('text-sm', color)}> {item}</li>
))}
</ul>
) : (
<p className={clsx('text-sm leading-relaxed', color)}>{content}</p>
)}
</div>
)
}
export default function RapportIA() {
const [days, setDays] = useState(30)
const [selectedHistoryId, setSelectedHistoryId] = useState<number | null>(null)
const [showHistory, setShowHistory] = useState(false)
const queryClient = useQueryClient()
const { data: rawData, isLoading: loadingRaw, refetch } = usePortfolioReportData(days)
const { mutate: generate, isPending: generating, data: freshReportData, reset: resetFresh } = useGeneratePortfolioReport()
const { data: historyList } = useAiReportsList()
const { data: historicReport } = useAiReport(selectedHistoryId)
const history: any[] = (historyList as any)?.reports ?? []
// Active report: either a loaded historic one, or the freshly generated one
const activeHistoric = historicReport as any
const fresh = freshReportData as any
const raw = rawData as any
const rep = selectedHistoryId && activeHistoric ? activeHistoric : fresh
const report = rep?.report ?? null
const stats = rep?.stats ?? raw
const winners = rep?.winners ?? raw?.winners ?? []
const losers = rep?.losers ?? raw?.losers ?? []
const avgPnl = stats?.avg_pnl_pct ?? null
function handleGenerate() {
setSelectedHistoryId(null)
resetFresh()
generate(days, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ai-reports-list'] })
},
})
}
return (
<div className="flex h-full">
{/* History sidebar */}
{showHistory && (
<aside className="w-64 shrink-0 border-r border-slate-700/40 bg-dark-800 flex flex-col h-screen sticky top-0 overflow-y-auto">
<div className="p-3 border-b border-slate-700/40 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Rapports archivés</span>
<button onClick={() => setShowHistory(false)} className="text-slate-600 hover:text-slate-400 text-xs"></button>
</div>
{history.length === 0 ? (
<div className="p-4 text-xs text-slate-600 text-center mt-4">
Aucun rapport archivé.<br />Générez votre premier rapport.
</div>
) : (
<div className="flex flex-col divide-y divide-slate-800">
{history.map((r: any) => {
const avg = r.stats?.avg_pnl_pct
const date = r.created_at?.slice(0, 16).replace('T', ' ') ?? ''
const isActive = selectedHistoryId === r.id
return (
<button
key={r.id}
onClick={() => { setSelectedHistoryId(r.id) }}
className={clsx(
'text-left px-3 py-2.5 hover:bg-dark-700/50 transition-colors',
isActive && 'bg-blue-900/20 border-l-2 border-blue-500'
)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[11px] font-mono text-slate-500">{date}</span>
<span className="text-[10px] text-slate-600">{r.days}j</span>
</div>
{r.report?.headline && (
<p className="text-xs text-slate-400 line-clamp-2 leading-snug">{r.report.headline}</p>
)}
{avg != null && (
<span className={clsx('text-[11px] font-mono font-bold mt-1 block',
avg >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avg >= 0 ? '+' : ''}{avg.toFixed(1)}% moy.
</span>
)}
{isActive && <ChevronRight className="w-3 h-3 text-blue-400 mt-1" />}
</button>
)
})}
</div>
)}
</aside>
)}
<div className="flex-1 p-6 space-y-6 max-w-5xl overflow-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{!showHistory && (
<button
onClick={() => setShowHistory(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-slate-700/40 text-slate-500 hover:text-slate-300 text-xs"
title={`${history.length} rapport${history.length !== 1 ? 's' : ''} archivé${history.length !== 1 ? 's' : ''}`}
>
<Clock className="w-3.5 h-3.5" />
{history.length > 0 && <span className="font-mono">{history.length}</span>}
</button>
)}
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Brain className="w-6 h-6 text-blue-400" />
Rapport IA Performance & Analyse
{selectedHistoryId && activeHistoric && (
<span className="text-xs font-normal text-slate-500 ml-2">
archivé · {activeHistoric.created_at?.slice(0, 10)}
</span>
)}
</h1>
<p className="text-sm text-slate-500 mt-0.5">
Synthèse des top mouvements + explication GPT-4o basée sur les traces de raisonnement
</p>
</div>
</div>
<div className="flex items-center gap-3">
{selectedHistoryId && (
<button
onClick={() => setSelectedHistoryId(null)}
className="text-xs text-slate-500 hover:text-slate-300 px-2.5 py-1.5 rounded border border-slate-700/40"
>
Nouveau
</button>
)}
{/* Période — only relevant for new generation */}
{!selectedHistoryId && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Période :</span>
{[7, 14, 30, 90].map(d => (
<button
key={d}
onClick={() => setDays(d)}
className={clsx(
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
days === d
? 'bg-blue-900/40 text-blue-300 border border-blue-700/40'
: 'bg-dark-700 text-slate-500 hover:text-slate-300'
)}
>
{d}j
</button>
))}
</div>
)}
{!selectedHistoryId && (
<button
onClick={() => refetch()}
disabled={loadingRaw}
className="text-slate-600 hover:text-slate-400 disabled:opacity-40"
>
<RefreshCw className={clsx('w-4 h-4', loadingRaw && 'animate-spin')} />
</button>
)}
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
<Zap className={clsx('w-4 h-4', generating && 'animate-pulse')} />
{generating ? 'Génération GPT-4o…' : 'Générer rapport IA'}
</button>
</div>
</div>
{/* Stats bar */}
{(loadingRaw || stats) && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Trades total', value: stats?.total_trades ?? '…' },
{ label: 'Trades pricés', value: stats?.priced_count ?? '…' },
{
label: 'P&L moyen',
value: avgPnl != null
? <span className={clsx('font-bold', avgPnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}%
</span>
: '—'
},
].map(({ label, value }) => (
<div key={label} className="card text-center">
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-slate-500 mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* GPT-4o report */}
{report && (
<div className="card border border-blue-700/30 space-y-5">
<div className="flex items-center gap-2 text-blue-400 font-semibold">
<Brain className="w-4 h-4" />
Analyse GPT-4o
</div>
{report.headline && (
<p className="text-base font-medium text-white border-l-2 border-blue-500 pl-3">
{report.headline}
</p>
)}
<div className="grid grid-cols-2 gap-5">
{report.winners_analysis && (
<ReportSection
icon={TrendingUp}
title="Pourquoi les gains"
content={report.winners_analysis}
color="text-emerald-300"
/>
)}
{report.losers_analysis && (
<ReportSection
icon={TrendingDown}
title="Pourquoi les pertes"
content={report.losers_analysis}
color="text-red-300"
/>
)}
</div>
{report.regime_assessment && (
<ReportSection
icon={Eye}
title="Alignement régime macro"
content={report.regime_assessment}
/>
)}
{report.key_lessons?.length > 0 && (
<ReportSection
icon={BookOpen}
title="Leçons clés"
content={report.key_lessons}
color="text-blue-300"
/>
)}
{report.blind_spots && (
<ReportSection
icon={AlertTriangle}
title="Angles morts du scoring"
content={report.blind_spots}
color="text-yellow-300"
/>
)}
{report.next_cycle_priorities && (
<ReportSection
icon={Target}
title="Priorités prochain cycle"
content={report.next_cycle_priorities}
color="text-purple-300"
/>
)}
{report.risk_watch && (
<ReportSection
icon={AlertTriangle}
title="Risques à surveiller"
content={report.risk_watch}
color="text-orange-300"
/>
)}
</div>
)}
{/* Top movers */}
{(winners.length > 0 || losers.length > 0) && (
<div className="grid grid-cols-2 gap-6">
{/* Winners */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-emerald-400 uppercase tracking-wide flex items-center gap-2">
<TrendingUp className="w-4 h-4" /> Top Gains
</h2>
{winners.length === 0 ? (
<div className="card text-center py-8 text-slate-600 text-sm">Aucun trade pricé</div>
) : (
winners.map((t: any, i: number) => (
<MoverCard key={t.id ?? i} trade={t} rank={i + 1} type="winner" />
))
)}
</div>
{/* Losers */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-red-400 uppercase tracking-wide flex items-center gap-2">
<TrendingDown className="w-4 h-4" /> Top Pertes
</h2>
{losers.length === 0 ? (
<div className="card text-center py-8 text-slate-600 text-sm">Aucun trade pricé</div>
) : (
losers.map((t: any, i: number) => (
<MoverCard key={t.id ?? i} trade={t} rank={i + 1} type="loser" />
))
)}
</div>
</div>
)}
{!loadingRaw && !raw && !report && (
<div className="card text-center py-16 text-slate-600">
<Brain className="w-12 h-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">Cliquer "Générer rapport IA" pour lancer l'analyse GPT-4o</p>
<p className="text-xs mt-1 text-slate-700">
Le rapport compare les trades gagnants et perdants, identifie les patterns récurrents
et génère des recommandations pour le prochain cycle.
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,451 @@
import { useState } from 'react'
import {
Brain, RefreshCw, Clock, ChevronDown, ChevronUp,
AlertTriangle, Target, TrendingUp, TrendingDown,
Shield, Zap, BookOpen, CheckCircle, XCircle, HelpCircle,
BarChart2, Activity,
} from 'lucide-react'
import {
useKnowledgeState, useKnowledgeHistory, useKnowledgeStateVersion,
useKnowledgeEntries, useSynthesizeKnowledge, usePatchKbEntryStatus,
} from '../hooks/useApi'
// ─── Status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
active: { icon: <CheckCircle className="w-3 h-3" />, color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', label: 'Validé' },
tentative: { icon: <HelpCircle className="w-3 h-3" />, color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', label: 'Tentative' },
invalidated: { icon: <XCircle className="w-3 h-3" />, color: 'text-rose-400 bg-rose-400/10 border-rose-400/20', label: 'Invalidé' },
}
const s = map[status] || map['tentative']
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-medium ${s.color}`}>
{s.icon}{s.label}
</span>
)
}
// ─── Confidence bar ───────────────────────────────────────────────────────────
function ConfidenceBar({ value }: { value: number }) {
const color = value >= 70 ? 'bg-emerald-500' : value >= 40 ? 'bg-amber-500' : 'bg-rose-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-slate-700">
<div className={`h-full rounded-full ${color}`} style={{ width: `${value}%` }} />
</div>
<span className="text-[10px] text-slate-400 w-7 text-right">{value}%</span>
</div>
)
}
// ─── KB Entry card ────────────────────────────────────────────────────────────
function KbEntry({ entry, onStatusChange }: { entry: any; onStatusChange: (id: number, s: string) => void }) {
const [open, setOpen] = useState(false)
return (
<div className="bg-slate-800/60 border border-slate-700/50 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-slate-200">{entry.title}</span>
<StatusBadge status={entry.status} />
</div>
<ConfidenceBar value={entry.confidence} />
</div>
<button onClick={() => setOpen(o => !o)} className="text-slate-500 hover:text-slate-300 mt-0.5">
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
{open && (
<div className="space-y-2 pt-1">
<p className="text-xs text-slate-300 leading-relaxed">{entry.content}</p>
{entry.tags && (
<div className="flex gap-1 flex-wrap">
{entry.tags.split(',').filter(Boolean).map((t: string) => (
<span key={t} className="px-1.5 py-0.5 rounded bg-slate-700 text-[10px] text-slate-400">{t.trim()}</span>
))}
</div>
)}
<div className="flex gap-2 pt-1">
{['active', 'tentative', 'invalidated'].map(s => (
<button
key={s}
onClick={() => onStatusChange(entry.id, s)}
className={`text-[10px] px-2 py-1 rounded border transition-colors ${
entry.status === s
? 'bg-slate-600 border-slate-500 text-slate-200'
: 'border-slate-700 text-slate-500 hover:border-slate-500 hover:text-slate-300'
}`}
>
{s === 'active' ? 'Valider' : s === 'tentative' ? 'Tentative' : 'Invalider'}
</button>
))}
</div>
<p className="text-[10px] text-slate-600">
Vu le {entry.first_seen_at?.slice(0, 10)} · Confirmé {entry.confirmation_count}×
</p>
</div>
)}
</div>
)
}
// ─── Category section ─────────────────────────────────────────────────────────
const CAT_META: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
régimes: { icon: <Activity className="w-4 h-4" />, color: 'text-blue-400', label: 'Régimes macro' },
patterns: { icon: <BarChart2 className="w-4 h-4" />, color: 'text-purple-400', label: 'Patterns' },
erreurs: { icon: <AlertTriangle className="w-4 h-4" />, color: 'text-rose-400', label: 'Erreurs récurrentes' },
corrélations: { icon: <TrendingUp className="w-4 h-4" />, color: 'text-emerald-400', label: 'Corrélations' },
général: { icon: <BookOpen className="w-4 h-4" />, color: 'text-slate-400', label: 'Général' },
}
function CategorySection({
category, entries, onStatusChange,
}: { category: string; entries: any[]; onStatusChange: (id: number, s: string) => void }) {
const [open, setOpen] = useState(true)
const meta = CAT_META[category] || CAT_META['général']
const active = entries.filter(e => e.status !== 'invalidated').length
return (
<div className="border border-slate-700/50 rounded-xl overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-slate-800/80 hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2">
<span className={meta.color}>{meta.icon}</span>
<span className="text-sm font-medium text-slate-200">{meta.label}</span>
<span className="text-xs text-slate-500">{active}/{entries.length} actifs</span>
</div>
{open ? <ChevronUp className="w-4 h-4 text-slate-500" /> : <ChevronDown className="w-4 h-4 text-slate-500" />}
</button>
{open && (
<div className="p-3 grid gap-2 bg-slate-900/40">
{entries.map(e => (
<KbEntry key={e.id} entry={e} onStatusChange={onStatusChange} />
))}
</div>
)}
</div>
)
}
// ─── Synthesis insight list ───────────────────────────────────────────────────
function InsightList({ title, icon, items, color }: {
title: string; icon: React.ReactNode; items: string[]; color: string
}) {
if (!items?.length) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${color}`}>
{icon}{title}
</div>
<ul className="space-y-1">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-300">
<span className={`mt-0.5 w-1.5 h-1.5 rounded-full flex-shrink-0 ${color.replace('text-', 'bg-')}`} />
{typeof item === 'string' ? item : (item as any).mistake || JSON.stringify(item)}
</li>
))}
</ul>
</div>
)
}
// ─── Macro regime card ────────────────────────────────────────────────────────
function RegimeCard({ r }: { r: any }) {
const conf = r.confidence ?? 50
const color = conf >= 70 ? 'border-emerald-500/30 bg-emerald-500/5' : conf >= 40 ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-700/50 bg-slate-800/30'
return (
<div className={`border rounded-lg p-3 space-y-1 ${color}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-200">{r.regime}</span>
<span className="text-[10px] text-slate-400">{conf}% conf · {r.trade_count ?? '?'} trades</span>
</div>
<p className="text-xs text-slate-400">{r.observation}</p>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function SuperContexte() {
const [selectedHistoryId, setSelectedHistoryId] = useState<number | null>(null)
const [showHistory, setShowHistory] = useState(false)
const { data: stateData, isLoading: stateLoading } = useKnowledgeState()
const { data: histData } = useKnowledgeHistory()
const { data: entriesData } = useKnowledgeEntries()
const { data: versionData } = useKnowledgeStateVersion(selectedHistoryId)
const synthesize = useSynthesizeKnowledge()
const patchStatus = usePatchKbEntryStatus()
const currentState = selectedHistoryId
? versionData?.state
: stateData?.state
const synthesis = currentState?.synthesis || {}
const narrative = currentState?.narrative || ''
const history = histData?.history || []
const byCategory = entriesData?.by_category || {}
const totalEntries = entriesData?.total || 0
const handleSynthesize = () => {
synthesize.mutate(undefined, {
onSuccess: () => setSelectedHistoryId(null),
})
}
const handleStatusChange = (id: number, status: string) => {
patchStatus.mutate({ id, status })
}
return (
<div className="min-h-screen bg-slate-950 text-white">
{/* Header */}
<div className="border-b border-slate-800 bg-slate-900/60 backdrop-blur px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Brain className="w-6 h-6 text-violet-400" />
<div>
<h1 className="text-xl font-bold text-slate-100">Super Contexte</h1>
<p className="text-xs text-slate-500">Base de raisonnement évolutive cerveau du système</p>
</div>
</div>
<div className="flex items-center gap-3">
{currentState && (
<div className="text-right text-xs text-slate-500">
<div>v{currentState.version} · {currentState.created_at?.slice(0, 16)}</div>
<div>{currentState.reports_used} rapports · {currentState.trades_analyzed} trades analysés</div>
</div>
)}
<button
onClick={() => setShowHistory(h => !h)}
className={`p-2 rounded-lg border transition-colors ${showHistory ? 'border-violet-500/50 text-violet-400 bg-violet-500/10' : 'border-slate-700 text-slate-400 hover:border-slate-500'}`}
>
<Clock className="w-4 h-4" />
</button>
<button
onClick={handleSynthesize}
disabled={synthesize.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
>
{synthesize.isPending ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Brain className="w-4 h-4" />
)}
{synthesize.isPending ? 'Synthèse en cours…' : 'Lancer synthèse GPT-4o'}
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-6 flex gap-6">
{/* History sidebar */}
{showHistory && (
<div className="w-56 flex-shrink-0 space-y-2">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Versions</p>
<button
onClick={() => setSelectedHistoryId(null)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${!selectedHistoryId ? 'bg-violet-600/20 border border-violet-500/30 text-violet-300' : 'border border-slate-700/50 text-slate-400 hover:border-slate-600'}`}
>
Dernière version
</button>
{history.map((h: any) => (
<button
key={h.id}
onClick={() => setSelectedHistoryId(h.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${selectedHistoryId === h.id ? 'bg-violet-600/20 border border-violet-500/30 text-violet-300' : 'border border-slate-700/50 text-slate-400 hover:border-slate-600'}`}
>
<div className="font-medium">v{h.version}</div>
<div className="text-slate-500">{h.created_at?.slice(0, 16)}</div>
<div className="text-slate-600">{h.reports_used} rapports · {h.trades_analyzed} trades</div>
</button>
))}
</div>
)}
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{stateLoading && (
<div className="flex items-center justify-center h-40 text-slate-500">
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Chargement
</div>
)}
{!currentState && !stateLoading && (
<div className="border border-violet-500/20 rounded-xl p-8 text-center bg-violet-500/5">
<Brain className="w-12 h-12 text-violet-400/50 mx-auto mb-3" />
<p className="text-slate-300 font-medium mb-1">Aucune synthèse disponible</p>
<p className="text-slate-500 text-sm mb-4">
Lance la première synthèse GPT-4o pour initialiser la base de raisonnement.
Le système analysera tous les rapports et trades disponibles.
</p>
<button
onClick={handleSynthesize}
disabled={synthesize.isPending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-sm font-medium transition-colors"
>
{synthesize.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Brain className="w-4 h-4" />}
{synthesize.isPending ? 'Synthèse en cours…' : 'Initialiser le Super Contexte'}
</button>
</div>
)}
{synthesize.isSuccess && (
<div className="border border-emerald-500/30 bg-emerald-500/5 rounded-xl p-4 flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-emerald-400 flex-shrink-0" />
<div className="text-sm">
<span className="text-emerald-300 font-medium">Synthèse complète. </span>
<span className="text-slate-400">
{(synthesize.data as any)?.kb_entries_added ?? 0} entrées KB ajoutées ·{' '}
{(synthesize.data as any)?.sources?.reports ?? 0} rapports ·{' '}
{(synthesize.data as any)?.sources?.trades ?? 0} trades analysés.
</span>
</div>
</div>
)}
{currentState && (
<>
{/* Narrative */}
<div className="border border-violet-500/20 bg-violet-500/5 rounded-xl p-5 space-y-3">
<div className="flex items-center gap-2 text-violet-300">
<Brain className="w-5 h-5" />
<span className="text-sm font-semibold">Narrative de raisonnement</span>
{selectedHistoryId && (
<span className="text-xs text-slate-500 ml-auto">v{currentState.version} (archivé)</span>
)}
</div>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-wrap">{narrative}</p>
</div>
{/* Synthesis grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Régimes */}
{synthesis.regime_insights?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-blue-400 text-xs font-semibold uppercase tracking-wide">
<Activity className="w-4 h-4" />Régimes macro identifiés
</div>
<div className="space-y-2">
{synthesis.regime_insights.map((r: any, i: number) => (
<RegimeCard key={i} r={r} />
))}
</div>
</div>
)}
{/* Patterns */}
{synthesis.pattern_insights?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-purple-400 text-xs font-semibold uppercase tracking-wide">
<BarChart2 className="w-4 h-4" />Patterns documentés
</div>
<div className="space-y-2">
{synthesis.pattern_insights.map((p: any, i: number) => (
<div key={i} className="border border-slate-700/40 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">{p.pattern}</span>
<span className="text-[10px] text-slate-500">conf {p.confidence}% · win {p.win_rate_pct}%</span>
</div>
<p className="text-xs text-slate-400">{p.observation}</p>
</div>
))}
</div>
</div>
)}
{/* Macro correlations */}
{synthesis.macro_correlations?.length > 0 && (
<div className="border border-slate-700/50 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold uppercase tracking-wide">
<TrendingUp className="w-4 h-4" />Corrélations macro/géo
</div>
<div className="space-y-2">
{synthesis.macro_correlations.map((c: any, i: number) => (
<div key={i} className="border border-slate-700/40 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">{c.trigger}</span>
<span className={`text-[10px] ${c.reliability === 'haute' ? 'text-emerald-400' : c.reliability === 'faible' ? 'text-rose-400' : 'text-amber-400'}`}>
{c.reliability}
</span>
</div>
<p className="text-xs text-slate-400">{c.market_reaction}</p>
</div>
))}
</div>
</div>
)}
{/* Risk params + insights */}
<div className="border border-slate-700/50 rounded-xl p-4 space-y-4">
<InsightList
title="Priorités stratégiques"
icon={<Target className="w-4 h-4" />}
items={synthesis.strategic_priorities || []}
color="text-violet-400"
/>
<InsightList
title="Forces identifiées"
icon={<Zap className="w-4 h-4" />}
items={synthesis.strengths || []}
color="text-emerald-400"
/>
<InsightList
title="Angles morts"
icon={<AlertTriangle className="w-4 h-4" />}
items={synthesis.blind_spots || []}
color="text-amber-400"
/>
<InsightList
title="Erreurs récurrentes"
icon={<TrendingDown className="w-4 h-4" />}
items={(synthesis.recurring_mistakes || []).map((m: any) =>
typeof m === 'string' ? m : `${m.mistake}${m.mitigation}`
)}
color="text-rose-400"
/>
{synthesis.risk_parameters && (
<>
<InsightList
title="Préférer quand"
icon={<Shield className="w-4 h-4" />}
items={synthesis.risk_parameters.prefer_when || []}
color="text-blue-400"
/>
<InsightList
title="Éviter quand"
icon={<XCircle className="w-4 h-4" />}
items={synthesis.risk_parameters.avoid_when || []}
color="text-rose-400"
/>
</>
)}
</div>
</div>
</>
)}
{/* Knowledge Base entries */}
{totalEntries > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-slate-400" />
<h2 className="text-sm font-semibold text-slate-300">Base de connaissances ({totalEntries} entrées)</h2>
</div>
<div className="space-y-3">
{Object.entries(byCategory).map(([cat, entries]: [string, any]) => (
<CategorySection
key={cat}
category={cat}
entries={entries}
onStatusChange={handleStatusChange}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

136
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,136 @@
export type AssetClass = 'energy' | 'metals' | 'agriculture' | 'equities' | 'indices' | 'forex' | 'crypto' | 'rates'
export type RiskLevel = 'low' | 'medium' | 'high' | 'extreme'
export type GeoCategory =
| 'military' | 'sanctions' | 'elections' | 'natural_disaster'
| 'health_crisis' | 'resource_scarcity' | 'trade_war'
| 'energy' | 'political_speech' | 'financial_crisis' | 'general'
export interface Quote {
symbol: string
name: string
price: number
change: number
change_pct: number
volume: number
iv?: number
asset_class: AssetClass
timestamp: string
error?: string
}
export interface GeoNews {
id: string
title: string
summary: string
source: string
category: GeoCategory
impact_score: number
asset_impacts: Partial<Record<AssetClass, number>>
date: string
tags: string[]
url: string
}
export interface GeoRiskScore {
score: number
level: RiskLevel
breakdown: Record<string, number>
top_risks: [string, number][]
}
export interface GeoPattern {
id: string
name: string
description: string
triggers: string[]
keywords: string[]
historical_instances: Array<{date: string; event: string; [key: string]: unknown}>
suggested_trades: Array<{strategy: string; underlying: string; rationale: string}>
asset_class: AssetClass
expected_move_pct: number
probability: number
horizon_days: number
}
export interface PatternMatch extends GeoPattern {
pattern_id: string
similarity: number
}
export interface TradeIdea {
id: string
title: string
rationale: string
pattern: string
asset_class: AssetClass
underlying: string
strategy: string
expected_move_pct: number
confidence: number
horizon_days: number
capital_required: number
risk_level: RiskLevel
pattern_similarity: number
geo_trigger?: string
expiry_days?: number
max_loss_eur?: number
target_gain_eur?: number
strike_guidance?: string
timing?: string
invalidation?: string
rank?: number
}
export interface OptionPrice {
price: number
delta: number
gamma: number
theta: number
vega: number
rho: number
underlying_price: number
sigma: number
}
export interface PnLPoint {
underlying: number
pnl: number
}
export interface BacktestResult {
symbol: string
strategy: string
period: string
total_trades: number
wins: number
losses: number
win_rate: number
total_pnl: number
total_return_pct: number
max_drawdown_pct: number
profit_factor: number
final_capital: number
equity_curve: Array<{index: number; capital: number}>
trades: Array<Record<string, unknown>>
error?: string
}
export interface EconomicEvent {
title: string
country: string
importance: 'low' | 'medium' | 'high'
date: string
asset_impact: AssetClass[]
previous?: string
forecast?: string
actual?: string
}
export interface HistoricalCandle {
date: string
open: number
high: number
low: number
close: number
volume: number
}

View File

@@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
dark: {
900: '#0a0e17',
800: '#0f1623',
700: '#141c2e',
600: '#1a2540',
500: '#1e2d4d',
},
accent: {
blue: '#3b82f6',
cyan: '#06b6d4',
green: '#10b981',
red: '#ef4444',
orange: '#f97316',
yellow: '#f59e0b',
purple: '#8b5cf6',
},
},
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
},
keyframes: {
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.25s ease-out',
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

90
start.ps1 Normal file
View File

@@ -0,0 +1,90 @@
# GeoOptions - Start script
# Usage: powershell -ExecutionPolicy Bypass -File c:\DataS\OpenFin\start.ps1
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host ""
Write-Host " ============================================" -ForegroundColor Cyan
Write-Host " GeoOptions Intelligence Cockpit v2.0" -ForegroundColor Cyan
Write-Host " ============================================" -ForegroundColor Cyan
Write-Host ""
# 1. Kill existing processes on ports 8000 and 5173
Write-Host " [1/4] Liberation des ports..." -ForegroundColor Yellow
foreach ($port in @(8000, 5173)) {
$lines = netstat -ano | Select-String ":$port\s" | Select-String "LISTENING"
foreach ($line in $lines) {
$p = ($line -split '\s+')[-1].Trim()
if ($p -match '^\d+$' -and $p -ne '0') {
try {
Stop-Process -Id ([int]$p) -Force -ErrorAction Stop
Write-Host " Port $port : PID $p stoppe." -ForegroundColor Gray
} catch {}
}
}
}
Start-Sleep -Seconds 1
# 2. Write temp bat files
$backendBat = "$Root\_start_backend.bat"
$frontendBat = "$Root\_start_frontend.bat"
$backendLines = @(
"@echo off",
"title GeoOptions Backend",
"cd /d `"$Root\backend`"",
"if not exist venv python -m venv venv",
"call venv\Scripts\activate.bat",
"pip install -r requirements.txt -q",
"python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
)
$backendLines | Set-Content -Path $backendBat -Encoding ASCII
$frontendLines = @(
"@echo off",
"title GeoOptions Frontend",
"cd /d `"$Root\frontend`"",
"if not exist node_modules npm install",
"npm run dev"
)
$frontendLines | Set-Content -Path $frontendBat -Encoding ASCII
# 3. Start backend
Write-Host " [2/4] Demarrage du backend..." -ForegroundColor Yellow
Start-Process "cmd.exe" -ArgumentList "/k `"$backendBat`"" -WindowStyle Normal
# 4. Wait until backend port is open (max 90s)
Write-Host " [3/4] Attente backend sur port 8000..." -ForegroundColor Yellow
$timeout = 90
$elapsed = 0
$ready = $false
while ($elapsed -lt $timeout) {
Start-Sleep -Seconds 2
$elapsed += 2
$tcp = New-Object System.Net.Sockets.TcpClient
try {
$tcp.Connect("127.0.0.1", 8000)
if ($tcp.Connected) { $ready = $true; $tcp.Close(); break }
} catch {}
finally { if ($tcp) { $tcp.Close() } }
Write-Host " ...attente ($elapsed`s / $timeout`s)" -ForegroundColor DarkGray
}
if ($ready) {
Write-Host " Backend OK" -ForegroundColor Green
} else {
Write-Host " ATTENTION : backend non repond apres $timeout`s" -ForegroundColor Red
Write-Host " Verifie la fenetre GeoOptions Backend pour voir l'erreur." -ForegroundColor Red
}
# 5. Start frontend
Write-Host " [4/4] Demarrage du frontend..." -ForegroundColor Yellow
Start-Process "cmd.exe" -ArgumentList "/k `"$frontendBat`"" -WindowStyle Normal
Start-Sleep -Seconds 4
Start-Process "http://localhost:5173"
Write-Host ""
Write-Host " Cockpit : http://localhost:5173" -ForegroundColor Green
Write-Host " API : http://localhost:8000/docs" -ForegroundColor Green
Write-Host ""

35
start_all.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
title GeoOptions Intelligence - Launcher
echo.
echo ============================================
echo GeoOptions Intelligence Cockpit v2.0
echo ============================================
echo.
echo Liberation des ports...
for /f "tokens=5" %%a in ('netstat -ano ^| findstr :8000 ^| findstr LISTENING') do (
taskkill /PID %%a /F >nul 2>&1
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr :5173 ^| findstr LISTENING') do (
taskkill /PID %%a /F >nul 2>&1
)
echo Ports 8000 et 5173 liberes.
echo.
echo Demarrage du backend (port 8000)...
start "GeoOptions Backend" cmd /k "cd /d %~dp0backend && (if not exist venv python -m venv venv) && call venv\Scripts\activate.bat && pip install -r requirements.txt -q && python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
timeout /t 5 /nobreak >nul
echo Demarrage du frontend (port 5173)...
start "GeoOptions Frontend" cmd /k "cd /d %~dp0frontend && (if not exist node_modules npm install) && npm run dev"
timeout /t 5 /nobreak >nul
echo Ouverture du navigateur...
start http://localhost:5173
echo.
echo Cockpit disponible sur: http://localhost:5173
echo API disponible sur: http://localhost:8000/docs
echo.

36
start_backend.bat Normal file
View File

@@ -0,0 +1,36 @@
@echo off
title GeoOptions - Backend API
cd /d "%~dp0backend"
echo ============================================
echo GeoOptions Intelligence - Backend FastAPI
echo ============================================
echo.
:: Check Python
python --version >nul 2>&1
if errorlevel 1 (
echo ERREUR: Python n'est pas installe ou pas dans le PATH
pause
exit /b 1
)
:: Create venv if needed
if not exist "venv" (
echo Creation de l'environnement virtuel...
python -m venv venv
)
:: Activate venv
call venv\Scripts\activate.bat
:: Install deps if needed
echo Installation des dependances...
pip install -r requirements.txt -q
echo.
echo Backend demarre sur http://localhost:8000
echo Documentation API: http://localhost:8000/docs
echo.
python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
pause

29
start_frontend.bat Normal file
View File

@@ -0,0 +1,29 @@
@echo off
title GeoOptions - Frontend React
cd /d "%~dp0frontend"
echo ============================================
echo GeoOptions Intelligence - Frontend React
echo ============================================
echo.
:: Check Node
node --version >nul 2>&1
if errorlevel 1 (
echo ERREUR: Node.js n'est pas installe ou pas dans le PATH
echo Telecharger depuis: https://nodejs.org/
pause
exit /b 1
)
:: Install deps if needed
if not exist "node_modules" (
echo Installation des dependances npm...
npm install
)
echo.
echo Frontend demarre sur http://localhost:5173
echo.
npm run dev
pause