feat: trade lifecycle management — close, archive, target/stop alerts

- DB: 9 new columns on trade_entry_prices (status, closed_at, close_reason,
  close_note, pnl_realized, close_price, target_pct, stop_loss_pct, signal_threshold)
  via ALTER TABLE migration; close_trade(), get_closed_trades(),
  update_trade_exit_params() helpers; exit_defaults config key
- Backend: PATCH /trades/{id}/close, PATCH /trades/{id}/exit-params,
  GET/PUT /exit-defaults, GET /closed-trades with win-rate/avg-PnL stats;
  trade-mtm now computes alert_type (target_reached|stop_loss) per trade
- Journal: new "Fermés" tab with closed trades table + stats banner (win rate,
  avg PnL, total PnL, best trade); open trades show Cible/Stop progress bar +
  🎯/🛑 alert badges + 1-click close modal (price, reason, note)
- Config: new "Paramètres de sortie" panel — target_pct, stop_loss_pct,
  signal_reversal_mode, signal_reversal_threshold with live sliders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-19 14:17:29 +02:00
parent 0ba73cae0b
commit ee69f3cbd9
5 changed files with 673 additions and 13 deletions

View File

@@ -1,7 +1,14 @@
from fastapi import APIRouter
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from typing import Any, Dict, List, Optional
import math
from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices, _trade_maturity
from pydantic import BaseModel
from services.database import (
get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices,
get_closed_trades, close_trade, update_trade_exit_params,
get_trade_entry_by_id, get_config, set_config, reset_journal_history,
_fetch_live_prices, _trade_maturity,
)
import json
def _sanitize(obj: Any) -> Any:
@@ -69,6 +76,17 @@ def trade_mtm(days: int = 30):
horizon = e.get("horizon_days") or 90
maturity = _trade_maturity(days_held or 0, horizon)
defaults = _get_exit_defaults()
target = e.get("target_pct") if e.get("target_pct") is not None else defaults.get("target_pct", 30.0)
stop = e.get("stop_loss_pct") if e.get("stop_loss_pct") is not None else defaults.get("stop_loss_pct", -50.0)
alert_type = None
if pnl_pct is not None:
if pnl_pct >= target:
alert_type = "target_reached"
elif pnl_pct <= stop:
alert_type = "stop_loss"
result.append({
**e,
"current_price": current_price,
@@ -76,11 +94,121 @@ def trade_mtm(days: int = 30):
"days_held": days_held,
"direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish",
"maturity": maturity,
"alert_type": alert_type,
"target_pct": target,
"stop_loss_pct": stop,
})
return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)})
def _get_exit_defaults() -> Dict[str, Any]:
raw = get_config("exit_defaults")
if raw:
try:
return json.loads(raw)
except Exception:
pass
return {"target_pct": 30.0, "stop_loss_pct": -50.0,
"signal_reversal_mode": "badge_only", "signal_reversal_threshold": 25}
@router.get("/exit-defaults")
def exit_defaults():
return _get_exit_defaults()
class ExitDefaultsRequest(BaseModel):
target_pct: Optional[float] = None
stop_loss_pct: Optional[float] = None
signal_reversal_mode: Optional[str] = None
signal_reversal_threshold: Optional[float] = None
@router.put("/exit-defaults")
def save_exit_defaults(body: ExitDefaultsRequest):
current = _get_exit_defaults()
if body.target_pct is not None:
current["target_pct"] = body.target_pct
if body.stop_loss_pct is not None:
current["stop_loss_pct"] = body.stop_loss_pct
if body.signal_reversal_mode is not None:
current["signal_reversal_mode"] = body.signal_reversal_mode
if body.signal_reversal_threshold is not None:
current["signal_reversal_threshold"] = body.signal_reversal_threshold
set_config("exit_defaults", json.dumps(current))
return current
@router.get("/closed-trades")
def closed_trades(days: int = 180):
trades = get_closed_trades(days)
if not trades:
return _sanitize({"trades": [], "days": days, "stats": {}})
pnls = [t["pnl_realized"] for t in trades if t.get("pnl_realized") is not None]
wins = [p for p in pnls if p >= 0]
losses = [p for p in pnls if p < 0]
stats = {
"total": len(trades),
"with_pnl": len(pnls),
"win_rate": round(len(wins) / len(pnls) * 100, 1) if pnls else None,
"avg_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
"total_pnl": round(sum(pnls), 2) if pnls else None,
"avg_win": round(sum(wins) / len(wins), 2) if wins else None,
"avg_loss": round(sum(losses) / len(losses), 2) if losses else None,
"best": max(pnls, default=None),
"worst": min(pnls, default=None),
}
return _sanitize({"trades": trades, "days": days, "stats": stats})
class CloseTradeRequest(BaseModel):
close_price: float
pnl_realized: Optional[float] = None
close_reason: str = "manual"
close_note: str = ""
class ExitParamsRequest(BaseModel):
target_pct: Optional[float] = None
stop_loss_pct: Optional[float] = None
signal_threshold: Optional[float] = None
@router.patch("/trades/{trade_id}/exit-params")
def set_exit_params(trade_id: int, body: ExitParamsRequest):
ok = update_trade_exit_params(
trade_id,
target_pct=body.target_pct,
stop_loss_pct=body.stop_loss_pct,
signal_threshold=body.signal_threshold,
)
if not ok:
raise HTTPException(404, "Trade non trouvé")
return {"updated": True}
@router.patch("/trades/{trade_id}/close")
def close_trade_endpoint(trade_id: int, body: CloseTradeRequest):
trade = get_trade_entry_by_id(trade_id)
if not trade:
raise HTTPException(404, "Trade non trouvé")
if trade.get("status") == "closed":
raise HTTPException(409, "Trade déjà clôturé")
pnl = body.pnl_realized
if pnl is None and trade.get("entry_price") and body.close_price > 0:
raw = (body.close_price - trade["entry_price"]) / trade["entry_price"] * 100
is_bearish = any(kw in (trade.get("strategy") or "").lower()
for kw in _BEARISH_KEYWORDS)
pnl = round(-raw if is_bearish else raw, 2)
ok = close_trade(trade_id, body.close_price, pnl, body.close_reason, body.close_note)
if not ok:
raise HTTPException(409, "Impossible de clôturer ce trade")
return {"closed": True, "trade_id": trade_id, "pnl_realized": pnl}
@router.delete("/reset")
def reset_journal():
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""

