feat: option lab

This commit is contained in:
OpenSquared
2026-07-28 11:14:31 +02:00
parent 568414ca0c
commit d2c393b8e5
11 changed files with 757 additions and 29 deletions

View File

@@ -326,6 +326,31 @@ export const usePositionPayoff = (posId: string, enabled: boolean) =>
enabled,
})
// "What would have been optimal, in hindsight?" — reprices the position's real legs and
// runs the Strategy Builder optimizer against the REAL historical Saxo chain + the REALIZED
// spot/IV move since entry (not a guessed scenario). Expensive (full optimizer run), so
// on-demand only — not auto-fetched, call refetch() from a button.
export type RetrospectiveCandidate = {
template_name: string; legs: StrategyLeg[]; score: number; return_on_capital_pct: number | null
net_pnl: number; max_gain: number | null; max_loss: number | null; net_delta_now: number
}
export type RetrospectiveComparison = {
available: boolean; reason?: string
underlying?: string; entry_date?: string; as_of?: string; horizon_days?: number
spot_entry?: number; spot_realized?: number; realized_spot_shock_pct?: number
iv_entry?: number; iv_realized?: number; realized_iv_shift?: number
actual_return_pct?: number | null
optimal_candidates?: RetrospectiveCandidate[]
}
export const useRetrospectiveOptimal = (posId: string, asOf?: string) =>
useQuery<RetrospectiveComparison>({
queryKey: ['portfolio-retrospective-optimal', posId, asOf],
queryFn: () => api.get(`/portfolio/positions/${posId}/retrospective-optimal`, { params: asOf ? { as_of: asOf } : {} }).then(r => r.data),
enabled: false,
staleTime: 5 * 60_000,
})
// 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.
@@ -1048,6 +1073,36 @@ export const useSaxoIvSnapshot = (symbol: string) =>
staleTime: 5 * 60_000,
})
// Options Lab — "was this option well priced between two dates?" Picks the strike closest
// to the underlying's realized outcome at date_b (hindsight), reprices at both dates from
// real Saxo history, decomposes the move into Delta/Theta/Vega + a residual.
export type PricingCheckLeg = {
available: boolean
price_a?: number; price_b?: number; actual_change?: number
iv_a?: number | null; iv_b?: number | null
intrinsic_a?: number; time_value_a?: number; intrinsic_b?: number; time_value_b?: number
greeks_a?: { delta: number; gamma: number; theta: number; vega: number }
attribution?: { delta_pnl: number; theta_pnl: number; vega_pnl: number; explained: number; residual: number }
}
export type PricingCheckResult = {
available: boolean; reason?: string
ticker?: string; date_a?: string; date_b?: string; elapsed_days?: number; target_dte_used?: number
expiry_date?: string; expired_by_date_b?: boolean
spot_a?: number; spot_b?: number; spot_change_pct?: number; chosen_strike?: number
iv_a?: number | null; realized_vol?: number | null; vol_risk_premium?: number | null
legs?: { call: PricingCheckLeg; put: PricingCheckLeg }
}
// targetDte omitted/undefined -> backend defaults to the expiry closest to dateB (hindsight,
// same principle as the strike selection) — only pass it to force a different expiry.
export const usePricingCheck = (ticker: string, dateA: string, dateB: string, targetDte: number | undefined, enabled: boolean) =>
useQuery<PricingCheckResult>({
queryKey: ['options-pricing-check', ticker, dateA, dateB, targetDte],
queryFn: () => api.get('/saxo/pricing-check', { params: { ticker, date_a: dateA, date_b: dateB, target_dte: targetDte } }).then(r => r.data),
enabled: enabled && !!ticker && !!dateA && !!dateB,
staleTime: 5 * 60_000,
})
export const useSaxoIvHistory = (symbol: string, days = 90) =>
useQuery({
queryKey: ['saxo-iv-history', symbol, days],

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'
import {
useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker,
useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist,
useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory, useInstrumentsWatchlist, usePricingCheck,
} from '../hooks/useApi'
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2 } from 'lucide-react'
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2, Search } from 'lucide-react'
import { api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
@@ -528,6 +528,175 @@ function WatchlistManager() {
)
}
// ── Pricing check — "était-ce bien pricé entre 2 dates ?" ───────────────────────
function fmtSigned(v: number | null | undefined, digits = 2): string {
if (v == null) return '—'
return `${v >= 0 ? '+' : ''}${v.toFixed(digits)}`
}
function PricingCheckLegCard({ label, leg }: { label: string; leg: any }) {
if (!leg?.available) {
return (
<div className="card-sm">
<div className="stat-label mb-1">{label}</div>
<div className="text-xs text-slate-600">Pas de cotation exploitable pour cette jambe.</div>
</div>
)
}
const attr = leg.attribution
return (
<div className="card-sm space-y-2">
<div className="flex items-center justify-between">
<div className="stat-label">{label}</div>
<div className={clsx('text-sm font-mono font-bold', leg.actual_change >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtSigned(leg.actual_change, 4)}
</div>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
<div className="text-slate-500">Prix</div>
<div className="text-right font-mono text-slate-300">{leg.price_a?.toFixed(4)} {leg.price_b?.toFixed(4)}</div>
<div className="text-slate-500">IV</div>
<div className="text-right font-mono text-slate-300">
{leg.iv_a != null ? `${(leg.iv_a * 100).toFixed(1)}%` : '—'} {leg.iv_b != null ? `${(leg.iv_b * 100).toFixed(1)}%` : 'expiré'}
</div>
<div className="text-slate-500">Intrinsèque</div>
<div className="text-right font-mono text-slate-300">{leg.intrinsic_a?.toFixed(2)} {leg.intrinsic_b?.toFixed(2)}</div>
<div className="text-slate-500">Valeur temps</div>
<div className="text-right font-mono text-slate-300">{leg.time_value_a?.toFixed(2)} {leg.time_value_b?.toFixed(2)}</div>
</div>
{attr && (
<div className="pt-2 border-t border-slate-700/30">
<div className="text-[10px] text-slate-600 uppercase tracking-wide mb-1">Décomposition (Greeks à la date A)</div>
<div className="space-y-0.5 text-[11px] font-mono">
<div className="flex justify-between"><span className="text-slate-500">Δ spot × Delta</span><span className="text-slate-300">{fmtSigned(attr.delta_pnl)}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Temps × Theta</span><span className="text-slate-300">{fmtSigned(attr.theta_pnl)}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Δ IV × Vega</span><span className="text-slate-300">{fmtSigned(attr.vega_pnl)}</span></div>
<div className="flex justify-between border-t border-slate-700/20 pt-0.5 mt-0.5">
<span className="text-slate-400">Expliqué par les Greeks</span><span className="text-slate-200 font-semibold">{fmtSigned(attr.explained)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Résidu (gamma, skew, anomalie)</span>
<span className={clsx('font-semibold', Math.abs(attr.residual) > Math.abs(leg.actual_change) * 0.3 ? 'text-amber-400' : 'text-slate-200')}>
{fmtSigned(attr.residual)}
</span>
</div>
</div>
</div>
)}
</div>
)
}
function PricingCheckPanel() {
const { data: watchlistInstruments } = useInstrumentsWatchlist()
const tickers: any[] = (watchlistInstruments as any[]) ?? []
const [ticker, setTicker] = useState('')
const [dateA, setDateA] = useState('')
const [dateB, setDateB] = useState('')
const [targetDte, setTargetDte] = useState<number | undefined>(undefined)
const [run, setRun] = useState(false)
const { data, isFetching, refetch } = usePricingCheck(ticker, dateA, dateB, targetDte, run)
const handleAnalyze = () => {
setRun(true)
refetch()
}
return (
<div className="space-y-4">
<div className="card">
<div className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Search className="w-4 h-4 text-blue-400" /> Vérification de pricing historique
</div>
<p className="text-[11px] text-slate-500 mb-3">
Choisit, avec le recul, le strike le plus proche de le sous-jacent a réellement fini le contrat le
plus révélateur pour juger si la volatilité était bien pricée à la date de départ. Repricing réel depuis
l'historique Saxo accumulé, décomposé Delta/Theta/Vega.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<label className="text-slate-400 block mb-1">Instrument</label>
<select value={ticker} onChange={(e) => setTicker(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200">
<option value="">—</option>
{tickers.map(t => <option key={t.ticker} value={t.ticker}>{t.name || t.ticker}</option>)}
</select>
</div>
<div>
<label className="text-slate-400 block mb-1">Date A (départ)</label>
<input type="date" value={dateA} onChange={(e) => setDateA(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
</div>
<div>
<label className="text-slate-400 block mb-1">Date B (fin)</label>
<input type="date" value={dateB} onChange={(e) => setDateB(e.target.value)}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
</div>
<div>
<label className="text-slate-400 block mb-1" title="Vide = échéance la plus proche de la date B (même logique que le strike)">
DTE cible (optionnel)
</label>
<input type="number" min={1} max={365} placeholder="auto (date B)" value={targetDte ?? ''}
onChange={(e) => setTargetDte(e.target.value === '' ? undefined : parseInt(e.target.value))}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
</div>
</div>
<button
onClick={handleAnalyze}
disabled={!ticker || !dateA || !dateB || isFetching}
className="mt-3 flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
>
<Search className="w-3.5 h-3.5" /> {isFetching ? 'Analyse' : 'Analyser'}
</button>
</div>
{data && !data.available && (
<div className="card border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">{data.reason}</div>
)}
{data?.available && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Spot</div>
<div className="text-sm font-mono text-white">{data.spot_a?.toFixed(2)} → {data.spot_b?.toFixed(2)}</div>
<div className={clsx('text-[11px] font-mono', (data.spot_change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{fmtSigned(data.spot_change_pct)}%
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Strike choisi (recul)</div>
<div className="text-sm font-mono text-white">{data.chosen_strike}</div>
<div className="text-[10px] text-slate-600">
échéance {data.expiry_date}{data.expired_by_date_b ? ' (expirée)' : ''} · {data.target_dte_used}j visés
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">IV (départ) vs Vol réalisée</div>
<div className="text-sm font-mono text-white">
{data.iv_a != null ? `${(data.iv_a * 100).toFixed(1)}%` : ''} / {data.realized_vol != null ? `${(data.realized_vol * 100).toFixed(1)}%` : ''}
</div>
</div>
<div className="card-sm text-center">
<div className="text-[10px] text-slate-500">Prime de risque de vol</div>
<div className={clsx('text-sm font-mono font-bold', (data.vol_risk_premium ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{data.vol_risk_premium != null ? `${fmtSigned(data.vol_risk_premium * 100, 1)}pts` : ''}
</div>
<div className="text-[9px] text-slate-600">{(data.vol_risk_premium ?? 0) >= 0 ? 'IV a surpayé la vol réalisée' : 'IV a sous-payé la vol réalisée'}</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<PricingCheckLegCard label="Call" leg={data.legs?.call} />
<PricingCheckLegCard label="Put" leg={data.legs?.put} />
</div>
</>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function OptionsLab() {
// yfinance-based IV watchlist — disabled per user request (2026-07-21): Options Lab
@@ -562,6 +731,7 @@ export default function OptionsLab() {
// // Show bootstrap banner if most items have no meaningful IV Rank (stuck at 50 or null)
// const needsBootstrap = items.length > 0 && items.filter(i => i.iv_rank == null || i.iv_rank === 50).length > items.length * 0.6
const [activeTab, setActiveTab] = useState<'iv-rank' | 'pricing-check'>('iv-rank')
const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist()
const { data: watchlistInstruments } = useInstrumentsWatchlist()
// Cross-reference the Saxo IV watchlist (keyed by Saxo option symbol, e.g. "MCLU6")
@@ -608,8 +778,28 @@ export default function OptionsLab() {
Saxo broker data · IV Rank · Term Structure · Skew
</p>
</div>
<div className="flex gap-1 bg-dark-700 p-1 rounded">
<button onClick={() => setActiveTab('iv-rank')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors', {
'bg-blue-600 text-white': activeTab === 'iv-rank',
'text-slate-400 hover:text-slate-200': activeTab !== 'iv-rank',
})}>
IV Rank
</button>
<button onClick={() => setActiveTab('pricing-check')}
className={clsx('px-3 py-1.5 rounded text-xs font-semibold transition-colors', {
'bg-blue-600 text-white': activeTab === 'pricing-check',
'text-slate-400 hover:text-slate-200': activeTab !== 'pricing-check',
})}>
Vérification de pricing
</button>
</div>
</div>
{activeTab === 'pricing-check' && <PricingCheckPanel />}
{activeTab === 'iv-rank' && (
<>
{/* Légende */}
<div className="grid grid-cols-2 gap-3">
<div className="card bg-red-900/10 border-red-700/20 text-xs">
@@ -729,6 +919,8 @@ export default function OptionsLab() {
{/* yfinance IV Watchlist Manager — disabled along with the section above.
<WatchlistManager />
*/}
</>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
useAddPosition, useClosePosition, usePositionPayoff
useAddPosition, useClosePosition, usePositionPayoff, useRetrospectiveOptimal
} from '../hooks/useApi'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import axios from 'axios'
@@ -263,6 +263,91 @@ function PositionPayoffChart({ posId, enabled, legs }: { posId: string; enabled:
)
}
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)
@@ -447,6 +532,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
</div>
<PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} />
<RetrospectiveComparisonCard posId={pos.id} enabled={showDetails} />
</div>
)}