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:
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal 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
179
GUIDE.md
Normal 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 0–100 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
7
_start_backend.bat
Normal 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
5
_start_frontend.bat
Normal 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
20
backend/Dockerfile
Normal 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
70
backend/main.py
Normal 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)
|
||||
0
backend/models/__init__.py
Normal file
0
backend/models/__init__.py
Normal file
155
backend/models/schemas.py
Normal file
155
backend/models/schemas.py
Normal 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
20
backend/requirements.txt
Normal 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
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
297
backend/routers/ai.py
Normal file
297
backend/routers/ai.py
Normal file
@@ -0,0 +1,297 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
from services.ai_analyzer import (
|
||||
analyze_speech, evaluate_pattern, suggest_pattern_from_context,
|
||||
rank_trade_ideas, analyze_news_item, get_client, score_patterns_with_context,
|
||||
suggest_patterns_from_market_context, ai_score_news_batch,
|
||||
DEFAULT_ANALYSIS_TEMPLATE, _chat,
|
||||
)
|
||||
from services.data_fetcher import fetch_geo_news, get_all_quotes
|
||||
from services.geo_analyzer import compute_geo_risk_score, match_patterns
|
||||
from services.database import get_config, get_custom_patterns, get_analysis_config, save_pattern_scores, get_pattern_scores, get_score_deltas, compute_pattern_similarity, log_geo_alert, log_trade_entries
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||
|
||||
|
||||
def require_ai():
|
||||
key = get_config("openai_api_key") or ""
|
||||
if not key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée — aller dans Config")
|
||||
import os
|
||||
os.environ["OPENAI_API_KEY"] = key
|
||||
|
||||
|
||||
class SpeechRequest(BaseModel):
|
||||
text: str
|
||||
speaker: Optional[str] = ""
|
||||
|
||||
|
||||
class PatternEvalRequest(BaseModel):
|
||||
pattern: Dict[str, Any]
|
||||
|
||||
|
||||
class PatternSuggestRequest(BaseModel):
|
||||
context: str
|
||||
|
||||
|
||||
class NewsAnalyzeRequest(BaseModel):
|
||||
title: str
|
||||
summary: str
|
||||
|
||||
|
||||
class ScorePatternsRequest(BaseModel):
|
||||
top_n: Optional[int] = None # override config default
|
||||
category_filter: Optional[str] = None # "all"|"energy"|"metals"|"agriculture"|"forex"|"indices"|"equities"
|
||||
template: Optional[str] = None # override config template
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def ai_status():
|
||||
key = get_config("openai_api_key") or ""
|
||||
return {
|
||||
"enabled": bool(key),
|
||||
"key_configured": bool(key),
|
||||
"model": "gpt-4o",
|
||||
"fast_model": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/analyze-speech")
|
||||
def analyze_speech_endpoint(req: SpeechRequest):
|
||||
require_ai()
|
||||
return analyze_speech(req.text, req.speaker)
|
||||
|
||||
|
||||
@router.post("/analyze-news")
|
||||
def analyze_news_endpoint(req: NewsAnalyzeRequest):
|
||||
require_ai()
|
||||
return analyze_news_item(req.title, req.summary)
|
||||
|
||||
|
||||
@router.post("/evaluate-pattern")
|
||||
def evaluate_pattern_endpoint(req: PatternEvalRequest):
|
||||
require_ai()
|
||||
return evaluate_pattern(req.pattern)
|
||||
|
||||
|
||||
@router.post("/suggest-pattern")
|
||||
def suggest_pattern_endpoint(req: PatternSuggestRequest):
|
||||
require_ai()
|
||||
return suggest_pattern_from_context(req.context)
|
||||
|
||||
|
||||
@router.get("/top-ideas")
|
||||
def top_ideas():
|
||||
require_ai()
|
||||
from routers.geopolitical import _news_cache
|
||||
news = _news_cache.get("data") or fetch_geo_news()
|
||||
matches = match_patterns(news)
|
||||
geo_score = compute_geo_risk_score(news)
|
||||
ideas = rank_trade_ideas(matches, geo_score, news, {})
|
||||
return {"ideas": ideas, "count": len(ideas)}
|
||||
|
||||
|
||||
@router.post("/score-patterns")
|
||||
def score_patterns(req: ScorePatternsRequest):
|
||||
"""Score all patterns with rich context (news + prices + IV) via GPT-4o."""
|
||||
require_ai()
|
||||
|
||||
# Load config defaults
|
||||
cfg = get_analysis_config()
|
||||
top_n = req.top_n or cfg.get("top_n", 10)
|
||||
category_filter = req.category_filter or cfg.get("category_filter", "all")
|
||||
template = req.template or cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE
|
||||
|
||||
# Gather context
|
||||
from routers.geopolitical import _news_cache
|
||||
news = _news_cache.get("data") or fetch_geo_news()
|
||||
|
||||
# AI-rescore news: get accurate impact magnitudes + directional signals per asset class
|
||||
# This enables contra-signal detection in pattern scoring (e.g. peace deal → oil bearish)
|
||||
news = ai_score_news_batch(news)
|
||||
_news_cache["data"] = news # propagate AI-enriched scores back to news feed
|
||||
|
||||
geo_score = compute_geo_risk_score(news)
|
||||
quotes = get_all_quotes()
|
||||
|
||||
# Macro regime context (use cached value from /api/market/macro-regime if available)
|
||||
from routers.market_data import _macro_cache
|
||||
macro_regime = _macro_cache.get("data")
|
||||
if not macro_regime:
|
||||
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
|
||||
gauges = get_macro_gauges()
|
||||
macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)}
|
||||
|
||||
# All patterns from DB (builtin-seeded + custom)
|
||||
all_patterns = get_custom_patterns()
|
||||
|
||||
# Score all active patterns
|
||||
scored = score_patterns_with_context(
|
||||
patterns=all_patterns,
|
||||
recent_news=news,
|
||||
quotes_by_class=quotes,
|
||||
geo_score=geo_score,
|
||||
template=template,
|
||||
top_n=len(all_patterns),
|
||||
category_filter=None,
|
||||
macro_regime=macro_regime,
|
||||
)
|
||||
|
||||
# Persist scores for later retrieval
|
||||
run_id = save_pattern_scores(scored, meta={"geo_score": geo_score.get("score"), "total": len(scored)})
|
||||
|
||||
# Journal de Bord — log geo alert + trade entry prices at this moment
|
||||
top_patterns_for_log = sorted(
|
||||
[{"pattern_id": sp.get("pattern_id"), "name": sp.get("geo_trigger"), "score": sp.get("score", 0)}
|
||||
for sp in scored if sp.get("score", 0) > 0],
|
||||
key=lambda x: -x["score"]
|
||||
)[:10]
|
||||
log_geo_alert(
|
||||
geo_score=int(geo_score.get("score") or 0),
|
||||
top_patterns=top_patterns_for_log,
|
||||
news_count=len(news),
|
||||
run_id=run_id,
|
||||
)
|
||||
log_trade_entries(run_id=run_id, scored_patterns=scored, quotes=quotes)
|
||||
|
||||
return {
|
||||
"scored_patterns": scored,
|
||||
"count": len(scored),
|
||||
"top_n": top_n,
|
||||
"category_filter": category_filter,
|
||||
"geo_score": geo_score.get("score"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/last-scores")
|
||||
def last_scores():
|
||||
"""Return last persisted AI pattern scores with inter-run score deltas."""
|
||||
data = get_pattern_scores()
|
||||
deltas = get_score_deltas()
|
||||
scored = data.get("scores", [])
|
||||
for sp in scored:
|
||||
pid = sp.get("pattern_id", "")
|
||||
sp["score_trend"] = deltas.get(pid) # None = first run, int = change vs previous run
|
||||
return {
|
||||
"scored_patterns": scored,
|
||||
"scored_at": data.get("scored_at"),
|
||||
"meta": data.get("meta", {}),
|
||||
"count": len(scored),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pattern-similarity")
|
||||
def pattern_similarity():
|
||||
"""Return pairs of patterns with high keyword overlap (Jaccard >= 0.25)."""
|
||||
patterns = get_custom_patterns()
|
||||
pairs = compute_pattern_similarity(patterns, threshold=0.25)
|
||||
return {"pairs": pairs, "count": len(pairs)}
|
||||
|
||||
|
||||
@router.post("/suggest-new-patterns")
|
||||
def suggest_new_patterns():
|
||||
"""Ask GPT-4o to propose new patterns from current geo/market context (no text input needed)."""
|
||||
require_ai()
|
||||
from routers.geopolitical import _news_cache
|
||||
news = _news_cache.get("data") or fetch_geo_news()
|
||||
quotes = get_all_quotes()
|
||||
from services.data_fetcher import get_economic_calendar
|
||||
calendar = get_economic_calendar()
|
||||
|
||||
# Macro regime context
|
||||
from routers.market_data import _macro_cache
|
||||
macro_regime = _macro_cache.get("data")
|
||||
if not macro_regime:
|
||||
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
|
||||
gauges = get_macro_gauges()
|
||||
macro_regime = {"gauges": gauges, "scenarios": score_macro_scenarios(gauges)}
|
||||
|
||||
# Geo risk score for additional context
|
||||
geo_score = compute_geo_risk_score(news)
|
||||
|
||||
patterns = suggest_patterns_from_market_context(news, quotes, calendar, macro_regime=macro_regime, geo_score=geo_score)
|
||||
return {"suggested_patterns": patterns, "count": len(patterns)}
|
||||
|
||||
|
||||
@router.get("/analysis-template")
|
||||
def get_template():
|
||||
cfg = get_analysis_config()
|
||||
return {
|
||||
"template": cfg.get("template") or DEFAULT_ANALYSIS_TEMPLATE,
|
||||
"top_n": cfg.get("top_n", 10),
|
||||
"category_filter": cfg.get("category_filter", "all"),
|
||||
}
|
||||
|
||||
|
||||
class MacroNarrationRequest(BaseModel):
|
||||
macro_regime: Dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/macro-narration")
|
||||
def macro_narration(req: MacroNarrationRequest):
|
||||
"""Ask GPT-4o to narrate the current macro regime for the MacroRegime page."""
|
||||
require_ai()
|
||||
sc = req.macro_regime.get("scenarios", {})
|
||||
gauges = req.macro_regime.get("gauges", {})
|
||||
dom = sc.get("dominant", "incertain")
|
||||
scores = sc.get("scores", {})
|
||||
reasons = sc.get("reasons", {})
|
||||
|
||||
def gv(key: str) -> str:
|
||||
v = gauges.get(key, {}).get("value")
|
||||
return str(round(v, 3)) if v is not None else "N/A"
|
||||
|
||||
def gc(key: str) -> str:
|
||||
v = gauges.get(key, {}).get("change_pct")
|
||||
return f"{v:+.2f}%" if v is not None else "N/A"
|
||||
|
||||
user = f"""Tu es un stratège macro senior utilisant un framework à 3 axes : Inflation / Croissance / Liquidité.
|
||||
|
||||
Scénario dominant: {dom.upper()}
|
||||
Scores des 8 scénarios: {json.dumps(scores, ensure_ascii=False)}
|
||||
Raisons du scénario dominant: {json.dumps(reasons.get(dom, []), ensure_ascii=False)}
|
||||
|
||||
AXE INFLATION (énergie + taux réels):
|
||||
- Brent (var J-1): {gc('brent')}
|
||||
- Gaz naturel (var J-1): {gc('ng')}
|
||||
- TIPS ETF (var J-1): {gc('tips')}
|
||||
|
||||
AXE CROISSANCE (cycle réel):
|
||||
- Cuivre (var J-1): {gc('copper')} — "Dr Copper"
|
||||
- S&P vs 200j MA: {gv('spx_vs_200d')}%
|
||||
- Russell vs S&P (breadth): {gv('iwm_spx_ratio')} pts%
|
||||
- Industriels XLI (proxy ISM, var J-1): {gc('xli')}
|
||||
|
||||
AXE LIQUIDITÉ / STRESS (conditions financières):
|
||||
- VIX: {gv('vix')}
|
||||
- Pente 10Y–3M: {gv('slope_10y3m')}% (négatif = récession probable)
|
||||
- HYG spreads HY (var J-1): {gc('hyg')}
|
||||
- LQD spreads IG (var J-1): {gc('lqd')}
|
||||
- Dollar DXY (var J-1): {gc('dxy')}
|
||||
|
||||
SIGNAUX DÉRIVÉS:
|
||||
- Ratio Or/Cuivre: {gv('gold_copper_ratio')} (>700 = peur, <500 = expansion)
|
||||
- Or (var J-1): {gc('gold')}
|
||||
- IEF Trésor (var J-1): {gc('ief')}
|
||||
|
||||
Donne une analyse narrative COURTE (5-7 phrases) en français pour un trader options:
|
||||
1. Confirme le régime dominant et ses 2-3 signaux les plus forts
|
||||
2. Identifie 1-2 compteurs en contradiction ou tension (signal ambigu)
|
||||
3. Évalue si la LIQUIDITÉ confirme ou contredit le régime inflation/croissance
|
||||
4. Cite les 2-3 classes d'actifs les plus favorisées/défavorisées
|
||||
5. Donne 1 biais tactique concret options pour les prochains jours
|
||||
|
||||
Réponds UNIQUEMENT en JSON: {{"narration": "<ton texte 5-7 phrases>"}}"""
|
||||
|
||||
result = _chat(
|
||||
"Tu es un stratège macro senior. Analyse concise et actionnable pour traders options. JSON uniquement.",
|
||||
user,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=600,
|
||||
)
|
||||
if not result:
|
||||
return {"narration": "IA non disponible — vérifier la clé OpenAI."}
|
||||
return {"narration": result.get("narration", "")}
|
||||
126
backend/routers/backtest.py
Normal file
126
backend/routers/backtest.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import yfinance as yf
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from services.options_pricer import black_scholes
|
||||
|
||||
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
symbol: str
|
||||
start_date: str
|
||||
end_date: str
|
||||
strategy: str # "long_call" | "long_put" | "bull_call_spread" | "bear_put_spread" | "straddle"
|
||||
strike_offset_pct: float = 0.05 # e.g. 5% OTM
|
||||
expiry_days: int = 90
|
||||
capital: float = 1000.0
|
||||
geo_filter: Optional[str] = None # optional pattern id to filter
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_backtest(req: BacktestRequest):
|
||||
try:
|
||||
ticker = yf.Ticker(req.symbol)
|
||||
hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d")
|
||||
if hist.empty or len(hist) < 20:
|
||||
return {"error": "Insufficient data for the period"}
|
||||
|
||||
hist = hist.reset_index()
|
||||
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
|
||||
|
||||
trades = []
|
||||
equity = [req.capital]
|
||||
capital = req.capital
|
||||
r = 0.05
|
||||
T_open = req.expiry_days / 365
|
||||
|
||||
step = max(1, req.expiry_days // 3)
|
||||
for i in range(0, len(hist) - req.expiry_days, step):
|
||||
row = hist.iloc[i]
|
||||
S = float(row["Close"])
|
||||
date_str = str(row["Date"])[:10]
|
||||
|
||||
sigma_window = returns.iloc[max(0, i - 30):i]
|
||||
if len(sigma_window) < 5:
|
||||
continue
|
||||
sigma = float(sigma_window.std() * np.sqrt(252))
|
||||
if sigma < 0.01:
|
||||
sigma = 0.20
|
||||
|
||||
if req.strategy in ["long_call", "bull_call_spread"]:
|
||||
K = S * (1 + req.strike_offset_pct)
|
||||
else:
|
||||
K = S * (1 - req.strike_offset_pct)
|
||||
|
||||
result = black_scholes(S, K, T_open, r, sigma, "call" if "call" in req.strategy else "put")
|
||||
premium = result["price"]
|
||||
contracts = max(1, int((capital * 0.1) / (premium * 100)))
|
||||
cost = contracts * premium * 100
|
||||
|
||||
expiry_idx = min(i + req.expiry_days, len(hist) - 1)
|
||||
S_expiry = float(hist.iloc[expiry_idx]["Close"])
|
||||
date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10]
|
||||
|
||||
if req.strategy in ["long_call", "bull_call_spread"]:
|
||||
intrinsic = max(0, S_expiry - K)
|
||||
else:
|
||||
intrinsic = max(0, K - S_expiry)
|
||||
|
||||
pnl = (intrinsic - premium) * contracts * 100
|
||||
capital += pnl
|
||||
equity.append(round(capital, 2))
|
||||
|
||||
trades.append({
|
||||
"entry_date": date_str,
|
||||
"exit_date": date_expiry,
|
||||
"strategy": req.strategy,
|
||||
"S_entry": round(S, 2),
|
||||
"K": round(K, 2),
|
||||
"premium": round(premium, 4),
|
||||
"contracts": contracts,
|
||||
"cost": round(cost, 2),
|
||||
"S_expiry": round(S_expiry, 2),
|
||||
"intrinsic": round(intrinsic, 4),
|
||||
"pnl": round(pnl, 2),
|
||||
"capital": round(capital, 2),
|
||||
})
|
||||
|
||||
if not trades:
|
||||
return {"error": "No trades generated"}
|
||||
|
||||
wins = [t for t in trades if t["pnl"] > 0]
|
||||
losses = [t for t in trades if t["pnl"] <= 0]
|
||||
total_pnl = sum(t["pnl"] for t in trades)
|
||||
gross_profit = sum(t["pnl"] for t in wins) if wins else 0
|
||||
gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 1
|
||||
|
||||
eq = np.array(equity)
|
||||
peak = np.maximum.accumulate(eq)
|
||||
drawdown = (eq - peak) / peak
|
||||
max_dd = float(drawdown.min()) * 100
|
||||
|
||||
equity_curve = [{"index": i, "capital": v} for i, v in enumerate(equity)]
|
||||
|
||||
return {
|
||||
"symbol": req.symbol,
|
||||
"strategy": req.strategy,
|
||||
"period": f"{req.start_date} → {req.end_date}",
|
||||
"total_trades": len(trades),
|
||||
"wins": len(wins),
|
||||
"losses": len(losses),
|
||||
"win_rate": round(len(wins) / len(trades) * 100, 1) if trades else 0,
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"total_return_pct": round((capital - req.capital) / req.capital * 100, 2),
|
||||
"max_drawdown_pct": round(max_dd, 2),
|
||||
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss else 0,
|
||||
"final_capital": round(capital, 2),
|
||||
"equity_curve": equity_curve,
|
||||
"trades": trades[-20:],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
91
backend/routers/config.py
Normal file
91
backend/routers/config.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
from services.database import get_all_config, set_config, get_sources, update_sources, get_analysis_config, save_analysis_config
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
|
||||
class ApiKeysRequest(BaseModel):
|
||||
openai_api_key: Optional[str] = None
|
||||
newsapi_key: Optional[str] = None
|
||||
eia_api_key: Optional[str] = None
|
||||
fred_api_key: Optional[str] = None
|
||||
|
||||
|
||||
class SourcesRequest(BaseModel):
|
||||
sources: Dict[str, Any]
|
||||
|
||||
|
||||
class SettingsRequest(BaseModel):
|
||||
ai_enabled: Optional[str] = None
|
||||
ai_auto_rescore: Optional[str] = None
|
||||
|
||||
|
||||
class AnalysisConfigRequest(BaseModel):
|
||||
top_n: Optional[int] = None
|
||||
category_filter: Optional[str] = None
|
||||
template: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_config_all():
|
||||
return get_all_config()
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
def list_sources():
|
||||
return get_sources()
|
||||
|
||||
|
||||
@router.put("/sources")
|
||||
def update_sources_endpoint(req: SourcesRequest):
|
||||
update_sources(req.sources)
|
||||
return {"status": "ok", "updated": len(req.sources)}
|
||||
|
||||
|
||||
@router.put("/api-keys")
|
||||
def update_api_keys(req: ApiKeysRequest):
|
||||
updated = []
|
||||
if req.openai_api_key is not None:
|
||||
set_config("openai_api_key", req.openai_api_key)
|
||||
os.environ["OPENAI_API_KEY"] = req.openai_api_key
|
||||
updated.append("openai")
|
||||
if req.newsapi_key is not None:
|
||||
set_config("newsapi_key", req.newsapi_key)
|
||||
updated.append("newsapi")
|
||||
if req.eia_api_key is not None:
|
||||
set_config("eia_api_key", req.eia_api_key)
|
||||
updated.append("eia")
|
||||
if req.fred_api_key is not None:
|
||||
set_config("fred_api_key", req.fred_api_key)
|
||||
updated.append("fred")
|
||||
return {"status": "ok", "updated": updated}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_settings(req: SettingsRequest):
|
||||
if req.ai_enabled is not None:
|
||||
set_config("ai_enabled", req.ai_enabled)
|
||||
if req.ai_auto_rescore is not None:
|
||||
set_config("ai_auto_rescore", req.ai_auto_rescore)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/analysis")
|
||||
def get_analysis_config_endpoint():
|
||||
return get_analysis_config()
|
||||
|
||||
|
||||
@router.put("/analysis")
|
||||
def save_analysis_config_endpoint(req: AnalysisConfigRequest):
|
||||
current = get_analysis_config()
|
||||
if req.top_n is not None:
|
||||
current["top_n"] = req.top_n
|
||||
if req.category_filter is not None:
|
||||
current["category_filter"] = req.category_filter
|
||||
if req.template is not None:
|
||||
current["template"] = req.template
|
||||
save_analysis_config(current)
|
||||
return {"status": "ok", "config": current}
|
||||
74
backend/routers/cycle.py
Normal file
74
backend/routers/cycle.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config
|
||||
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
|
||||
|
||||
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def cycle_status():
|
||||
"""Current scheduler state + last cycle summary."""
|
||||
return get_status()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def cycle_history(limit: int = 20):
|
||||
"""List recent cycle runs."""
|
||||
runs = get_cycle_runs(limit=limit)
|
||||
# Parse commentary JSON for frontend
|
||||
import json
|
||||
for r in runs:
|
||||
if r.get("commentary"):
|
||||
try:
|
||||
r["commentary_parsed"] = json.loads(r["commentary"])
|
||||
except Exception:
|
||||
r["commentary_parsed"] = {"commentary": r["commentary"]}
|
||||
return {"runs": runs, "count": len(runs)}
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
def trigger_cycle():
|
||||
"""Manually trigger one cycle immediately (non-blocking)."""
|
||||
key = get_config("openai_api_key") or ""
|
||||
if not key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée")
|
||||
trigger_manual()
|
||||
return {"triggered": True, "message": "Cycle lancé en arrière-plan"}
|
||||
|
||||
|
||||
class CycleConfigRequest(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
interval_hours: Optional[float] = None
|
||||
similarity_threshold: Optional[float] = None
|
||||
min_ev_threshold: Optional[float] = None
|
||||
min_score_threshold: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
def update_cycle_config(req: CycleConfigRequest):
|
||||
"""Update auto-cycle + filter configuration and restart scheduler."""
|
||||
if req.enabled is not None:
|
||||
set_config("auto_cycle_enabled", "true" if req.enabled else "false")
|
||||
if req.interval_hours is not None:
|
||||
if not (0.5 <= req.interval_hours <= 24):
|
||||
raise HTTPException(400, "interval_hours must be between 0.5 and 24")
|
||||
set_config("auto_cycle_hours", str(req.interval_hours))
|
||||
if req.similarity_threshold is not None:
|
||||
if not (0.0 <= req.similarity_threshold <= 1.0):
|
||||
raise HTTPException(400, "similarity_threshold must be between 0 and 1")
|
||||
set_config("auto_cycle_similarity_threshold", str(req.similarity_threshold))
|
||||
if req.min_ev_threshold is not None:
|
||||
if not (0.0 <= req.min_ev_threshold <= 1.0):
|
||||
raise HTTPException(400, "min_ev_threshold must be between 0 and 1")
|
||||
set_config("min_ev_threshold", str(req.min_ev_threshold))
|
||||
if req.min_score_threshold is not None:
|
||||
if not (0 <= req.min_score_threshold <= 100):
|
||||
raise HTTPException(400, "min_score_threshold must be between 0 and 100")
|
||||
set_config("min_score_threshold", str(req.min_score_threshold))
|
||||
|
||||
# Restart scheduler to pick up changes
|
||||
restart_scheduler()
|
||||
|
||||
return get_status()
|
||||
72
backend/routers/geopolitical.py
Normal file
72
backend/routers/geopolitical.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from typing import Optional, List
|
||||
from services.data_fetcher import fetch_geo_news, get_economic_calendar
|
||||
from services.geo_analyzer import compute_geo_risk_score, match_patterns, generate_trade_ideas, compute_pattern_relevance
|
||||
from services.database import get_custom_patterns
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/geo", tags=["geopolitical"])
|
||||
|
||||
_news_cache: dict = {"data": [], "ts": 0}
|
||||
|
||||
|
||||
@router.get("/news")
|
||||
def geo_news(force_refresh: bool = False):
|
||||
import time
|
||||
now = time.time()
|
||||
if not force_refresh and _news_cache["data"] and (now - _news_cache["ts"]) < 3600:
|
||||
return _news_cache["data"]
|
||||
news = fetch_geo_news()
|
||||
_news_cache["data"] = news
|
||||
_news_cache["ts"] = now
|
||||
return news
|
||||
|
||||
|
||||
@router.get("/risk-score")
|
||||
def risk_score():
|
||||
news = _news_cache["data"] or fetch_geo_news()
|
||||
return compute_geo_risk_score(news)
|
||||
|
||||
|
||||
@router.get("/pattern-matches")
|
||||
def pattern_matches():
|
||||
news = _news_cache["data"] or fetch_geo_news()
|
||||
all_patterns = get_custom_patterns()
|
||||
return match_patterns(news, patterns=all_patterns)
|
||||
|
||||
|
||||
@router.get("/pattern-relevance")
|
||||
def pattern_relevance(days: int = 2):
|
||||
"""Return ALL active patterns with news-keyword relevance over the last N days."""
|
||||
all_news = _news_cache["data"] or fetch_geo_news()
|
||||
# Filter news to last N days
|
||||
if days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
recent: list = []
|
||||
for n in all_news:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
d = parsedate_to_datetime(str(n.get("date", "")))
|
||||
if d >= cutoff:
|
||||
recent.append(n)
|
||||
except Exception:
|
||||
recent.append(n)
|
||||
news = recent if recent else all_news
|
||||
else:
|
||||
news = all_news
|
||||
all_patterns = get_custom_patterns()
|
||||
return compute_pattern_relevance(news, patterns=all_patterns)
|
||||
|
||||
|
||||
@router.get("/trade-ideas")
|
||||
def trade_ideas():
|
||||
news = _news_cache["data"] or fetch_geo_news()
|
||||
all_patterns = get_custom_patterns()
|
||||
matches = match_patterns(news, patterns=all_patterns)
|
||||
geo_score = compute_geo_risk_score(news)
|
||||
return generate_trade_ideas(matches, geo_score)
|
||||
|
||||
|
||||
@router.get("/calendar")
|
||||
def calendar():
|
||||
return get_economic_calendar()
|
||||
112
backend/routers/journal.py
Normal file
112
backend/routers/journal.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fastapi import APIRouter
|
||||
from typing import Any, Dict, List
|
||||
import math
|
||||
from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices
|
||||
|
||||
|
||||
def _sanitize(obj: Any) -> Any:
|
||||
"""Replace NaN/Inf with None recursively for JSON compliance."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize(v) for v in obj]
|
||||
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
|
||||
return None
|
||||
return obj
|
||||
|
||||
router = APIRouter(prefix="/api/journal", tags=["journal"])
|
||||
|
||||
# Bearish strategies — P&L is inverted (profit when price falls)
|
||||
_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"}
|
||||
|
||||
|
||||
def _is_bearish(strategy: str) -> bool:
|
||||
s = (strategy or "").lower()
|
||||
return any(kw in s for kw in _BEARISH_KEYWORDS)
|
||||
|
||||
|
||||
@router.get("/macro-history")
|
||||
def macro_history(days: int = 15):
|
||||
"""Macro regime snapshots for the last N days."""
|
||||
return _sanitize({"history": get_macro_regime_history(days), "days": days})
|
||||
|
||||
|
||||
@router.get("/geo-history")
|
||||
def geo_history(days: int = 30):
|
||||
"""Geo alert score history for the last N days."""
|
||||
return {"history": get_geo_alert_history(days), "days": days}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trade-mtm")
|
||||
def trade_mtm(days: int = 30):
|
||||
"""
|
||||
Mark-to-market for all logged trade suggestions.
|
||||
Enriches with live prices via shared _fetch_live_prices utility.
|
||||
"""
|
||||
entries = get_trade_entry_prices(days)
|
||||
tickers_needed = list({(e.get("underlying") or "").upper() for e in entries if e.get("underlying")})
|
||||
current_prices = _fetch_live_prices(tickers_needed, timeout=20)
|
||||
|
||||
from datetime import date as _date
|
||||
result: List[Dict[str, Any]] = []
|
||||
for e in entries:
|
||||
ticker = (e.get("underlying") or "").upper()
|
||||
entry_price = e.get("entry_price")
|
||||
current_price = current_prices.get(ticker)
|
||||
pnl_pct = None
|
||||
if entry_price and current_price and entry_price > 0:
|
||||
raw_pnl = (current_price - entry_price) / entry_price * 100
|
||||
pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2)
|
||||
|
||||
days_held = None
|
||||
try:
|
||||
days_held = (_date.today() - _date.fromisoformat(e["entry_date"])).days
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result.append({
|
||||
**e,
|
||||
"current_price": current_price,
|
||||
"pnl_pct": pnl_pct,
|
||||
"days_held": days_held,
|
||||
"direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish",
|
||||
})
|
||||
|
||||
return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)})
|
||||
|
||||
|
||||
@router.delete("/reset")
|
||||
def reset_journal():
|
||||
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""
|
||||
reset_journal_history()
|
||||
return {"reset": True, "message": "Journal de bord réinitialisé"}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def journal_summary():
|
||||
"""Quick stats for the Journal de Bord header."""
|
||||
macro = get_macro_regime_history(15)
|
||||
geo = get_geo_alert_history(30)
|
||||
trades = get_trade_entry_prices(30)
|
||||
|
||||
# Detect regime transitions (consecutive different dominants)
|
||||
transitions = []
|
||||
for i in range(1, len(macro)):
|
||||
if macro[i - 1]["dominant"] != macro[i]["dominant"]:
|
||||
transitions.append({
|
||||
"from": macro[i]["dominant"],
|
||||
"to": macro[i - 1]["dominant"],
|
||||
"at": macro[i - 1]["timestamp"],
|
||||
})
|
||||
|
||||
return {
|
||||
"macro_snapshots": len(macro),
|
||||
"regime_transitions": transitions[:5],
|
||||
"current_dominant": macro[0]["dominant"] if macro else None,
|
||||
"geo_alerts": len(geo),
|
||||
"avg_geo_score": round(sum(g["geo_score"] for g in geo) / len(geo), 1) if geo else None,
|
||||
"max_geo_score": max((g["geo_score"] for g in geo), default=None),
|
||||
"trade_entries_logged": len(trades),
|
||||
}
|
||||
309
backend/routers/knowledge.py
Normal file
309
backend/routers/knowledge.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Optional
|
||||
import json
|
||||
import os
|
||||
|
||||
from services.database import (
|
||||
get_kb_entries, get_all_kb_entries, save_kb_entry, update_kb_entry_status,
|
||||
get_latest_reasoning_state, get_reasoning_history, get_reasoning_state_by_id,
|
||||
save_reasoning_state, list_ai_reports, get_mtm_trades_with_traces,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
|
||||
|
||||
|
||||
def _build_synthesis_prompt(reports: List[Dict], trades: List[Dict], kb_entries: List[Dict]):
|
||||
"""Build the GPT-4o synthesis prompt from all accumulated data."""
|
||||
now_str = __import__("datetime").datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Portfolio reports summary
|
||||
reports_block = ""
|
||||
for r in reports[:10]:
|
||||
rpt = r.get("report") or {}
|
||||
stats = r.get("stats") or {}
|
||||
date = r.get("created_at", "")[:16]
|
||||
headline = rpt.get("headline", "")
|
||||
winners = rpt.get("winners_analysis", "")
|
||||
losers = rpt.get("losers_analysis", "")
|
||||
lessons = rpt.get("key_lessons", [])
|
||||
blind = rpt.get("blind_spots", "")
|
||||
next_p = rpt.get("next_cycle_priorities", "")
|
||||
lessons_str = " | ".join(lessons) if isinstance(lessons, list) else str(lessons)
|
||||
reports_block += f"""
|
||||
--- Rapport du {date} ---
|
||||
Headline: {headline}
|
||||
Stats: {stats}
|
||||
Gagnants: {winners[:300]}
|
||||
Perdants: {losers[:300]}
|
||||
Leçons clés: {lessons_str[:400]}
|
||||
Angles morts: {blind[:200]}
|
||||
Priorités cycle suivant: {next_p[:200]}
|
||||
"""
|
||||
|
||||
# Trade history
|
||||
winners = [t for t in trades if (t.get("pnl_pct") or 0) > 0.5]
|
||||
losers = [t for t in trades if (t.get("pnl_pct") or 0) < -0.5]
|
||||
neutral = [t for t in trades if t not in winners and t not in losers]
|
||||
|
||||
def trade_line(t):
|
||||
return (f"{t.get('underlying','?')} {t.get('strategy','?')} "
|
||||
f"P&L={t.get('pnl_pct',0):.2f}% score={t.get('latest_score','?')} "
|
||||
f"regime={t.get('macro_regime','?')}")
|
||||
|
||||
trades_block = f"""
|
||||
Gagnants ({len(winners)}): {' | '.join(trade_line(t) for t in winners[:8])}
|
||||
Perdants ({len(losers)}): {' | '.join(trade_line(t) for t in losers[:8])}
|
||||
Neutres ({len(neutral)}): {len(neutral)} trades sans signal fort
|
||||
"""
|
||||
|
||||
# Existing KB
|
||||
kb_block = ""
|
||||
if kb_entries:
|
||||
by_cat: Dict[str, List] = {}
|
||||
for e in kb_entries:
|
||||
cat = e.get("category", "général")
|
||||
by_cat.setdefault(cat, []).append(e)
|
||||
for cat, items in by_cat.items():
|
||||
kb_block += f"\n[{cat.upper()}]\n"
|
||||
for item in items[:5]:
|
||||
kb_block += f" - [{item['confidence']}%] {item['title']}: {item['content'][:150]}\n"
|
||||
|
||||
system = """Tu es l'intelligence analytique centrale d'un système de trading d'options géopolitiques.
|
||||
Tu dois synthétiser TOUT l'historique disponible pour produire un document de raisonnement évolutif.
|
||||
Ce document sera utilisé comme contexte enrichi pour tous les prochains cycles d'analyse.
|
||||
Réponds UNIQUEMENT en JSON valide selon le schéma spécifié."""
|
||||
|
||||
user = f"""Date: {now_str}
|
||||
|
||||
=== HISTORIQUE DES RAPPORTS DE PERFORMANCE ({len(reports)} rapports) ===
|
||||
{reports_block}
|
||||
|
||||
=== HISTORIQUE DES TRADES ({len(trades)} trades) ===
|
||||
{trades_block}
|
||||
|
||||
=== BASE DE CONNAISSANCES EXISTANTE ===
|
||||
{kb_block if kb_block else "Aucune entrée existante — première synthèse."}
|
||||
|
||||
=== MISSION ===
|
||||
Produis un JSON avec ces champs:
|
||||
|
||||
{{
|
||||
"narrative": "Un texte narratif riche (500-800 mots) qui décrit l'état actuel du raisonnement du système, les patterns qui fonctionnent, les erreurs récurrentes, les corrélations géopolitiques/macro identifiées, les régimes qui favorisent nos stratégies, et les priorités d'amélioration. C'est le 'cerveau' du système.",
|
||||
|
||||
"regime_insights": [
|
||||
{{"regime": "nom du régime macro", "observation": "ce qu'on sait de ce régime", "confidence": 0-100, "trade_count": N}}
|
||||
],
|
||||
|
||||
"pattern_insights": [
|
||||
{{"pattern": "nom du pattern", "observation": "performance et conditions", "confidence": 0-100, "win_rate_pct": 0-100}}
|
||||
],
|
||||
|
||||
"macro_correlations": [
|
||||
{{"trigger": "événement géopolitique/macro", "market_reaction": "réaction observée", "reliability": "haute/moyenne/faible"}}
|
||||
],
|
||||
|
||||
"recurring_mistakes": [
|
||||
{{"mistake": "description de l'erreur", "frequency": "souvent/parfois", "mitigation": "comment l'éviter"}}
|
||||
],
|
||||
|
||||
"strengths": ["point fort 1", "point fort 2"],
|
||||
|
||||
"blind_spots": ["angle mort 1", "angle mort 2"],
|
||||
|
||||
"strategic_priorities": ["priorité 1", "priorité 2", "priorité 3"],
|
||||
|
||||
"risk_parameters": {{
|
||||
"avoid_when": ["condition 1", "condition 2"],
|
||||
"prefer_when": ["condition 1", "condition 2"]
|
||||
}}
|
||||
}}"""
|
||||
|
||||
return system, user
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
def get_state():
|
||||
"""Latest synthesized reasoning state."""
|
||||
state = get_latest_reasoning_state()
|
||||
return {"state": state}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def get_history(limit: int = 10):
|
||||
"""List of reasoning state versions."""
|
||||
return {"history": get_reasoning_history(limit)}
|
||||
|
||||
|
||||
@router.get("/history/{state_id}")
|
||||
def get_state_version(state_id: int):
|
||||
state = get_reasoning_state_by_id(state_id)
|
||||
if not state:
|
||||
raise HTTPException(404, "Version introuvable")
|
||||
return {"state": state}
|
||||
|
||||
|
||||
@router.get("/entries")
|
||||
def list_entries(status: str = "all"):
|
||||
if status == "all":
|
||||
entries = get_all_kb_entries()
|
||||
else:
|
||||
entries = get_kb_entries(status)
|
||||
by_cat: Dict[str, List] = {}
|
||||
for e in entries:
|
||||
by_cat.setdefault(e.get("category", "général"), []).append(e)
|
||||
return {"entries": entries, "by_category": by_cat, "total": len(entries)}
|
||||
|
||||
|
||||
class KbEntryIn(BaseModel):
|
||||
category: str
|
||||
title: str
|
||||
content: str
|
||||
confidence: int = 50
|
||||
tags: str = ""
|
||||
existing_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/entries")
|
||||
def add_entry(body: KbEntryIn):
|
||||
entry_id = save_kb_entry(
|
||||
category=body.category,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
confidence=body.confidence,
|
||||
tags=body.tags,
|
||||
existing_id=body.existing_id,
|
||||
)
|
||||
return {"id": entry_id}
|
||||
|
||||
|
||||
@router.patch("/entries/{entry_id}/status")
|
||||
def patch_entry_status(entry_id: int, body: Dict[str, str]):
|
||||
status = body.get("status", "active")
|
||||
if status not in ("active", "tentative", "invalidated"):
|
||||
raise HTTPException(400, "status must be active | tentative | invalidated")
|
||||
update_kb_entry_status(entry_id, status)
|
||||
return {"id": entry_id, "status": status}
|
||||
|
||||
|
||||
@router.post("/synthesize")
|
||||
async def synthesize():
|
||||
"""Run GPT-4o synthesis over all historical data and save new reasoning state."""
|
||||
ai_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not ai_key:
|
||||
raise HTTPException(400, "OpenAI API key not configured")
|
||||
|
||||
import openai
|
||||
client = openai.OpenAI(api_key=ai_key)
|
||||
|
||||
reports = list_ai_reports(limit=10)
|
||||
mtm_data = get_mtm_trades_with_traces(days=90)
|
||||
trades = mtm_data.get("all_trades", []) if isinstance(mtm_data, dict) else []
|
||||
kb_entries = get_all_kb_entries()
|
||||
|
||||
system_msg, user_msg = _build_synthesis_prompt(reports, trades, kb_entries)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=2500,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
synthesis = json.loads(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"GPT-4o error: {e}")
|
||||
|
||||
narrative = synthesis.pop("narrative", "Synthèse non disponible.")
|
||||
|
||||
state_id = save_reasoning_state(
|
||||
narrative=narrative,
|
||||
synthesis=synthesis,
|
||||
sources_count=len(reports) + len(trades),
|
||||
reports_used=len(reports),
|
||||
trades_analyzed=len(trades),
|
||||
)
|
||||
|
||||
# Persist KB entries from synthesis
|
||||
for regime in synthesis.get("regime_insights", []):
|
||||
if regime.get("observation"):
|
||||
save_kb_entry(
|
||||
category="régimes",
|
||||
title=f"Régime: {regime.get('regime', '?')}",
|
||||
content=regime.get("observation", ""),
|
||||
confidence=regime.get("confidence", 50),
|
||||
tags="auto-synth",
|
||||
)
|
||||
|
||||
for pattern in synthesis.get("pattern_insights", []):
|
||||
if pattern.get("observation"):
|
||||
save_kb_entry(
|
||||
category="patterns",
|
||||
title=f"Pattern: {pattern.get('pattern', '?')}",
|
||||
content=pattern.get("observation", ""),
|
||||
confidence=pattern.get("confidence", 50),
|
||||
tags="auto-synth",
|
||||
)
|
||||
|
||||
for mistake in synthesis.get("recurring_mistakes", []):
|
||||
if mistake.get("mistake"):
|
||||
save_kb_entry(
|
||||
category="erreurs",
|
||||
title=mistake.get("mistake", "")[:80],
|
||||
content=f"{mistake.get('mistake','')} → {mistake.get('mitigation','')}",
|
||||
confidence=70,
|
||||
tags="auto-synth",
|
||||
)
|
||||
|
||||
return {
|
||||
"state_id": state_id,
|
||||
"narrative_preview": narrative[:200],
|
||||
"kb_entries_added": (
|
||||
len(synthesis.get("regime_insights", [])) +
|
||||
len(synthesis.get("pattern_insights", [])) +
|
||||
len(synthesis.get("recurring_mistakes", []))
|
||||
),
|
||||
"sources": {"reports": len(reports), "trades": len(trades)},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/context-for-cycle")
|
||||
def context_for_cycle():
|
||||
"""Compact context to inject into AI cycle prompts."""
|
||||
state = get_latest_reasoning_state()
|
||||
if not state:
|
||||
return {"available": False, "context": ""}
|
||||
|
||||
synthesis = state.get("synthesis") or {}
|
||||
narrative = state.get("narrative", "")
|
||||
|
||||
priorities = synthesis.get("strategic_priorities", [])
|
||||
avoid = synthesis.get("risk_parameters", {}).get("avoid_when", [])
|
||||
prefer = synthesis.get("risk_parameters", {}).get("prefer_when", [])
|
||||
mistakes = [m.get("mistake", "") for m in synthesis.get("recurring_mistakes", [])[:3]]
|
||||
strengths = synthesis.get("strengths", [])
|
||||
|
||||
context = f"""=== SUPER CONTEXTE — BASE DE RAISONNEMENT ({state.get('created_at','')[:16]}) ===
|
||||
{narrative[:600]}
|
||||
|
||||
PRIORITÉS STRATÉGIQUES: {' | '.join(priorities[:3])}
|
||||
ERREURS À ÉVITER: {' | '.join(mistakes)}
|
||||
PRÉFÉRER QUAND: {' | '.join(prefer[:2])}
|
||||
ÉVITER QUAND: {' | '.join(avoid[:2])}
|
||||
FORCES: {' | '.join(strengths[:2])}
|
||||
"""
|
||||
return {
|
||||
"available": True,
|
||||
"context": context,
|
||||
"version": state.get("version"),
|
||||
"created_at": state.get("created_at"),
|
||||
"sources": {
|
||||
"reports_used": state.get("reports_used"),
|
||||
"trades_analyzed": state.get("trades_analyzed"),
|
||||
},
|
||||
}
|
||||
77
backend/routers/market_data.py
Normal file
77
backend/routers/market_data.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from services.data_fetcher import get_all_quotes, get_historical, compute_historical_iv, WATCHLIST
|
||||
|
||||
_macro_cache: Dict[str, Any] = {}
|
||||
|
||||
router = APIRouter(prefix="/api/market", tags=["market"])
|
||||
|
||||
|
||||
@router.get("/quotes")
|
||||
def quotes_all():
|
||||
return get_all_quotes()
|
||||
|
||||
|
||||
@router.get("/quote/{symbol}")
|
||||
def quote_single(symbol: str):
|
||||
from services.data_fetcher import get_quote
|
||||
return get_quote(symbol)
|
||||
|
||||
|
||||
@router.get("/history/{symbol}")
|
||||
def history(
|
||||
symbol: str,
|
||||
period: str = Query("1y", description="1d,5d,1mo,3mo,6mo,1y,2y,5y"),
|
||||
interval: str = Query("1d", description="1m,5m,15m,1h,1d,1wk,1mo"),
|
||||
):
|
||||
return get_historical(symbol, period, interval)
|
||||
|
||||
|
||||
@router.get("/iv/{symbol}")
|
||||
def implied_vol(symbol: str, window: int = 30):
|
||||
iv = compute_historical_iv(symbol, window)
|
||||
return {"symbol": symbol, "iv": iv, "window": window}
|
||||
|
||||
|
||||
@router.get("/watchlist")
|
||||
def watchlist():
|
||||
return WATCHLIST
|
||||
|
||||
|
||||
@router.get("/macro-regime")
|
||||
def macro_regime(force: bool = False):
|
||||
"""Macro gauge values + 5-scenario scoring. Cached 15 min."""
|
||||
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
|
||||
now = datetime.utcnow()
|
||||
if not force and _macro_cache.get("data") and _macro_cache.get("ts"):
|
||||
age = (now - _macro_cache["ts"]).total_seconds()
|
||||
if age < 900:
|
||||
return {**_macro_cache["data"], "cached": True, "cache_age_sec": int(age)}
|
||||
gauges = get_macro_gauges()
|
||||
scenarios = score_macro_scenarios(gauges)
|
||||
result: Dict[str, Any] = {
|
||||
"gauges": gauges,
|
||||
"scenarios": scenarios,
|
||||
"fetched_at": now.isoformat(),
|
||||
"cached": False,
|
||||
}
|
||||
_macro_cache["data"] = result
|
||||
_macro_cache["ts"] = now
|
||||
|
||||
if force:
|
||||
# Build a compact gauge summary (key → value + change_pct) for the journal
|
||||
gauges_summary = {
|
||||
k: {"value": v.get("value"), "change_pct": v.get("change_pct"), "label": v.get("label")}
|
||||
for k, v in gauges.items()
|
||||
if v.get("value") is not None or v.get("change_pct") is not None
|
||||
}
|
||||
from services.database import log_macro_regime
|
||||
log_macro_regime(
|
||||
dominant=scenarios.get("dominant", "incertain"),
|
||||
scores=scenarios.get("scores", {}),
|
||||
reasons=scenarios.get("reasons", {}),
|
||||
gauges_summary=gauges_summary,
|
||||
)
|
||||
|
||||
return result
|
||||
111
backend/routers/options.py
Normal file
111
backend/routers/options.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from typing import Optional
|
||||
from services.options_pricer import (
|
||||
black_scholes, compute_pnl_curve, bull_call_spread,
|
||||
bear_put_spread, long_straddle, implied_vol_surface
|
||||
)
|
||||
from services.data_fetcher import get_quote, compute_historical_iv
|
||||
|
||||
router = APIRouter(prefix="/api/options", tags=["options"])
|
||||
|
||||
|
||||
@router.get("/price")
|
||||
def price_option(
|
||||
symbol: str = Query(...),
|
||||
strike: float = Query(...),
|
||||
expiry_days: int = Query(90),
|
||||
option_type: str = Query("call"),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else strike
|
||||
sigma = compute_historical_iv(symbol)
|
||||
T = expiry_days / 365
|
||||
result = black_scholes(S, strike, T, rate, sigma, option_type)
|
||||
result["underlying_price"] = S
|
||||
result["sigma"] = sigma
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/pnl-curve")
|
||||
def pnl_curve(
|
||||
symbol: str = Query(...),
|
||||
strike: float = Query(...),
|
||||
expiry_days: int = Query(90),
|
||||
option_type: str = Query("call"),
|
||||
quantity: int = Query(1),
|
||||
premium_paid: float = Query(...),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else strike
|
||||
sigma = compute_historical_iv(symbol)
|
||||
T = expiry_days / 365
|
||||
return compute_pnl_curve(S, strike, T, rate, sigma, option_type, quantity, premium_paid)
|
||||
|
||||
|
||||
@router.get("/strategy/bull-call-spread")
|
||||
def bull_spread(
|
||||
symbol: str = Query(...),
|
||||
strike_low: float = Query(...),
|
||||
strike_high: float = Query(...),
|
||||
expiry_days: int = Query(90),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else strike_low
|
||||
sigma = compute_historical_iv(symbol)
|
||||
T = expiry_days / 365
|
||||
result = bull_call_spread(S, strike_low, strike_high, T, rate, sigma)
|
||||
result["underlying_price"] = S
|
||||
result["sigma"] = sigma
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/strategy/bear-put-spread")
|
||||
def bear_spread(
|
||||
symbol: str = Query(...),
|
||||
strike_high: float = Query(...),
|
||||
strike_low: float = Query(...),
|
||||
expiry_days: int = Query(90),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else strike_high
|
||||
sigma = compute_historical_iv(symbol)
|
||||
T = expiry_days / 365
|
||||
result = bear_put_spread(S, strike_high, strike_low, T, rate, sigma)
|
||||
result["underlying_price"] = S
|
||||
result["sigma"] = sigma
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/strategy/straddle")
|
||||
def straddle(
|
||||
symbol: str = Query(...),
|
||||
strike: float = Query(...),
|
||||
expiry_days: int = Query(90),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else strike
|
||||
sigma = compute_historical_iv(symbol)
|
||||
T = expiry_days / 365
|
||||
result = long_straddle(S, strike, T, rate, sigma)
|
||||
result["underlying_price"] = S
|
||||
result["sigma"] = sigma
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/iv-surface")
|
||||
def iv_surface(
|
||||
symbol: str = Query(...),
|
||||
rate: float = Query(0.05),
|
||||
):
|
||||
q = get_quote(symbol)
|
||||
S = q["price"] if q and "price" in q else 100.0
|
||||
sigma = compute_historical_iv(symbol)
|
||||
strikes_pct = [0.80, 0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15, 1.20]
|
||||
expiries = [7, 14, 30, 60, 90, 180]
|
||||
surface = implied_vol_surface(S, strikes_pct, expiries, rate, sigma)
|
||||
return {"symbol": symbol, "spot": S, "surface": surface}
|
||||
70
backend/routers/patterns.py
Normal file
70
backend/routers/patterns.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from services.database import (
|
||||
save_custom_pattern, get_custom_patterns, delete_custom_pattern, toggle_pattern_active
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/patterns", tags=["patterns"])
|
||||
|
||||
|
||||
class PatternRequest(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str
|
||||
description: str
|
||||
triggers: List[str]
|
||||
keywords: List[str]
|
||||
historical_instances: Optional[List[Dict[str, Any]]] = []
|
||||
suggested_trades: Optional[List[Dict[str, Any]]] = []
|
||||
asset_class: str
|
||||
expected_move_pct: float
|
||||
probability: float
|
||||
horizon_days: int
|
||||
ai_quality_score: Optional[int] = None
|
||||
ai_evaluation: Optional[Dict[str, Any]] = None
|
||||
source: Optional[str] = "custom"
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
def list_all():
|
||||
"""Return all active patterns from DB (builtin + custom)."""
|
||||
return get_custom_patterns()
|
||||
|
||||
|
||||
@router.get("/builtin")
|
||||
def list_builtin():
|
||||
return [p for p in get_custom_patterns() if p.get("source") == "builtin"]
|
||||
|
||||
|
||||
@router.get("/custom")
|
||||
def list_custom():
|
||||
return [p for p in get_custom_patterns() if p.get("source") != "builtin"]
|
||||
|
||||
|
||||
@router.post("/custom")
|
||||
def create_pattern(req: PatternRequest):
|
||||
data = req.model_dump()
|
||||
data["source"] = "custom"
|
||||
pat_id = save_custom_pattern(data)
|
||||
return {"id": pat_id, "status": "saved"}
|
||||
|
||||
|
||||
@router.put("/custom/{pat_id}")
|
||||
def update_pattern(pat_id: str, req: PatternRequest):
|
||||
data = req.model_dump()
|
||||
data["id"] = pat_id
|
||||
save_custom_pattern(data)
|
||||
return {"id": pat_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/custom/{pat_id}")
|
||||
def delete_pattern(pat_id: str):
|
||||
delete_custom_pattern(pat_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/toggle/{pat_id}")
|
||||
def toggle_pattern(pat_id: str):
|
||||
"""Enable or disable a pattern (works for builtin and custom)."""
|
||||
new_state = toggle_pattern_active(pat_id)
|
||||
return {"id": pat_id, "is_active": new_state}
|
||||
298
backend/routers/portfolio.py
Normal file
298
backend/routers/portfolio.py
Normal file
@@ -0,0 +1,298 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import traceback as tb_mod
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, date, timedelta
|
||||
from services.database import (
|
||||
add_position, get_positions, close_position,
|
||||
update_position_notes, compute_ib_fees
|
||||
)
|
||||
from services.data_fetcher import get_quote
|
||||
from services.options_pricer import black_scholes
|
||||
from services.data_fetcher import compute_historical_iv
|
||||
import math
|
||||
|
||||
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
|
||||
|
||||
|
||||
class AddPositionRequest(BaseModel):
|
||||
title: str
|
||||
underlying: str
|
||||
strategy: str
|
||||
asset_class: Optional[str] = "indices"
|
||||
entry_date: Optional[str] = None
|
||||
expiry_date: Optional[str] = None
|
||||
expiry_days: Optional[int] = 90
|
||||
legs: List[Dict[str, Any]]
|
||||
capital_invested: float
|
||||
entry_underlying_price: Optional[float] = None
|
||||
geo_trigger: Optional[str] = ""
|
||||
rationale: Optional[str] = ""
|
||||
notes: Optional[str] = ""
|
||||
|
||||
|
||||
class ClosePositionRequest(BaseModel):
|
||||
close_value: float
|
||||
|
||||
|
||||
class NotesRequest(BaseModel):
|
||||
notes: str
|
||||
|
||||
|
||||
def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Compute current value of a position using live prices + Black-Scholes."""
|
||||
underlying = pos["underlying"]
|
||||
q = get_quote(underlying)
|
||||
S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0
|
||||
|
||||
legs = pos.get("legs", [])
|
||||
if not legs:
|
||||
return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0,
|
||||
"current_underlying": S, "greeks": {}}
|
||||
|
||||
# Compute days to expiry
|
||||
expiry_date = pos.get("expiry_date") or ""
|
||||
if expiry_date:
|
||||
try:
|
||||
exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
||||
T = max(0.001, (exp - date.today()).days / 365)
|
||||
except Exception:
|
||||
T = max(0.001, (pos.get("expiry_days", 90) - 30) / 365)
|
||||
else:
|
||||
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
||||
days_elapsed = (date.today() - entry).days
|
||||
T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365)
|
||||
|
||||
from services.data_fetcher import compute_historical_iv as get_iv
|
||||
sigma = get_iv(underlying)
|
||||
r = 0.05
|
||||
|
||||
total_current_value = 0.0
|
||||
total_entry_value = 0.0
|
||||
net_delta = 0.0
|
||||
net_theta = 0.0
|
||||
net_vega = 0.0
|
||||
entry_from_legs = False
|
||||
|
||||
# T at entry (full original duration) — used to reprice legs at entry if premium_paid not stored
|
||||
S_entry = float(pos.get("entry_underlying_price") or S)
|
||||
entry_date_str = pos.get("entry_date", "")
|
||||
if expiry_date and entry_date_str:
|
||||
try:
|
||||
exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
||||
entry_dt = datetime.strptime(entry_date_str[:10], "%Y-%m-%d").date()
|
||||
T_entry = max(0.001, (exp_dt - entry_dt).days / 365)
|
||||
except Exception:
|
||||
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
|
||||
else:
|
||||
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
|
||||
|
||||
for leg in legs:
|
||||
K = leg.get("strike") or S
|
||||
K_entry = leg.get("strike") or S_entry
|
||||
opt_type = leg.get("option_type", "call")
|
||||
qty = leg.get("quantity", 1)
|
||||
sign = 1 if leg.get("position", "long") == "long" else -1
|
||||
bs = black_scholes(S, K, T, r, sigma, opt_type)
|
||||
leg_value = bs["price"] * qty * 100 * sign
|
||||
total_current_value += leg_value
|
||||
net_delta += bs["delta"] * qty * sign
|
||||
net_theta += bs["theta"] * qty * sign
|
||||
net_vega += bs["vega"] * qty * sign
|
||||
if leg.get("premium_paid") is not None:
|
||||
total_entry_value += leg["premium_paid"] * qty * 100 * sign
|
||||
entry_from_legs = True
|
||||
else:
|
||||
# No stored premium: reprice at entry conditions for a consistent PnL baseline
|
||||
bs_entry = black_scholes(S_entry, K_entry, T_entry, r, sigma, opt_type)
|
||||
total_entry_value += bs_entry["price"] * qty * 100 * sign
|
||||
|
||||
# Entry reference: always from legs (either stored premium or BS at entry conditions)
|
||||
ib_entry = pos.get("ib_fees_entry", 0)
|
||||
entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"]
|
||||
pnl = total_current_value - entry_ref - ib_entry
|
||||
pnl_pct = (pnl / max(abs(entry_ref), 1) * 100) if entry_ref else 0
|
||||
|
||||
return {
|
||||
**pos,
|
||||
"current_underlying": round(S, 4),
|
||||
"current_value": round(total_current_value, 2),
|
||||
"entry_ref": round(entry_ref, 2),
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"days_remaining": max(0, int(T * 365)),
|
||||
"sigma_used": round(sigma, 4),
|
||||
"greeks": {
|
||||
"net_delta": round(net_delta, 4),
|
||||
"net_theta": round(net_theta, 4),
|
||||
"net_vega": round(net_vega, 4),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/positions")
|
||||
def list_positions(status: str = "open"):
|
||||
positions = get_positions(status)
|
||||
if status == "open":
|
||||
return [mark_to_market(p) for p in positions]
|
||||
return positions
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def portfolio_summary():
|
||||
open_pos = get_positions("open")
|
||||
closed_pos = get_positions("closed")
|
||||
|
||||
marked = [mark_to_market(p) for p in open_pos]
|
||||
total_invested = sum(p["capital_invested"] for p in open_pos)
|
||||
total_current = sum(p.get("current_value", p["capital_invested"]) for p in marked)
|
||||
total_pnl = sum(p.get("pnl", 0) for p in marked)
|
||||
total_fees = sum(p.get("ib_fees_entry", 0) for p in open_pos)
|
||||
|
||||
realized_pnl = 0.0
|
||||
for p in closed_pos:
|
||||
if p.get("close_value") is not None:
|
||||
realized_pnl += (p["close_value"] - p["capital_invested"]
|
||||
- p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0))
|
||||
|
||||
return {
|
||||
"open_positions": len(open_pos),
|
||||
"closed_positions": len(closed_pos),
|
||||
"total_invested": round(total_invested, 2),
|
||||
"total_current_value": round(total_current, 2),
|
||||
"unrealized_pnl": round(total_pnl, 2),
|
||||
"unrealized_pnl_pct": round(total_pnl / total_invested * 100, 2) if total_invested else 0,
|
||||
"realized_pnl": round(realized_pnl, 2),
|
||||
"total_fees_paid": round(total_fees, 2),
|
||||
"net_pnl": round(total_pnl + realized_pnl, 2),
|
||||
}
|
||||
|
||||
|
||||
TICKER_HINTS: Dict[str, str] = {
|
||||
# Indices
|
||||
"s&p 500": "^GSPC", "sp500": "^GSPC", "s&p500": "^GSPC", "spx": "^GSPC",
|
||||
"nasdaq": "^NDX", "nasdaq 100": "^NDX", "ndx": "^NDX", "qqq": "QQQ",
|
||||
"dow jones": "^DJI", "djia": "^DJI",
|
||||
"vix": "^VIX",
|
||||
"euro stoxx": "^STOXX50E", "stoxx50": "^STOXX50E",
|
||||
"nikkei": "^N225",
|
||||
# Metals
|
||||
"gold": "GC=F", "or": "GC=F", "gold futures": "GC=F",
|
||||
"silver": "SI=F", "argent": "SI=F",
|
||||
"copper": "HG=F", "cuivre": "HG=F",
|
||||
"platinum": "PL=F", "platine": "PL=F",
|
||||
# Energy
|
||||
"wti": "CL=F", "crude oil": "CL=F", "pétrole": "CL=F", "crude": "CL=F",
|
||||
"brent": "BZ=F",
|
||||
"natural gas": "NG=F", "gaz naturel": "NG=F", "natgas": "NG=F",
|
||||
# Agriculture
|
||||
"corn": "ZC=F", "maïs": "ZC=F",
|
||||
"wheat": "ZW=F", "blé": "ZW=F",
|
||||
"soybean": "ZS=F", "soja": "ZS=F",
|
||||
"coffee": "KC=F", "café": "KC=F",
|
||||
# Forex — yfinance format: {BASE}{QUOTE}=X
|
||||
"eurusd": "EURUSD=X", "eur/usd": "EURUSD=X", "euro": "EURUSD=X",
|
||||
"usdjpy": "USDJPY=X", "usd/jpy": "USDJPY=X",
|
||||
"gbpusd": "GBPUSD=X", "gbp/usd": "GBPUSD=X",
|
||||
"usdchf": "USDCHF=X", "usd/chf": "USDCHF=X",
|
||||
"usdcnh": "USDCNH=X", "usd/cnh": "USDCNH=X",
|
||||
"usdcny": "USDCNY=X", "usd/cny": "USDCNY=X", "cny": "USDCNY=X",
|
||||
"usdrub": "USDRUB=X", "usd/rub": "USDRUB=X",
|
||||
"usdtry": "USDTRY=X", "usd/try": "USDTRY=X",
|
||||
"usdmxn": "USDMXN=X", "usd/mxn": "USDMXN=X",
|
||||
"audusd": "AUDUSD=X", "aud/usd": "AUDUSD=X",
|
||||
"dxy": "DX-Y.NYB", "dollar index": "DX-Y.NYB",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
def add_pos(req: AddPositionRequest):
|
||||
import traceback
|
||||
try:
|
||||
data = req.model_dump()
|
||||
|
||||
# Normalize common names to yfinance tickers
|
||||
raw = req.underlying.strip()
|
||||
normalized = TICKER_HINTS.get(raw.lower(), raw)
|
||||
data["underlying"] = normalized
|
||||
|
||||
# Fetch live underlying price
|
||||
q = get_quote(normalized)
|
||||
S = q.get("price") if q else None
|
||||
if not S:
|
||||
hint = TICKER_HINTS.get(raw.lower())
|
||||
tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI)."
|
||||
raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance.{tip}")
|
||||
if not data.get("entry_underlying_price"):
|
||||
data["entry_underlying_price"] = S
|
||||
|
||||
# Auto-fill entry date and expiry
|
||||
if not data.get("entry_date"):
|
||||
data["entry_date"] = datetime.utcnow().isoformat()[:10]
|
||||
if not data.get("expiry_date") and data.get("expiry_days"):
|
||||
data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat()
|
||||
|
||||
# Auto-price legs that have no premium_paid using BS at entry
|
||||
# This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget)
|
||||
if S and data.get("legs"):
|
||||
sigma = compute_historical_iv(req.underlying)
|
||||
T = max(0.001, data.get("expiry_days", 90) / 365)
|
||||
r = 0.05
|
||||
for leg in data["legs"]:
|
||||
if leg.get("premium_paid") is None:
|
||||
K = leg.get("strike") or S # ATM if no explicit strike
|
||||
if not leg.get("strike"):
|
||||
leg["strike"] = round(S, 2)
|
||||
opt_type = leg.get("option_type", "call")
|
||||
bs = black_scholes(S, K, T, r, sigma, opt_type)
|
||||
leg["premium_paid"] = round(bs["price"], 4)
|
||||
|
||||
pos_id = add_position(data)
|
||||
return {"id": pos_id, "status": "added"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
raise HTTPException(status_code=500, detail=f"{str(e)}\n\n{tb}")
|
||||
|
||||
|
||||
@router.post("/close/{pos_id}")
|
||||
def close_pos(pos_id: str, req: ClosePositionRequest):
|
||||
return close_position(pos_id, req.close_value)
|
||||
|
||||
|
||||
@router.delete("/{pos_id}")
|
||||
def delete_pos(pos_id: str):
|
||||
from services.database import get_conn
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM portfolio WHERE id=?", (pos_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "deleted", "id": pos_id}
|
||||
|
||||
|
||||
@router.patch("/notes/{pos_id}")
|
||||
def update_notes(pos_id: str, req: NotesRequest):
|
||||
update_position_notes(pos_id, req.notes)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/pnl-history")
|
||||
def pnl_history():
|
||||
"""Equity curve from closed positions."""
|
||||
closed = get_positions("closed")
|
||||
closed_sorted = sorted(closed, key=lambda p: p.get("close_date", ""))
|
||||
curve = []
|
||||
cumulative = 0.0
|
||||
for p in closed_sorted:
|
||||
if p.get("close_value") is not None:
|
||||
pnl = (p["close_value"] - p["capital_invested"]
|
||||
- p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0))
|
||||
cumulative += pnl
|
||||
curve.append({
|
||||
"date": p.get("close_date", ""),
|
||||
"pnl": round(pnl, 2),
|
||||
"cumulative": round(cumulative, 2),
|
||||
"title": p.get("title", p.get("underlying", "")),
|
||||
})
|
||||
return curve
|
||||
88
backend/routers/profiles.py
Normal file
88
backend/routers/profiles.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from services.database import get_risk_profiles, upsert_risk_profile, delete_risk_profile, _compute_trade_score
|
||||
|
||||
router = APIRouter(prefix="/api/profiles", tags=["profiles"])
|
||||
|
||||
|
||||
class RiskProfileRequest(BaseModel):
|
||||
id: Optional[int] = None
|
||||
name: str
|
||||
min_score: int
|
||||
min_gain_pct: float
|
||||
color: Optional[str] = "#3b82f6"
|
||||
enabled: Optional[bool] = True
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_profiles():
|
||||
"""List all risk profiles ordered by sort_order."""
|
||||
profiles = get_risk_profiles()
|
||||
# Annotate each profile with the EV breakeven info
|
||||
result = []
|
||||
for p in profiles:
|
||||
# At the exact frontier: score = min_score, gain = min_gain_pct
|
||||
_, ev_net, trade_score = _compute_trade_score(p["min_score"], p["min_gain_pct"])
|
||||
result.append({
|
||||
**p,
|
||||
"ev_net_at_frontier": round(ev_net, 3),
|
||||
"trade_score_at_frontier": trade_score,
|
||||
})
|
||||
return {"profiles": result}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_profile(req: RiskProfileRequest):
|
||||
"""Create a new risk profile."""
|
||||
if not (0 <= req.min_score <= 100):
|
||||
raise HTTPException(400, "min_score must be between 0 and 100")
|
||||
if req.min_gain_pct < 0:
|
||||
raise HTTPException(400, "min_gain_pct must be >= 0")
|
||||
pid = upsert_risk_profile(req.model_dump())
|
||||
profiles = get_risk_profiles()
|
||||
return {"id": pid, "profiles": profiles}
|
||||
|
||||
|
||||
@router.put("/{profile_id}")
|
||||
def update_profile(profile_id: int, req: RiskProfileRequest):
|
||||
"""Update an existing risk profile."""
|
||||
if not (0 <= req.min_score <= 100):
|
||||
raise HTTPException(400, "min_score must be between 0 and 100")
|
||||
data = req.model_dump()
|
||||
data["id"] = profile_id
|
||||
upsert_risk_profile(data)
|
||||
return {"profiles": get_risk_profiles()}
|
||||
|
||||
|
||||
@router.delete("/{profile_id}")
|
||||
def remove_profile(profile_id: int):
|
||||
"""Delete a risk profile."""
|
||||
profiles = get_risk_profiles()
|
||||
if len([p for p in profiles if p["enabled"]]) <= 1:
|
||||
# Allow deletion but warn
|
||||
pass
|
||||
delete_risk_profile(profile_id)
|
||||
return {"profiles": get_risk_profiles()}
|
||||
|
||||
|
||||
@router.get("/preview")
|
||||
def preview_score(score: int = 50, gain_pct: float = 100.0):
|
||||
"""
|
||||
Preview the trade metrics for a given (score, gain_pct) pair.
|
||||
Useful for the Config UI slider simulation.
|
||||
"""
|
||||
ev_gross, ev_net, trade_score = _compute_trade_score(score, gain_pct)
|
||||
profiles = get_risk_profiles(enabled_only=True)
|
||||
from services.database import _matches_profile
|
||||
matched = _matches_profile(score, gain_pct, profiles)
|
||||
return {
|
||||
"score": score,
|
||||
"gain_pct": gain_pct,
|
||||
"ev_gross": ev_gross,
|
||||
"ev_net": ev_net,
|
||||
"trade_score": trade_score,
|
||||
"matched_profile": matched,
|
||||
"accepted": matched is not None,
|
||||
}
|
||||
332
backend/routers/reasoning.py
Normal file
332
backend/routers/reasoning.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
AI Reasoning Traces — store and query the full reasoning chain behind each trade.
|
||||
|
||||
Endpoints:
|
||||
GET /api/reasoning/postmortem/{trade_id} — reasoning chain (no GPT call)
|
||||
POST /api/reasoning/postmortem/{trade_id}/analyze — GPT-4o post-mortem analysis
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from services.database import (
|
||||
get_config,
|
||||
get_ai_report,
|
||||
get_mtm_trades_with_traces,
|
||||
get_pattern_scoring_history,
|
||||
get_scoring_trace,
|
||||
get_suggestion_trace,
|
||||
get_trade_entry_by_id,
|
||||
list_ai_reports,
|
||||
save_ai_report,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/reasoning", tags=["reasoning"])
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _bucket_summary(buckets: list) -> str:
|
||||
lines = []
|
||||
for b in buckets:
|
||||
pct = round(b.get("score", 0) / b.get("max", 1) * 100) if b.get("max") else 0
|
||||
lines.append(f" {b.get('label', b.get('id'))}: {b.get('score')}/{b.get('max')} ({pct}%) — {(b.get('comment') or '')[:90]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _rankings_summary(rankings: list) -> str:
|
||||
lines = []
|
||||
for r in rankings:
|
||||
delta = r.get("score_delta", 0)
|
||||
sign = "+" if delta >= 0 else ""
|
||||
lines.append(f" {r.get('underlying')} {r.get('strategy')} — delta {sign}{delta} | {(r.get('rationale') or '')[:80]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/postmortem/{trade_id}")
|
||||
def get_postmortem(trade_id: int):
|
||||
"""
|
||||
Return the full AI reasoning chain for a logged trade:
|
||||
- why the pattern was suggested (suggestion trace)
|
||||
- why it was scored at that level (scoring trace with pillar breakdown)
|
||||
- score evolution across cycles (trend)
|
||||
"""
|
||||
trade = get_trade_entry_by_id(trade_id)
|
||||
if not trade:
|
||||
raise HTTPException(404, f"Trade {trade_id} not found")
|
||||
|
||||
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
|
||||
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
|
||||
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=8)
|
||||
|
||||
return {
|
||||
"trade": trade,
|
||||
"scoring_context": scoring_trace,
|
||||
"suggestion_context": suggestion_trace,
|
||||
"score_history": [
|
||||
{
|
||||
"run_id": t["run_id"],
|
||||
"created_at": t["created_at"],
|
||||
"score": t["output"].get("score"),
|
||||
"key_catalyst": t["output"].get("key_catalyst"),
|
||||
"macro_dominant": t["macro_dominant"],
|
||||
"geo_score": t["geo_score"],
|
||||
"summary": t["output"].get("summary"),
|
||||
}
|
||||
for t in score_history
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/postmortem/{trade_id}/analyze")
|
||||
def analyze_postmortem(trade_id: int):
|
||||
"""
|
||||
Ask GPT-4o to explain why a trade did/didn't work based on the full reasoning chain.
|
||||
Returns a structured analysis with diagnostic, lessons, and next-cycle recommendations.
|
||||
"""
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
if not ai_key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée")
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
trade = get_trade_entry_by_id(trade_id)
|
||||
if not trade:
|
||||
raise HTTPException(404, f"Trade {trade_id} not found")
|
||||
|
||||
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
|
||||
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
|
||||
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=5)
|
||||
|
||||
scoring_out = scoring_trace["output"] if scoring_trace else {}
|
||||
scoring_ctx = scoring_trace["input_context"] if scoring_trace else {}
|
||||
suggestion_out = suggestion_trace["output"] if suggestion_trace else {}
|
||||
|
||||
buckets_text = _bucket_summary(scoring_out.get("buckets", []))
|
||||
rankings_text = _rankings_summary(scoring_out.get("trade_rankings", []))
|
||||
|
||||
score_trend = " → ".join(
|
||||
f"{t['output'].get('score', '?')}/100 ({t['macro_dominant'] or '?'} régime, géo {t['geo_score'] or '?'})"
|
||||
for t in reversed(score_history)
|
||||
)
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse le post-mortem d'un trade options.
|
||||
|
||||
═══ TRADE ANALYSÉ ═══
|
||||
Pattern : {trade.get("pattern_name")}
|
||||
Instrument : {trade.get("underlying")} — {trade.get("strategy")}
|
||||
Entrée : {trade.get("entry_date")} @ {trade.get("entry_price") or "N/A"}
|
||||
Score entrée: {trade.get("score_at_entry")}/100 | Trade Score: {trade.get("trade_score") or "N/A"} | EV nette: {trade.get("ev_net") or "N/A"}
|
||||
Profil : {trade.get("matched_profile")} | Gain prévu: {trade.get("expected_move_pct") or "N/A"}%
|
||||
|
||||
═══ CONTEXTE AU MOMENT DU SCORING ═══
|
||||
Régime macro : {scoring_trace.get("macro_dominant") if scoring_trace else "N/A"} | Biais asset: {scoring_ctx.get("asset_bias", "N/A")}
|
||||
Scores macro : {json.dumps(scoring_ctx.get("macro_scores", {}), ensure_ascii=False)}
|
||||
Risque géo : {scoring_trace.get("geo_score") if scoring_trace else "N/A"}/100
|
||||
Gain prévu : {scoring_ctx.get("expected_move_pct") or "N/A"}%
|
||||
|
||||
═══ POURQUOI CE PATTERN A ÉTÉ CRÉÉ ═══
|
||||
{suggestion_out.get("macro_fit") or "N/A"}
|
||||
{suggestion_out.get("description") or ""}
|
||||
|
||||
═══ SCORE DÉTAILLÉ PAR PILIER ═══
|
||||
Score global : {scoring_out.get("score", 0)}/100 (confiance {scoring_out.get("confidence", 0)}%)
|
||||
{buckets_text or "Non disponible"}
|
||||
Catalyseur clé : {scoring_out.get("key_catalyst") or "N/A"}
|
||||
Synthèse : {scoring_out.get("summary") or "N/A"}
|
||||
Contra-signal fort : {"OUI" if scoring_out.get("has_strong_contra") else "non"}
|
||||
|
||||
═══ CLASSEMENT DES TRADES AU SCORING ═══
|
||||
{rankings_text or "Non disponible"}
|
||||
|
||||
═══ ÉVOLUTION DU SCORE DANS LE TEMPS ═══
|
||||
{score_trend or "Premier scoring — pas d'historique"}
|
||||
|
||||
Analyse ce trade en JSON :
|
||||
{{
|
||||
"diagnostic": "<2-3 phrases: qu'explique la performance (bonne ou mauvaise) de ce trade ?>",
|
||||
"what_worked": "<ce qui était correct dans l'analyse initiale>",
|
||||
"what_missed": "<ce que l'IA a sous/sur-estimé, ou n'a pas anticipé>",
|
||||
"regime_alignment": "<le régime macro était-il vraiment favorable ? a-t-il évolué depuis ?>",
|
||||
"contra_assessment": "<les contra-signals détectés étaient-ils le vrai risque ? ou un faux signal ?>",
|
||||
"lesson": "<1 règle précise à retenir pour scorer ce type de pattern plus finement>",
|
||||
"next_cycle": "<comment enrichir le contexte et les critères pour ce pattern dans les prochains cycles ?>"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = _chat(
|
||||
"Tu es un stratège macro-géopolitique senior. Post-mortem concis et actionnable. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=900,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Postmortem] GPT-4o call failed: {e}")
|
||||
raise HTTPException(503, "GPT-4o indisponible")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
|
||||
|
||||
return {
|
||||
"trade_id": trade_id,
|
||||
"trade": trade,
|
||||
"scoring_context": scoring_trace,
|
||||
"suggestion_context": suggestion_trace,
|
||||
"analysis": result,
|
||||
}
|
||||
|
||||
|
||||
# ── Portfolio AI Report ────────────────────────────────────────────────────────
|
||||
|
||||
def _trade_summary_block(label: str, trades: list) -> str:
|
||||
if not trades:
|
||||
return f"{label} : aucun trade pricé"
|
||||
lines = [f"{label} :"]
|
||||
for t in trades:
|
||||
pnl = t.get("pnl_pct")
|
||||
sc = t.get("scoring_context") or {}
|
||||
sc_out = sc.get("output", {}) if isinstance(sc, dict) else {}
|
||||
sg = t.get("suggestion_context") or {}
|
||||
sg_out = sg.get("output", {}) if isinstance(sg, dict) else {}
|
||||
macro = sc.get("macro_dominant") if isinstance(sc, dict) else "?"
|
||||
geo = sc.get("geo_score") if isinstance(sc, dict) else "?"
|
||||
catalyst = sc_out.get("key_catalyst") or "N/A"
|
||||
macro_fit = sg_out.get("macro_fit") or sg_out.get("description") or "N/A"
|
||||
trend = " → ".join(str(s) for s in (t.get("score_trend") or [])) or "N/A"
|
||||
buckets = sc_out.get("buckets", [])
|
||||
weak = [b.get("label", b.get("id", "")) for b in buckets if b.get("max") and b.get("score", 0) / b["max"] < 0.4]
|
||||
lines.append(
|
||||
f" • {t.get('pattern_name')} | {t.get('underlying')} {t.get('strategy')}"
|
||||
f" | P&L {'+' if (pnl or 0) >= 0 else ''}{(pnl or 0):.1f}%"
|
||||
f" | Score entrée {t.get('score_at_entry')}/100 | Régime {macro} | Géo {geo}"
|
||||
f"\n Thèse : {macro_fit[:120]}"
|
||||
f"\n Catalyseur : {catalyst}"
|
||||
f"\n Trend score : {trend}"
|
||||
+ (f"\n Piliers faibles : {', '.join(weak)}" if weak else "")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("/portfolio-report")
|
||||
def get_portfolio_report_data(days: int = 90):
|
||||
"""Return raw MTM + traces data (no GPT-4o call) for the report page."""
|
||||
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/portfolio-report/generate")
|
||||
def generate_portfolio_report(days: int = 90):
|
||||
"""
|
||||
Generate a GPT-4o AI report: key highlights, explanations for top movers,
|
||||
macro regime assessment, and actionable next-cycle recommendations.
|
||||
"""
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
if not ai_key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée")
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
|
||||
winners = data["winners"]
|
||||
losers = data["losers"]
|
||||
|
||||
winners_block = _trade_summary_block("TOP GAINS", winners)
|
||||
losers_block = _trade_summary_block("TOP PERTES", losers)
|
||||
|
||||
avg_pnl = data.get("avg_pnl_pct")
|
||||
avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A"
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior. Génère un rapport synthétique sur notre portefeuille options.
|
||||
|
||||
═══ STATISTIQUES GLOBALES ═══
|
||||
Période : {days} derniers jours
|
||||
Trades total: {data['total_trades']} | Pricés: {data['priced_count']} | P&L moyen: {avg_str}
|
||||
|
||||
═══ {winners_block}
|
||||
|
||||
═══ {losers_block}
|
||||
|
||||
Génère un rapport JSON structuré :
|
||||
{{
|
||||
"headline": "<1 phrase résumant la performance de la période>",
|
||||
"regime_assessment": "<le régime macro a-t-il bien servi nos thèses ? convergence ou divergence ?>",
|
||||
"winners_analysis": "<pourquoi ces trades ont marché — pattern commun, catalyseur, régime ? 3-4 phrases>",
|
||||
"losers_analysis": "<pourquoi ces trades ont déçu — mauvaise thèse, mauvais timing, contra-signal manqué ? 3-4 phrases>",
|
||||
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
|
||||
"blind_spots": "<ce que notre système de scoring n'a pas bien capturé cette période>",
|
||||
"next_cycle_priorities": "<3 priorités concrètes pour améliorer les prochains cycles : patterns à surveiller, ajustements de scoring, régimes à anticiper>",
|
||||
"risk_watch": "<1-2 risques macro-géopolitiques à surveiller de près qui pourraient impacter nos positions actuelles>"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = _chat(
|
||||
"Tu es un stratège macro senior. Rapport synthétique et actionnable. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=1200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PortfolioReport] GPT-4o call failed: {e}")
|
||||
raise HTTPException(503, "GPT-4o indisponible")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
|
||||
|
||||
stats = {
|
||||
"total_trades": data["total_trades"],
|
||||
"priced_count": data["priced_count"],
|
||||
"avg_pnl_pct": avg_pnl,
|
||||
}
|
||||
|
||||
report_id = save_ai_report(
|
||||
days=days,
|
||||
stats=stats,
|
||||
winners=winners,
|
||||
losers=losers,
|
||||
report=result,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": report_id,
|
||||
"days": days,
|
||||
"stats": stats,
|
||||
"winners": winners,
|
||||
"losers": losers,
|
||||
"report": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(report_type: str = "portfolio", limit: int = 20):
|
||||
"""List archived AI reports (newest first), summary only."""
|
||||
reports = list_ai_reports(report_type=report_type, limit=limit)
|
||||
return {
|
||||
"reports": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"days": r["days"],
|
||||
"created_at": r["created_at"],
|
||||
"stats": r["stats"],
|
||||
"headline": r["report"].get("headline", ""),
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
def get_report(report_id: int):
|
||||
"""Retrieve a full archived AI report by ID."""
|
||||
report = get_ai_report(report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, f"Report {report_id} not found")
|
||||
return report
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
934
backend/services/ai_analyzer.py
Normal file
934
backend/services/ai_analyzer.py
Normal 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
|
||||
697
backend/services/auto_cycle.py
Normal file
697
backend/services/auto_cycle.py
Normal 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()),
|
||||
}
|
||||
593
backend/services/data_fetcher.py
Normal file
593
backend/services/data_fetcher.py
Normal 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 10Y–3M", "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
1405
backend/services/database.py
Normal file
File diff suppressed because it is too large
Load Diff
348
backend/services/geo_analyzer.py
Normal file
348
backend/services/geo_analyzer.py
Normal 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
|
||||
126
backend/services/options_pricer.py
Normal file
126
backend/services/options_pricer.py
Normal 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
8
deploy/.env.example
Normal 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
49
deploy/docker-compose.yml
Normal 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
|
||||
25
deploy/nginx/nginx-http-init.conf
Normal file
25
deploy/nginx/nginx-http-init.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
61
deploy/nginx/nginx-https.conf
Normal file
61
deploy/nginx/nginx-https.conf
Normal 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
93
deploy/setup-vps.sh
Normal 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
21
deploy/update.sh
Normal 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
16
frontend/Dockerfile
Normal 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
18
frontend/index.html
Normal 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
16
frontend/nginx-spa.conf
Normal 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
3476
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
49
frontend/src/App.tsx
Normal file
49
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/layout/Sidebar.tsx
Normal file
105
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
545
frontend/src/hooks/useApi.ts
Normal file
545
frontend/src/hooks/useApi.ts
Normal 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
52
frontend/src/index.css
Normal 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
19
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
310
frontend/src/pages/Backtest.tsx
Normal file
310
frontend/src/pages/Backtest.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
frontend/src/pages/CalendarPage.tsx
Normal file
199
frontend/src/pages/CalendarPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
733
frontend/src/pages/Config.tsx
Normal file
733
frontend/src/pages/Config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
867
frontend/src/pages/Dashboard.tsx
Normal file
867
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
345
frontend/src/pages/GeoRadar.tsx
Normal file
345
frontend/src/pages/GeoRadar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
846
frontend/src/pages/JournalDeBord.tsx
Normal file
846
frontend/src/pages/JournalDeBord.tsx
Normal 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 été 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>
|
||||
)
|
||||
}
|
||||
353
frontend/src/pages/MacroRegime.tsx
Normal file
353
frontend/src/pages/MacroRegime.tsx
Normal 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 10Y–3M (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 ? 'Ré-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>
|
||||
)
|
||||
}
|
||||
353
frontend/src/pages/Markets.tsx
Normal file
353
frontend/src/pages/Markets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
frontend/src/pages/OptionsLab.tsx
Normal file
341
frontend/src/pages/OptionsLab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
690
frontend/src/pages/PatternEditor.tsx
Normal file
690
frontend/src/pages/PatternEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
627
frontend/src/pages/Portfolio.tsx
Normal file
627
frontend/src/pages/Portfolio.tsx
Normal 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&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>
|
||||
)
|
||||
}
|
||||
509
frontend/src/pages/RapportIA.tsx
Normal file
509
frontend/src/pages/RapportIA.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
451
frontend/src/pages/SuperContexte.tsx
Normal file
451
frontend/src/pages/SuperContexte.tsx
Normal 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
136
frontend/src/types/index.ts
Normal 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
|
||||
}
|
||||
39
frontend/tailwind.config.js
Normal file
39
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
15
frontend/vite.config.ts
Normal 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
90
start.ps1
Normal 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
35
start_all.bat
Normal 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
36
start_backend.bat
Normal 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
29
start_frontend.bat
Normal 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
|
||||
Reference in New Issue
Block a user