feat: option
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy,
|
||||
useScenarios, useSaveScenario, useDeleteScenario,
|
||||
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
||||
useWatchlistTickers,
|
||||
useWatchlistTickers, useSaxoCatalog,
|
||||
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
|
||||
type OptimizeConstraints, type SavedScenario,
|
||||
} from '../hooks/useApi'
|
||||
@@ -236,6 +236,92 @@ function ScenarioGrid({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Volatility surface heatmap ────────────────────────────────────────────────
|
||||
// Sequential single-hue ramp (light → dark), light steps get dark text for contrast —
|
||||
// magnitude encoding per dataviz convention, no rainbow, no dual-hue diverging misuse.
|
||||
const IV_RAMP = ['#1e2d4d', '#1e3a6e', '#1d4f9e', '#1a63c4', '#3b82f6', '#60a5fa', '#93c5fd', '#bfdbfe']
|
||||
|
||||
function ivCellColor(value: number, min: number, max: number): { bg: string; fg: string } {
|
||||
const range = max - min || 1
|
||||
const t = Math.min(1, Math.max(0, (value - min) / range))
|
||||
const idx = Math.min(IV_RAMP.length - 1, Math.floor(t * IV_RAMP.length))
|
||||
const bg = IV_RAMP[IV_RAMP.length - 1 - idx] // low IV -> light, high IV -> dark
|
||||
const fg = idx >= IV_RAMP.length - 3 ? '#0f1623' : '#ffffff'
|
||||
return { bg, fg }
|
||||
}
|
||||
|
||||
function VolSurfaceHeatmap({ chain, spot }: { chain: any; spot: number }) {
|
||||
const cells = useMemo(() => {
|
||||
if (!chain) return []
|
||||
const out: { expiry: string; days: number; pct: number; iv: number | null }[] = []
|
||||
for (const exp of chain.expiries) {
|
||||
for (const pct of STRIKE_PCTS) {
|
||||
const target = spot * (pct / 100)
|
||||
const rows = [...exp.calls, ...exp.puts].filter((r: any) => r.iv > 0.01)
|
||||
if (!rows.length) {
|
||||
out.push({ expiry: exp.expiry_date, days: exp.days_to_expiry, pct, iv: null })
|
||||
continue
|
||||
}
|
||||
const nearest = rows.reduce((best: any, r: any) =>
|
||||
Math.abs(r.strike - target) < Math.abs(best.strike - target) ? r : best)
|
||||
out.push({ expiry: exp.expiry_date, days: exp.days_to_expiry, pct, iv: nearest.iv })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [chain, spot])
|
||||
|
||||
if (!chain) return null
|
||||
const ivValues = cells.map(c => c.iv).filter((v): v is number => v != null)
|
||||
if (!ivValues.length) return <div className="card-sm text-xs text-slate-500">Pas assez de données d'IV pour construire la surface.</div>
|
||||
const min = Math.min(...ivValues)
|
||||
const max = Math.max(...ivValues)
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="stat-label">Surface de volatilité (strike × expiry)</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<span>{(min * 100).toFixed(1)}%</span>
|
||||
<div className="flex h-2 w-24 rounded overflow-hidden">
|
||||
{[...IV_RAMP].reverse().map(c => <div key={c} className="flex-1" style={{ background: c }} />)}
|
||||
</div>
|
||||
<span>{(max * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px] border-separate" style={{ borderSpacing: 2 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-slate-500 pr-2">Expiry</th>
|
||||
{STRIKE_PCTS.map(p => <th key={p} className="text-slate-500 font-normal px-1">{p}%</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chain.expiries.map((exp: any) => (
|
||||
<tr key={exp.expiry_date}>
|
||||
<td className="text-slate-400 pr-2 whitespace-nowrap">{exp.expiry_date} ({exp.days_to_expiry}j)</td>
|
||||
{STRIKE_PCTS.map(pct => {
|
||||
const cell = cells.find(c => c.expiry === exp.expiry_date && c.pct === pct)
|
||||
if (!cell || cell.iv == null) {
|
||||
return <td key={pct} className="text-center text-slate-700 bg-dark-700/40 rounded">—</td>
|
||||
}
|
||||
const { bg, fg } = ivCellColor(cell.iv, min, max)
|
||||
return (
|
||||
<td key={pct} className="text-center rounded font-semibold" style={{ background: bg, color: fg }}
|
||||
title={`${exp.expiry_date} · ${pct}% (${(spot * pct / 100).toFixed(2)}) · IV ${(cell.iv * 100).toFixed(1)}%`}>
|
||||
{(cell.iv * 100).toFixed(1)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Manual leg builder ────────────────────────────────────────────────────────
|
||||
|
||||
function LegRow({
|
||||
@@ -492,9 +578,11 @@ export default function StrategyBuilder() {
|
||||
const [activeTemplate, setActiveTemplate] = useState<string | null>(null)
|
||||
|
||||
const { data: watchlistData } = useWatchlistTickers()
|
||||
const watchlistTickers = ((watchlistData?.tickers ?? []) as WatchlistEntry[])
|
||||
.filter(t => t.is_active)
|
||||
.map(t => t.ticker)
|
||||
const { data: saxoCatalog } = useSaxoCatalog(undefined, undefined, { enabled: true, limit: 500 })
|
||||
const watchlistTickers = Array.from(new Set([
|
||||
...((watchlistData?.tickers ?? []) as WatchlistEntry[]).filter(t => t.is_active).map(t => t.ticker),
|
||||
...(saxoCatalog ?? []).map(c => c.symbol),
|
||||
])).sort()
|
||||
|
||||
const { data: chain, isLoading: chainLoading, isError: chainError, refetch: refetchChain, isFetching } =
|
||||
useOptionChainSlice(symbol, horizonDays, 3)
|
||||
@@ -605,6 +693,7 @@ export default function StrategyBuilder() {
|
||||
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({symbol})…</div>}
|
||||
|
||||
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
|
||||
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
|
||||
|
||||
{chain && (
|
||||
<div className="card space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user