Files
OpenFin/frontend/src/pages/Portfolio.tsx
2026-07-28 11:14:31 +02:00

789 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition, usePositionPayoff, useRetrospectiveOptimal
} from '../hooks/useApi'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import axios from 'axios'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, BarChart, Bar, LineChart, Line
} from 'recharts'
import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react'
import type { TradeIdea } from '../types'
const useDeletePosition = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => axios.delete(`/api/portfolio/${id}`).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['portfolio'] })
qc.invalidateQueries({ queryKey: ['portfolio-summary'] })
},
})
}
const PRICING_SOURCE_LABELS: Record<string, string> = {
saxo_quote: 'Cotation Saxo réelle',
saxo_surface: 'Surface de vol Saxo',
yfinance_bs: 'BS (vol yfinance)',
}
const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex']
interface AddModalProps {
prefill?: Partial<TradeIdea>
onClose: () => void
}
const STRADDLE_STRATEGIES = ['Long Straddle', 'Short Straddle', 'Long Strangle', 'Short Strangle']
function AddPositionModal({ prefill, onClose }: AddModalProps) {
const { mutate: addPos, isPending } = useAddPosition()
const [addError, setAddError] = useState<string | null>(null)
const [form, setForm] = useState({
title: prefill?.title || '',
underlying: prefill?.underlying || '',
strategy: prefill?.strategy || 'Long Call',
asset_class: prefill?.asset_class || 'indices',
expiry_days: prefill?.horizon_days || 90,
capital_invested: prefill?.capital_required || 1000,
geo_trigger: prefill?.geo_trigger || '',
rationale: prefill?.rationale || '',
strike: '',
option_type: 'call',
quantity: 1,
premium: '',
})
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const isStraddle = STRADDLE_STRATEGIES.includes(form.strategy)
const isShort = form.strategy.startsWith('Short')
const submit = () => {
setAddError(null)
const strikeVal = parseFloat(form.strike) || undefined
const premiumVal = parseFloat(form.premium) || undefined
const legs = isStraddle
? [
{ strike: strikeVal, option_type: 'call', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
{ strike: strikeVal, option_type: 'put', quantity: form.quantity, position: isShort ? 'short' : 'long', premium_paid: premiumVal },
]
: [{
strike: strikeVal,
option_type: form.option_type,
quantity: form.quantity,
position: form.strategy.startsWith('Short') ? 'short' : 'long',
premium_paid: premiumVal,
}]
addPos({
title: form.title || `${form.strategy} ${form.underlying}`,
underlying: form.underlying,
strategy: form.strategy,
asset_class: form.asset_class,
expiry_days: form.expiry_days,
capital_invested: form.capital_invested,
geo_trigger: form.geo_trigger,
rationale: form.rationale,
legs,
}, {
onSuccess: onClose,
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setAddError(detail || 'Error adding position')
}
})
}
return (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
<div className="card w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Plus className="w-4 h-4 text-blue-400" /> Add to portfolio
</h2>
<button onClick={onClose} className="text-slate-500 hover:text-slate-200"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Title</label>
<input value={form.title} onChange={e => set('title', e.target.value)}
placeholder="Ex: Long Call GLD Q3 2026"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Underlying <span className="text-slate-600">(Yahoo Finance ticker)</span>
</label>
<input value={form.underlying} onChange={e => { set('underlying', e.target.value.toUpperCase()); setAddError(null) }}
placeholder="^GSPC, GLD, CL=F..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<div className="text-xs text-slate-600 mt-0.5">S&amp;P 500: ^GSPC · Gold: GC=F · WTI: CL=F · GLD · USO</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Strategy</label>
<select value={form.strategy} onChange={e => set('strategy', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{STRATEGIES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Asset class</label>
<select value={form.asset_class} onChange={e => set('asset_class', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{ASSET_CLASSES.map(s => <option key={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike</label>
<input type="number" value={form.strike} onChange={e => set('strike', e.target.value)}
placeholder="Exercise price"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Type</label>
<select value={form.option_type} onChange={e => set('option_type', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="call">Call</option>
<option value="put">Put</option>
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Premium paid</label>
<input type="number" value={form.premium} onChange={e => set('premium', e.target.value)}
placeholder="Option price"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Capital invested ()</label>
<input type="number" value={form.capital_invested} onChange={e => set('capital_invested', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Duration (days)</label>
<input type="number" value={form.expiry_days} onChange={e => set('expiry_days', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Geopolitical trigger</label>
<input value={form.geo_trigger} onChange={e => set('geo_trigger', e.target.value)}
placeholder="Ex: Middle East escalation → oil spike"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Rationale</label>
<textarea value={form.rationale} onChange={e => set('rationale', e.target.value)}
rows={2} placeholder="Why this trade..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none" />
</div>
{isStraddle && (
<div className="bg-blue-900/30 border border-blue-700/30 rounded p-2 text-xs text-blue-300">
Straddle: 2 legs will be created automatically (CALL + PUT at the same strike)
</div>
)}
<div className="bg-dark-700 rounded p-2 text-xs text-slate-500">
<div className="font-semibold text-slate-400 mb-1">Simulated IB fees</div>
<div>Options: $0.65/contract (min $1.00) at entry + exit</div>
<div>1 contract = <span className="text-white">$0.65</span> · 5 contracts = <span className="text-white">$3.25</span></div>
</div>
{addError && (
<div className="bg-red-900/40 border border-red-700/40 rounded p-2 text-xs text-red-300">
{addError}
</div>
)}
<button onClick={submit} disabled={isPending || !form.underlying}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-2 text-sm font-semibold">
{isPending ? 'Adding...' : '+ Add to portfolio'}
</button>
</div>
</div>
</div>
)
}
// P&L-vs-underlying-price payoff diagram — "at expiry" (pure intrinsic, no vol) vs "today"
// (Black-Scholes reprice holding each leg's current implied vol fixed, from the real Saxo
// surface when the option chain is linked — see services.portfolio_pricing.compute_payoff).
function PositionPayoffChart({ posId, enabled, legs }: { posId: string; enabled: boolean; legs: any[] }) {
const { data, isLoading } = usePositionPayoff(posId, enabled)
if (!enabled) return null
if (isLoading) return <div className="text-slate-600 text-center py-6 mt-2">Calcul du payoff</div>
if (!data || !data.spot_range?.length) return null
const chartData = data.spot_range.map((s: number, i: number) => ({
spot: s, atExpiry: data.at_expiry[i], today: data.today[i],
}))
const strikes: number[] = data.strikes ?? []
return (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="flex items-center justify-between mb-1.5">
<span className="text-slate-500 uppercase tracking-wide text-[10px]">Payoff P&L vs. spot sous-jacent</span>
<span className={clsx('text-[10px]', data.pricing_source === 'yfinance_bs' ? 'text-amber-400' : 'text-emerald-400')}>
{data.pricing_source === 'yfinance_bs' ? 'Vol yfinance (pas de chain Saxo lié)' : 'Vol Saxo réelle'}
</span>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={chartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
<CartesianGrid stroke="#1e2d4d" strokeDasharray="3 3" />
<XAxis dataKey="spot" tick={{ fontSize: 9, fill: '#64748b' }}
tickFormatter={(v: number) => v.toFixed(v >= 1000 ? 0 : 2)} />
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}k`} />
<Tooltip
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
formatter={(v: number, name: string) => [`${v.toFixed(2)}`, name === 'atExpiry' ? 'À échéance' : "Aujourd'hui"]}
labelFormatter={(v: number) => `Spot ${v.toFixed(2)}`}
/>
<ReferenceLine y={0} stroke="#475569" />
{data.current_spot != null && <ReferenceLine x={data.current_spot} stroke="#3b82f6" strokeDasharray="4 2" label={{ value: 'Spot', fontSize: 9, fill: '#3b82f6', position: 'top' }} />}
{data.entry_spot != null && <ReferenceLine x={data.entry_spot} stroke="#64748b" strokeDasharray="2 2" />}
{strikes.map((k: number) => (
<ReferenceLine key={k} x={k} stroke="#f59e0b" strokeOpacity={0.4} strokeDasharray="2 2" />
))}
<Line type="monotone" dataKey="atExpiry" stroke="#22c55e" strokeWidth={1.5} dot={false} isAnimationActive={false} name="atExpiry" />
<Line type="monotone" dataKey="today" stroke="#3b82f6" strokeWidth={1.5} strokeDasharray="4 2" dot={false} isAnimationActive={false} name="today" />
</LineChart>
</ResponsiveContainer>
<div className="flex items-center gap-3 text-[9px] text-slate-600 mt-1">
<span className="flex items-center gap-1"><span className="w-2.5 h-0.5 bg-emerald-500 inline-block" /> À échéance</span>
<span className="flex items-center gap-1"><span className="w-2.5 h-0.5 bg-blue-500 inline-block" style={{ borderTop: '1.5px dashed #3b82f6', background: 'none' }} /> Aujourd'hui (vol actuelle)</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500/40 inline-block" /> Strike{legs.length > 1 ? 's' : ''}</span>
</div>
</div>
)
}
function RetrospectiveComparisonCard({ posId, enabled }: { posId: string; enabled: boolean }) {
const { data, isFetching, refetch, isFetched } = useRetrospectiveOptimal(posId)
if (!enabled) return null
return (
<div className="mt-3 pt-3 border-t border-slate-700/30">
<div className="flex items-center justify-between mb-1.5">
<span className="text-slate-500 uppercase tracking-wide text-[10px]">
Comparaison rétrospective — qu'aurait-il fallu faire ?
</span>
<button
onClick={() => refetch()}
disabled={isFetching}
className="text-[10px] bg-blue-600/80 hover:bg-blue-500 disabled:opacity-50 text-white px-2 py-1 rounded font-semibold"
>
{isFetching ? 'Calcul… (~1 min)' : isFetched ? 'Recalculer' : 'Calculer'}
</button>
</div>
{!isFetched && !isFetching && (
<p className="text-[11px] text-slate-600">
Reconstruit le chain Saxo réel à la date d'entrée et le compare au mouvement réellement survenu depuis —
pas un scénario deviné. Fait tourner l'optimiseur complet (~1 min), calculé à la demande.
</p>
)}
{isFetching && (
<p className="text-[11px] text-slate-600">Recherche parmi les stratégies possibles à la date d'entrée…</p>
)}
{data && !data.available && (
<p className="text-[11px] text-amber-400">{data.reason}</p>
)}
{data?.available && (
<div className="space-y-2">
<div className="text-[11px] text-slate-400">
Du {data.entry_date} au {data.as_of} ({data.horizon_days}j) — mouvement réel :{' '}
<span className={clsx('font-mono font-semibold', (data.realized_spot_shock_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
spot {(data.realized_spot_shock_pct ?? 0) >= 0 ? '+' : ''}{data.realized_spot_shock_pct}%
</span>
{', '}
<span className="font-mono font-semibold text-slate-300">
IV {(data.realized_iv_shift ?? 0) >= 0 ? '+' : ''}{((data.realized_iv_shift ?? 0) * 100).toFixed(1)}pts
</span>
</div>
<div className="flex items-center gap-4 text-xs">
<div>
<span className="text-slate-500">Votre position : </span>
<span className={clsx('font-mono font-bold', (data.actual_return_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{data.actual_return_pct != null ? `${data.actual_return_pct >= 0 ? '+' : ''}${data.actual_return_pct}%` : ''}
</span>
</div>
</div>
<table className="w-full text-[11px]">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-2">Structure optimale (rétrospective)</th>
<th className="py-1 pr-2 text-right" title="Même capital que votre position réelle">Retour sur même capital</th>
<th className="py-1 text-right">Δ net</th>
</tr>
</thead>
<tbody>
{(data.optimal_candidates ?? []).map((c, i) => (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1 pr-2 text-slate-300">{c.template_name}</td>
<td className="py-1 pr-2 text-right font-mono text-slate-200">
{c.return_on_capital_pct != null ? `${c.return_on_capital_pct >= 0 ? '+' : ''}${c.return_on_capital_pct}%` : ''}
</td>
<td className="py-1 text-right font-mono text-slate-500">{c.net_delta_now.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
<p className="text-[10px] text-slate-600 italic">
Comparaison en % du même capital investi que votre position réelle — pas en dollars bruts, les deux
moteurs de pricing (Portfolio et Strategy Builder) n'utilisent pas la même convention de taille de contrat.
</p>
</div>
)}
</div>
)
}
function PositionCard({ pos }: { pos: Record<string, any> }) {
const navigate = useNavigate()
const [showClose, setShowClose] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [closeVal, setCloseVal] = useState('')
const { mutate: closePos, isPending: closing } = useClosePosition()
const { mutate: deletePos, isPending: deleting } = useDeletePosition()
const pnl = pos.pnl ?? 0
const pnlPct = pos.pnl_pct ?? 0
const isProfit = pnl >= 0
const entryRef = pos.entry_ref ?? pos.capital_invested
const hasPremium = pos.entry_ref != null && pos.entry_ref !== pos.capital_invested
return (
<div className={clsx('card transition-all', {
'border-emerald-700/40': isProfit && pnl !== 0,
'border-red-700/40': !isProfit && pnl !== 0,
})}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{pos.title}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="badge badge-blue">{pos.strategy}</span>
<button
onClick={() => navigate(`/markets?symbol=${pos.underlying}`)}
className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-0.5 transition-colors"
title="View in markets"
>
{pos.underlying} <ExternalLink className="w-2.5 h-2.5" />
</button>
<span className="text-xs text-slate-600">{pos.entry_date?.slice(0, 10)}</span>
{pos.sigma_used && (
<span className="text-xs text-slate-600">IV {(pos.sigma_used * 100).toFixed(1)}%</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-3 shrink-0">
<div className="text-right">
<div className={clsx('text-lg font-bold', isProfit ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}
</div>
<div className={clsx('text-xs font-mono', isProfit ? 'positive' : 'negative')}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
</div>
</div>
<button onClick={() => deletePos(pos.id)} disabled={deleting}
className="text-slate-600 hover:text-red-400 transition-colors" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* KPI grid */}
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="card-sm text-center">
<div className="text-xs text-slate-600">{hasPremium ? 'Premium paid' : 'Capital invested'}</div>
<div className="text-xs text-white font-mono mt-0.5">{entryRef.toFixed(2)}</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Current BS value</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_value != null ? `${pos.current_value.toFixed(2)}` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Underlying spot</div>
<div className="text-xs text-white font-mono mt-0.5">
{pos.current_underlying != null ? `$${pos.current_underlying}` : '—'}
</div>
</div>
<div className="card-sm text-center">
<div className="text-xs text-slate-600">Days remaining</div>
<div className={clsx('text-xs font-mono mt-0.5', {
'text-red-400': (pos.days_remaining ?? 99) < 14,
'text-yellow-400': (pos.days_remaining ?? 99) < 30,
'text-white': (pos.days_remaining ?? 99) >= 30,
})}>
{pos.days_remaining != null ? `${pos.days_remaining}d` : '—'}
</div>
</div>
</div>
{/* Greeks + fees */}
{pos.greeks && (
<div className="flex items-center gap-4 text-xs mb-2">
<span className="text-slate-600">Greeks:</span>
<span>Δ <span className="text-slate-300 font-mono">{pos.greeks.net_delta?.toFixed(3)}</span></span>
<span>Θ <span className="text-red-400 font-mono">{pos.greeks.net_theta?.toFixed(3)}</span>/d</span>
<span>ν <span className="text-blue-400 font-mono">{pos.greeks.net_vega?.toFixed(3)}</span></span>
<span className="ml-auto text-slate-600">IB fees: <span className="text-orange-400">{(pos.ib_fees_entry || 0).toFixed(2)}</span></span>
</div>
)}
{/* Contract detail toggle */}
{pos.legs && pos.legs.length > 0 && (
<button
onClick={() => setShowDetails(s => !s)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail de la valorisation
{pos.pricing_source_summary && (
<span className={clsx('ml-1 px-1.5 py-0.5 rounded text-[10px] font-normal',
pos.pricing_source_summary === 'yfinance_bs' ? 'bg-amber-900/30 text-amber-400' : 'bg-emerald-900/30 text-emerald-400'
)}>
{PRICING_SOURCE_LABELS[pos.pricing_source_summary] ?? pos.pricing_source_summary}
</span>
)}
</button>
)}
{/* Contract details panel */}
{showDetails && pos.legs && pos.legs.length > 0 && (
<div className="mb-3 bg-dark-700/40 rounded p-2.5 text-xs border border-slate-700/30">
<div className="flex flex-wrap items-center gap-3 mb-2 text-slate-500 border-b border-slate-700/30 pb-1.5">
{pos.entry_underlying_price != null && (
<span>Entry spot: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
)}
{pos.sigma_used != null && (
<span>σ (moy. legs): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
)}
<span>r: <span className="text-slate-300">5%</span></span>
{pos.expiry_days != null && (
<span>Initial duration: <span className="text-slate-300">{pos.expiry_days}d</span></span>
)}
</div>
<table className="w-full mb-2">
<thead>
<tr className="text-slate-600">
<th className="text-left pb-1 font-normal">Leg</th>
<th className="text-left pb-1 font-normal">Strike</th>
<th className="text-right pb-1 font-normal">Qty</th>
<th className="text-right pb-1 font-normal">Premium/contract</th>
<th className="text-right pb-1 font-normal">Total cost</th>
<th className="text-right pb-1 font-normal">Source</th>
</tr>
</thead>
<tbody>
{pos.legs.map((leg: any, i: number) => {
const legTotal = leg.premium_paid != null
? leg.premium_paid * (leg.quantity ?? 1) * 100
: null
return (
<tr key={i} className="border-t border-slate-700/20">
<td className="py-1">
<span className={clsx(
'badge text-xs',
(leg.option_type ?? 'call') === 'call' ? 'badge-green' : 'badge-red'
)}>
{(leg.option_type ?? 'call').toUpperCase()}
</span>
<span className="ml-1 text-slate-500">
{leg.position === 'long' ? '▲' : '▼'} {leg.position ?? 'long'}
</span>
</td>
<td className="py-1 font-mono text-slate-300">
{leg.strike != null ? `$${Number(leg.strike).toFixed(2)}` : 'ATM'}
</td>
<td className="py-1 text-right text-slate-300">{leg.quantity ?? 1}</td>
<td className="py-1 text-right font-mono text-slate-300">
{leg.premium_paid != null ? `$${Number(leg.premium_paid).toFixed(4)}` : '—'}
</td>
<td className="py-1 text-right font-mono text-white">
{legTotal != null ? `$${legTotal.toFixed(2)}` : '—'}
</td>
<td className="py-1 text-right">
{leg.pricing_source && (
<span className={clsx('text-[10px]', leg.pricing_source === 'yfinance_bs' ? 'text-amber-400' : 'text-emerald-400')}>
{PRICING_SOURCE_LABELS[leg.pricing_source] ?? leg.pricing_source}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="text-slate-600 italic">
1 contract = 100 shares · r=5% · Prix réel Saxo si l'option chain de cet instrument est liée (Config → Instruments Watchlist), sinon Black-Scholes sur vol historique yfinance
</div>
<PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} />
<RetrospectiveComparisonCard posId={pos.id} enabled={showDetails} />
</div>
)}
{/* Geo trigger */}
{pos.geo_trigger && (
<div className="text-xs text-slate-500 mb-2">
<span className="text-slate-600">⚡ Trigger: </span>{pos.geo_trigger}
</div>
)}
{/* P&L bar */}
<div className="mb-3">
<div className="bg-dark-600 rounded-full h-1.5 relative overflow-hidden">
<div className="absolute inset-y-0 left-1/2 w-px bg-slate-500 z-10"></div>
<div
className={clsx('h-1.5 rounded-full transition-all absolute top-0', isProfit ? 'bg-emerald-500' : 'bg-red-500')}
style={{
width: `${Math.min(50, Math.abs(pnlPct) / 4)}%`,
left: isProfit ? '50%' : `${50 - Math.min(50, Math.abs(pnlPct) / 4)}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-slate-700 mt-0.5">
<span>-100%</span><span>0</span><span>+100%</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button onClick={() => setShowClose(!showClose)}
className="flex-1 text-xs border border-slate-700 hover:border-emerald-600 text-slate-400 hover:text-emerald-400 rounded py-1.5 transition-colors">
{showClose ? 'Cancel' : '💰 Close position'}
</button>
</div>
{showClose && (
<div className="mt-2 space-y-1">
<div className="text-xs text-slate-500">Resale value (€) — e.g.: {pos.current_value?.toFixed(2)}</div>
<div className="flex gap-2">
<input type="number" value={closeVal} onChange={e => setCloseVal(e.target.value)}
placeholder={`Current value: ~${pos.current_value?.toFixed(2)}€`}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-blue-500" />
<button
onClick={() => closePos({ id: pos.id, close_value: parseFloat(closeVal) || 0 },
{ onSuccess: () => setShowClose(false) })}
disabled={closing || !closeVal}
className="bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 text-white px-3 rounded text-xs font-semibold">
{closing ? '...' : 'Confirm'}
</button>
</div>
{closeVal && (
<div className={clsx('text-xs', parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1 >= 0 ? 'positive' : 'negative')}>
Net IB P&L: {(parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1).toFixed(2)}€
(exit fees: ~$1.00)
</div>
)}
</div>
)}
</div>
)
}
function ClosedRow({ pos, fees, pnl }: { pos: Record<string, any>; fees: number; pnl: number | null }) {
const { mutate: deletePos, isPending } = useDeletePosition()
return (
<tr className="border-b border-slate-700/20 group">
<td className="py-1.5 text-white">{pos.title}</td>
<td className="py-1.5 text-slate-400">{pos.strategy}</td>
<td className="py-1.5 text-right font-mono">{pos.capital_invested}€</td>
<td className="py-1.5 text-right font-mono">{pos.close_value?.toFixed(2) ?? ''}€</td>
<td className="py-1.5 text-right font-mono text-orange-400">{fees.toFixed(2)}€</td>
<td className={clsx('py-1.5 text-right font-mono font-bold', pnl != null ? (pnl >= 0 ? 'positive' : 'negative') : 'text-slate-500')}>
{pnl != null ? `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}€` : ''}
</td>
<td className="py-1.5 text-slate-500">{pos.close_date?.slice(0, 10)}</td>
<td className="py-1.5 pl-2">
<button onClick={() => deletePos(pos.id)} disabled={isPending}
className="opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
)
}
export default function Portfolio() {
const { data: positions, isLoading, refetch } = usePortfolioPositions('open')
const { data: closed } = usePortfolioPositions('closed')
const { data: summary } = usePortfolioSummary()
const { data: pnlHistory } = usePnlHistory()
const [showModal, setShowModal] = useState(false)
const [tab, setTab] = useState<'open' | 'closed' | 'history'>('open')
return (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<DollarSign className="w-5 h-5 text-blue-400" /> Portfolio
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Real-time tracking · Mark-to-market Saxo (option chain lié) ou Black-Scholes · Simulated IB fees
</p>
</div>
<div className="flex gap-2">
<button onClick={() => refetch()} className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200">
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
<button onClick={() => setShowModal(true)}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> New position
</button>
</div>
</div>
{/* Summary KPIs */}
{summary && (
<div className="grid grid-cols-5 gap-3">
{[
{ label: 'Open positions', value: summary.open_positions, color: 'text-white' },
{ label: 'Capital deployed', value: `${summary.total_invested}€`, color: 'text-white' },
{ label: 'Unrealized P&L', value: `${summary.unrealized_pnl >= 0 ? '+' : ''}${summary.unrealized_pnl}€`, color: summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Realized P&L', value: `${summary.realized_pnl >= 0 ? '+' : ''}${summary.realized_pnl}€`, color: summary.realized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
{ label: 'Total IB fees', value: `${summary.total_fees_paid}€`, color: 'text-orange-400' },
].map(({ label, value, color }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('text-lg font-bold mt-1', color)}>{value}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'open', label: `Open positions (${positions?.length ?? 0})` },
{ key: 'closed', label: `Closed (${closed?.length ?? 0})` },
{ key: 'history', label: 'P&L curve' },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm transition-colors', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{/* Open positions */}
{tab === 'open' && (
<>
{isLoading ? (
<div className="space-y-3">
{[1,2,3].map(i => <div key={i} className="card animate-pulse h-32 bg-dark-700" />)}
</div>
) : positions && positions.length > 0 ? (
<div className="space-y-3">
{(positions as Record<string, any>[]).map(pos => (
<PositionCard key={pos.id} pos={pos} />
))}
</div>
) : (
<div className="card text-center py-12 text-slate-500">
<DollarSign className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>No open positions</div>
<div className="text-xs mt-1">Add a trade from ideas or manually</div>
</div>
)}
</>
)}
{/* Closed positions */}
{tab === 'closed' && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-600 border-b border-slate-700/40">
<th className="text-left pb-2">Position</th>
<th className="text-left pb-2">Strategy</th>
<th className="text-right pb-2">Invested</th>
<th className="text-right pb-2">Close</th>
<th className="text-right pb-2">IB fees</th>
<th className="text-right pb-2">Net P&L</th>
<th className="text-left pb-2">Close date</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{(closed as Record<string, any>[] || []).map(pos => {
const fees = (pos.ib_fees_entry || 0) + (pos.ib_fees_exit || 0)
const pnl = pos.close_value != null ? pos.close_value - pos.capital_invested - fees : null
return (
<ClosedRow key={pos.id} pos={pos} fees={fees} pnl={pnl} />
)
})}
{(!closed || closed.length === 0) && (
<tr><td colSpan={8} className="py-8 text-center text-slate-600">No closed trades</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* P&L history */}
{tab === 'history' && (
<div className="card">
<div className="section-title">Cumulative P&L curve (realized trades)</div>
{pnlHistory && pnlHistory.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={pnlHistory}>
<defs>
<linearGradient id="pnl-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} />
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
tickFormatter={v => `${v.toFixed(0)}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
formatter={(v: number) => [`${v.toFixed(2)}€`, 'Cumulative P&L']} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<Area type="monotone" dataKey="cumulative" stroke="#10b981" fill="url(#pnl-grad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={120} className="mt-2">
<BarChart data={pnlHistory}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="date" tick={{ fill: '#475569', fontSize: 8 }} />
<YAxis tick={{ fill: '#475569', fontSize: 8 }} tickFormatter={v => `${v}€`} />
<Tooltip contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 10 }}
formatter={(v: number) => [`${v >= 0 ? '+' : ''}${v.toFixed(2)}€`, 'P&L']} />
<Bar dataKey="pnl" fill="#3b82f6" radius={[2,2,0,0]}
label={false} />
</BarChart>
</ResponsiveContainer>
</>
) : (
<div className="h-40 flex items-center justify-center text-slate-600 text-sm">
Close positions to see the P&L curve
</div>
)}
</div>
)}
{showModal && <AddPositionModal onClose={() => setShowModal(false)} />}
</div>
)
}