Backend:
- DB: add system_logs table (level/source/cycle_id/ticker/message) and
iv_watchlist table (ticker/added_by/is_active); seed builtin 18 tickers
- DBLogHandler attached at startup — all WARNING+ logs auto-persist to DB
- log_system_event() helper for structured manual events
- New router /api/logs: GET with filters (level, source, cycle_id, ticker,
date range), GET /sources, GET /cycles for dropdowns, DELETE /clear
- iv_watchlist now read from DB instead of hardcoded constant; options_vol
watchlist/refresh/bootstrap endpoints all use get_watchlist_tickers()
- New endpoints: POST/DELETE /options-vol/watchlist-tickers/{ticker} to
add/remove tickers; adding triggers background 1-year bootstrap
- auto_cycle: after log_trade_entries(), auto-detect new underlying proxies
not yet in watchlist, add them and bootstrap their IV history
Frontend:
- New page SystemLogs (/logs): log table with level/source/cycle/ticker/date
filters, color-coded rows, expandable JSON details, auto-refresh 30s
- Options Lab: WatchlistManager section — add ticker input, chip list with
builtin/auto/manual color coding, remove button for non-builtins
- Sidebar: Logs Système nav link (ScrollText icon)
- useApi: useSystemLogs, useLogSources, useLogCycles, useClearLogs,
useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
499 lines
23 KiB
TypeScript
499 lines
23 KiB
TypeScript
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: <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' }
|
||
}
|
||
|
||
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>
|
||
}
|
||
|
||
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="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_source === 'history' ? '~' : ''}${item.iv_current_pct}%` : '—'}
|
||
</span>
|
||
<div className="text-[8px] text-slate-600">{item.iv_source === 'history' ? 'IV estimée' : '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-500">{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>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div className="card space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<List className="w-4 h-4 text-slate-400" />
|
||
<span className="text-sm font-semibold text-slate-300">Gestion de la Watchlist IV</span>
|
||
<span className="ml-auto text-xs text-slate-600">{active.length} tickers actifs</span>
|
||
</div>
|
||
|
||
{/* Add ticker */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
className="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-xs text-slate-300 placeholder:text-slate-600"
|
||
placeholder="Ajouter ticker (ex: AAPL, FXI, EEM…)"
|
||
value={input}
|
||
onChange={e => setInput(e.target.value.toUpperCase())}
|
||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||
/>
|
||
<button
|
||
onClick={handleAdd}
|
||
disabled={adding || !input.trim()}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border border-blue-600/40 text-blue-400 rounded disabled:opacity-40 transition-all"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
{adding ? 'Ajout...' : 'Ajouter'}
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] text-slate-600">Le système bootstrappe automatiquement 1 an d'historique IV pour chaque nouveau ticker ajouté.</p>
|
||
|
||
{/* Active tickers */}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{active.map(e => (
|
||
<span key={e.ticker} className={clsx(
|
||
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono',
|
||
e.added_by === 'builtin' ? 'bg-slate-800 text-slate-400 border border-slate-700/50' :
|
||
e.added_by === 'cycle' ? 'bg-blue-900/30 text-blue-300 border border-blue-700/30' :
|
||
'bg-emerald-900/20 text-emerald-300 border border-emerald-700/30'
|
||
)}>
|
||
{e.ticker}
|
||
{e.added_by !== 'builtin' && (
|
||
<button onClick={() => handleRemove(e.ticker)} className="opacity-50 hover:opacity-100 ml-0.5">
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="text-[9px] text-slate-700 flex gap-3">
|
||
<span className="text-slate-800 border-b border-slate-700">■</span> Builtins
|
||
<span className="text-blue-700 border-b border-blue-700">■</span> Auto (cycle)
|
||
<span className="text-emerald-700 border-b border-emerald-700">■</span> Manuel
|
||
</div>
|
||
|
||
{inactive.length > 0 && (
|
||
<button onClick={() => setShowInactive(!showInactive)} className="text-[10px] text-slate-600 hover:text-slate-400">
|
||
{showInactive ? 'Masquer' : `Voir ${inactive.length} ticker(s) désactivés`}
|
||
</button>
|
||
)}
|
||
{showInactive && inactive.map(e => (
|
||
<span key={e.ticker} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-mono bg-slate-900 text-slate-700 line-through mr-1">
|
||
{e.ticker}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
export default function OptionsLab() {
|
||
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
||
const [bootstrapping, setBootstrapping] = useState(false)
|
||
const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(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 (
|
||
<div className="p-6 space-y-5">
|
||
{needsBootstrap && !bootstrapMsg && (
|
||
<div className="flex items-center justify-between gap-3 px-4 py-3 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
||
<span>⚠️ Historique IV insuffisant — IV Rank bloqué à 50%. Bootstrappe 1 an de données réalisées pour calibrer le Rank.</span>
|
||
<button
|
||
onClick={handleBootstrap}
|
||
disabled={bootstrapping}
|
||
className="flex items-center gap-1.5 text-xs bg-amber-700/30 hover:bg-amber-700/50 border border-amber-600/50 text-amber-200 px-3 py-1.5 rounded transition-all whitespace-nowrap disabled:opacity-50"
|
||
>
|
||
<Database className={clsx('w-3.5 h-3.5', bootstrapping && 'animate-pulse')} />
|
||
{bootstrapping ? 'Chargement...' : 'Initialiser historique'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{bootstrapMsg && (
|
||
<div className="px-4 py-3 rounded border border-blue-700/40 bg-blue-900/10 text-xs text-blue-300">
|
||
ℹ️ {bootstrapMsg}
|
||
</div>
|
||
)}
|
||
|
||
<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 > 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 < 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 < longue (marché calme, bon pour vendre du court terme) ·
|
||
<strong className="text-slate-500"> Backwardation</strong> = IV courte > 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 > 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 < 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 20–80% ({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-500 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>
|
||
)}
|
||
|
||
<WatchlistManager />
|
||
</div>
|
||
)
|
||
}
|