View File

@@ -209,6 +209,15 @@ def init_db():
("capital_invested", "REAL"),
("strike_guidance", "TEXT"),
("expiry_days_at_entry", "INTEGER"),
("status", "TEXT DEFAULT 'open'"),
("closed_at", "TEXT"),
("close_reason", "TEXT"),
("close_note", "TEXT"),
("pnl_realized", "REAL"),
("close_price", "REAL"),
("target_pct", "REAL"),
("stop_loss_pct", "REAL"),
("signal_threshold", "REAL"),
]:
try:
c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}")
@@ -216,6 +225,7 @@ def init_db():
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_tep_date ON trade_entry_prices(entry_date DESC)")
c.execute("CREATE INDEX IF NOT EXISTS idx_tep_status ON trade_entry_prices(status, closed_at DESC)")
except Exception:
pass
@@ -273,6 +283,12 @@ def init_db():
"auto_cycle_similarity_threshold": "0.30",
"min_ev_threshold": "0.0",
"min_score_threshold": "0",
"exit_defaults": json.dumps({
"target_pct": 30.0,
"stop_loss_pct": -50.0,
"signal_reversal_mode": "badge_only",
"signal_reversal_threshold": 25,
}),
}
for k, v in defaults.items():
c.execute("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", (k, v))
@@ -1160,7 +1176,8 @@ def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM trade_entry_prices
WHERE entry_date >= date('now', ?)
WHERE (status IS NULL OR status = 'open')
AND entry_date >= date('now', ?)
ORDER BY entry_date DESC, score_at_entry DESC""",
(f"-{days} days",)
).fetchall()
@@ -1168,6 +1185,57 @@ def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]:
return [dict(r) for r in rows]
def get_closed_trades(days: int = 180) -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"""SELECT * FROM trade_entry_prices
WHERE status = 'closed'
AND closed_at >= date('now', ?)
ORDER BY closed_at DESC""",
(f"-{days} days",)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def close_trade(trade_id: int, close_price: float, pnl_realized: float,
close_reason: str, close_note: str = "") -> bool:
conn = get_conn()
cur = conn.execute(
"""UPDATE trade_entry_prices
SET status='closed', closed_at=datetime('now'), close_price=?,
pnl_realized=?, close_reason=?, close_note=?
WHERE id=? AND (status IS NULL OR status='open')""",
(close_price, pnl_realized, close_reason, close_note, trade_id)
)
conn.commit()
conn.close()
return cur.rowcount > 0
def update_trade_exit_params(trade_id: int, target_pct: float = None,
stop_loss_pct: float = None,
signal_threshold: float = None) -> bool:
updates: List[str] = []
vals: List[Any] = []
if target_pct is not None:
updates.append("target_pct=?"); vals.append(target_pct)
if stop_loss_pct is not None:
updates.append("stop_loss_pct=?"); vals.append(stop_loss_pct)
if signal_threshold is not None:
updates.append("signal_threshold=?"); vals.append(signal_threshold)
if not updates:
return False
conn = get_conn()
cur = conn.execute(
f"UPDATE trade_entry_prices SET {', '.join(updates)} WHERE id=?",
vals + [trade_id]
)
conn.commit()
conn.close()
return cur.rowcount > 0
def get_trade_entry_by_id(trade_id: int) -> Optional[Dict[str, Any]]:
conn = get_conn()
row = conn.execute("SELECT * FROM trade_entry_prices WHERE id=?", (trade_id,)).fetchone()

View File

@@ -404,10 +404,60 @@ export const useTradeMtm = (days = 30) =>
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
refetchInterval: 5 * 60_000,
refetchIntervalInBackground: false,
})
export const useClosedTrades = (days = 180) =>
useQuery({
queryKey: ['journal-closed', days],
queryFn: () => api.get(`/journal/closed-trades?days=${days}`).then(r => r.data),
staleTime: 30_000,
})
export const useExitDefaults = () =>
useQuery({
queryKey: ['journal-exit-defaults'],
queryFn: () => api.get('/journal/exit-defaults').then(r => r.data),
staleTime: 60_000,
})
export const useCloseTrade = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: {
close_price: number; pnl_realized?: number;
close_reason: string; close_note: string
}}) => api.patch(`/journal/trades/${id}/close`, body).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['journal-mtm'] })
qc.invalidateQueries({ queryKey: ['journal-closed'] })
qc.invalidateQueries({ queryKey: ['journal-summary'] })
},
})
}
export const useUpdateExitParams = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: {
target_pct?: number; stop_loss_pct?: number; signal_threshold?: number
}}) => api.patch(`/journal/trades/${id}/exit-params`, body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['journal-mtm'] }),
})
}
export const useSaveExitDefaults = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: {
target_pct?: number; stop_loss_pct?: number;
signal_reversal_mode?: string; signal_reversal_threshold?: number
}) => api.put('/journal/exit-defaults', body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['journal-exit-defaults'] }),
})
}
// ── Risk Profiles ─────────────────────────────────────────────────────────────
export const useRiskProfiles = () =>

View File

@@ -1,6 +1,6 @@
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 { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock } from 'lucide-react'
import clsx from 'clsx'
const SOURCE_CATEGORIES = {
@@ -338,6 +338,8 @@ export default function Config() {
const { data: cycleStatus, refetch: refetchCycle } = useCycleStatus()
const { mutate: updateCycleConfig, isPending: savingCycle } = useUpdateCycleConfig()
const { mutate: triggerCycle, isPending: triggeringCycle } = useTriggerCycle()
const { data: exitDefaultsData } = useExitDefaults()
const { mutate: saveExitDefaults, isPending: savingExitDefaults } = useSaveExitDefaults()
const [localSources, setLocalSources] = useState<Record<string, any> | null>(null)
const [openaiKey, setOpenaiKey] = useState('')
@@ -352,6 +354,21 @@ export default function Config() {
const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all')
const [analysisTemplate, setAnalysisTemplate] = useState('')
// Exit defaults local state
const [exitTarget, setExitTarget] = useState(30)
const [exitStop, setExitStop] = useState(-50)
const [exitSignalMode, setExitSignalMode] = useState('badge_only')
const [exitSignalThreshold, setExitSignalThreshold] = useState(25)
useEffect(() => {
const ed = exitDefaultsData as any
if (ed) {
setExitTarget(ed.target_pct ?? 30)
setExitStop(ed.stop_loss_pct ?? -50)
setExitSignalMode(ed.signal_reversal_mode ?? 'badge_only')
setExitSignalThreshold(ed.signal_reversal_threshold ?? 25)
}
}, [exitDefaultsData])
// Auto-cycle local state
const cs = cycleStatus as any
const [cycleEnabled, setCycleEnabled] = useState(false)
@@ -651,6 +668,96 @@ export default function Config() {
</div>
</div>
{/* ── Fermeture de trades (Exit Defaults) ── */}
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
<Lock className="w-4 h-4 text-amber-400" /> Paramètres de sortie des trades
</h2>
<p className="text-xs text-slate-500 mb-4">
Valeurs par défaut pour les objectifs et stop-loss. Chaque trade peut avoir ses propres seuils (via la colonne Cible/Stop dans Journal Ouverts).
</p>
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">
Objectif de gain : <span className="text-emerald-400 font-mono font-bold">+{exitTarget}%</span>
<span className="text-slate-600 ml-1">(P&L du sous-jacent)</span>
</label>
<input type="range" min="5" max="150" step="5"
value={exitTarget}
onChange={e => setExitTarget(parseFloat(e.target.value))}
className="w-full accent-emerald-500" />
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
<span>+5%</span><span>+150%</span>
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-2 block">
Stop-loss : <span className="text-red-400 font-mono font-bold">{exitStop}%</span>
<span className="text-slate-600 ml-1">(P&L du sous-jacent)</span>
</label>
<input type="range" min="-90" max="-5" step="5"
value={exitStop}
onChange={e => setExitStop(parseFloat(e.target.value))}
className="w-full accent-red-500" />
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
<span>-90%</span><span>-5%</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">Mode signal IA de retournement</label>
<div className="flex gap-2">
{[
{ value: 'badge_only', label: '🔔 Badge uniquement' },
{ value: 'auto', label: '⚡ Auto (futur)' },
].map(opt => (
<button key={opt.value} onClick={() => setExitSignalMode(opt.value)}
className={clsx('flex-1 py-2 rounded text-xs border transition-all', {
'bg-blue-600 border-blue-500 text-white': exitSignalMode === opt.value,
'bg-dark-700 border-slate-700 text-slate-400 hover:text-slate-200': exitSignalMode !== opt.value,
})}>
{opt.label}
</button>
))}
</div>
<div className="text-[10px] text-slate-600 mt-1">
Badge only : alerte visible, vous confirmez manuellement. Auto : fermeture proposée par le système.
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-2 block">
Seuil de retournement signal : <span className="text-blue-400 font-mono font-bold">{exitSignalThreshold}pts</span>
<span className="text-slate-600 ml-1">(chute du score)</span>
</label>
<input type="range" min="10" max="60" step="5"
value={exitSignalThreshold}
onChange={e => setExitSignalThreshold(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
<span>10pts (sensible)</span><span>60pts (tolérant)</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => saveExitDefaults(
{ target_pct: exitTarget, stop_loss_pct: exitStop,
signal_reversal_mode: exitSignalMode, signal_reversal_threshold: exitSignalThreshold },
{ onSuccess: () => { setSavedMsg('Paramètres de sortie sauvegardés'); setTimeout(() => setSavedMsg(''), 2000) } }
)}
disabled={savingExitDefaults}
className="flex items-center gap-1.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
<Save className="w-4 h-4" />
{savingExitDefaults ? 'Sauvegarde...' : 'Sauvegarder les seuils'}
</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">

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, Fragment } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal } from 'lucide-react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp, Terminal, Lock } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useClosedTrades, useCloseTrade, useUpdateExitParams, useExitDefaults, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, useKellySizing, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -101,6 +101,255 @@ function KellyCell({ patternId, capital = 10000 }: { patternId: string; capital?
)
}
// ── Close Trade Modal ─────────────────────────────────────────────────────────
const CLOSE_REASONS = [
{ value: 'target', label: '🎯 Objectif atteint' },
{ value: 'stop_loss', label: '🛑 Stop-loss déclenché' },
{ value: 'signal_reversal', label: '🔄 Signal IA retourné' },
{ value: 'manual', label: '✍️ Fermeture manuelle' },
]
function CloseTradeModal({ trade, onClose }: { trade: any; onClose: () => void }) {
const { mutate, isPending, isError } = useCloseTrade()
const [closePrice, setClosePrice] = useState(String(trade.current_price ?? trade.entry_price ?? ''))
const [reason, setReason] = useState(trade.alert_type === 'target_reached' ? 'target' :
trade.alert_type === 'stop_loss' ? 'stop_loss' : 'manual')
const [note, setNote] = useState('')
const ep = parseFloat(closePrice) || 0
const isBearish = trade.direction === 'bearish'
const pnlPreview = trade.entry_price && ep > 0
? (() => {
const raw = (ep - trade.entry_price) / trade.entry_price * 100
return round2(isBearish ? -raw : raw)
})()
: null
function round2(n: number) { return Math.round(n * 100) / 100 }
const handleSubmit = () => {
mutate({
id: trade.id,
body: { close_price: ep, close_reason: reason, close_note: note }
}, { onSuccess: onClose })
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-dark-800 border border-slate-700/60 rounded-xl w-full max-w-md p-6 space-y-5 shadow-2xl">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-white flex items-center gap-2">
<Lock className="w-4 h-4 text-amber-400" /> Fermer le trade
</div>
<div className="text-xs text-slate-500 mt-0.5">
{trade.underlying} · {trade.strategy} · entrée {trade.entry_price?.toFixed(2) ?? '—'}
</div>
</div>
<button onClick={onClose} className="text-slate-600 hover:text-slate-400"><X className="w-4 h-4" /></button>
</div>
{trade.alert_type && (
<div className={clsx('text-xs rounded px-3 py-2 font-semibold border',
trade.alert_type === 'target_reached'
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
: 'bg-red-900/30 border-red-700/40 text-red-300')}>
{trade.alert_type === 'target_reached' ? '🎯 Objectif atteint !' : '🛑 Stop-loss atteint'}
{' — P&L actuel '}
<span className="font-mono">{trade.pnl_pct >= 0 ? '+' : ''}{trade.pnl_pct?.toFixed(2)}%</span>
</div>
)}
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Prix de clôture</label>
<input
type="number" step="0.01"
value={closePrice}
onChange={e => setClosePrice(e.target.value)}
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white font-mono"
placeholder="Prix actuel du sous-jacent"
/>
{pnlPreview !== null && (
<div className={clsx('text-xs mt-1 font-mono font-bold',
pnlPreview >= 0 ? 'text-emerald-400' : 'text-red-400')}>
P&L réalisé : {pnlPreview >= 0 ? '+' : ''}{pnlPreview}%
</div>
)}
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Motif de fermeture</label>
<select
value={reason}
onChange={e => setReason(e.target.value)}
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white"
>
{CLOSE_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Note (optionnel)</label>
<input
type="text"
value={note}
onChange={e => setNote(e.target.value)}
className="w-full bg-dark-700 border border-slate-600/50 rounded px-3 py-1.5 text-sm text-white"
placeholder="Contexte, raison…"
maxLength={200}
/>
</div>
</div>
{isError && <div className="text-xs text-red-400">Erreur lors de la fermeture du trade.</div>}
<div className="flex gap-3 pt-1">
<button onClick={onClose}
className="flex-1 px-4 py-2 rounded border border-slate-700/50 text-slate-400 hover:text-slate-200 text-sm">
Annuler
</button>
<button
onClick={handleSubmit}
disabled={isPending || ep === 0}
className="flex-1 px-4 py-2 rounded bg-amber-600 hover:bg-amber-500 disabled:opacity-50 text-white text-sm font-semibold flex items-center justify-center gap-2"
>
<Lock className="w-3.5 h-3.5" />
{isPending ? 'Fermeture…' : 'Confirmer la fermeture'}
</button>
</div>
</div>
</div>
)
}
// ── Closed Trades Section ─────────────────────────────────────────────────────
function ClosedTradesSection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useClosedTrades(days)
const trades: any[] = (data as any)?.trades ?? []
const stats: any = (data as any)?.stats ?? {}
const REASON_META: Record<string, { label: string; color: string }> = {
target: { label: '🎯 Objectif', color: 'text-emerald-400' },
stop_loss: { label: '🛑 Stop-loss', color: 'text-red-400' },
signal_reversal: { label: '🔄 Signal IA', color: 'text-blue-400' },
manual: { label: '✍️ Manuel', color: 'text-slate-400' },
}
return (
<div className="space-y-4">
{/* Stats banner */}
{trades.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Trades fermés', value: stats.total ?? 0, sub: '', color: 'text-white' },
{ label: 'Taux de succès', value: stats.win_rate != null ? `${stats.win_rate}%` : '—', sub: '', color: stats.win_rate >= 50 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'P&L moyen', value: stats.avg_pnl != null ? `${stats.avg_pnl >= 0 ? '+' : ''}${stats.avg_pnl?.toFixed(1)}%` : '—', sub: '', color: (stats.avg_pnl ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'P&L total', value: stats.total_pnl != null ? `${stats.total_pnl >= 0 ? '+' : ''}${stats.total_pnl?.toFixed(1)}%` : '—', sub: `meilleur ${stats.best?.toFixed(1) ?? '—'}%`, color: (stats.total_pnl ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400' },
].map(s => (
<div key={s.label} className="card text-center py-3">
<div className={clsx('text-lg font-bold font-mono', s.color)}>{s.value}</div>
<div className="text-[11px] text-slate-600">{s.label}</div>
{s.sub && <div className="text-[10px] text-slate-700 mt-0.5">{s.sub}</div>}
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
{trades.length} trades clôturés · {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="card h-32 animate-pulse bg-dark-700" />
) : trades.length === 0 ? (
<div className="card text-center py-12 text-slate-600 text-sm">
<Lock className="w-8 h-8 mx-auto mb-2 opacity-20" />
Aucun trade clôturé dans les {days} derniers jours
</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">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">Entrée</th>
<th className="text-right px-3 py-2 font-medium">Sortie</th>
<th className="text-right px-3 py-2 font-medium">Date entrée</th>
<th className="text-right px-3 py-2 font-medium">Date sortie</th>
<th className="text-right px-3 py-2 font-medium">Durée</th>
<th className="text-right px-3 py-2 font-medium">P&L réalisé</th>
<th className="text-left px-3 py-2 font-medium">Motif</th>
<th className="text-left px-3 py-2 font-medium">Note</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{trades.map((t: any) => {
const meta = REASON_META[t.close_reason] ?? REASON_META.manual
const daysHeld = t.entry_date && t.closed_at
? Math.round((new Date(t.closed_at).getTime() - new Date(t.entry_date).getTime()) / 86400000)
: 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-[100px] truncate">{t.pattern_name || t.pattern_id}</td>
<td className="px-3 py-2">
<span className={clsx('badge text-[10px]',
t.strategy?.toLowerCase().includes('put') || t.strategy?.toLowerCase().includes('bear')
? 'badge-red' : 'badge-green')}>
{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 font-mono text-slate-500 text-[11px]">
{t.entry_price != null ? t.entry_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right font-mono text-slate-400 text-[11px]">
{t.close_price != null ? t.close_price.toFixed(2) : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">{t.entry_date ?? '—'}</td>
<td className="px-3 py-2 text-right text-slate-600 text-[11px]">
{t.closed_at ? t.closed_at.slice(0, 10) : '—'}
</td>
<td className="px-3 py-2 text-right text-slate-600 text-[11px] font-mono">
{daysHeld != null ? `${daysHeld}j` : '—'}
</td>
<td className="px-3 py-2 text-right">
{t.pnl_realized != null ? (
<span className={clsx('font-bold font-mono text-xs',
t.pnl_realized >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{t.pnl_realized >= 0 ? '+' : ''}{t.pnl_realized.toFixed(2)}%
</span>
) : <span className="text-slate-600"></span>}
</td>
<td className="px-3 py-2">
<span className={clsx('text-[10px] font-medium', meta.color)}>{meta.label}</span>
</td>
<td className="px-3 py-2 text-slate-600 text-[10px] max-w-[120px] truncate" title={t.close_note}>
{t.close_note || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
// ── IBKR Ticket helpers ───────────────────────────────────────────────────────
function computeStrikeDollars(price: number, guidance: string, strategy: string): number {
@@ -499,6 +748,7 @@ 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 [closingTrade, setClosingTrade] = useState<any | null>(null)
const allTrades: any[] = (data as any)?.trades ?? []
const trades = allTrades.filter((t: any) => (t.latest_score ?? t.score_at_entry ?? 0) >= minScoreFilter)
@@ -536,6 +786,10 @@ function TradeMtmSection({ days }: { days: number }) {
</div>
</div>
{closingTrade && (
<CloseTradeModal trade={closingTrade} onClose={() => setClosingTrade(null)} />
)}
{isLoading ? (
<div className="card h-32 animate-pulse bg-dark-700" />
) : trades.length === 0 ? (
@@ -563,10 +817,12 @@ function TradeMtmSection({ days }: { days: number }) {
<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">Cible/Stop</th>
<th className="text-right px-3 py-2 font-medium">Maturité</th>
<th className="text-right px-3 py-2 font-medium">IV Rank</th>
<th className="text-right px-3 py-2 font-medium">Kelly</th>
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
<th className="text-right px-3 py-2 font-medium">P&L</th>
<th className="px-3 py-2 font-medium w-8"></th>
<th className="px-3 py-2 font-medium w-8"></th>
</tr>
</thead>
@@ -633,6 +889,41 @@ function TradeMtmSection({ days }: { days: number }) {
<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">
{t.pnl_pct != null ? (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5">
{t.alert_type === 'target_reached' && (
<span className="text-[9px] font-bold text-emerald-400 bg-emerald-900/30 border border-emerald-700/30 rounded px-1">🎯</span>
)}
{t.alert_type === 'stop_loss' && (
<span className="text-[9px] font-bold text-red-400 bg-red-900/30 border border-red-700/30 rounded px-1">🛑</span>
)}
</div>
{/* progress bar: stop_loss (negative) to target (positive) */}
<div className="w-16 relative">
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={clsx('h-full rounded-full',
t.pnl_pct >= 0 ? 'bg-emerald-500' : 'bg-red-500')}
style={{
width: `${Math.min(
100,
t.pnl_pct >= 0
? (t.pnl_pct / (t.target_pct || 30)) * 100
: (Math.abs(t.pnl_pct) / Math.abs(t.stop_loss_pct || 50)) * 100
)}%`
}}
/>
</div>
<div className="flex justify-between text-[8px] text-slate-700 mt-0.5 font-mono">
<span>{t.stop_loss_pct ?? -50}%</span>
<span>+{t.target_pct ?? 30}%</span>
</div>
</div>
</div>
) : <span className="text-slate-700 text-[10px]">—</span>}
</td>
<td className="px-3 py-2 text-right">
{t.maturity ? (
<div className="flex flex-col items-end gap-0.5">
@@ -688,10 +979,24 @@ function TradeMtmSection({ days }: { days: number }) {
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <Search className="w-3.5 h-3.5" />}
</button>
</td>
<td className="px-2 py-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => setClosingTrade(t)}
className={clsx(
'p-1 rounded transition-colors',
t.alert_type
? 'text-amber-400 bg-amber-900/30 hover:bg-amber-900/50'
: 'text-slate-700 hover:text-amber-400 hover:bg-amber-900/20'
)}
title="Fermer le trade"
>
<Lock className="w-3.5 h-3.5" />
</button>
</td>
</tr>
{isExpanded && (
<tr className="bg-dark-900/60">
<td colSpan={18} className="px-4 py-3 border-b border-blue-700/20 space-y-3">
<td colSpan={20} className="px-4 py-3 border-b border-blue-700/20 space-y-3">
<IBKRTicket
strategy={t.strategy ?? ''}
underlying={t.underlying}
@@ -920,12 +1225,13 @@ function CyclesSection() {
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: 'mtm', label: 'Ouverts', icon: TrendingUp },
{ key: 'closed', label: 'Fermés', icon: Lock },
{ key: 'geo', label: 'Alertes Géo', icon: AlertTriangle },
] as const
export default function JournalDeBord() {
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'geo'>('cycles')
const [tab, setTab] = useState<'cycles' | 'macro' | 'mtm' | 'closed' | 'geo'>('cycles')
const [days, setDays] = useState(15)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
@@ -1068,6 +1374,7 @@ export default function JournalDeBord() {
{tab === 'cycles' && <CyclesSection />}
{tab === 'macro' && <MacroHistorySection days={days} />}
{tab === 'mtm' && <TradeMtmSection days={days} />}
{tab === 'closed' && <ClosedTradesSection days={days} />}
{tab === 'geo' && <GeoHistorySection days={days} />}
</div>
)