feat: cockpit
This commit is contained in:
@@ -919,6 +919,31 @@ export const useIvForTrade = (underlying: string) =>
|
||||
staleTime: 60 * 60_000,
|
||||
})
|
||||
|
||||
// ── Saxo-only IV analytics (Options Lab "Saxo" section) — computed from our own
|
||||
// accumulated saxo_option_snapshots, never blended with the yfinance-based hooks above ──
|
||||
export const useSaxoIvWatchlist = () =>
|
||||
useQuery({
|
||||
queryKey: ['saxo-iv-watchlist'],
|
||||
queryFn: () => api.get('/saxo/iv-watchlist').then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
export const useSaxoIvSnapshot = (symbol: string) =>
|
||||
useQuery({
|
||||
queryKey: ['saxo-iv-snapshot', symbol],
|
||||
queryFn: () => api.get(`/saxo/iv-snapshot/${encodeURIComponent(symbol)}`).then(r => r.data),
|
||||
enabled: !!symbol,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
export const useSaxoIvHistory = (symbol: string, days = 90) =>
|
||||
useQuery({
|
||||
queryKey: ['saxo-iv-history', symbol, days],
|
||||
queryFn: () => api.get(`/saxo/iv-history/${encodeURIComponent(symbol)}`, { params: { days } }).then(r => r.data),
|
||||
enabled: !!symbol,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
|
||||
// ── Analytics Phase 4 — Bayesian + Clustering + Embeddings ───────────────────
|
||||
|
||||
export const useBayesianPosteriors = () =>
|
||||
|
||||
@@ -122,16 +122,16 @@ function EcoEventRow({ ev, showActual }: { ev: any; showActual: boolean }) {
|
||||
<div className="flex items-center gap-1.5 text-[10px]">
|
||||
<span className="shrink-0">{CURRENCY_FLAGS[ev.currency] ?? ev.currency}</span>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', IMPACT_DOT[ev.impact] ?? 'bg-slate-400')} />
|
||||
<span className="text-white truncate flex-1 line-clamp-1">{ev.event_name}</span>
|
||||
<span className="text-slate-200 truncate flex-1 line-clamp-1 max-w-[108px]" title={ev.event_name}>{ev.event_name}</span>
|
||||
{showActual && ev.actual_value ? (
|
||||
<span className="text-[9px] font-mono shrink-0">
|
||||
<span className="text-[10px] font-mono shrink-0">
|
||||
<span className="text-white font-semibold">{ev.actual_value}</span>
|
||||
{ev.forecast_value && <span className="text-slate-600"> /{ev.forecast_value}</span>}
|
||||
{ev.forecast_value && <span className="text-slate-400"> /{ev.forecast_value}</span>}
|
||||
</span>
|
||||
) : ev.forecast_value ? (
|
||||
<span className="text-[9px] font-mono text-slate-600 shrink-0">F {ev.forecast_value}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400 shrink-0">F {ev.forecast_value}</span>
|
||||
) : null}
|
||||
{ev.event_time && <span className="text-slate-700 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
|
||||
{ev.event_time && <span className="text-slate-500 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export default function Dashboard() {
|
||||
<div className="section-title flex items-center gap-1 mb-0">
|
||||
<Globe className="w-3 h-3" /> Geopolitical Risk
|
||||
</div>
|
||||
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
||||
<Link to="/geo" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
|
||||
Geo news <ArrowUpRight className="w-2.5 h-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -376,14 +376,16 @@ export default function Dashboard() {
|
||||
<div className={clsx('h-2 rounded-full', gauge.bg)} style={{ width: `${riskScore.score}%` }} />
|
||||
</div>
|
||||
{riskScore.computed_at && (
|
||||
<div className="mt-1.5 text-[9px] text-slate-600 shrink-0">
|
||||
Score IA — figé au dernier cycle ({formatTimeShort(riskScore.computed_at)})
|
||||
{riskScore.rationale && <span className="text-slate-500"> · {riskScore.rationale}</span>}
|
||||
<div className="mt-1.5 text-[9px] text-slate-400 shrink-0">
|
||||
<span>AI Score — frozen at last cycle ({formatTimeShort(riskScore.computed_at)})</span>
|
||||
{riskScore.rationale && (
|
||||
<p className="text-slate-500 line-clamp-2 mt-0.5" title={riskScore.rationale}>{riskScore.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{topNews.length > 0 && (
|
||||
<div className="mt-2.5 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1 text-[9px] text-slate-600 mb-1 shrink-0">
|
||||
<div className="mt-2 pt-2 border-t border-slate-700/30 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1 text-[9px] text-slate-400 mb-1 shrink-0">
|
||||
<Newspaper className="w-2.5 h-2.5" /> Top news
|
||||
</div>
|
||||
<div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
|
||||
@@ -410,7 +412,7 @@ export default function Dashboard() {
|
||||
<div ref={watchlistCardRef} className="card col-span-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="section-title mb-0">📡 Watchlist Radar</div>
|
||||
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-600 hover:text-slate-300 transition-colors">
|
||||
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
|
||||
Manage <ArrowUpRight className="w-2.5 h-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -419,7 +421,7 @@ export default function Dashboard() {
|
||||
<ResponsiveContainer width="100%" height={130}>
|
||||
<RadarChart data={watchlistRadarData}>
|
||||
<PolarGrid stroke="#1e2d4d" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#64748b', fontSize: 9 }} />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 9 }} />
|
||||
<Radar dataKey="ref" stroke="#334155" strokeDasharray="3 3" fill="transparent" isAnimationActive={false} />
|
||||
<Radar dataKey="value" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.25} />
|
||||
</RadarChart>
|
||||
@@ -427,9 +429,9 @@ export default function Dashboard() {
|
||||
<div className="mt-1.5 pt-1.5 border-t border-slate-700/30 space-y-1">
|
||||
{((watchlistQuotesData as any)?.items ?? []).map((it: any) => (
|
||||
<div key={it.ticker} className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-slate-400 font-mono">{it.ticker}</span>
|
||||
<span className="text-slate-300 font-mono">{it.ticker}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-mono">{fmtPrice(it.price)}</span>
|
||||
<span className="text-slate-400 font-mono">{fmtPrice(it.price)}</span>
|
||||
<span className={clsx('font-mono font-bold w-12 text-right', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
@@ -499,7 +501,7 @@ export default function Dashboard() {
|
||||
style={row1Height ? { height: row1Height } : undefined}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
|
||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
||||
<ArrowUpRight className="w-3 h-3 text-slate-500" />
|
||||
</div>
|
||||
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
||||
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
||||
@@ -512,7 +514,7 @@ export default function Dashboard() {
|
||||
const isDom = key === dom
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span className={clsx('text-[9px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-500')}>
|
||||
<span className={clsx('text-[10px] w-16 truncate', isDom ? 'text-slate-200 font-semibold' : 'text-slate-400')}>
|
||||
{REGIME_LABELS[key] ?? key}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||||
@@ -521,7 +523,7 @@ export default function Dashboard() {
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={clsx('text-[9px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-600')}>
|
||||
<span className={clsx('text-[10px] font-mono w-5 text-right', isDom ? 'text-blue-400' : 'text-slate-400')}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
@@ -542,11 +544,11 @@ export default function Dashboard() {
|
||||
const h = hint(val, chg)
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-slate-600 w-16 shrink-0">{label}</span>
|
||||
<span className={clsx('text-[9px] font-mono font-semibold shrink-0', c)}>
|
||||
<span className="text-[10px] text-slate-400 w-16 shrink-0">{label}</span>
|
||||
<span className={clsx('text-[10px] font-mono font-semibold shrink-0', c)}>
|
||||
{fmt(val, chg)}
|
||||
</span>
|
||||
<span className="text-[8px] text-slate-700 truncate">{h}</span>
|
||||
<span className="text-[9px] text-slate-500 truncate">{h}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -575,7 +577,7 @@ export default function Dashboard() {
|
||||
{todayEvents.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-semibold text-slate-300 bg-dark-700/50 rounded px-1.5 py-0.5 mb-1">
|
||||
Aujourd'hui <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>
|
||||
Today <span className="badge-blue text-[8px] px-1 py-0">TODAY</span>
|
||||
</div>
|
||||
<div className="space-y-1 pl-0.5">
|
||||
{todayEvents.map((ev: any, i: number) => <EcoEventRow key={i} ev={ev} showActual />)}
|
||||
@@ -593,7 +595,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="text-slate-600 text-xs mt-2">No events this week</div>}
|
||||
) : <div className="text-slate-500 text-xs mt-2">No events this week</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 {
|
||||
useIvWatchlist, useIvSnapshot, useIvHistory, useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker,
|
||||
useSaxoIvWatchlist, useSaxoIvSnapshot, useSaxoIvHistory,
|
||||
} from '../hooks/useApi'
|
||||
import { Activity, TrendingUp, TrendingDown, Minus, RefreshCw, ChevronDown, ChevronUp, Database, Plus, Trash2, List, Link2 } from 'lucide-react'
|
||||
import { api } from '../hooks/useApi'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import clsx from 'clsx'
|
||||
@@ -176,7 +179,7 @@ function TickerDetail({ ticker }: { ticker: string }) {
|
||||
{' · '}{snap.history_days}d of history
|
||||
{snap.iv_change_1d_pct != null && (
|
||||
<>
|
||||
{' · '}vs veille{' '}
|
||||
{' · '}vs prior day{' '}
|
||||
<span className={snap.iv_change_1d_pct > 0 ? 'text-orange-400' : snap.iv_change_1d_pct < 0 ? 'text-blue-400' : 'text-slate-400'}>
|
||||
{snap.iv_change_1d_pct >= 0 ? '+' : ''}{snap.iv_change_1d_pct}pt
|
||||
</span>
|
||||
@@ -261,6 +264,170 @@ function WatchlistRow({ item }: { item: any }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Saxo-only expanded detail — separate from TickerDetail above by design: this reads
|
||||
// exclusively from /api/saxo/iv-*, computed from our own accumulated saxo_option_snapshots,
|
||||
// never blended with the yfinance-based IV watchlist. No Options Flow section — Saxo
|
||||
// snapshots carry no open interest/volume, so there's nothing honest to show there. ──
|
||||
function SaxoTickerDetail({ ticker }: { ticker: string }) {
|
||||
const { data: snap, isLoading } = useSaxoIvSnapshot(ticker)
|
||||
const { data: histData } = useSaxoIvHistory(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 history: any[] = histData?.history || []
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-slate-700/30 pt-3 grid grid-cols-2 gap-4">
|
||||
{/* Term Structure */}
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Term Structure (Saxo)</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-500 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>}
|
||||
{!ts.iv_30d && !ts.iv_60d && !ts.iv_90d && !ts.iv_180d && (
|
||||
<div className="text-[9px] text-slate-500 italic">Not enough expiries captured yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skew */}
|
||||
<div>
|
||||
<div className="text-[9px] text-slate-500 uppercase tracking-wide font-semibold mb-2">Skew Put/Call (Saxo)</div>
|
||||
{skew.put_skew != null ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[9px]">
|
||||
<span className="text-slate-500">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-500">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-500 italic leading-tight">{skew.interpretation}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[9px] text-slate-500 italic">Insufficient data</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History sparkline */}
|
||||
{history.length > 4 && (
|
||||
<div className="col-span-2 flex items-center gap-3 pt-1 border-t border-slate-700/20">
|
||||
<span className="text-[9px] text-slate-500 shrink-0">IV 90d</span>
|
||||
<IvSparkline history={history} />
|
||||
<div className="text-[9px] text-slate-500">
|
||||
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 Saxo history
|
||||
{snap.iv_change_1d_pct != null && (
|
||||
<>
|
||||
{' · '}vs prior day{' '}
|
||||
<span className={snap.iv_change_1d_pct > 0 ? 'text-orange-400' : snap.iv_change_1d_pct < 0 ? 'text-blue-400' : 'text-slate-400'}>
|
||||
{snap.iv_change_1d_pct >= 0 ? '+' : ''}{snap.iv_change_1d_pct}pt
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{history.length <= 4 && (
|
||||
<div className="col-span-2 text-[9px] text-slate-500 italic pt-1 border-t border-slate-700/20">
|
||||
Not enough accumulated days yet for a Rank/Percentile — keep the Saxo watchlist snapshotting and it fills in on its own.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Saxo Watchlist Row — same layout as WatchlistRow, fed by /api/saxo/iv-* only ──
|
||||
function SaxoWatchlistRow({ 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>
|
||||
<div className="text-[8px] text-slate-500 flex items-center gap-0.5"><Link2 className="w-2 h-2" /> Saxo</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>
|
||||
{item.iv_change_1d_pct != null && Math.abs(item.iv_change_1d_pct) >= 0.1 && (
|
||||
<span className={clsx('ml-1 text-[9px] font-mono', item.iv_change_1d_pct > 0 ? 'text-orange-400' : 'text-blue-400')}>
|
||||
{item.iv_change_1d_pct >= 0 ? '+' : ''}{item.iv_change_1d_pct.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-[8px] text-slate-500">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-500">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-500">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 Saxo history</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-slate-500">
|
||||
{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">
|
||||
<SaxoTickerDetail ticker={item.ticker} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Watchlist Manager ─────────────────────────────────────────────────────────
|
||||
function WatchlistManager() {
|
||||
const qc = useQueryClient()
|
||||
@@ -364,9 +531,11 @@ function WatchlistManager() {
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
export default function OptionsLab() {
|
||||
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
||||
const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist()
|
||||
const [bootstrapping, setBootstrapping] = useState(false)
|
||||
const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(null)
|
||||
const items: any[] = data?.items || []
|
||||
const saxoItems: any[] = saxoData?.items || []
|
||||
|
||||
const sellVol = items.filter(i => (i.iv_rank ?? 50) >= 80)
|
||||
const buyVol = items.filter(i => i.iv_rank != null && i.iv_rank < 20)
|
||||
@@ -505,6 +674,45 @@ export default function OptionsLab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Saxo section — deliberately separate from the yfinance watchlist above.
|
||||
Symbols come from the Saxo watchlist (Config → Saxo), not from IV Watchlist
|
||||
Manager below — everything here is computed purely from our own accumulated
|
||||
saxo_option_snapshots, never blended with yfinance IV. ── */}
|
||||
<div className="pt-2 border-t border-slate-700/40">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-emerald-400" /> Saxo Broker Data
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||
IV Rank · Term Structure · Skew, computed only from your Saxo watchlist's accumulated option snapshots — manage the symbol list in Config → Saxo.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetchSaxo()}
|
||||
disabled={saxoFetching}
|
||||
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', saxoFetching && 'animate-spin')} />
|
||||
{saxoFetching ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saxoLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, i) => <div key={i} className="h-12 animate-pulse bg-dark-700 rounded-lg" />)}
|
||||
</div>
|
||||
) : saxoItems.length === 0 ? (
|
||||
<div className="card text-center py-8 text-slate-500 text-xs">
|
||||
No Saxo IV data yet — add symbols to the Saxo watchlist (Config → Saxo) and let a few snapshot cycles accumulate.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{saxoItems.map(item => <SaxoWatchlistRow key={item.ticker} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WatchlistManager />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user