feat: risk

This commit is contained in:
OpenSquared
2026-07-26 14:10:49 +02:00
parent ad7ab35d1d
commit 3704724b0b
8 changed files with 719 additions and 57 deletions

View File

@@ -2,14 +2,14 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition
useAddPosition, useClosePosition, usePositionPayoff
} 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
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'
@@ -25,6 +25,12 @@ const useDeletePosition = () => {
})
}
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']
@@ -205,6 +211,58 @@ function AddPositionModal({ prefill, onClose }: AddModalProps) {
)
}
// 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 PositionCard({ pos }: { pos: Record<string, any> }) {
const navigate = useNavigate()
const [showClose, setShowClose] = useState(false)
@@ -307,7 +365,14 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
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" />}
Black-Scholes simulation detail
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>
)}
@@ -319,7 +384,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
<span>Entry spot: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
)}
{pos.sigma_used != null && (
<span>σ (hist. IV): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
<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 && (
@@ -334,6 +399,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
<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>
@@ -364,14 +430,23 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
<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">
Black-Scholes · 1 contract = 100 shares · Price computed at entry time (spot, historical IV, r=5%)
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} />
</div>
)}
@@ -473,7 +548,7 @@ export default function Portfolio() {
<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 Black-Scholes · Simulated IB fees
Real-time tracking · Mark-to-market Saxo (option chain lié) ou Black-Scholes · Simulated IB fees
</p>
</div>
<div className="flex gap-2">