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)}
+
+
+
+
+ {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})
+
+
+
+
+
+ | Position |
+ PnL A |
+ PnL B |
+ Δ PnL % |
+ Δ PnL € |
+
+
+
+ {changed_positions.map((p: any) => (
+
+ |
+ {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 ? (
+
+
+
+ | Ticker |
+ Stratégie |
+ Entrée |
+ Prix actuel |
+ PnL % |
+ PnL € |
+
+
+
+ {trades.map((t: any, i: number) => (
+
+ | {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 && (
+
+ )}
+
+ {/* 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
+
+
+ )}
+
+
+ )}
+
+ )
+}