feat: page Historique Positions + diff entre 2 snapshots PnL

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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 06:36:55 +02:00
parent 67546d39af
commit 4a1dc76d26
5 changed files with 692 additions and 3 deletions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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() {
<Route path="/analytics-advanced" element={<AnalyticsAdvanced />} />
<Route path="/risk" element={<RiskDashboard />} />
<Route path="/var" element={<VaRAnalysis />} />
<Route path="/position-history" element={<PositionHistory />} />
<Route path="/logs" element={<SystemLogs />} />
</Routes>
</main>

View File

@@ -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' },

View File

@@ -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 <span className="text-slate-500 text-xs"> 0</span>
return (
<span className={clsx('text-xs font-semibold', v > 0 ? 'text-emerald-400' : 'text-red-400')}>
{v > 0 ? '▲' : '▼'} {Math.abs(v).toFixed(unit === '%' ? 3 : 0)}{unit}
</span>
)
}
// ─── 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 (
<div className={clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-lg border text-xs cursor-default transition-colors',
isA ? 'bg-blue-900/20 border-blue-700/50'
: isB ? 'bg-violet-900/20 border-violet-700/50'
: 'bg-dark-800 border-slate-700/30 hover:border-slate-600/50'
)}>
{/* Date */}
<span className="text-slate-400 font-mono w-32 shrink-0">{fmtDate(snap.snapped_at)}</span>
{/* Positions */}
<span className="text-slate-500 w-16 shrink-0">{snap.n_open} pos.</span>
{/* PnL */}
<span className={clsx('font-semibold w-20 shrink-0', pnl >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(pnl)}
</span>
<span className={clsx('w-24 shrink-0', pnl >= 0 ? 'text-emerald-400/60' : 'text-red-400/60')}>
{fmtEur(snap.total_pnl_eur)}
</span>
{/* Actions */}
<div className="flex gap-1.5 ml-auto">
<button
onClick={() => onSelectA(snap.id)}
className={clsx('px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors',
isA ? 'bg-blue-600 border-blue-500 text-white'
: 'border-slate-600 text-slate-500 hover:text-blue-400 hover:border-blue-600/50')}
>A</button>
<button
onClick={() => onSelectB(snap.id)}
className={clsx('px-2 py-0.5 rounded text-[10px] font-semibold border transition-colors',
isB ? 'bg-violet-600 border-violet-500 text-white'
: 'border-slate-600 text-slate-500 hover:text-violet-400 hover:border-violet-600/50')}
>B</button>
</div>
</div>
)
}
// ─── 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 (
<div className="space-y-4">
{/* Portfolio delta header */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2 text-xs text-blue-400 bg-blue-900/20 border border-blue-700/40 px-3 py-1.5 rounded-lg">
<span className="font-mono font-semibold">A</span>
<span className="text-slate-400">{fmtDate(sa.snapped_at)}</span>
{sa.macro_regime && <span className="text-slate-500 capitalize">· {sa.macro_regime}</span>}
</div>
<ArrowRight className="w-4 h-4 text-slate-600" />
<div className="flex items-center gap-2 text-xs text-violet-400 bg-violet-900/20 border border-violet-700/40 px-3 py-1.5 rounded-lg">
<span className="font-mono font-semibold">B</span>
<span className="text-slate-400">{fmtDate(sb.snapped_at)}</span>
{sb.macro_regime && <span className="text-slate-500 capitalize">· {sb.macro_regime}</span>}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-[10px] text-slate-500 uppercase mb-1">Δ PnL %</div>
<DeltaBadge v={pd.pnl_pct_delta} />
<div className="text-[10px] text-slate-600 mt-0.5">
{fmt(sa.total_pnl_pct)} {fmt(sb.total_pnl_pct)}
</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase mb-1">Δ PnL </div>
<DeltaBadge v={pd.pnl_eur_delta} unit="€" />
<div className="text-[10px] text-slate-600 mt-0.5">
{fmtEur(sa.total_pnl_eur)} {fmtEur(sb.total_pnl_eur)}
</div>
</div>
<div>
<div className="text-[10px] text-slate-500 uppercase mb-1">Δ Capital</div>
<DeltaBadge v={pd.capital_delta_eur} unit="€" />
</div>
<div className="flex gap-3">
{pd.positions_opened > 0 && (
<div className="text-center">
<div className="text-emerald-400 font-bold text-lg">+{pd.positions_opened}</div>
<div className="text-[10px] text-slate-500">ouvertes</div>
</div>
)}
{pd.positions_closed > 0 && (
<div className="text-center">
<div className="text-red-400 font-bold text-lg">-{pd.positions_closed}</div>
<div className="text-[10px] text-slate-500">fermées</div>
</div>
)}
</div>
</div>
</div>
{/* New positions */}
{new_positions.length > 0 && (
<div className="bg-dark-800 border border-emerald-700/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Plus className="w-4 h-4 text-emerald-400" />
<h3 className="text-sm font-semibold text-emerald-400">
Nouvelles positions ({new_positions.length})
</h3>
</div>
<div className="space-y-1.5">
{new_positions.map((p: any) => (
<div key={p.id} className="flex items-center justify-between text-xs bg-emerald-900/10 rounded px-3 py-2">
<div>
<span className="text-white font-semibold">{p.ticker}</span>
<span className="text-slate-400 ml-2">{p.strategy}</span>
</div>
<div className="flex gap-4">
<span className="text-slate-500">Entrée: {p.entry_price?.toFixed(2) ?? '—'}</span>
<span className={clsx('font-semibold', (p.pnl_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(p.pnl_pct)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Closed positions */}
{closed_positions.length > 0 && (
<div className="bg-dark-800 border border-red-700/30 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Minus className="w-4 h-4 text-red-400" />
<h3 className="text-sm font-semibold text-red-400">
Positions fermées ({closed_positions.length})
</h3>
</div>
<div className="space-y-1.5">
{closed_positions.map((p: any) => (
<div key={p.id} className="flex items-center justify-between text-xs bg-red-900/10 rounded px-3 py-2">
<div>
<span className="text-slate-400 font-semibold">{p.ticker}</span>
<span className="text-slate-500 ml-2">{p.strategy}</span>
</div>
<div className="flex gap-4">
<span className="text-slate-500">Dernière val.: {fmt(p.pnl_pct)}</span>
<span className={clsx('font-semibold', (p.pnl_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtEur(p.pnl_eur)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Changed positions */}
{changed_positions.length > 0 && (
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4">
<h3 className="text-sm font-semibold text-white mb-3">
Évolution des positions ({changed_positions.length})
</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/40">
<th className="text-left pb-2 font-medium">Position</th>
<th className="text-right pb-2 font-medium">PnL A</th>
<th className="text-right pb-2 font-medium">PnL B</th>
<th className="text-right pb-2 font-medium">Δ PnL %</th>
<th className="text-right pb-2 font-medium">Δ PnL </th>
</tr>
</thead>
<tbody>
{changed_positions.map((p: any) => (
<tr key={p.id} className="border-b border-slate-700/20 last:border-0">
<td className="py-2">
<span className="text-white font-semibold">{p.ticker}</span>
<span className="text-slate-500 ml-2">{p.strategy}</span>
</td>
<td className={clsx('text-right py-2', (p.pnl_pct_a ?? 0) >= 0 ? 'text-emerald-400/70' : 'text-red-400/70')}>
{fmt(p.pnl_pct_a)}
</td>
<td className={clsx('text-right py-2 font-semibold', (p.pnl_pct_b ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(p.pnl_pct_b)}
</td>
<td className="text-right py-2">
<DeltaBadge v={p.delta_pct} />
</td>
<td className="text-right py-2">
<DeltaBadge v={p.delta_eur} unit="€" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
// ─── 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 <div className="text-slate-600 text-sm p-4">Chargement</div>
if (!data) return null
const trades: any[] = data.trades_snapshot ?? []
const macro = data.macro_regime
return (
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h3 className="text-sm font-semibold text-white">{fmtDate(data.snapped_at)} UTC</h3>
{macro?.dominant && (
<span className="text-xs text-slate-500 capitalize">Régime : {macro.dominant}</span>
)}
</div>
<div className="flex gap-4 text-xs">
<div>
<span className="text-slate-500">Capital </span>
<span className="text-white font-semibold">
{(data.total_capital_eur ?? 0).toLocaleString('fr-FR', { maximumFractionDigits: 0 })}
</span>
</div>
<div>
<span className="text-slate-500">PnL </span>
<span className={clsx('font-semibold', (data.total_pnl_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(data.total_pnl_pct)} · {fmtEur(data.total_pnl_eur)}
</span>
</div>
<div>
<span className="text-slate-500">{data.n_open} ouvertes · {data.n_closed} fermées</span>
</div>
</div>
</div>
{trades.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/40">
<th className="text-left pb-2 font-medium">Ticker</th>
<th className="text-left pb-2 font-medium">Stratégie</th>
<th className="text-right pb-2 font-medium">Entrée</th>
<th className="text-right pb-2 font-medium">Prix actuel</th>
<th className="text-right pb-2 font-medium">PnL %</th>
<th className="text-right pb-2 font-medium">PnL </th>
</tr>
</thead>
<tbody>
{trades.map((t: any, i: number) => (
<tr key={i} className="border-b border-slate-700/20 last:border-0">
<td className="py-2 font-semibold text-white">{t.ticker}</td>
<td className="py-2 text-slate-400">{t.strategy}</td>
<td className="py-2 text-right text-slate-400 font-mono">{t.entry_price?.toFixed(2) ?? '—'}</td>
<td className="py-2 text-right text-slate-300 font-mono">{t.current_price?.toFixed(2) ?? '—'}</td>
<td className={clsx('py-2 text-right font-semibold', (t.pnl_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(t.pnl_pct)}
</td>
<td className={clsx('py-2 text-right', (t.pnl_eur ?? 0) >= 0 ? 'text-emerald-400/70' : 'text-red-400/70')}>
{fmtEur(t.pnl_eur)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-slate-600 text-sm">Aucune position dans ce snapshot</div>
)}
</div>
)
}
// ─── PnL sparkline tooltip ────────────────────────────────────────────────────
function SparkTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="bg-dark-800 border border-slate-700 rounded px-2.5 py-1.5 text-xs">
<div className="text-slate-500 mb-0.5">{label}</div>
<div className={clsx('font-semibold', payload[0].value >= 0 ? 'text-emerald-400' : 'text-red-400')}>
PnL: {fmt(payload[0].value)}
</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function PositionHistory() {
const [selectedA, setSelectedA] = useState<number | null>(null)
const [selectedB, setSelectedB] = useState<number | null>(null)
const [viewMode, setViewMode] = useState<'detail' | 'diff'>('detail')
const [activeDetail, setActiveDetail] = useState<number | null>(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 (
<div className="p-6 space-y-5 max-w-screen-xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<div className="flex items-center gap-3 mb-1">
<History className="w-6 h-6 text-violet-400" />
<h1 className="text-xl font-bold text-white">Historique des Positions</h1>
</div>
<p className="text-slate-400 text-sm">
{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)}`}
</p>
</div>
<div className="flex gap-2 flex-wrap">
{canDiff && (
<div className="flex gap-1">
<button
onClick={() => setViewMode('detail')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors',
viewMode === 'detail' ? 'bg-slate-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}
>
Détail
</button>
<button
onClick={() => setViewMode('diff')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors',
viewMode === 'diff' ? 'bg-violet-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}
>
Diff AB
</button>
</div>
)}
{(selectedA || selectedB) && (
<button
onClick={() => { setSelectedA(null); setSelectedB(null); setViewMode('detail') }}
className="px-3 py-1.5 rounded text-xs text-slate-500 hover:text-slate-300 bg-dark-700"
>
Effacer sélection
</button>
)}
<button onClick={() => refetch()}
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-dark-700 text-slate-400 hover:text-white">
<RefreshCw className="w-3.5 h-3.5" /> Actualiser
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-900/20 border border-red-700/40 rounded-xl p-3 text-red-400 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" /> {String(error)}
</div>
)}
{/* Selection banner */}
{(selectedA || selectedB) && (
<div className="flex items-center gap-3 bg-dark-800 border border-slate-700/40 rounded-xl px-4 py-3 text-xs flex-wrap">
<span className="text-slate-500">Sélection diff :</span>
{selectedA ? (
<span className="flex items-center gap-1.5 bg-blue-900/30 text-blue-400 border border-blue-700/40 px-2.5 py-1 rounded">
<span className="font-bold">A</span>
{fmtDate(snapshots.find(s => s.id === selectedA)?.snapped_at ?? '')}
</span>
) : <span className="text-slate-600">A : non sélectionné</span>}
<ArrowRight className="w-3.5 h-3.5 text-slate-600" />
{selectedB ? (
<span className="flex items-center gap-1.5 bg-violet-900/30 text-violet-400 border border-violet-700/40 px-2.5 py-1 rounded">
<span className="font-bold">B</span>
{fmtDate(snapshots.find(s => s.id === selectedB)?.snapped_at ?? '')}
</span>
) : <span className="text-slate-600">B : non sélectionné</span>}
{canDiff && (
<button onClick={() => setViewMode('diff')}
className="ml-auto bg-violet-600 hover:bg-violet-500 text-white px-3 py-1 rounded text-xs font-semibold">
Voir le diff
</button>
)}
</div>
)}
{/* Empty state */}
{!isLoading && snapshots.length === 0 && (
<div className="text-center py-20">
<History className="w-14 h-14 text-slate-700 mx-auto mb-4" />
<div className="text-slate-400 font-semibold mb-2">Aucun snapshot PnL disponible</div>
<p className="text-slate-600 text-sm max-w-md mx-auto">
Activez le Scheduler PnL dans la Configuration ou déclenchez un snapshot manuellement
depuis la page Configuration Auto-Cycle &amp; Logging.
</p>
</div>
)}
{snapshots.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Left: timeline list */}
<div className="lg:col-span-1 space-y-3">
{/* Mini sparkline */}
{sparkData.length > 2 && (
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-3">
<div className="text-xs text-slate-500 mb-2">PnL portfolio dans le temps</div>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={sparkData} margin={{ top: 4, right: 4, left: -30, bottom: 0 }}>
<XAxis dataKey="date" hide />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} unit="%" />
<Tooltip content={<SparkTooltip />} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="3 2" />
<Line type="monotone" dataKey="pnl" stroke="#a78bfa" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Snapshot list */}
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-3 space-y-1.5 max-h-[calc(100vh-320px)] overflow-y-auto">
<div className="text-[10px] text-slate-600 uppercase tracking-wider px-1 mb-2">
{snapshots.length} snapshots cliquez pour détail · A/B pour diff
</div>
{snapshots.map(s => (
<div key={s.id} onClick={() => { setActiveDetail(s.id); setViewMode('detail') }}>
<SnapRow
snap={s}
selectedA={selectedA}
selectedB={selectedB}
onSelectA={id => { handleSelectA(id); setActiveDetail(null) }}
onSelectB={id => { handleSelectB(id); setActiveDetail(null) }}
/>
</div>
))}
</div>
</div>
{/* Right: detail or diff */}
<div className="lg:col-span-2">
{viewMode === 'diff' && canDiff && (
diffLoading
? <div className="text-slate-500 text-sm p-4">Calcul du diff</div>
: diffError
? <div className="text-red-400 text-sm p-4">{String(diffError)}</div>
: diffData ? <DiffView diffData={diffData} />
: null
)}
{viewMode === 'detail' && activeDetail && (
<SnapshotDetail snapId={activeDetail} />
)}
{viewMode === 'detail' && !activeDetail && !canDiff && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<TrendingUp className="w-12 h-12 text-slate-700 mb-4" />
<div className="text-slate-500 text-sm">
Cliquez sur un snapshot pour voir le détail<br />
ou sélectionnez A et B pour comparer deux valuations
</div>
</div>
)}
{viewMode === 'diff' && !canDiff && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<TrendingDown className="w-12 h-12 text-slate-700 mb-4" />
<div className="text-slate-500 text-sm">
Sélectionnez deux snapshots (A et B) depuis la liste pour voir le diff
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}