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}
opened
)}
{pd.positions_closed > 0 && (
-{pd.positions_closed}
closed
)}
{/* New positions */}
{new_positions.length > 0 && (
New positions ({new_positions.length})
{new_positions.map((p: any) => (
{p.ticker}
{p.strategy}
Entry: {p.entry_price?.toFixed(2) ?? '—'}
= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmt(p.pnl_pct)}
))}
)}
{/* Closed positions */}
{closed_positions.length > 0 && (
Closed positions ({closed_positions.length})
{closed_positions.map((p: any) => (
{p.ticker}
{p.strategy}
Last val.: {fmt(p.pnl_pct)}
= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtEur(p.pnl_eur)}
))}
)}
{/* Changed positions */}
{changed_positions.length > 0 && (
Position changes ({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 Loading…
if (!data) return null
const trades: any[] = data.trades_snapshot ?? []
const macro = data.macro_regime
return (
{fmtDate(data.snapped_at)} UTC
{macro?.dominant && (
Regime: {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} open · {data.n_closed} closed
{trades.length > 0 ? (
| Ticker |
Strategy |
Entry |
Current price |
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)}
|
))}
) : (
No positions in this 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 */}
Position History
{snapshots.length} PnL snapshot{snapshots.length !== 1 ? 's' : ''} recorded
{snapshots.length > 0 && ` · from ${fmtDate(snapshots[snapshots.length - 1]?.snapped_at)} to ${fmtDate(snapshots[0]?.snapped_at)}`}
{canDiff && (
)}
{(selectedA || selectedB) && (
)}
{/* Error */}
{error && (
)}
{/* Selection banner */}
{(selectedA || selectedB) && (
Diff selection:
{selectedA ? (
A
{fmtDate(snapshots.find(s => s.id === selectedA)?.snapped_at ?? '')}
) :
A: not selected}
{selectedB ? (
B
{fmtDate(snapshots.find(s => s.id === selectedB)?.snapped_at ?? '')}
) :
B: not selected}
{canDiff && (
)}
)}
{/* Empty state */}
{!isLoading && snapshots.length === 0 && (
No PnL snapshots available
Enable the PnL Scheduler in Configuration or trigger a snapshot manually
from the Configuration → Auto-Cycle & Logging page.
)}
{snapshots.length > 0 && (
{/* Left: timeline list */}
{/* Mini sparkline */}
{sparkData.length > 2 && (
Portfolio PnL over time
} />
)}
{/* Snapshot list */}
{snapshots.length} snapshots — click for detail · A/B to 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
?
Computing diff…
: diffError
?
{String(diffError)}
: diffData ?
: null
)}
{viewMode === 'detail' && activeDetail && (
)}
{viewMode === 'detail' && !activeDetail && !canDiff && (
Click a snapshot to see the detail
or select A and B to compare two valuations
)}
{viewMode === 'diff' && !canDiff && (
Select two snapshots (A and B) from the list to view the diff
)}
)}
)
}