feat: option
This commit is contained in:
0
backend/data/geooptions.db-journal
Normal file
0
backend/data/geooptions.db-journal
Normal file
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import saxo_auth
|
||||
from services.saxo_scheduler import get_watchlist, set_watchlist
|
||||
from services.saxo_scheduler import get_watchlist, set_watchlist, get_snapshot_minutes, set_snapshot_minutes, run_snapshot_pass
|
||||
from services.database import (
|
||||
get_saxo_snapshots, get_saxo_snapshot_symbols,
|
||||
get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows,
|
||||
@@ -72,6 +72,29 @@ def symbols_with_history():
|
||||
return get_saxo_snapshot_symbols()
|
||||
|
||||
|
||||
class SettingsRequest(BaseModel):
|
||||
snapshot_minutes: float
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings():
|
||||
return {"snapshot_minutes": get_snapshot_minutes()}
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_settings(req: SettingsRequest):
|
||||
set_snapshot_minutes(req.snapshot_minutes)
|
||||
return {"snapshot_minutes": get_snapshot_minutes()}
|
||||
|
||||
|
||||
@router.post("/snapshot-now")
|
||||
def snapshot_now_all():
|
||||
"""Manual immediate refresh of the whole watchlist (doesn't wait for the periodic poll)."""
|
||||
if not saxo_auth.get_status().get("connected"):
|
||||
raise HTTPException(status_code=401, detail="Saxo n'est pas connecté")
|
||||
return {"results": run_snapshot_pass()}
|
||||
|
||||
|
||||
@router.post("/snapshot-now/{symbol}")
|
||||
def snapshot_now(symbol: str):
|
||||
from services.saxo_client import snapshot_options_chain, SaxoNotConnected, SaxoApiError
|
||||
|
||||
@@ -30,6 +30,13 @@ def saxo_callback(code: str = Query(...), state: str = Query(...)):
|
||||
except Exception as e:
|
||||
logger.error(f"[Saxo OAuth] Token exchange failed: {e}")
|
||||
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_error=1")
|
||||
|
||||
# Snapshot the whole watchlist right away — don't make the user wait for the next
|
||||
# scheduled poll after connecting. Runs in the background so the redirect isn't delayed.
|
||||
import threading
|
||||
from services.saxo_scheduler import run_snapshot_pass
|
||||
threading.Thread(target=run_snapshot_pass, daemon=True, name="saxo-connect-snapshot").start()
|
||||
|
||||
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_connected=1")
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,32 @@ def _rows_from_df(df) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch the real option chain for `symbol` — yfinance by default, with an automatic
|
||||
Saxo fallback for instruments yfinance can't handle (FX/futures options).
|
||||
|
||||
Dispatch: a Saxo-formatted symbol (exchange suffix, e.g. "OG:xcme") goes straight to
|
||||
Saxo; otherwise yfinance is tried first (unchanged, proven path for stocks/ETFs), and
|
||||
only falls back to Saxo if yfinance fails AND a Saxo connection is available.
|
||||
"""
|
||||
if ":" in symbol:
|
||||
from services.saxo_client import get_chain_slice_saxo
|
||||
return get_chain_slice_saxo(symbol, target_days, n_expiries)
|
||||
|
||||
try:
|
||||
return _get_chain_slice_yfinance(symbol, target_days, n_expiries)
|
||||
except ValueError:
|
||||
from services import saxo_auth
|
||||
from services.saxo_client import get_chain_slice_saxo
|
||||
if saxo_auth.get_status().get("connected"):
|
||||
try:
|
||||
return get_chain_slice_saxo(symbol, target_days, n_expiries)
|
||||
except Exception as e:
|
||||
logger.debug(f"[OptionChain] Saxo fallback failed for {symbol}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _get_chain_slice_yfinance(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch the real option chain for `symbol` around a target horizon (days).
|
||||
Returns the `n_expiries` expirations closest to target_days, each with
|
||||
|
||||
@@ -291,3 +291,65 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
|
||||
if not rows:
|
||||
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
|
||||
return rows
|
||||
|
||||
|
||||
def get_chain_slice_saxo(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Saxo-backed equivalent of services/option_chain.get_chain_slice — same output shape
|
||||
({symbol, proxy, spot, expiries: [{expiry_date, days_to_expiry, calls, puts}]}, each row
|
||||
{strike, bid, ask, mid, last, iv, open_interest, volume}) so vol_surface.py/strategy_engine.py
|
||||
work unchanged regardless of data source. Reuses snapshot_options_chain (already flat,
|
||||
already bid/ask/mid/greeks per contract) and reshapes/filters it down to n_expiries.
|
||||
"""
|
||||
instrument = resolve_instrument(symbol)
|
||||
flat_rows = snapshot_options_chain(symbol)
|
||||
spot = flat_rows[0]["spot"] if flat_rows else None
|
||||
today = date.today()
|
||||
|
||||
by_expiry: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for r in flat_rows:
|
||||
if r.get("expiry_date"):
|
||||
by_expiry.setdefault(r["expiry_date"], []).append(r)
|
||||
|
||||
def _days_to(expiry_date: str) -> int:
|
||||
return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days
|
||||
|
||||
selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)]
|
||||
|
||||
def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]:
|
||||
bid = r.get("bid") or 0.0
|
||||
ask = r.get("ask") or 0.0
|
||||
mid = r.get("mid") or (round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else 0.0)
|
||||
vol_pct = r.get("volatility_pct")
|
||||
return {
|
||||
"strike": float(r["strike"]),
|
||||
"bid": float(bid),
|
||||
"ask": float(ask),
|
||||
"mid": float(mid),
|
||||
"last": float(mid), # Saxo's chain snapshot has no separate last-traded field
|
||||
"iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0,
|
||||
"open_interest": 0,
|
||||
"volume": 0,
|
||||
}
|
||||
|
||||
expiries_out = []
|
||||
for expiry_date in sorted(selected, key=_days_to):
|
||||
rows = by_expiry[expiry_date]
|
||||
calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"])
|
||||
puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"])
|
||||
expiries_out.append({
|
||||
"expiry_date": expiry_date,
|
||||
"days_to_expiry": _days_to(expiry_date),
|
||||
"calls": calls,
|
||||
"puts": puts,
|
||||
})
|
||||
|
||||
if not expiries_out:
|
||||
raise ValueError(f"Aucune chaîne exploitable pour '{symbol}' via Saxo")
|
||||
|
||||
return {
|
||||
"symbol": symbol.upper(),
|
||||
"proxy": instrument["symbol"] or symbol.upper(),
|
||||
"spot": round(float(spot), 4) if spot is not None else None,
|
||||
"expiries": expiries_out,
|
||||
}
|
||||
|
||||
@@ -61,28 +61,47 @@ def _refresh_loop(stop: threading.Event):
|
||||
stop.wait(timeout=REFRESH_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _snapshot_loop(stop: threading.Event):
|
||||
while not stop.wait(0):
|
||||
try:
|
||||
def get_snapshot_minutes() -> float:
|
||||
from .database import get_config
|
||||
from .saxo_auth import get_status
|
||||
minutes = float(get_config("saxo_snapshot_minutes") or "20")
|
||||
return float(get_config("saxo_snapshot_minutes") or "5")
|
||||
|
||||
if get_status().get("connected"):
|
||||
|
||||
def set_snapshot_minutes(minutes: float):
|
||||
from .database import set_config
|
||||
set_config("saxo_snapshot_minutes", str(minutes))
|
||||
|
||||
|
||||
def run_snapshot_pass() -> dict[str, int]:
|
||||
"""Snapshot every watchlist symbol once — shared by the periodic loop, the
|
||||
'connect now' trigger (saxo_oauth.py callback), and the manual refresh button."""
|
||||
from .saxo_client import snapshot_options_chain
|
||||
from .database import save_saxo_snapshot_rows
|
||||
results: dict[str, int] = {}
|
||||
for symbol in get_watchlist():
|
||||
try:
|
||||
rows = snapshot_options_chain(symbol)
|
||||
save_saxo_snapshot_rows(rows)
|
||||
results[symbol] = len(rows)
|
||||
logger.info(f"[Saxo Scheduler] Snapshot saved: {symbol} ({len(rows)} rows)")
|
||||
except Exception as e:
|
||||
results[symbol] = 0
|
||||
logger.warning(f"[Saxo Scheduler] Snapshot failed for {symbol}: {e}")
|
||||
return results
|
||||
|
||||
|
||||
def _snapshot_loop(stop: threading.Event):
|
||||
while not stop.wait(0):
|
||||
try:
|
||||
from .saxo_auth import get_status
|
||||
minutes = get_snapshot_minutes()
|
||||
|
||||
if get_status().get("connected"):
|
||||
run_snapshot_pass()
|
||||
else:
|
||||
minutes = max(minutes, 5) # don't busy-loop while disconnected
|
||||
except Exception as e:
|
||||
logger.error(f"[Saxo Scheduler] Snapshot loop error: {e}")
|
||||
minutes = 20
|
||||
minutes = 5
|
||||
|
||||
stop.wait(timeout=minutes * 60)
|
||||
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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