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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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-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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user