Files
OpenFin/frontend/src/pages/OptionsLab.tsx
OpenSquared dcbc9f19fc feat: translate all UI strings to English for international release
Complete French→English translation across all frontend pages and backend
services — every label, button, header, empty state, toast, and nav item
is now in English. Build verified clean (tsc + vite). No i18n library
added; direct string replacement throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 09:06:37 +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: 'Sell vol', icon: <TrendingDown className="w-3 h-3" />, cls: 'text-red-400' }
if (rank < 20) return { text: 'Buy vol', icon: <TrendingUp className="w-3 h-3" />, cls: 'text-blue-400' }
return { text: 'Neutral', 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">
{([['30d', ts.iv_30d], ['60d', ts.iv_60d], ['90d', ts.iv_90d], ['180d', 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">Insufficient data</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">Unusual strikes</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">No OI available</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 90d</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}d of history
</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' ? 'Estimated IV' : 'Current IV'}</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}d history</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">IV Watchlist Manager</span>
<span className="ml-auto text-xs text-slate-600">{active.length} active tickers</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="Add ticker (e.g. 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 ? 'Adding...' : 'Add'}
</button>
</div>
<p className="text-[10px] text-slate-600">The system automatically bootstraps 1 year of IV history for each new ticker added.</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> Manual
</div>
{inactive.length > 0 && (
<button onClick={() => setShowInactive(!showInactive)} className="text-[10px] text-slate-600 hover:text-slate-400">
{showInactive ? 'Hide' : `Show ${inactive.length} disabled ticker(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 started (~60s) — refresh in 1 minute to see updated IV Ranks.')
setTimeout(() => refetch(), 70_000)
} catch {
setBootstrapMsg('Error during 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> Insufficient IV history IV Rank stuck at 50%. Bootstrap 1 year of realized data to calibrate the 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 ? 'Loading...' : 'Initialize history'}
</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 ? 'Loading...' : 'Refresh'}
</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% Sell vol</span>
</div>
<div className="text-slate-500 leading-snug">Credit spreads, iron condors, covered calls. Premium is high seller has the edge.</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% Buy convexity</span>
</div>
<div className="text-slate-500 leading-snug">Long calls, long puts, straddles, strangles. Premium is cheap good time to buy gamma.</div>
</div>
</div>
<div className="text-[10px] text-slate-600 -mt-2 px-1">
<strong className="text-slate-500">Contango</strong> = short IV &lt; long IV (calm market, good for selling short term) ·
<strong className="text-slate-500"> Backwardation</strong> = short IV &gt; long IV (stress, good for buying short protection) ·
<strong className="text-slate-500"> Skew +</strong> = puts more expensive than calls (institutional bearish bias)
</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">No IV data available</div>
<div className="text-xs text-slate-600">
US ETFs are required (SPY, QQQ, GLD...).
<br />IV Rank requires history data accumulates with each refresh.
</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">
Start collection
</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" /> Sell 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" /> Buy 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" /> Neutral zone 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">No history ({noData.length})</div>
<div className="space-y-1.5">{noData.map(item => <WatchlistRow key={item.ticker} item={item} />)}</div>
</div>
)}
</div>
)}
<WatchlistManager />
</div>
)
}