import { useState } from 'react' import { useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker } from '../hooks/useApi' import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List } from 'lucide-react' import { api } from '../hooks/useApi' import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' // ── Helpers ─────────────────────────────────────────────────────────────────── 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' } 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' } function ivSignalLabel(rank: number | null | undefined) { if (rank == null) return null if (rank >= 80) return { text: 'Vendre de la vol', icon: , cls: 'text-red-400' } if (rank < 20) return { text: 'Acheter de la vol', icon: , cls: 'text-blue-400' } return { text: 'Neutre', icon: , cls: 'text-slate-500' } } function StructureBadge({ structure }: { structure: string | null | undefined }) { if (!structure) return null const map: Record = { 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 {s.label} } function IvBar({ rank }: { rank: number | null | undefined }) { if (rank == null) return
const color = rank >= 80 ? 'bg-red-500' : rank >= 50 ? 'bg-amber-500' : rank >= 20 ? 'bg-emerald-500' : 'bg-blue-500' return (
) } 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 ( ) } // ── 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
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 (
{/* Term Structure */}
Term Structure
{([['30j', ts.iv_30d], ['60j', ts.iv_60d], ['90j', ts.iv_90d], ['180j', ts.iv_180d]] as [string, number | undefined][]).map(([label, iv]) => iv ? (
{label}
{(iv * 100).toFixed(1)}%
) : null )} {ts.structure &&
}
{/* Skew */}
Skew Put/Call
{skew.put_skew != null ? (
Put 25Δ {skew.iv_put_25d}%
Call 25Δ {skew.iv_call_25d}%
3 ? 'text-red-400' : skew.skew_pct < -2 ? 'text-blue-400' : 'text-slate-400')}> {skew.skew_pct > 0 ? '+' : ''}{skew.skew_pct} pts
{skew.interpretation && (
{skew.interpretation}
)}
) : (
Données insuffisantes
)}
{/* Options Flow */}
Options Flow
{flow.pc_oi_ratio != null ? (
P/C OI ratio 1.3 ? 'text-red-400' : flow.pc_oi_ratio < 0.7 ? 'text-blue-400' : 'text-slate-300')}> {flow.pc_oi_ratio}
{flow.flow_bias &&
{flow.flow_bias}
} {flow.gamma_bias &&
{flow.gamma_bias}
} {flow.unusual_strikes?.length > 0 && (
Strikes inhabituels
{flow.unusual_strikes.slice(0, 2).map((s: any) => (
{s.strike} {s.type.toUpperCase()} OI={s.total_oi.toLocaleString()} ({s.pct_otm > 0 ? '+' : ''}{s.pct_otm}%)
))}
)}
) : (
Pas d'OI disponible
)}
{/* History sparkline */} {history.length > 4 && (
IV 90j
min {snap.iv_min_52w_pct}% {' · '}max {snap.iv_max_52w_pct}% {' · '}{snap.history_days}j d'historique
)}
) } // ── Watchlist Row ───────────────────────────────────────────────────────────── function WatchlistRow({ item }: { item: any }) { const [expanded, setExpanded] = useState(false) const signal = ivSignalLabel(item.iv_rank) return (
setExpanded(!expanded)} >
{item.ticker}
{item.proxy && item.proxy !== item.ticker && (
↳ {item.proxy}
)}
{item.iv_current_pct != null ? `${item.iv_source === 'history' ? '~' : ''}${item.iv_current_pct}%` : '—'}
{item.iv_source === 'history' ? 'IV estimée' : 'IV actuelle'}
IV Rank {item.iv_rank != null ? `${item.iv_rank}%` : 'N/A'}
Pctile
{item.iv_percentile != null ? `${item.iv_percentile}%` : '—'}
{signal && (
{signal.icon} {signal.text}
)} {item.history_days > 0 && (
{item.history_days}j historique
)}
{expanded ? : }
{expanded && (
)}
) } // ── Watchlist Manager ───────────────────────────────────────────────────────── function WatchlistManager() { const qc = useQueryClient() const { data } = useWatchlistTickers() const { mutate: addTicker, isPending: adding } = useAddWatchlistTicker() const { mutate: removeTicker } = useRemoveWatchlistTicker() const [input, setInput] = useState('') const [showInactive, setShowInactive] = useState(false) const entries: any[] = data?.tickers ?? [] const active = entries.filter(e => e.is_active) const inactive = entries.filter(e => !e.is_active) const handleAdd = () => { const t = input.trim().toUpperCase() if (!t) return addTicker(t, { onSuccess: () => { setInput('') qc.invalidateQueries({ queryKey: ['watchlist-tickers'] }) qc.invalidateQueries({ queryKey: ['iv-watchlist'] }) } }) } const handleRemove = (ticker: string) => { removeTicker(ticker, { onSuccess: () => { qc.invalidateQueries({ queryKey: ['watchlist-tickers'] }) qc.invalidateQueries({ queryKey: ['iv-watchlist'] }) } }) } return (
Gestion de la Watchlist IV {active.length} tickers actifs
{/* Add ticker */}
setInput(e.target.value.toUpperCase())} onKeyDown={e => e.key === 'Enter' && handleAdd()} />

