From 4a1dc76d26a80c4da7a228601b3da41496e8b4b2 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sat, 20 Jun 2026 06:36:55 +0200 Subject: [PATCH] feat: page Historique Positions + diff entre 2 snapshots PnL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - get_pnl_snapshot(id) : détail complet d'un snapshot avec trades parsés - diff_pnl_snapshots(a, b) : diff positions entre deux snapshots (nouvelles / fermées / évolution PnL par position + delta portfolio) - GET /api/var/pnl/snapshots/{id} : détail snapshot - GET /api/var/pnl/diff?a=&b= : calcul du diff Frontend PositionHistory.tsx : - Timeline scrollable des snapshots avec sparkline PnL - Clic snapshot → détail des positions à ce moment (prix entrée, prix actuel, PnL %, PnL €, régime macro) - Boutons A/B par snapshot → sélection de deux points à comparer - Vue diff A→B : nouvelles positions, fermées, évolution PnL par trade, delta portfolio (capital, PnL %, PnL €) - Route /position-history + nav sidebar Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/var.py | 22 +- backend/services/var_service.py | 104 ++++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/pages/PositionHistory.tsx | 564 +++++++++++++++++++++ 5 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/PositionHistory.tsx diff --git a/backend/routers/var.py b/backend/routers/var.py index 19e1a74..b4b8b9c 100644 --- a/backend/routers/var.py +++ b/backend/routers/var.py @@ -3,7 +3,8 @@ from pydantic import BaseModel from services.var_service import ( compute_var, save_var_snapshot, get_var_snapshots, get_var_snapshot, get_latest_var_snapshot, - get_pnl_snapshots, get_latest_pnl_snapshot, save_pnl_snapshot, + get_pnl_snapshots, get_pnl_snapshot, get_latest_pnl_snapshot, save_pnl_snapshot, + diff_pnl_snapshots, ) from services.var_scheduler import ( get_scheduler_status, restart_var_scheduler, restart_pnl_scheduler, @@ -64,10 +65,27 @@ def pnl_latest(): @router.get("/pnl/snapshots") -def pnl_snapshots(limit: int = Query(default=48, ge=1, le=200)): +def pnl_snapshots(limit: int = Query(default=100, ge=1, le=500)): return {"snapshots": get_pnl_snapshots(limit)} +@router.get("/pnl/snapshots/{snapshot_id}") +def pnl_snapshot_detail(snapshot_id: int): + snap = get_pnl_snapshot(snapshot_id) + if not snap: + raise HTTPException(404, "Snapshot not found") + return snap + + +@router.get("/pnl/diff") +def pnl_diff(a: int = Query(..., description="ID snapshot A (earlier)"), + b: int = Query(..., description="ID snapshot B (later)")): + try: + return diff_pnl_snapshots(a, b) + except ValueError as e: + raise HTTPException(404, str(e)) + + @router.post("/pnl/run-now") def pnl_run_now(): try: diff --git a/backend/services/var_service.py b/backend/services/var_service.py index a9a561f..3b87584 100644 --- a/backend/services/var_service.py +++ b/backend/services/var_service.py @@ -462,3 +462,107 @@ def get_latest_pnl_snapshot() -> Optional[Dict]: except Exception: pass return d + + +def get_pnl_snapshot(snapshot_id: int) -> Optional[Dict]: + conn = get_conn() + row = conn.execute("SELECT * FROM pnl_snapshots WHERE id=?", (snapshot_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + for key in ("ticker_prices", "macro_regime", "trades_snapshot"): + if d.get(key): + try: + d[key] = json.loads(d[key]) + except Exception: + pass + return d + + +def diff_pnl_snapshots(id_a: int, id_b: int) -> Dict: + """Compute position-level diff between two PnL snapshots (a=earlier, b=later).""" + snap_a = get_pnl_snapshot(id_a) + snap_b = get_pnl_snapshot(id_b) + if not snap_a: + raise ValueError(f"Snapshot {id_a} introuvable") + if not snap_b: + raise ValueError(f"Snapshot {id_b} introuvable") + + trades_a: Dict[int, Dict] = {t["id"]: t for t in (snap_a.get("trades_snapshot") or [])} + trades_b: Dict[int, Dict] = {t["id"]: t for t in (snap_b.get("trades_snapshot") or [])} + + ids_a = set(trades_a) + ids_b = set(trades_b) + + new_positions = [] # in B not A + closed_positions = [] # in A not B + changed_positions = [] # in both — with delta + + for tid in ids_b - ids_a: + t = trades_b[tid] + new_positions.append({**t, "change_type": "new"}) + + for tid in ids_a - ids_b: + t = trades_a[tid] + closed_positions.append({**t, "change_type": "closed"}) + + for tid in ids_a & ids_b: + ta, tb = trades_a[tid], trades_b[tid] + pnl_a = ta.get("pnl_pct") or 0.0 + pnl_b = tb.get("pnl_pct") or 0.0 + delta_pct = round(pnl_b - pnl_a, 3) + pnl_eur_a = ta.get("pnl_eur") or 0.0 + pnl_eur_b = tb.get("pnl_eur") or 0.0 + delta_eur = round(pnl_eur_b - pnl_eur_a, 2) + changed_positions.append({ + **tb, + "pnl_pct_a": pnl_a, + "pnl_pct_b": pnl_b, + "delta_pct": delta_pct, + "pnl_eur_a": pnl_eur_a, + "pnl_eur_b": pnl_eur_b, + "delta_eur": delta_eur, + "change_type": "changed", + }) + + # Sort changed by abs delta descending + changed_positions.sort(key=lambda x: abs(x["delta_pct"]), reverse=True) + + # Portfolio-level delta + cap_a = snap_a.get("total_capital_eur") or 0.0 + cap_b = snap_b.get("total_capital_eur") or 0.0 + pnl_pct_a = snap_a.get("total_pnl_pct") or 0.0 + pnl_pct_b = snap_b.get("total_pnl_pct") or 0.0 + pnl_eur_a = snap_a.get("total_pnl_eur") or 0.0 + pnl_eur_b = snap_b.get("total_pnl_eur") or 0.0 + + # Macro regime diff + regime_a = (snap_a.get("macro_regime") or {}).get("dominant") if isinstance(snap_a.get("macro_regime"), dict) else None + regime_b = (snap_b.get("macro_regime") or {}).get("dominant") if isinstance(snap_b.get("macro_regime"), dict) else None + + return { + "snapshot_a": { + "id": snap_a["id"], "snapped_at": snap_a["snapped_at"], + "n_open": snap_a.get("n_open"), "total_capital_eur": cap_a, + "total_pnl_pct": pnl_pct_a, "total_pnl_eur": pnl_eur_a, + "macro_regime": regime_a, + }, + "snapshot_b": { + "id": snap_b["id"], "snapped_at": snap_b["snapped_at"], + "n_open": snap_b.get("n_open"), "total_capital_eur": cap_b, + "total_pnl_pct": pnl_pct_b, "total_pnl_eur": pnl_eur_b, + "macro_regime": regime_b, + }, + "portfolio_delta": { + "capital_delta_eur": round(cap_b - cap_a, 2), + "pnl_pct_delta": round(pnl_pct_b - pnl_pct_a, 3), + "pnl_eur_delta": round(pnl_eur_b - pnl_eur_a, 2), + "positions_opened": len(new_positions), + "positions_closed": len(closed_positions), + "positions_unchanged": sum(1 for p in changed_positions if p["delta_pct"] == 0), + }, + "new_positions": new_positions, + "closed_positions": closed_positions, + "changed_positions": changed_positions, + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9adf5e8..996debd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import AnalyticsAdvanced from './pages/AnalyticsAdvanced' import RiskDashboard from './pages/RiskDashboard' import SystemLogs from './pages/SystemLogs' import VaRAnalysis from './pages/VaRAnalysis' +import PositionHistory from './pages/PositionHistory' import { useCycleWatcher } from './hooks/useApi' function GlobalWatcher() { @@ -50,6 +51,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 6b390bb..fb4cddf 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -21,6 +21,7 @@ const nav = [ { to: '/analytics-advanced', icon: Microscope, label: 'Analytics Avancées' }, { to: '/risk', icon: ShieldAlert, label: 'Risk Dashboard' }, { to: '/var', icon: Gauge, label: 'VaR Analyse' }, + { to: '/position-history', icon: GitCompare, label: 'Historique Positions' }, { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendrier' }, { to: '/logs', icon: ScrollText, label: 'Logs Système' }, diff --git a/frontend/src/pages/PositionHistory.tsx b/frontend/src/pages/PositionHistory.tsx new file mode 100644 index 0000000..09cf171 --- /dev/null +++ b/frontend/src/pages/PositionHistory.tsx @@ -0,0 +1,564 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, +} from 'recharts' +import { History, TrendingUp, TrendingDown, Plus, Minus, RefreshCw, ArrowRight, AlertTriangle } from 'lucide-react' +import clsx from 'clsx' + +// ─── API ───────────────────────────────────────────────────────────────────── + +async function fetchSnapshots() { + const r = await fetch('/api/var/pnl/snapshots?limit=200') + if (!r.ok) throw new Error(`${r.status}`) + return r.json() +} + +async function fetchSnapshotDetail(id: number) { + const r = await fetch(`/api/var/pnl/snapshots/${id}`) + if (!r.ok) throw new Error(`${r.status}`) + return r.json() +} + +async function fetchDiff(a: number, b: number) { + const r = await fetch(`/api/var/pnl/diff?a=${a}&b=${b}`) + if (!r.ok) throw new Error(`${r.status}`) + return r.json() +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const fmt = (v: number | null | undefined, digits = 2) => + v == null ? '—' : `${v >= 0 ? '+' : ''}${v.toFixed(digits)}%` + +const fmtEur = (v: number | null | undefined) => + v == null ? '—' : `${v < 0 ? '' : '+'}${v.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €` + +const fmtDate = (s: string) => s?.replace('T', ' ').slice(0, 16) + +function DeltaBadge({ v, unit = '%' }: { v: number; unit?: string }) { + if (Math.abs(v) < 0.001) return ≈ 0 + return ( + 0 ? 'text-emerald-400' : 'text-red-400')}> + {v > 0 ? '▲' : '▼'} {Math.abs(v).toFixed(unit === '%' ? 3 : 0)}{unit} + + ) +} + +// ─── Snapshot list item ─────────────────────────────────────────────────────── + +function SnapRow({ + snap, selectedA, selectedB, onSelectA, onSelectB, +}: { + snap: any; selectedA: number | null; selectedB: number | null + onSelectA: (id: number) => void; onSelectB: (id: number) => void +}) { + const isA = selectedA === snap.id + const isB = selectedB === snap.id + const pnl = snap.total_pnl_pct ?? 0 + + return ( +
+ {/* Date */} + {fmtDate(snap.snapped_at)} + {/* Positions */} + {snap.n_open} pos. + {/* PnL */} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmt(pnl)} + + = 0 ? 'text-emerald-400/60' : 'text-red-400/60')}> + {fmtEur(snap.total_pnl_eur)} + + {/* Actions */} +
+ + +
+
+ ) +} + +// ─── Diff view ─────────────────────────────────────────────────────────────── + +function DiffView({ diffData }: { diffData: any }) { + const { snapshot_a: sa, snapshot_b: sb, portfolio_delta: pd, + new_positions, closed_positions, changed_positions } = diffData + + return ( +
+ {/* Portfolio delta header */} +
+
+
+ A + {fmtDate(sa.snapped_at)} + {sa.macro_regime && · {sa.macro_regime}} +
+ +
+ B + {fmtDate(sb.snapped_at)} + {sb.macro_regime && · {sb.macro_regime}} +
+
+
+
+
Δ PnL %
+ +
+ {fmt(sa.total_pnl_pct)} → {fmt(sb.total_pnl_pct)} +
+
+
+
Δ PnL €
+ +
+ {fmtEur(sa.total_pnl_eur)} → {fmtEur(sb.total_pnl_eur)} +
+
+
+
Δ Capital
+ +
+
+ {pd.positions_opened > 0 && ( +
+
+{pd.positions_opened}
+
ouvertes
+
+ )} + {pd.positions_closed > 0 && ( +
+
-{pd.positions_closed}
+
fermées
+
+ )} +
+
+
+ + {/* New positions */} + {new_positions.length > 0 && ( +
+
+ +

+ Nouvelles positions ({new_positions.length}) +

+
+
+ {new_positions.map((p: any) => ( +
+
+ {p.ticker} + {p.strategy} +
+
+ Entrée: {p.entry_price?.toFixed(2) ?? '—'} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmt(p.pnl_pct)} + +
+
+ ))} +
+
+ )} + + {/* Closed positions */} + {closed_positions.length > 0 && ( +
+
+ +

+ Positions fermées ({closed_positions.length}) +

+
+
+ {closed_positions.map((p: any) => ( +
+
+ {p.ticker} + {p.strategy} +
+
+ Dernière val.: {fmt(p.pnl_pct)} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmtEur(p.pnl_eur)} + +
+
+ ))} +
+
+ )} + + {/* Changed positions */} + {changed_positions.length > 0 && ( +
+

+ Évolution des positions ({changed_positions.length}) +

+
+ + + + + + + + + + + + {changed_positions.map((p: any) => ( + + + + + + + + ))} + +
PositionPnL APnL BΔ PnL %Δ PnL €
+ {p.ticker} + {p.strategy} + = 0 ? 'text-emerald-400/70' : 'text-red-400/70')}> + {fmt(p.pnl_pct_a)} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmt(p.pnl_pct_b)} + + + + +
+
+
+ )} +
+ ) +} + +// ─── Snapshot detail ────────────────────────────────────────────────────────── + +function SnapshotDetail({ snapId }: { snapId: number }) { + const { data, isLoading } = useQuery({ + queryKey: ['pnl-snap-detail', snapId], + queryFn: () => fetchSnapshotDetail(snapId), + staleTime: 300_000, + }) + + if (isLoading) return
Chargement…
+ if (!data) return null + + const trades: any[] = data.trades_snapshot ?? [] + const macro = data.macro_regime + + return ( +
+
+
+

{fmtDate(data.snapped_at)} UTC

+ {macro?.dominant && ( + Régime : {macro.dominant} + )} +
+
+
+ Capital + + {(data.total_capital_eur ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € + +
+
+ PnL + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmt(data.total_pnl_pct)} · {fmtEur(data.total_pnl_eur)} + +
+
+ {data.n_open} ouvertes · {data.n_closed} fermées +
+
+
+ + {trades.length > 0 ? ( + + + + + + + + + + + + + {trades.map((t: any, i: number) => ( + + + + + + + + + ))} + +
TickerStratégieEntréePrix actuelPnL %PnL €
{t.ticker}{t.strategy}{t.entry_price?.toFixed(2) ?? '—'}{t.current_price?.toFixed(2) ?? '—'}= 0 ? 'text-emerald-400' : 'text-red-400')}> + {fmt(t.pnl_pct)} + = 0 ? 'text-emerald-400/70' : 'text-red-400/70')}> + {fmtEur(t.pnl_eur)} +
+ ) : ( +
Aucune position dans ce snapshot
+ )} +
+ ) +} + +// ─── PnL sparkline tooltip ──────────────────────────────────────────────────── + +function SparkTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + return ( +
+
{label}
+
= 0 ? 'text-emerald-400' : 'text-red-400')}> + PnL: {fmt(payload[0].value)} +
+
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function PositionHistory() { + const [selectedA, setSelectedA] = useState(null) + const [selectedB, setSelectedB] = useState(null) + const [viewMode, setViewMode] = useState<'detail' | 'diff'>('detail') + const [activeDetail, setActiveDetail] = useState(null) + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['pnl-snapshots-all'], + queryFn: fetchSnapshots, + staleTime: 60_000, + }) + + const { data: diffData, isLoading: diffLoading, error: diffError } = useQuery({ + queryKey: ['pnl-diff', selectedA, selectedB], + queryFn: () => fetchDiff(selectedA!, selectedB!), + enabled: selectedA != null && selectedB != null && selectedA !== selectedB, + staleTime: 60_000, + }) + + const snapshots: any[] = data?.snapshots ?? [] + + // Build sparkline data (chronological) + const sparkData = [...snapshots].reverse().map(s => ({ + date: s.snapped_at?.slice(5, 16).replace('T', ' '), + pnl: s.total_pnl_pct ?? 0, + })) + + const handleSelectA = (id: number) => { + setSelectedA(id) + setViewMode('diff') + } + const handleSelectB = (id: number) => { + setSelectedB(id) + setViewMode('diff') + } + + const canDiff = selectedA != null && selectedB != null && selectedA !== selectedB + + return ( +
+ + {/* Header */} +
+
+
+ +

