feat: option

This commit is contained in:
OpenSquared
2026-07-18 22:04:43 +02:00
parent 6db2705466
commit b0b7f9bfda
9 changed files with 300 additions and 24 deletions

View File

@@ -1706,6 +1706,25 @@ export const useSnapshotSaxoNow = () =>
mutationFn: (symbol: string) => api.post(`/saxo/snapshot-now/${encodeURIComponent(symbol)}`).then(r => r.data),
})
export const useSnapshotAllSaxoNow = () =>
useMutation({
mutationFn: () => api.post('/saxo/snapshot-now').then(r => r.data as { results: Record<string, number> }),
})
export const useSaxoSettings = () =>
useQuery<{ snapshot_minutes: number }>({
queryKey: ['saxo-settings'],
queryFn: () => api.get('/saxo/settings').then(r => r.data),
})
export const useUpdateSaxoSettings = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (snapshotMinutes: number) => api.put('/saxo/settings', { snapshot_minutes: snapshotMinutes }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['saxo-settings'] }),
})
}
export type SaxoSnapshotRow = {
id: string; symbol: string; snapshot_date: string; spot: number | null
expiry_date: string; strike: number; option_type: 'call' | 'put'
@@ -1745,11 +1764,13 @@ export const useValidateSaxoWatchlist = () =>
export type SaxoCatalogEntry = { uic: number; symbol: string; asset_type: string; description: string; updated_at: string }
export type SaxoCatalogSummary = { asset_type: string; count: number; last_refreshed: string }
export const useSaxoCatalog = (assetType?: string, q?: string) =>
export const useSaxoCatalog = (assetType?: string, q?: string, opts?: { enabled?: boolean; limit?: number }) =>
useQuery<SaxoCatalogEntry[]>({
queryKey: ['saxo-catalog', assetType, q],
queryFn: () => api.get('/saxo/catalog', { params: { ...(assetType ? { asset_type: assetType } : {}), ...(q ? { q } : {}) } }).then(r => r.data),
enabled: !!(assetType || q),
queryKey: ['saxo-catalog', assetType, q, opts?.limit],
queryFn: () => api.get('/saxo/catalog', {
params: { ...(assetType ? { asset_type: assetType } : {}), ...(q ? { q } : {}), ...(opts?.limit ? { limit: opts.limit } : {}) },
}).then(r => r.data),
enabled: opts?.enabled ?? !!(assetType || q),
})
export const useSaxoCatalogSummary = () =>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, type CycleStepDef } from '../hooks/useApi'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, type CycleStepDef } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react'
import clsx from 'clsx'
@@ -430,6 +430,9 @@ function SaxoConnectionCard() {
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
const { data: catalogSummary } = useSaxoCatalogSummary()
const refreshCatalog = useRefreshSaxoCatalog()
const { data: settings } = useSaxoSettings()
const updateSettings = useUpdateSaxoSettings()
const snapshotAllNow = useSnapshotAllSaxoNow()
const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('')
const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined)
@@ -470,6 +473,17 @@ function SaxoConnectionCard() {
}
}
const handleSnapshotAllNow = async () => {
setSnapMsg('')
try {
const r = await snapshotAllNow.mutateAsync()
const total = Object.values(r.results).reduce((a, b) => a + b, 0)
setSnapMsg(`✓ Watchlist entière — ${total} lignes enregistrées (${Object.keys(r.results).length} symboles)`)
} catch (e: any) {
setSnapMsg(`${e?.response?.data?.detail ?? 'échec'}`)
}
}
return (
<div className="card">
<div className="flex items-center justify-between mb-3">
@@ -517,10 +531,25 @@ function SaxoConnectionCard() {
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-500">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
<div className="text-xs text-slate-500 flex items-center gap-2">
<span>Watchlist snapshotée toutes les</span>
<input
type="number" min={1} max={120}
value={settings?.snapshot_minutes ?? ''}
onChange={e => updateSettings.mutate(parseFloat(e.target.value) || 5)}
className="w-14 bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-xs text-white text-center"
/>
<span>min (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleSnapshotAllNow}
disabled={!status?.connected || snapshotAllNow.isPending}
title="Snapshot immédiat de toute la watchlist, sans attendre le prochain cycle"
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200 border border-slate-700/50 px-2 py-1 rounded disabled:opacity-40"
>
<RefreshCw className={clsx('w-3.5 h-3.5', snapshotAllNow.isPending && 'animate-spin')} /> Rafraîchir maintenant
</button>
<button
onClick={() => runValidation()}
disabled={!status?.connected || validating}

View File

@@ -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">