Le système bootstrappe automatiquement 1 an d'historique IV pour chaque nouveau ticker ajouté.

{/* Active tickers */}
{active.map(e => ( {e.ticker} {e.added_by !== 'builtin' && ( )} ))}
Builtins   Auto (cycle)   Manuel
{inactive.length > 0 && ( )} {showInactive && inactive.map(e => ( {e.ticker} ))}
) } // ── Main page ───────────────────────────────────────────────────────────────── export default function OptionsLab() { const { data, isLoading, refetch, isFetching } = useIvWatchlist() const [bootstrapping, setBootstrapping] = useState(false) const [bootstrapMsg, setBootstrapMsg] = useState(null) 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) const handleBootstrap = async () => { setBootstrapping(true) setBootstrapMsg(null) try { await api.post('/options-vol/bootstrap-history') setBootstrapMsg('Bootstrap lancé (~60s) — actualise dans 1 minute pour voir les IV Rank mis à jour.') setTimeout(() => refetch(), 70_000) } catch { setBootstrapMsg('Erreur lors du bootstrap.') } finally { setBootstrapping(false) } } // 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 return (
{needsBootstrap && !bootstrapMsg && (
⚠️ Historique IV insuffisant — IV Rank bloqué à 50%. Bootstrappe 1 an de données réalisées pour calibrer le Rank.
)} {bootstrapMsg && (
ℹ️ {bootstrapMsg}
)}

Options Lab

IV Rank · IV Percentile · Term Structure · Skew · Options Flow

{/* Légende */}
IV Rank > 80% — Vendre de la vol
Credit spreads, iron condors, covered calls. La prime est élevée → avantage au vendeur.
IV Rank < 20% — Acheter de la convexité
Long calls, long puts, straddles, strangles. La prime est cheap → bon moment pour acheter du gamma.
Contango = IV courte < longue (marché calme, bon pour vendre du court terme) · Backwardation = IV courte > longue (stress, bon pour acheter de la protection courte) · Skew + = puts plus chers que calls (biais baissier institutionnel)
{isLoading ? (
{[...Array(6)].map((_, i) =>
)}
) : items.length === 0 ? (
Aucune donnée IV disponible
Les ETFs US sont requis (SPY, QQQ, GLD...).
L'IV Rank nécessite de l'historique — les données s'accumulent à chaque actualisation.
) : (
{sellVol.length > 0 && (
Vendre de la vol — IVR > 80% ({sellVol.length})
{sellVol.map(item => )}
)} {buyVol.length > 0 && (
Acheter de la vol — IVR < 20% ({buyVol.length})
{buyVol.map(item => )}
)} {neutral.length > 0 && (
Zone neutre — IVR 20–80% ({neutral.length})
{neutral.map(item => )}
)} {noData.length > 0 && (
Sans historique ({noData.length})
{noData.map(item => )}
)}
)}
) }