feat: option lab
This commit is contained in:
@@ -26,14 +26,16 @@ def list_watchlist():
|
|||||||
@router.get("/quotes")
|
@router.get("/quotes")
|
||||||
def watchlist_quotes():
|
def watchlist_quotes():
|
||||||
from services.database import get_instruments_watchlist
|
from services.database import get_instruments_watchlist
|
||||||
from services.data_fetcher import get_quote
|
from services.data_fetcher import get_quote_with_volatility
|
||||||
items = []
|
items = []
|
||||||
for row in get_instruments_watchlist():
|
for row in get_instruments_watchlist():
|
||||||
q = get_quote(row["ticker"]) or {}
|
q = get_quote_with_volatility(row["ticker"]) or {}
|
||||||
items.append({
|
items.append({
|
||||||
**row,
|
**row,
|
||||||
"price": q.get("price"),
|
"price": q.get("price"),
|
||||||
"change_pct": q.get("change_pct"),
|
"change_pct": q.get("change_pct"),
|
||||||
|
"volatility_pct": q.get("volatility_pct"),
|
||||||
|
"volatility_change_pct": q.get("volatility_change_pct"),
|
||||||
})
|
})
|
||||||
return {"items": items}
|
return {"items": items}
|
||||||
|
|
||||||
@@ -78,3 +80,19 @@ def reorder(body: ReorderBody):
|
|||||||
from services.database import reorder_instruments_watchlist
|
from services.database import reorder_instruments_watchlist
|
||||||
reorder_instruments_watchlist(body.tickers)
|
reorder_instruments_watchlist(body.tickers)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SaxoLinkBody(BaseModel):
|
||||||
|
saxo_symbol: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{ticker}/saxo-link")
|
||||||
|
def set_saxo_link(ticker: str, body: SaxoLinkBody):
|
||||||
|
"""Link this tracked instrument to a Saxo watchlist symbol (or pass null to unlink)
|
||||||
|
so Options Lab's Saxo section shows broker data for it. Adds the symbol to
|
||||||
|
services.saxo_scheduler's watchlist automatically if it wasn't already there."""
|
||||||
|
from services.database import set_instrument_watchlist_saxo_symbol
|
||||||
|
ok = set_instrument_watchlist_saxo_symbol(ticker, body.saxo_symbol)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist")
|
||||||
|
return {"ticker": ticker.strip().upper(), "saxo_symbol": (body.saxo_symbol or "").strip().upper() or None}
|
||||||
|
|||||||
@@ -132,6 +132,60 @@ def get_quote(symbol: str) -> Optional[Dict[str, Any]]:
|
|||||||
return {"symbol": symbol, "price": None, "error": "no data"}
|
return {"symbol": symbol, "price": None, "error": "no data"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_quote_with_volatility(symbol: str, vol_window: int = 20) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Like get_quote(), plus a realized volatility overlay: annualized %, rolling
|
||||||
|
`vol_window`-day stddev of log returns — same formula as the Instrument Analysis
|
||||||
|
chart's volatility overlay (services.instrument_service). Needs more history than
|
||||||
|
get_quote()'s 5d/1mo window, so it's kept as a separate function rather than slowing
|
||||||
|
down get_quote()'s many other callers that don't need volatility."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
for period in ("3mo", "6mo"):
|
||||||
|
try:
|
||||||
|
ticker = yf.Ticker(symbol)
|
||||||
|
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
|
||||||
|
if hist.empty:
|
||||||
|
continue
|
||||||
|
hist = hist.dropna(subset=["Close"])
|
||||||
|
if len(hist) < vol_window + 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
close = hist["Close"]
|
||||||
|
price = float(close.iloc[-1])
|
||||||
|
last_date = hist.index[-1].date()
|
||||||
|
prior_rows = hist[hist.index.date < last_date]
|
||||||
|
prev = float(prior_rows["Close"].iloc[-1]) if not prior_rows.empty else price
|
||||||
|
change = price - prev
|
||||||
|
change_pct = (change / prev * 100) if prev else 0
|
||||||
|
|
||||||
|
log_ret = np.log(close / close.shift(1))
|
||||||
|
vol_series = (log_ret.rolling(vol_window).std() * np.sqrt(252) * 100).dropna()
|
||||||
|
if vol_series.empty:
|
||||||
|
continue
|
||||||
|
volatility_pct = float(vol_series.iloc[-1])
|
||||||
|
|
||||||
|
# D-1 vol: same explicit date-comparison approach as the price above, not
|
||||||
|
# just "the point before last" (guards the same near-24h double-row case).
|
||||||
|
volatility_change_pct = None
|
||||||
|
prior_vol = vol_series[vol_series.index.date < last_date]
|
||||||
|
if not prior_vol.empty:
|
||||||
|
volatility_change_pct = round(volatility_pct - float(prior_vol.iloc[-1]), 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"price": round(price, 4),
|
||||||
|
"change": round(change, 4),
|
||||||
|
"change_pct": round(change_pct, 2),
|
||||||
|
"volatility_pct": round(volatility_pct, 2),
|
||||||
|
"volatility_change_pct": volatility_change_pct,
|
||||||
|
"volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {"symbol": symbol, "price": None, "error": "no data"}
|
||||||
|
|
||||||
|
|
||||||
def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
|
def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
|
||||||
result = {}
|
result = {}
|
||||||
for asset_class, assets in WATCHLIST.items():
|
for asset_class, assets in WATCHLIST.items():
|
||||||
|
|||||||
@@ -183,6 +183,10 @@ def init_db():
|
|||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
added_at TEXT DEFAULT (datetime('now'))
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
)""",
|
)""",
|
||||||
|
# Optional link to a Saxo watchlist symbol (services.saxo_scheduler) — lets Options
|
||||||
|
# Lab's Saxo section show only broker data for instruments the user actually tracks
|
||||||
|
# here, instead of an independently-managed Saxo symbol list.
|
||||||
|
"ALTER TABLE instruments_watchlist ADD COLUMN saxo_symbol TEXT",
|
||||||
# Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's
|
# Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's
|
||||||
# WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally)
|
# WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally)
|
||||||
"""CREATE TABLE IF NOT EXISTS wavelet_simulations (
|
"""CREATE TABLE IF NOT EXISTS wavelet_simulations (
|
||||||
@@ -3270,13 +3274,40 @@ def remove_market_custom_ticker(ticker: str) -> bool:
|
|||||||
def get_instruments_watchlist() -> List[Dict]:
|
def get_instruments_watchlist() -> List[Dict]:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT ticker, name, asset_class, sort_order, added_at "
|
"SELECT ticker, name, asset_class, sort_order, added_at, saxo_symbol "
|
||||||
"FROM instruments_watchlist ORDER BY sort_order ASC, added_at ASC"
|
"FROM instruments_watchlist ORDER BY sort_order ASC, added_at ASC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool:
|
||||||
|
"""Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to a Saxo
|
||||||
|
watchlist symbol. Linking also adds that symbol to services.saxo_scheduler's own
|
||||||
|
watchlist if it isn't there yet, so it actually starts getting snapshotted. Unlinking
|
||||||
|
does NOT remove it from the Saxo watchlist — it may still be wanted directly, or by
|
||||||
|
another linked instrument; remove it from Config -> Saxo if it's truly no longer needed."""
|
||||||
|
from services.saxo_scheduler import get_watchlist, set_watchlist
|
||||||
|
|
||||||
|
ticker = ticker.upper()
|
||||||
|
saxo_symbol = (saxo_symbol or "").strip().upper() or None
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute("SELECT ticker FROM instruments_watchlist WHERE ticker = ?", (ticker,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
conn.execute("UPDATE instruments_watchlist SET saxo_symbol = ? WHERE ticker = ?", (saxo_symbol, ticker))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if saxo_symbol:
|
||||||
|
current = get_watchlist()
|
||||||
|
if saxo_symbol not in current:
|
||||||
|
set_watchlist(current + [saxo_symbol])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def add_instrument_watchlist(ticker: str, name: str = "", asset_class: str = "unknown") -> bool:
|
def add_instrument_watchlist(ticker: str, name: str = "", asset_class: str = "unknown") -> bool:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -151,6 +151,20 @@ export const useRemoveWatchlistInstrument = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useSetWatchlistSaxoLink = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) =>
|
||||||
|
api.put(`/watchlist/${encodeURIComponent(ticker)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['instruments-watchlist'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['instruments-watchlist-quotes'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['saxo-iv-watchlist'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['saxo-watchlist'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Latest cycle report — shares the 'cycle-report-latest' queryKey with RapportIA.tsx
|
// Latest cycle report — shares the 'cycle-report-latest' queryKey with RapportIA.tsx
|
||||||
export const useLatestCycleReport = () =>
|
export const useLatestCycleReport = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, 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, useSetWatchlistSaxoLink, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, 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 { 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'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -331,6 +331,54 @@ function RiskProfilesCard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline Saxo-symbol link editor for one watchlist row — lets Options Lab's Saxo section
|
||||||
|
// show only broker data for instruments actually tracked here, instead of relying on an
|
||||||
|
// independently-managed Saxo watchlist. Linking auto-adds the symbol to the Saxo
|
||||||
|
// scheduler's watchlist (Config -> Saxo below) so it starts getting snapshotted.
|
||||||
|
function SaxoLinkPicker({ ticker, saxoSymbol }: { ticker: string; saxoSymbol: string | null }) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(saxoSymbol ?? '')
|
||||||
|
const { mutate: setLink, isPending } = useSetWatchlistSaxoLink()
|
||||||
|
const { data: catalogMatches } = useSaxoCatalog(undefined, value.length >= 2 ? value : undefined)
|
||||||
|
const datalistId = `saxo-symbols-${ticker}`
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const sym = value.trim().toUpperCase() || null
|
||||||
|
setLink({ ticker, saxoSymbol: sym }, { onSuccess: () => setEditing(false) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return saxoSymbol ? (
|
||||||
|
<button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[9px] text-emerald-400 hover:text-emerald-300 bg-emerald-900/20 border border-emerald-700/30 px-1.5 py-0.5 rounded">
|
||||||
|
<Link2 className="w-2.5 h-2.5" /> {saxoSymbol}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setEditing(true)} className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 border border-slate-700/40 px-1.5 py-0.5 rounded">
|
||||||
|
<Unlink className="w-2.5 h-2.5" /> Link Saxo
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
list={datalistId}
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
|
||||||
|
onBlur={save}
|
||||||
|
placeholder="Saxo symbol"
|
||||||
|
className="bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-[9px] text-white w-24 focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
<datalist id={datalistId}>
|
||||||
|
{(catalogMatches ?? []).map((c: any) => <option key={c.symbol} value={c.symbol}>{c.description}</option>)}
|
||||||
|
</datalist>
|
||||||
|
{isPending && <span className="text-[9px] text-slate-600">…</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WatchlistCard() {
|
function WatchlistCard() {
|
||||||
const { data: items, isLoading } = useInstrumentsWatchlist()
|
const { data: items, isLoading } = useInstrumentsWatchlist()
|
||||||
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
|
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
|
||||||
@@ -399,9 +447,12 @@ function WatchlistCard() {
|
|||||||
<span className="text-[10px] text-slate-500 truncate">{item.name}</span>
|
<span className="text-[10px] text-slate-500 truncate">{item.name}</span>
|
||||||
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
|
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<X className="w-3.5 h-3.5" />
|
<SaxoLinkPicker ticker={item.ticker} saxoSymbol={item.saxo_symbol ?? null} />
|
||||||
</button>
|
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
|
useEcoCalendar, usePortfolioSummary, useLastScores, useAllPatterns, useMacroRegime,
|
||||||
useTradeMtm, useRiskDashboard, useGeoNews,
|
useTradeMtm, useRiskDashboard, useGeoNews,
|
||||||
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
useSimPortfolioRisk, useCycleStatus, useClosedTrades,
|
||||||
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useIvBatch, useLatestCycleReport,
|
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useSaxoIvWatchlist, useLatestCycleReport,
|
||||||
useWaveletWatchlistSignals,
|
useWaveletWatchlistSignals,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves } from 'lucide-react'
|
import { Clock, Globe, ShieldAlert, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { Quote } from '../types'
|
import type { Quote } from '../types'
|
||||||
@@ -122,16 +122,18 @@ function EcoEventRow({ ev, showActual }: { ev: any; showActual: boolean }) {
|
|||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
<span className="shrink-0">{CURRENCY_FLAGS[ev.currency] ?? ev.currency}</span>
|
<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={clsx('w-1.5 h-1.5 rounded-full shrink-0', IMPACT_DOT[ev.impact] ?? 'bg-slate-400')} />
|
||||||
<span className="text-slate-200 truncate flex-1 line-clamp-1 max-w-[108px]" title={ev.event_name}>{ev.event_name}</span>
|
<span className="text-slate-200 truncate line-clamp-1 max-w-[130px] shrink-0" title={ev.event_name}>{ev.event_name}</span>
|
||||||
{showActual && ev.actual_value ? (
|
<div className="ml-auto flex items-center gap-2.5 shrink-0">
|
||||||
<span className="text-[10px] font-mono shrink-0">
|
{showActual && ev.actual_value ? (
|
||||||
<span className="text-white font-semibold">{ev.actual_value}</span>
|
<span className="text-[10px] font-mono">
|
||||||
{ev.forecast_value && <span className="text-slate-400"> /{ev.forecast_value}</span>}
|
<span className="text-white font-semibold">{ev.actual_value}</span>
|
||||||
</span>
|
{ev.forecast_value && <span className="text-slate-400"> /{ev.forecast_value}</span>}
|
||||||
) : ev.forecast_value ? (
|
</span>
|
||||||
<span className="text-[10px] font-mono text-slate-400 shrink-0">F {ev.forecast_value}</span>
|
) : ev.forecast_value ? (
|
||||||
) : null}
|
<span className="text-[10px] font-mono text-slate-400">F {ev.forecast_value}</span>
|
||||||
{ev.event_time && <span className="text-slate-500 text-[9px] shrink-0 font-mono">{ev.event_time}</span>}
|
) : null}
|
||||||
|
{ev.event_time && <span className="text-slate-500 text-[9px] font-mono">{ev.event_time}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,8 +156,7 @@ export default function Dashboard() {
|
|||||||
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
const { data: watchlistQuotesData } = useInstrumentsWatchlistQuotes()
|
||||||
const { data: latestCycleReportData } = useLatestCycleReport()
|
const { data: latestCycleReportData } = useLatestCycleReport()
|
||||||
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
const { data: waveletSignalsData } = useWaveletWatchlistSignals()
|
||||||
const watchlistTickers: string[] = (watchlistItems ?? []).map((w: any) => w.ticker)
|
const { data: saxoIvWatchlistData } = useSaxoIvWatchlist()
|
||||||
const { data: ivBatchData } = useIvBatch(watchlistTickers.join(','))
|
|
||||||
|
|
||||||
// Historical PnL curve + latest VaR snapshot
|
// Historical PnL curve + latest VaR snapshot
|
||||||
const { data: pnlHistoryData } = useQuery({
|
const { data: pnlHistoryData } = useQuery({
|
||||||
@@ -272,6 +273,11 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
}, [watchlistQuotesData])
|
}, [watchlistQuotesData])
|
||||||
|
|
||||||
|
const watchlistAsOf = useMemo(() => {
|
||||||
|
const items: any[] = (watchlistQuotesData as any)?.items ?? []
|
||||||
|
return fmtAsOf(items.map((it: any) => it.timestamp).filter(Boolean).sort().pop())
|
||||||
|
}, [watchlistQuotesData])
|
||||||
|
|
||||||
// FF economic events — today (with actual/forecast as they release) + rest of the
|
// FF economic events — today (with actual/forecast as they release) + rest of the
|
||||||
// current week (Mon-Sun), grouped by date. Past events beyond today are dropped —
|
// current week (Mon-Sun), grouped by date. Past events beyond today are dropped —
|
||||||
// the point of this card is "am I up to date", not a historical log.
|
// the point of this card is "am I up to date", not a historical log.
|
||||||
@@ -412,9 +418,12 @@ export default function Dashboard() {
|
|||||||
<div ref={watchlistCardRef} className="card col-span-1">
|
<div ref={watchlistCardRef} className="card col-span-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="section-title mb-0">📡 Watchlist Radar</div>
|
<div className="section-title mb-0">📡 Watchlist Radar</div>
|
||||||
<Link to="/config" className="flex items-center gap-0.5 text-[10px] text-slate-400 hover:text-slate-300 transition-colors">
|
<div className="flex items-center gap-1.5">
|
||||||
Manage <ArrowUpRight className="w-2.5 h-2.5" />
|
{watchlistAsOf && <span className="text-[9px] text-slate-500" title="Last quote refresh">MAJ {watchlistAsOf}</span>}
|
||||||
</Link>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{watchlistRadarData.length > 0 ? (
|
{watchlistRadarData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -435,6 +444,14 @@ export default function Dashboard() {
|
|||||||
<span className={clsx('font-mono font-bold w-12 text-right', (it.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
<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)}%` : '—'}
|
{it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-mono text-slate-500 w-16 text-right" title="20d realized volatility (annualized) · change vs D-1">
|
||||||
|
{it.volatility_pct != null ? `σ${it.volatility_pct.toFixed(1)}%` : '—'}
|
||||||
|
{it.volatility_change_pct != null && Math.abs(it.volatility_change_pct) >= 0.1 && (
|
||||||
|
<span className={it.volatility_change_pct > 0 ? 'text-orange-400' : 'text-blue-400'}>
|
||||||
|
{' '}{it.volatility_change_pct >= 0 ? '+' : ''}{it.volatility_change_pct.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -501,7 +518,12 @@ export default function Dashboard() {
|
|||||||
style={row1Height ? { height: row1Height } : undefined}>
|
style={row1Height ? { height: row1Height } : undefined}>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
|
<span className="section-title mb-0 text-[10px]">🌐 Macro Regime</span>
|
||||||
<ArrowUpRight className="w-3 h-3 text-slate-500" />
|
<div className="flex items-center gap-1.5">
|
||||||
|
{fmtAsOf((macroData as any)?.fetched_at) && (
|
||||||
|
<span className="text-[9px] text-slate-500" title="Gauges computed at">MAJ {fmtAsOf((macroData as any)?.fetched_at)}</span>
|
||||||
|
)}
|
||||||
|
<ArrowUpRight className="w-3 h-3 text-slate-500" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
<div className={clsx('text-base font-bold mt-1', colorClass)}>
|
||||||
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
{macroInfo ? `${macroInfo.emoji} ${macroInfo.label}` : '—'}
|
||||||
@@ -945,41 +967,42 @@ export default function Dashboard() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Options Lab — IV highlights scoped to the watchlist */}
|
{/* Options Lab — Saxo-only IV highlights, scoped to watchlist instruments that have
|
||||||
|
a Saxo symbol linked (Config -> Instruments Watchlist). Deliberately not the
|
||||||
|
yfinance-based IV batch — matches Options Lab itself, which now only shows Saxo. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const watchlist: any[] = watchlistItems ?? []
|
const watchlist: any[] = watchlistItems ?? []
|
||||||
const snapshots: Record<string, any> = (ivBatchData as any)?.snapshots ?? {}
|
const linked = watchlist.filter((w: any) => w.saxo_symbol)
|
||||||
const highlights = watchlist
|
const bySaxoSymbol: Record<string, any> = {}
|
||||||
.map((w: any) => snapshots[w.ticker])
|
for (const item of ((saxoIvWatchlistData as any)?.items ?? [])) bySaxoSymbol[item.ticker] = item
|
||||||
|
const highlights = linked
|
||||||
|
.map((w: any) => bySaxoSymbol[w.saxo_symbol] ? { ...bySaxoSymbol[w.saxo_symbol], watchlistTicker: w.ticker } : null)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort((a: any, b: any) => Math.abs((b.iv_rank ?? 50) - 50) - Math.abs((a.iv_rank ?? 50) - 50))
|
.sort((a: any, b: any) => Math.abs((b.iv_rank ?? 50) - 50) - Math.abs((a.iv_rank ?? 50) - 50))
|
||||||
const asOf = fmtAsOf(highlights.map((h: any) => h.fetched_at).filter(Boolean).sort().pop())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to="/options" ref={optionsLabCardRef} className="card flex flex-col hover:border-slate-600/60 transition-all cursor-pointer">
|
<Link to="/options" ref={optionsLabCardRef} className="card flex flex-col hover:border-slate-600/60 transition-all cursor-pointer">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="section-title mb-0 text-[10px]">🧪 Options Lab</span>
|
<span className="section-title mb-0 text-[10px]">🧪 Options Lab</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1 text-[9px] text-slate-600">
|
||||||
{asOf && <span className="text-[9px] text-slate-600" title="Données IV mises à jour à">MAJ {asOf}</span>}
|
<Link2 className="w-2.5 h-2.5" /> Saxo
|
||||||
<ArrowUpRight className="w-3 h-3 text-slate-600" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{watchlist.length === 0 ? (
|
{linked.length === 0 ? (
|
||||||
<div className="text-[10px] text-slate-600 mt-1">
|
<div className="text-[10px] text-slate-600 mt-1">
|
||||||
Add instruments to your watchlist (Config) to see IV highlights
|
Link a watchlist instrument to a Saxo symbol (Config → Instruments Watchlist) to see IV highlights here
|
||||||
</div>
|
</div>
|
||||||
) : highlights.length > 0 ? (
|
) : highlights.length > 0 ? (
|
||||||
<div className="space-y-1.5 mt-1">
|
<div className="space-y-1.5 mt-1">
|
||||||
{highlights.map((h: any) => {
|
{highlights.map((h: any) => {
|
||||||
const rank = h.iv_rank
|
const rank = h.iv_rank
|
||||||
const skewPct = h.skew?.skew_pct
|
|
||||||
const ivCur = h.iv_current_pct
|
const ivCur = h.iv_current_pct
|
||||||
const ivChg = h.iv_change_1d_pct
|
const ivChg = h.iv_change_1d_pct
|
||||||
return (
|
return (
|
||||||
<div key={h.ticker} className="pb-1 border-b border-slate-700/20 last:border-0">
|
<div key={h.ticker} className="pb-1 border-b border-slate-700/20 last:border-0">
|
||||||
<div className="flex items-center gap-1.5 text-[10px]">
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
<span>{rank != null && rank > 80 ? '🔴' : rank != null && rank < 20 ? '🟢' : '⚪'}</span>
|
<span>{rank != null && rank > 80 ? '🔴' : rank != null && rank < 20 ? '🟢' : '⚪'}</span>
|
||||||
<span className="font-mono text-slate-200 font-bold shrink-0">{h.ticker}</span>
|
<span className="font-mono text-slate-200 font-bold shrink-0">{h.watchlistTicker}</span>
|
||||||
<span className="text-slate-500">IVR {rank != null ? rank.toFixed(0) : '—'}</span>
|
<span className="text-slate-500">IVR {rank != null ? rank.toFixed(0) : '—'}</span>
|
||||||
{ivCur != null && <span className="text-slate-600">· IV {ivCur.toFixed(1)}%</span>}
|
{ivCur != null && <span className="text-slate-600">· IV {ivCur.toFixed(1)}%</span>}
|
||||||
{ivChg != null && Math.abs(ivChg) >= 0.1 && (
|
{ivChg != null && Math.abs(ivChg) >= 0.1 && (
|
||||||
@@ -987,10 +1010,8 @@ export default function Dashboard() {
|
|||||||
{ivChg >= 0 ? '+' : ''}{ivChg.toFixed(1)}pt
|
{ivChg >= 0 ? '+' : ''}{ivChg.toFixed(1)}pt
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{skewPct != null && Math.abs(skewPct) > 3 && (
|
{h.history_days > 0 && (
|
||||||
<span className={clsx('ml-auto text-[9px]', skewPct > 0 ? 'text-orange-400' : 'text-blue-400')}>
|
<span className="ml-auto text-[8px] text-slate-700">{h.history_days}d</span>
|
||||||
skew {skewPct >= 0 ? '+' : ''}{skewPct.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -998,7 +1019,7 @@ export default function Dashboard() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-slate-600 mt-1">Loading IV data…</div>
|
<div className="text-[10px] text-slate-600 mt-1">Waiting for Saxo snapshots to accumulate…</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -530,37 +530,44 @@ function WatchlistManager() {
|
|||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function OptionsLab() {
|
export default function OptionsLab() {
|
||||||
const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
// yfinance-based IV watchlist — disabled per user request (2026-07-21): Options Lab
|
||||||
|
// should only show Saxo broker data now, linked from Config -> Instruments Watchlist.
|
||||||
|
// Kept commented out rather than deleted in case yfinance coverage is wanted again
|
||||||
|
// later (e.g. for symbols with no Saxo link).
|
||||||
|
//
|
||||||
|
// const { data, isLoading, refetch, isFetching } = useIvWatchlist()
|
||||||
|
// const [bootstrapping, setBootstrapping] = useState(false)
|
||||||
|
// const [bootstrapMsg, setBootstrapMsg] = useState<string | null>(null)
|
||||||
|
// 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)
|
||||||
|
//
|
||||||
|
// const handleBootstrap = async () => {
|
||||||
|
// setBootstrapping(true)
|
||||||
|
// setBootstrapMsg(null)
|
||||||
|
// try {
|
||||||
|
// await api.post('/options-vol/bootstrap-history')
|
||||||
|
// setBootstrapMsg('Bootstrap started (~60s) — refresh in 1 minute to see updated IV Ranks.')
|
||||||
|
// setTimeout(() => refetch(), 70_000)
|
||||||
|
// } catch {
|
||||||
|
// setBootstrapMsg('Error during bootstrap.')
|
||||||
|
// } finally {
|
||||||
|
// setBootstrapping(false)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Show bootstrap banner if most items have no meaningful IV Rank (stuck at 50 or null)
|
||||||
|
// const needsBootstrap = items.length > 0 && items.filter(i => i.iv_rank == null || i.iv_rank === 50).length > items.length * 0.6
|
||||||
|
|
||||||
const { data: saxoData, isLoading: saxoLoading, refetch: refetchSaxo, isFetching: saxoFetching } = useSaxoIvWatchlist()
|
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 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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
const handleBootstrap = async () => {
|
|
||||||
setBootstrapping(true)
|
|
||||||
setBootstrapMsg(null)
|
|
||||||
try {
|
|
||||||
await api.post('/options-vol/bootstrap-history')
|
|
||||||
setBootstrapMsg('Bootstrap started (~60s) — refresh in 1 minute to see updated IV Ranks.')
|
|
||||||
setTimeout(() => refetch(), 70_000)
|
|
||||||
} catch {
|
|
||||||
setBootstrapMsg('Error during bootstrap.')
|
|
||||||
} finally {
|
|
||||||
setBootstrapping(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show bootstrap banner if most items have no meaningful IV Rank (stuck at 50 or null)
|
|
||||||
const needsBootstrap = items.length > 0 && items.filter(i => i.iv_rank == null || i.iv_rank === 50).length > items.length * 0.6
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
|
{/* yfinance bootstrap banner — disabled along with the yfinance section below.
|
||||||
{needsBootstrap && !bootstrapMsg && (
|
{needsBootstrap && !bootstrapMsg && (
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-3 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
<div className="flex items-center justify-between gap-3 px-4 py-3 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
||||||
<span>⚠️ Insufficient IV history — IV Rank stuck at 50%. Bootstrap 1 year of realized data to calibrate the Rank.</span>
|
<span>⚠️ Insufficient IV history — IV Rank stuck at 50%. Bootstrap 1 year of realized data to calibrate the Rank.</span>
|
||||||
@@ -579,6 +586,7 @@ export default function OptionsLab() {
|
|||||||
ℹ️ {bootstrapMsg}
|
ℹ️ {bootstrapMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -586,17 +594,9 @@ export default function OptionsLab() {
|
|||||||
<Activity className="w-5 h-5 text-blue-400" /> Options Lab
|
<Activity className="w-5 h-5 text-blue-400" /> Options Lab
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
IV Rank · IV Percentile · Term Structure · Skew · Options Flow
|
Saxo broker data · IV Rank · Term Structure · Skew
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? 'Loading...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Légende */}
|
{/* Légende */}
|
||||||
@@ -622,6 +622,7 @@ export default function OptionsLab() {
|
|||||||
<strong className="text-slate-500"> Skew +</strong> = puts more expensive than calls (institutional bearish bias)
|
<strong className="text-slate-500"> Skew +</strong> = puts more expensive than calls (institutional bearish bias)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* yfinance IV watchlist rows — disabled along with the hooks/state above.
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[...Array(6)].map((_, i) => <div key={i} className="h-12 animate-pulse bg-dark-700 rounded-lg" />)}
|
{[...Array(6)].map((_, i) => <div key={i} className="h-12 animate-pulse bg-dark-700 rounded-lg" />)}
|
||||||
@@ -673,12 +674,13 @@ export default function OptionsLab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* ── Saxo section — deliberately separate from the yfinance watchlist above.
|
{/* ── Saxo section — the only IV data source now. Symbols come from the Saxo
|
||||||
Symbols come from the Saxo watchlist (Config → Saxo), not from IV Watchlist
|
watchlist (Config → Saxo), auto-populated by linking a tracked instrument to a
|
||||||
Manager below — everything here is computed purely from our own accumulated
|
Saxo symbol (Config → Instruments Watchlist). Computed purely from our own
|
||||||
saxo_option_snapshots, never blended with yfinance IV. ── */}
|
accumulated saxo_option_snapshots, never blended with yfinance IV. ── */}
|
||||||
<div className="pt-2 border-t border-slate-700/40">
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold text-white flex items-center gap-2">
|
<div className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
@@ -713,7 +715,9 @@ export default function OptionsLab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* yfinance IV Watchlist Manager — disabled along with the section above.
|
||||||
<WatchlistManager />
|
<WatchlistManager />
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user