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

@@ -317,6 +317,25 @@ export const usePnlHistory = () =>
queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data),
})
// P&L-vs-underlying-price payoff diagram for one position (services.portfolio_pricing.
// compute_payoff) — at-expiry (pure intrinsic) + today (current vol held fixed) curves.
export const usePositionPayoff = (posId: string, enabled: boolean) =>
useQuery({
queryKey: ['portfolio-payoff', posId],
queryFn: () => api.get(`/portfolio/positions/${posId}/payoff`).then(r => r.data),
enabled,
})
// Reprices every open position under a handful of named macro scenarios (Risk-Off,
// Risk-On, inflation persistante, dollar fort, baisse des matières premières) to surface
// when several differently-named positions are really the same underlying bet.
export const usePortfolioScenarioExposure = () =>
useQuery({
queryKey: ['portfolio-scenario-exposure'],
queryFn: () => api.get('/portfolio/scenario-exposure').then(r => r.data),
staleTime: 5 * 60_000,
})
export const useAddPosition = () => {
const qc = useQueryClient()
return useMutation({

View File

@@ -6,7 +6,7 @@ import {
useRiskDashboard, useRiskRadar, useGeoNews,
useCycleStatus,
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport,
useWatchlistCurveRegimes,
useWatchlistCurveRegimes, usePortfolioScenarioExposure,
} from '../hooks/useApi'
import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
import { Link, useNavigate } from 'react-router-dom'
@@ -117,6 +117,7 @@ export default function Dashboard() {
const { data: openPositionsData } = usePortfolioPositions('open')
const { data: riskDashboard } = useRiskDashboard()
const { data: riskRadarData } = useRiskRadar()
const { data: scenarioExposure } = usePortfolioScenarioExposure()
const { data: cycleStatusData } = useCycleStatus()
const { data: geoNews } = useGeoNews()
const { data: watchlistItems } = useInstrumentsWatchlist()
@@ -222,9 +223,19 @@ export default function Dashboard() {
// suffix-stripping needed, those were solving a mismatch that didn't actually exist.
const nameByUnderlyingTicker: Record<string, string> = {}
for (const w of ((watchlistItems as any[]) ?? [])) nameByUnderlyingTicker[String(w.ticker).trim().toUpperCase()] = w.name || w.ticker
// A handful of positions trade via a liquid options-ETF proxy rather than the
// Watchlist's own quote ticker for the same underlying (e.g. Brent futures options
// aren't broadly available, so Brent exposure trades via BNO — the United States Brent
// Oil Fund ETF — instead of the BZ=F/BRENT quote ticker). Extend here if another proxy
// ticker shows up unrenamed.
const PROXY_TICKER_ALIASES: Record<string, string> = { 'BNO': 'BRENT' }
const underlyingDisplayName = (underlying: string): string => {
if (!underlying) return underlying
return nameByUnderlyingTicker[underlying.trim().toUpperCase()] || underlying
const upper = underlying.trim().toUpperCase()
if (nameByUnderlyingTicker[upper]) return nameByUnderlyingTicker[upper]
const proxyTicker = PROXY_TICKER_ALIASES[upper]
if (proxyTicker && nameByUnderlyingTicker[proxyTicker]) return nameByUnderlyingTicker[proxyTicker]
return underlying
}
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
@@ -816,6 +827,8 @@ export default function Dashboard() {
.filter(d => d.value > 0)
.sort((a, b) => b.value - a.value)
const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 }))
const dominantScenario = (scenarioExposure as any)?.dominant_scenario
const scenarioWarning = (scenarioExposure as any)?.warning
return (
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
@@ -827,6 +840,12 @@ export default function Dashboard() {
<div className={clsx('text-[10px] font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
</div>
{scenarioWarning && dominantScenario && (
<div className="mt-1 text-[9px] leading-snug text-amber-400 bg-amber-900/10 border border-amber-700/20 rounded px-1.5 py-1">
{dominantScenario.pct_of_portfolio.toFixed(0)}% du book sur le même pari macro
(<span className="font-semibold">{dominantScenario.label}</span>)
</div>
)}
{radarAxes.length > 0 && (
<ResponsiveContainer width="100%" height={180}>
<RadarChart data={radarAxes}>

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">

View File

@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk } from '../hooks/useApi'
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk, usePortfolioScenarioExposure } from '../hooks/useApi'
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
import clsx from 'clsx'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart } from 'lucide-react'
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart, Layers } from 'lucide-react'
// ── Gauge component ──────────────────────────────────────────────────────────
function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) {
@@ -131,6 +131,85 @@ function RecommendationCard({ rec }: { rec: any }) {
)
}
// ── Scenario concentration ("same bet, different ticker") ───────────────────
function ScenarioExposureCard() {
const { data, isLoading } = usePortfolioScenarioExposure()
const exp: any = data
if (isLoading) return <div className="card animate-pulse h-40 bg-dark-700" />
if (!exp || !exp.positions) return null
const concentration: any[] = exp.concentration ?? []
const scenarios: any[] = exp.scenarios ?? []
return (
<div className="card">
<div className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" /> Concentration par scénario macro
</div>
<div className="text-[10px] text-slate-500 mb-3">
Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous 5 scénarios
révèle quand plusieurs positions différentes sont en réalité le même pari répété.
</div>
{exp.warning && (
<div className="mb-3 flex items-start gap-2 text-xs px-3 py-2 rounded border bg-amber-900/20 border-amber-700/30 text-amber-300">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
{exp.warning}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Concentration bars */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">
% du book dont c'est le scénario le plus favorable
</div>
<div className="space-y-2.5">
{concentration.map((c: any) => (
<div key={c.key}>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-300">{c.label}</span>
<span className={clsx('font-mono font-bold', c.pct_of_portfolio >= 60 ? 'text-amber-400' : 'text-slate-300')}>
{c.pct_of_portfolio}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-purple-500/70" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
{/* Sensitivity matrix */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">P&L estimé du portefeuille par scénario</div>
<table className="w-full text-xs">
<tbody>
{scenarios.map((s: any) => (
<tr key={s.key} className="border-b border-slate-800/40">
<td className="py-1.5 pr-3 text-slate-300">{s.label}</td>
<td className={clsx('py-1.5 text-right font-mono font-bold whitespace-nowrap',
s.portfolio_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{exp.unpriced?.length > 0 && (
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}
</div>
)}
</div>
)
}
// ── Main page ────────────────────────────────────────────────────────────────
function SimRiskPanel() {
@@ -416,6 +495,9 @@ export default function RiskDashboard() {
{/* Recommendation */}
<RecommendationCard rec={d.recommendation} />
{/* Scenario concentration — same bet, different ticker */}
<ScenarioExposureCard />
{/* Alerts */}
{(d.concentration_alerts ?? []).length > 0 && (
<div className="space-y-2">