Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
628 lines
30 KiB
TypeScript
628 lines
30 KiB
TypeScript
import { useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import {
|
||
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
|
||
useAddPosition, useClosePosition
|
||
} 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
|
||
} 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 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 || 'Erreur lors de l\'ajout de la 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" /> Ajouter au portefeuille
|
||
</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">Titre</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">
|
||
Sous-jacent <span className="text-slate-600">(ticker Yahoo Finance)</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&P 500: ^GSPC · Or: 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">Stratégie</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">Classe d'actif</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="Prix d'exercice"
|
||
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">Prime payée</label>
|
||
<input type="number" value={form.premium} onChange={e => set('premium', e.target.value)}
|
||
placeholder="Prix option"
|
||
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 investi (€)</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">Durée (jours)</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">Déclencheur géopolitique</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">Raisonnement</label>
|
||
<textarea value={form.rationale} onChange={e => set('rationale', e.target.value)}
|
||
rows={2} placeholder="Pourquoi ce 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 seront créés automatiquement (CALL + PUT au même strike)
|
||
</div>
|
||
)}
|
||
<div className="bg-dark-700 rounded p-2 text-xs text-slate-500">
|
||
<div className="font-semibold text-slate-400 mb-1">Frais IB simulés</div>
|
||
<div>Options: $0.65/contrat (min $1.00) à l'entrée + sortie</div>
|
||
<div>1 contrat = <span className="text-white">$0.65</span> · 5 contrats = <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 ? 'Ajout...' : '+ Ajouter au portefeuille'}
|
||
</button>
|
||
</div>
|
||
</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="Voir dans les marchés"
|
||
>
|
||
{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="Supprimer">
|
||
<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 ? 'Prime payée' : 'Capital investi'}</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">Valeur BS actuelle</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">Spot sous-jacent</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">Jours restants</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}j` : '—'}
|
||
</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>/j</span>
|
||
<span>ν <span className="text-blue-400 font-mono">{pos.greeks.net_vega?.toFixed(3)}</span></span>
|
||
<span className="ml-auto text-slate-600">Frais IB: <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 simulation Black-Scholes
|
||
</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>Spot entrée: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
|
||
)}
|
||
{pos.sigma_used != null && (
|
||
<span>σ (IV histo): <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>Durée initiale: <span className="text-slate-300">{pos.expiry_days}j</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">Qté</th>
|
||
<th className="text-right pb-1 font-normal">Prime/contrat</th>
|
||
<th className="text-right pb-1 font-normal">Coût total</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>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
<div className="text-slate-600 italic">
|
||
Black-Scholes · 1 contrat = 100 actions · Prix calculé au moment de l'ajout (spot, IV historique, r=5%)
|
||
</div>
|
||
</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 ? 'Annuler' : '💰 Clôturer la position'}
|
||
</button>
|
||
</div>
|
||
|
||
{showClose && (
|
||
<div className="mt-2 space-y-1">
|
||
<div className="text-xs text-slate-500">Valeur de revente (€) — ex: {pos.current_value?.toFixed(2)}</div>
|
||
<div className="flex gap-2">
|
||
<input type="number" value={closeVal} onChange={e => setCloseVal(e.target.value)}
|
||
placeholder={`Valeur actuelle: ~${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 ? '...' : 'Confirmer'}
|
||
</button>
|
||
</div>
|
||
{closeVal && (
|
||
<div className={clsx('text-xs', parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1 >= 0 ? 'positive' : 'negative')}>
|
||
P&L net IB: {(parseFloat(closeVal) - entryRef - (pos.ib_fees_entry||0) - 1).toFixed(2)}€
|
||
(frais sortie: ~$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="Supprimer">
|
||
<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" /> Portefeuille
|
||
</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">
|
||
Suivi temps réel · Mark-to-market Black-Scholes · Frais IB simulés
|
||
</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" /> Actualiser
|
||
</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" /> Nouvelle position
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary KPIs */}
|
||
{summary && (
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{[
|
||
{ label: 'Positions ouvertes', value: summary.open_positions, color: 'text-white' },
|
||
{ label: 'Capital engagé', value: `${summary.total_invested}€`, color: 'text-white' },
|
||
{ label: 'P&L latent', value: `${summary.unrealized_pnl >= 0 ? '+' : ''}${summary.unrealized_pnl}€`, color: summary.unrealized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
|
||
{ label: 'P&L réalisé', value: `${summary.realized_pnl >= 0 ? '+' : ''}${summary.realized_pnl}€`, color: summary.realized_pnl >= 0 ? 'text-emerald-400' : 'text-red-400' },
|
||
{ label: 'Frais IB total', 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: `Positions ouvertes (${positions?.length ?? 0})` },
|
||
{ key: 'closed', label: `Clôturées (${closed?.length ?? 0})` },
|
||
{ key: 'history', label: 'Courbe P&L' },
|
||
].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>Aucune position ouverte</div>
|
||
<div className="text-xs mt-1">Ajouter un trade depuis les idées ou manuellement</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">Stratégie</th>
|
||
<th className="text-right pb-2">Investi</th>
|
||
<th className="text-right pb-2">Clôture</th>
|
||
<th className="text-right pb-2">Frais IB</th>
|
||
<th className="text-right pb-2">P&L net</th>
|
||
<th className="text-left pb-2">Date clôture</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">Aucun trade clôturé</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* P&L history */}
|
||
{tab === 'history' && (
|
||
<div className="card">
|
||
<div className="section-title">Courbe de P&L cumulé (trades réalisés)</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)}€`, 'P&L cumulé']} />
|
||
<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">
|
||
Clôturer des positions pour voir la courbe P&L
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showModal && <AddPositionModal onClose={() => setShowModal(false)} />}
|
||
</div>
|
||
)
|
||
}
|