Historique des Positions

+
+

+ {snapshots.length} snapshot{snapshots.length !== 1 ? 's' : ''} PnL enregistré{snapshots.length !== 1 ? 's' : ''} + {snapshots.length > 0 && ` · de ${fmtDate(snapshots[snapshots.length - 1]?.snapped_at)} à ${fmtDate(snapshots[0]?.snapped_at)}`} +

+
+
+ {canDiff && ( +
+ + +
+ )} + {(selectedA || selectedB) && ( + + )} + +
+
+ + {/* Error */} + {error && ( +
+ {String(error)} +
+ )} + + {/* Selection banner */} + {(selectedA || selectedB) && ( +
+ Sélection diff : + {selectedA ? ( + + A + {fmtDate(snapshots.find(s => s.id === selectedA)?.snapped_at ?? '')} + + ) : A : non sélectionné} + + {selectedB ? ( + + B + {fmtDate(snapshots.find(s => s.id === selectedB)?.snapped_at ?? '')} + + ) : B : non sélectionné} + {canDiff && ( + + )} +
+ )} + + {/* Empty state */} + {!isLoading && snapshots.length === 0 && ( +
+ +
Aucun snapshot PnL disponible
+

+ Activez le Scheduler PnL dans la Configuration ou déclenchez un snapshot manuellement + depuis la page Configuration → Auto-Cycle & Logging. +

+
+ )} + + {snapshots.length > 0 && ( +
+ + {/* Left: timeline list */} +
+ {/* Mini sparkline */} + {sparkData.length > 2 && ( +
+
PnL portfolio dans le temps
+ + + + + } /> + + + + +
+ )} + + {/* Snapshot list */} +
+
+ {snapshots.length} snapshots — cliquez pour détail · A/B pour diff +
+ {snapshots.map(s => ( +
{ setActiveDetail(s.id); setViewMode('detail') }}> + { handleSelectA(id); setActiveDetail(null) }} + onSelectB={id => { handleSelectB(id); setActiveDetail(null) }} + /> +
+ ))} +
+
+ + {/* Right: detail or diff */} +
+ {viewMode === 'diff' && canDiff && ( + diffLoading + ?
Calcul du diff…
+ : diffError + ?
{String(diffError)}
+ : diffData ? + : null + )} + + {viewMode === 'detail' && activeDetail && ( + + )} + + {viewMode === 'detail' && !activeDetail && !canDiff && ( +
+ +
+ Cliquez sur un snapshot pour voir le détail
+ ou sélectionnez A et B pour comparer deux valuations +
+
+ )} + + {viewMode === 'diff' && !canDiff && ( +
+ +
+ Sélectionnez deux snapshots (A et B) depuis la liste pour voir le diff +
+
+ )} +
+
+ )} +
+ ) +}