feat: risk
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user