feat: Phase 1 — IV Rank, Term Structure, Skew, Options Flow (Sprint 1.1/1.2/1.3)

Backend:
- iv_engine.py: ATM IV, term structure (30/60/90/180j), put/call skew,
  options flow (P/C OI ratio, unusual strikes, gamma bias), proxy map for futures→ETFs
- database.py: iv_history table + save_iv_snapshot, get_iv_rank_percentile, get_iv_history
- routers/options_vol.py: /api/options-vol/ endpoints (snapshot, batch, watchlist, history)
- auto_cycle.py: inject IV context string into scoring prompt (step 3.5)
- ai_analyzer.py: score_patterns_with_context accepts iv_context param
- main.py: register options_vol router

Frontend:
- pages/OptionsLab.tsx: full IV dashboard (watchlist by IVR, term structure, skew, flow, sparkline)
- pages/JournalDeBord.tsx: IvRankCell component + IV Rank column per trade
- hooks/useApi.ts: useIvSnapshot, useIvWatchlist, useIvBatch, useIvHistory, useIvForTrade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-17 16:29:33 +02:00
parent 07c1a74704
commit 9a6b6f70b1
12 changed files with 3886 additions and 329 deletions

View File

@@ -573,3 +573,43 @@ export const useDeleteKbEntry = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge-entries'] }),
})
}
// ── Options Volatility ────────────────────────────────────────────────────────
export const useIvSnapshot = (ticker: string) =>
useQuery({
queryKey: ['iv-snapshot', ticker],
queryFn: () => api.get(`/options-vol/snapshot/${encodeURIComponent(ticker)}`).then(r => r.data),
enabled: !!ticker,
staleTime: 60 * 60_000, // 1h — IV doesn't change that fast
})
export const useIvWatchlist = () =>
useQuery({
queryKey: ['iv-watchlist'],
queryFn: () => api.get('/options-vol/watchlist').then(r => r.data),
staleTime: 60 * 60_000,
})
export const useIvBatch = (tickers: string) =>
useQuery({
queryKey: ['iv-batch', tickers],
queryFn: () => api.get('/options-vol/batch', { params: { tickers } }).then(r => r.data),
enabled: !!tickers,
staleTime: 60 * 60_000,
})
export const useIvHistory = (ticker: string, days = 90) =>
useQuery({
queryKey: ['iv-history', ticker, days],
queryFn: () => api.get(`/options-vol/history/${encodeURIComponent(ticker)}`, { params: { days } }).then(r => r.data),
enabled: !!ticker,
staleTime: 60 * 60_000,
})
export const useIvForTrade = (underlying: string) =>
useQuery({
queryKey: ['iv-for-trade', underlying],
queryFn: () => api.get(`/options-vol/for-trade/${encodeURIComponent(underlying)}`).then(r => r.data),
enabled: !!underlying,
staleTime: 60 * 60_000,
})

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { BookOpen, TrendingUp, TrendingDown, Activity, AlertTriangle, RefreshCw, Zap, CheckCircle, XCircle, Brain, Trash2, Search, X, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, api } from '../hooks/useApi'
import { useJournalSummary, useMacroHistory, useGeoHistory, useTradeMtm, useCycleHistory, useCycleStatus, useTriggerCycle, useTradePostmortem, useAnalyzePostmortem, useIvForTrade, api } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const SCENARIO_META: Record<string, { label: string; color: string; emoji: string }> = {
@@ -61,6 +61,27 @@ function ScoreDelta({ entry, latest }: { entry: number | null; latest: number |
// ── Section 1 : Historique des régimes macro ──────────────────────────────────
function IvRankCell({ underlying }: { underlying: string }) {
const { data, isLoading } = useIvForTrade(underlying)
if (isLoading) return <span className="text-slate-700 text-[10px]"></span>
const rank = data?.iv_rank
const iv = data?.iv_current_pct
if (rank == null) return <span className="text-slate-700 text-[10px]"></span>
const color = rank >= 80 ? 'text-red-400' : rank >= 50 ? 'text-amber-400' : rank >= 20 ? 'text-emerald-400' : 'text-blue-400'
const signal = rank >= 80 ? '↓vol' : rank < 20 ? '↑vol' : ''
return (
<div className="flex flex-col items-end gap-0.5">
<span className={clsx('text-[10px] font-bold font-mono', color)}>
{rank}%
</span>
<span className="text-[8px] text-slate-600">{iv != null ? `IV ${iv}%` : ''}</span>
{signal && <span className={clsx('text-[8px] font-semibold', color)}>{signal}</span>}
</div>
)
}
function MacroHistorySection({ days }: { days: number }) {
const { data, isLoading, refetch, isFetching } = useMacroHistory(days)
const history: any[] = (data as any)?.history ?? []
@@ -395,6 +416,7 @@ function TradeMtmSection({ days }: { days: number }) {
<th className="text-right px-3 py-2 font-medium">Prix entrée</th>
<th className="text-right px-3 py-2 font-medium">Prix actuel</th>
<th className="text-right px-3 py-2 font-medium">Maturité</th>
<th className="text-right px-3 py-2 font-medium">IV Rank</th>
<th className="text-right px-3 py-2 font-medium">P&L th.</th>
<th className="px-3 py-2 font-medium w-8"></th>
</tr>
@@ -483,6 +505,9 @@ function TradeMtmSection({ days }: { days: number }) {
</span>
)}
</td>
<td className="px-3 py-2 text-right">
<IvRankCell underlying={t.underlying} />
</td>
<td className="px-3 py-2 text-right">
<PnlBadge pnl={t.pnl_pct} />
</td>

View File

@@ -1,341 +1,356 @@
import { useState } from 'react'
import { usePnlCurve } from '../hooks/useApi'
import axios from 'axios'
import { useIvWatchlist, useIvSnapshot, useIvHistory } from '../hooks/useApi'
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, Legend,
} from 'recharts'
import { TrendingUp, FlaskConical, Calculator } from 'lucide-react'
import type { PnLPoint } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call', desc: 'Pari haussier, gain illimité, perte limitée à la prime' },
{ key: 'long_put', label: 'Long Put', desc: 'Pari baissier, gain important, perte limitée à la prime' },
{ key: 'bull_call_spread', label: 'Bull Call Spread', desc: 'Haussier modéré, coût réduit, gain plafonné' },
{ key: 'bear_put_spread', label: 'Bear Put Spread', desc: 'Baissier modéré, coût réduit, gain plafonné' },
{ key: 'straddle', label: 'Long Straddle', desc: 'Pari sur la volatilité, direction neutre' },
]
// ── Helpers ───────────────────────────────────────────────────────────────────
const WATCHLIST_QUICK = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'UUP', 'XOM', 'LMT',
]
interface Greeks {
price: number; delta: number; gamma: number; theta: number; vega: number; rho: number
underlying_price: number; sigma: number
function ivRankColor(rank: number | null | undefined): string {
if (rank == null) return 'text-slate-500'
if (rank >= 80) return 'text-red-400'
if (rank >= 50) return 'text-amber-400'
if (rank >= 20) return 'text-emerald-400'
return 'text-blue-400'
}
interface SpreadResult {
strategy: string; net_debit: number; max_loss: number; max_gain: number | null
breakeven?: number; breakevens?: number[]; legs: Array<{type: string; strike: number; premium: number}>
underlying_price: number; sigma: number
function ivRankBg(rank: number | null | undefined): string {
if (rank == null) return 'bg-dark-700/30 border-slate-700/30'
if (rank >= 80) return 'bg-red-900/20 border-red-700/30'
if (rank >= 50) return 'bg-amber-900/10 border-amber-700/20'
if (rank >= 20) return 'bg-emerald-900/10 border-emerald-700/20'
return 'bg-blue-900/20 border-blue-700/30'
}
export default function OptionsLab() {
const [strategy, setStrategy] = useState('long_call')
const [symbol, setSymbol] = useState('GLD')
const [strike, setStrike] = useState(200)
const [strikeHigh, setStrikeHigh] = useState(210)
const [expiry, setExpiry] = useState(90)
const [optionType, setOptionType] = useState('call')
const [quantity, setQuantity] = useState(1)
const [result, setResult] = useState<Greeks | SpreadResult | null>(null)
const [pnlData, setPnlData] = useState<PnLPoint[]>([])
const [loading, setLoading] = useState(false)
function ivSignalLabel(rank: number | null | undefined) {
if (rank == null) return null
if (rank >= 80) return { text: 'Vendre de la vol', icon: <TrendingDown className="w-3 h-3" />, cls: 'text-red-400' }
if (rank < 20) return { text: 'Acheter de la vol', icon: <TrendingUp className="w-3 h-3" />, cls: 'text-blue-400' }
return { text: 'Neutre', icon: <Minus className="w-3 h-3" />, cls: 'text-slate-500' }
}
const compute = async () => {
setLoading(true)
try {
let res: Greeks | SpreadResult
if (strategy === 'long_call' || strategy === 'long_put') {
const type = strategy === 'long_call' ? 'call' : 'put'
const r = await axios.get('/api/options/price', {
params: { symbol, strike, expiry_days: expiry, option_type: type }
})
res = r.data as Greeks
const pnl = await axios.get('/api/options/pnl-curve', {
params: { symbol, strike, expiry_days: expiry, option_type: type, quantity, premium_paid: res.price }
})
setPnlData(pnl.data as PnLPoint[])
} else if (strategy === 'bull_call_spread') {
const r = await axios.get('/api/options/strategy/bull-call-spread', {
params: { symbol, strike_low: strike, strike_high: strikeHigh, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else if (strategy === 'bear_put_spread') {
const r = await axios.get('/api/options/strategy/bear-put-spread', {
params: { symbol, strike_high: strikeHigh, strike_low: strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
} else {
const r = await axios.get('/api/options/strategy/straddle', {
params: { symbol, strike, expiry_days: expiry }
})
res = r.data as SpreadResult
setPnlData([])
}
setResult(res)
} catch (e) {
console.error(e)
}
setLoading(false)
function StructureBadge({ structure }: { structure: string | null | undefined }) {
if (!structure) return null
const map: Record<string, { label: string; cls: string }> = {
contango: { label: 'Contango ↗', cls: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/30' },
backwardation: { label: 'Backwardation ↘', cls: 'text-red-400 bg-red-900/20 border-red-700/30' },
flat: { label: 'Flat →', cls: 'text-slate-400 bg-slate-800 border-slate-700/30' },
}
const s = map[structure] || { label: structure, cls: 'text-slate-400 bg-slate-800 border-slate-700' }
return <span className={clsx('text-[9px] font-semibold px-1.5 py-0.5 rounded border', s.cls)}>{s.label}</span>
}
const isGreeks = result && 'delta' in result
const isSpread = result && 'net_debit' in result
function IvBar({ rank }: { rank: number | null | undefined }) {
if (rank == null) return <div className="h-1.5 w-full bg-slate-800 rounded-full" />
const color = rank >= 80 ? 'bg-red-500' : rank >= 50 ? 'bg-amber-500' : rank >= 20 ? 'bg-emerald-500' : 'bg-blue-500'
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<FlaskConical className="w-5 h-5 text-blue-400" /> Options Lab
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Pricer Black-Scholes · Greeks · Stratégies · Courbe P&L
</p>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Left: strategy builder */}
<div className="col-span-1 space-y-4">
{/* Strategy select */}
<div className="card">
<div className="section-title">Stratégie</div>
<div className="space-y-1.5">
{STRATEGIES.map(s => (
<button
key={s.key}
onClick={() => setStrategy(s.key)}
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': strategy === s.key,
'border-slate-700/40 text-slate-400 hover:border-slate-600': strategy !== s.key,
})}
>
<div className="font-semibold">{s.label}</div>
<div className="text-slate-500 mt-0.5">{s.desc}</div>
</button>
))}
</div>
</div>
{/* Parameters */}
<div className="card">
<div className="section-title flex items-center gap-1"><Calculator className="w-3 h-3" /> Paramètres</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</label>
<div className="flex gap-1 flex-wrap mb-1">
{WATCHLIST_QUICK.map(s => (
<button
key={s}
onClick={() => setSymbol(s)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': symbol === s,
'border-slate-700 text-slate-500 hover:border-slate-500': symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={symbol}
onChange={e => setSymbol(e.target.value.toUpperCase())}
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"
placeholder="Ex: GLD, USO, SPY"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">
Strike {strategy === 'bull_call_spread' || strategy === 'bear_put_spread' ? 'bas' : ''}
</label>
<input
type="number"
value={strike}
onChange={e => setStrike(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>
{(strategy === 'bull_call_spread' || strategy === 'bear_put_spread') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Strike haut</label>
<input
type="number"
value={strikeHigh}
onChange={e => setStrikeHigh(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">Expiration (jours)</label>
<div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => (
<button
key={d}
onClick={() => setExpiry(d)}
className={clsx('px-2 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': expiry === d,
'border-slate-700 text-slate-500 hover:border-slate-500': expiry !== d,
})}
>
{d}j
</button>
))}
</div>
<input
type="number"
value={expiry}
onChange={e => setExpiry(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>
{(strategy === 'long_call' || strategy === 'long_put') && (
<div>
<label className="text-xs text-slate-500 mb-1 block">Nb contrats</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
min={1}
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>
)}
<button
onClick={compute}
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold transition-colors"
>
{loading ? 'Calcul...' : '⚡ Calculer'}
</button>
</div>
</div>
</div>
{/* Right: results */}
<div className="col-span-2 space-y-4">
{/* Greeks / Spread summary */}
{isGreeks && (
<div className="card">
<div className="section-title">Prix & Greeks {symbol} {strike} {strategy === 'long_call' ? 'Call' : 'Put'} {expiry}j</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Prime', value: `$${result.price.toFixed(4)}`, highlight: true },
{ label: 'Coût (1 contrat)', value: `$${(result.price * 100).toFixed(2)}` },
{ label: 'Spot sous-jacent', value: `$${result.underlying_price?.toFixed(2)}` },
{ label: 'Vol. Réalisée', value: `${((result.sigma ?? 0) * 100).toFixed(1)}%` },
].map(({ label, value, highlight }) => (
<div key={label} className="card-sm text-center">
<div className="stat-label">{label}</div>
<div className={clsx('stat-value text-lg mt-1', highlight && 'text-blue-400')}>{value}</div>
</div>
))}
</div>
<div className="grid grid-cols-5 gap-2">
{[
{ label: 'Delta (Δ)', value: result.delta, desc: 'Sensibilité au prix' },
{ label: 'Gamma (Γ)', value: result.gamma, desc: 'Variation du delta' },
{ label: 'Theta (Θ)', value: result.theta, desc: 'Déclin temporel/j' },
{ label: 'Vega (ν)', value: result.vega, desc: 'Sensibilité à IV' },
{ label: 'Rho (ρ)', value: result.rho, desc: 'Sensibilité aux taux' },
].map(({ label, value, desc }) => (
<div key={label} className="card-sm text-center">
<div className="text-xs text-slate-500">{label}</div>
<div className={clsx('text-base font-bold mt-0.5', {
'text-emerald-400': value > 0,
'text-red-400': value < 0,
'text-slate-400': value === 0,
})}>
{value?.toFixed(4)}
</div>
<div className="text-xs text-slate-600 mt-0.5">{desc}</div>
</div>
))}
</div>
</div>
)}
{isSpread && (
<div className="card">
<div className="section-title">Résumé stratégie {result.strategy}</div>
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: 'Débit net', value: `$${result.net_debit.toFixed(4)}`, color: 'text-red-400' },
{ label: 'Perte max', value: `$${result.max_loss.toFixed(2)}`, color: 'text-red-400' },
{ label: 'Gain max', value: result.max_gain != null ? `$${result.max_gain.toFixed(2)}` : '∞', color: 'text-emerald-400' },
{ label: 'Seuil renta.', value: `$${(result.breakeven ?? (result.breakevens?.[0]) ?? 0).toFixed(2)}`, color: 'text-yellow-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>
<div>
<div className="text-xs text-slate-500 mb-2">Jambes de la stratégie</div>
{result.legs.map((leg, i) => (
<div key={i} className="flex items-center gap-3 text-xs py-1.5 border-b border-slate-700/30 last:border-0">
<span className={clsx('badge', leg.type.includes('long') ? 'badge-green' : 'badge-red')}>
{leg.type.toUpperCase()}
</span>
<span className="text-white">Strike: ${leg.strike}</span>
<span className="text-slate-400">Prime: ${leg.premium.toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
{/* P&L Chart */}
{pnlData.length > 0 && (
<div className="card">
<div className="section-title">Courbe P&L à l'expiration</div>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={pnlData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" tick={{ fill: '#475569', fontSize: 9 }}
tickFormatter={v => `$${v.toFixed(0)}`} />
<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']}
labelFormatter={v => `Prix sous-jacent: $${Number(v).toFixed(2)}`}
/>
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={strike} stroke="#f59e0b" strokeDasharray="4 4" label={{ value: 'Strike', fill: '#f59e0b', fontSize: 9 }} />
<Line
type="monotone" dataKey="pnl"
stroke="#3b82f6" strokeWidth={2} dot={false}
activeDot={{ r: 4, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{!result && !loading && (
<div className="card h-80 flex items-center justify-center text-slate-600 text-sm">
<div className="text-center">
<FlaskConical className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div>Configurer et calculer une stratégie</div>
<div className="text-xs mt-1">Les résultats apparaîtront ici</div>
</div>
</div>
)}
</div>
</div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${Math.min(rank, 100)}%` }} />
</div>
)
}
function IvSparkline({ history }: { history: { recorded_date: string; iv_current: number }[] }) {
if (!history || history.length < 3) return null
const vals = [...history].reverse().map(h => h.iv_current * 100)
const max = Math.max(...vals)
const min = Math.min(...vals)
const range = max - min || 1
const W = 100, H = 28
const points = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W
const y = H - ((v - min) / range) * (H - 2) - 1
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
return (
<svg width={W} height={H} className="opacity-70">
<polyline points={points} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
<circle
cx={W} cy={H - ((vals[vals.length - 1] - min) / range) * (H - 2) - 1}
r="2" fill="#60a5fa" />
</svg>
)
}
// ── Expanded detail panel for one ticker ─────────────────────────────────────
function TickerDetail({ ticker }: { ticker: string }) {
const { data: snap, isLoading } = useIvSnapshot(ticker)
const { data: histData } = useIvHistory(ticker, 90)
if (isLoading) return <div className="h-20 mt-3 animate-pulse bg-dark-700 rounded" />
if (!snap) return null
const ts = (snap.term_structure || {}) as any
const skew = (snap.skew || {}) as any
const flow = (snap.options_flow || {}) as any
const history: any[] = histData?.history || []
return (
<div className="mt-3 border-t border-slate-700/30 pt-3 grid grid-cols-3 gap-4">
{/* Term Structure */}
<div>
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Term Structure</div>
<div className="space-y-1.5">
{([['30j', ts.iv_30d], ['60j', ts.iv_60d], ['90j', ts.iv_90d], ['180j', ts.iv_180d]] as [string, number | undefined][]).map(([label, iv]) =>
iv ? (
<div key={label} className="flex items-center gap-2">
<span className="text-[9px] text-slate-600 w-7 shrink-0">{label}</span>
<div className="flex-1 h-1 bg-slate-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500/50 rounded-full" style={{ width: `${Math.min(iv * 300, 100)}%` }} />
</div>
<span className="text-[9px] text-slate-300 font-mono w-9 text-right">{(iv * 100).toFixed(1)}%</span>
</div>
) : null
)}
{ts.structure && <div className="mt-1"><StructureBadge structure={ts.structure} /></div>}
</div>
</div>
{/* Skew */}
<div>
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Skew Put/Call</div>
{skew.put_skew != null ? (
<div className="space-y-1">
<div className="flex justify-between text-[9px]">
<span className="text-slate-600">Put 25Δ</span>
<span className="text-slate-300 font-mono">{skew.iv_put_25d}%</span>
</div>
<div className="flex justify-between text-[9px]">
<span className="text-slate-600">Call 25Δ</span>
<span className="text-slate-300 font-mono">{skew.iv_call_25d}%</span>
</div>
<div className={clsx('text-[10px] font-bold mt-1',
skew.skew_pct > 3 ? 'text-red-400' : skew.skew_pct < -2 ? 'text-blue-400' : 'text-slate-400')}>
{skew.skew_pct > 0 ? '+' : ''}{skew.skew_pct} pts
</div>
{skew.interpretation && (
<div className="text-[9px] text-slate-600 italic leading-tight">{skew.interpretation}</div>
)}
</div>
) : (
<div className="text-[9px] text-slate-700 italic">Données insuffisantes</div>
)}
</div>
{/* Options Flow */}
<div>
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Options Flow</div>
{flow.pc_oi_ratio != null ? (
<div className="space-y-1">
<div className="flex justify-between text-[9px]">
<span className="text-slate-600">P/C OI ratio</span>
<span className={clsx('font-mono font-bold',
flow.pc_oi_ratio > 1.3 ? 'text-red-400' : flow.pc_oi_ratio < 0.7 ? 'text-blue-400' : 'text-slate-300')}>
{flow.pc_oi_ratio}
</span>
</div>
{flow.flow_bias && <div className="text-[9px] text-slate-500">{flow.flow_bias}</div>}
{flow.gamma_bias && <div className="text-[9px] text-amber-500/70">{flow.gamma_bias}</div>}
{flow.unusual_strikes?.length > 0 && (
<div className="mt-1 pt-1 border-t border-slate-700/30">
<div className="text-[8px] text-slate-600 mb-0.5">Strikes inhabituels</div>
{flow.unusual_strikes.slice(0, 2).map((s: any) => (
<div key={s.strike} className="text-[8px] font-mono text-slate-500">
{s.strike} {s.type.toUpperCase()} OI={s.total_oi.toLocaleString()}
<span className="text-slate-700"> ({s.pct_otm > 0 ? '+' : ''}{s.pct_otm}%)</span>
</div>
))}
</div>
)}
</div>
) : (
<div className="text-[9px] text-slate-700 italic">Pas d'OI disponible</div>
)}
</div>
{/* History sparkline */}
{history.length > 4 && (
<div className="col-span-3 flex items-center gap-3 pt-1 border-t border-slate-700/20">
<span className="text-[9px] text-slate-600 shrink-0">IV 90j</span>
<IvSparkline history={history} />
<div className="text-[9px] text-slate-600">
min <span className="text-slate-400">{snap.iv_min_52w_pct}%</span>
{' · '}max <span className="text-slate-400">{snap.iv_max_52w_pct}%</span>
{' · '}{snap.history_days}j d'historique
</div>
</div>
)}
</div>
)
}
// ── Watchlist Row ─────────────────────────────────────────────────────────────
function WatchlistRow({ item }: { item: any }) {
const [expanded, setExpanded] = useState(false)
const signal = ivSignalLabel(item.iv_rank)
return (
<div className={clsx('rounded-lg border transition-all', ivRankBg(item.iv_rank))}>
<div
className="flex items-center gap-3 px-3 py-2.5 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="w-14 shrink-0">
<div className="text-sm font-bold text-slate-200">{item.ticker}</div>
{item.proxy && item.proxy !== item.ticker && (
<div className="text-[8px] text-slate-700"> {item.proxy}</div>
)}
</div>
<div className="w-16 shrink-0">
<span className={clsx('text-sm font-bold font-mono', ivRankColor(item.iv_rank))}>
{item.iv_current_pct != null ? `${item.iv_current_pct}%` : '—'}
</span>
<div className="text-[8px] text-slate-600">IV actuelle</div>
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] text-slate-600">IV Rank</span>
<span className={clsx('text-[10px] font-bold font-mono', ivRankColor(item.iv_rank))}>
{item.iv_rank != null ? `${item.iv_rank}%` : 'N/A'}
</span>
</div>
<IvBar rank={item.iv_rank} />
</div>
<div className="w-16 text-right shrink-0">
<div className="text-[8px] text-slate-600">Pctile</div>
<div className={clsx('text-[10px] font-mono font-bold', ivRankColor(item.iv_rank))}>
{item.iv_percentile != null ? `${item.iv_percentile}%` : '—'}
</div>
</div>
<div className="w-32 text-right shrink-0">
{signal && (
<div className={clsx('flex items-center justify-end gap-1 text-[10px] font-semibold', signal.cls)}>
{signal.icon} {signal.text}
</div>
)}
{item.history_days > 0 && (
<div className="text-[8px] text-slate-700">{item.history_days}j historique</div>
)}
</div>
<div className="text-slate-700">
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</div>
</div>
{expanded && (
<div className="px-3 pb-3">
<TickerDetail ticker={item.proxy || item.ticker} />
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function OptionsLab() {
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
const items: any[] = data?.items || []
const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80)
const buyVol = items.filter(i => i.iv_rank != null && i.iv_rank < 20)
const neutral = items.filter(i => i.iv_rank != null && i.iv_rank >= 20 && i.iv_rank < 80)
const noData = items.filter(i => i.iv_rank == null)
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">
<Activity className="w-5 h-5 text-blue-400" /> Options Lab
</h1>
<p className="text-xs text-slate-500 mt-0.5">
IV Rank · IV Percentile · Term Structure · Skew · Options Flow
</p>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
{isFetching ? 'Chargement...' : 'Actualiser'}
</button>
</div>
{/* Légende */}
<div className="grid grid-cols-2 gap-3">
<div className="card bg-red-900/10 border-red-700/20 text-xs">
<div className="flex items-center gap-2 mb-1">
<TrendingDown className="w-3.5 h-3.5 text-red-400" />
<span className="font-semibold text-red-400">IV Rank &gt; 80% Vendre de la vol</span>
</div>
<div className="text-slate-500 leading-snug">Credit spreads, iron condors, covered calls. La prime est élevée avantage au vendeur.</div>
</div>
<div className="card bg-blue-900/10 border-blue-700/20 text-xs">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
<span className="font-semibold text-blue-400">IV Rank &lt; 20% Acheter de la convexité</span>
</div>
<div className="text-slate-500 leading-snug">Long calls, long puts, straddles, strangles. La prime est cheap bon moment pour acheter du gamma.</div>
</div>
</div>
<div className="text-[10px] text-slate-600 -mt-2 px-1">
<strong className="text-slate-500">Contango</strong> = IV courte &lt; longue (marché calme, bon pour vendre du court terme) ·
<strong className="text-slate-500"> Backwardation</strong> = IV courte &gt; longue (stress, bon pour acheter de la protection courte) ·
<strong className="text-slate-500"> Skew +</strong> = puts plus chers que calls (biais baissier institutionnel)
</div>
{isLoading ? (
<div className="space-y-2">
{[...Array(6)].map((_, i) => <div key={i} className="h-12 animate-pulse bg-dark-700 rounded-lg" />)}
</div>
) : items.length === 0 ? (
<div className="card text-center py-12 text-slate-600">
<Activity className="w-8 h-8 mx-auto mb-3 opacity-30" />
<div className="font-semibold mb-1 text-slate-500">Aucune donnée IV disponible</div>
<div className="text-xs text-slate-600">
Les ETFs US sont requis (SPY, QQQ, GLD...).
<br />L'IV Rank nécessite de l'historique les données s'accumulent à chaque actualisation.
</div>
<button onClick={() => refetch()} disabled={isFetching}
className="mt-4 text-xs bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded disabled:opacity-50">
Lancer la collecte
</button>
</div>
) : (
<div className="space-y-5">
{sellVol.length > 0 && (
<div>
<div className="text-xs font-semibold text-red-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<TrendingDown className="w-3.5 h-3.5" /> Vendre de la vol IVR &gt; 80% ({sellVol.length})
</div>
<div className="space-y-1.5">{sellVol.map(item => <WatchlistRow key={item.ticker} item={item} />)}</div>
</div>
)}
{buyVol.length > 0 && (
<div>
<div className="text-xs font-semibold text-blue-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5" /> Acheter de la vol IVR &lt; 20% ({buyVol.length})
</div>
<div className="space-y-1.5">{buyVol.map(item => <WatchlistRow key={item.ticker} item={item} />)}</div>
</div>
)}
{neutral.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Minus className="w-3.5 h-3.5" /> Zone neutre IVR 2080% ({neutral.length})
</div>
<div className="space-y-1.5">{neutral.map(item => <WatchlistRow key={item.ticker} item={item} />)}</div>
</div>
)}
{noData.length > 0 && (
<div>
<div className="text-xs text-slate-700 uppercase tracking-wide mb-2">Sans historique ({noData.length})</div>
<div className="space-y-1.5">{noData.map(item => <WatchlistRow key={item.ticker} item={item} />)}</div>
</div>
)}
</div>
)}
</div>
)
}