Files
OpenFin/frontend/src/pages/OptionsLab.tsx
OpenSquared 05a475fb04 feat: system logs page + dynamic IV watchlist with auto-add from cycle
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>
2026-06-18 13:07:35 +02:00

499 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &nbsp;
<span className="text-blue-700 border-b border-blue-700">■</span> Auto (cycle) &nbsp;
<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 &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-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>
)
}