diff --git a/backend/routers/options_vol.py b/backend/routers/options_vol.py index 7866bad..688b81f 100644 --- a/backend/routers/options_vol.py +++ b/backend/routers/options_vol.py @@ -69,7 +69,7 @@ def get_iv_watchlist(): Returns a summary list sorted by IV Rank descending. """ from services.iv_engine import get_atm_iv, _resolve_ticker - from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history, get_watchlist_tickers + from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history, get_watchlist_tickers, get_iv_change_1d from datetime import date results = [] @@ -97,10 +97,12 @@ def get_iv_watchlist(): if live_iv: save_iv_snapshot(proxy, today, iv) rank_data = get_iv_rank_percentile(proxy, iv) + iv_change_1d = get_iv_change_1d(proxy, iv) item = { "ticker": ticker, "iv_current_pct": round(iv * 100, 1), + "iv_change_1d_pct": round(iv_change_1d * 100, 1) if iv_change_1d is not None else None, "iv_rank": rank_data.get("iv_rank"), "iv_percentile": rank_data.get("iv_percentile"), "history_days": rank_data.get("history_days", 0), diff --git a/backend/services/database.py b/backend/services/database.py index 42be86d..dac97a1 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -3159,6 +3159,22 @@ def get_iv_history(ticker: str, days: int = 90) -> List[Dict]: return [dict(r) for r in rows] +def get_iv_change_1d(ticker: str, current_iv: float) -> Optional[float]: + """Day-over-day IV delta (decimal, e.g. 0.012 = +1.2 vol points) — current_iv minus + the most recent prior trading day's recorded iv_current. None if no prior day exists.""" + conn = get_conn() + row = conn.execute( + """SELECT iv_current FROM iv_history + WHERE ticker=? AND iv_current IS NOT NULL AND recorded_date < date('now') + ORDER BY recorded_date DESC LIMIT 1""", + (ticker.upper(),), + ).fetchone() + conn.close() + if not row or row["iv_current"] is None: + return None + return current_iv - row["iv_current"] + + # ── IV Watchlist ────────────────────────────────────────────────────────────── def get_watchlist_tickers() -> List[str]: diff --git a/backend/services/iv_engine.py b/backend/services/iv_engine.py index 1394d54..5705a11 100644 --- a/backend/services/iv_engine.py +++ b/backend/services/iv_engine.py @@ -404,7 +404,7 @@ def get_full_iv_snapshot(ticker: str) -> Dict[str, Any]: Full IV snapshot for a ticker: current IV, rank/percentile, term structure, skew, flow. Calls DB for historical rank/percentile. """ - from services.database import get_iv_rank_percentile, save_iv_snapshot + from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_change_1d proxy = _resolve_ticker(ticker) today = date.today().isoformat() @@ -423,16 +423,19 @@ def get_full_iv_snapshot(ticker: str) -> Dict[str, Any]: iv_current = recent[0]["iv_current"] rank_data: Dict[str, Any] = {} + iv_change_1d = None if iv_current: if live_iv and date.today().weekday() < 5: # Only persist weekday IV — weekend premium inflates IV and corrupts history save_iv_snapshot(proxy, today, iv_current, term.get("iv_30d"), term.get("iv_60d"), term.get("iv_90d")) rank_data = get_iv_rank_percentile(proxy, iv_current) + iv_change_1d = get_iv_change_1d(proxy, iv_current) return { "ticker": ticker, "proxy": proxy, "iv_current_pct": round(iv_current * 100, 1) if iv_current else None, + "iv_change_1d_pct": round(iv_change_1d * 100, 1) if iv_change_1d is not None else None, "iv_rank": rank_data.get("iv_rank"), "iv_percentile": rank_data.get("iv_percentile"), "history_days": rank_data.get("history_days", 0), diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py index e74d507..2cd22ad 100644 --- a/backend/services/option_chain.py +++ b/backend/services/option_chain.py @@ -28,6 +28,7 @@ def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> D ) spot = next((r["spot"] for r in flat_rows if r.get("spot") is not None), None) + as_of = max((r["created_at"] for r in flat_rows if r.get("created_at")), default=None) today = date.today() by_expiry: Dict[str, List[Dict[str, Any]]] = {} @@ -77,6 +78,7 @@ def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> D "symbol": symbol.upper(), "proxy": symbol.upper(), "spot": round(float(spot), 6) if spot is not None else None, + "as_of": as_of, "expiries": expiries_out, } diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 451ca1c..944ce68 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1520,7 +1520,7 @@ export const useRejectAiTradeProposal = () => { export type ChainRow = { strike: number; bid: number; ask: number; mid: number; last: number; iv: number; open_interest: number; volume: number } export type ChainExpiry = { expiry_date: string; days_to_expiry: number; calls: ChainRow[]; puts: ChainRow[] } -export type ChainSlice = { symbol: string; proxy: string; spot: number; expiries: ChainExpiry[] } +export type ChainSlice = { symbol: string; proxy: string; spot: number; as_of?: string | null; expiries: ChainExpiry[] } export type ManualGridCell = { days_to_expiry: number; strike_pct: number; iv: number | null } diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..9b598ab --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,26 @@ +/** + * Shared price/rate formatter. Raw quotes (spot, strike, bid/ask) always show a + * meaningful number of decimals — 4 for FX-scale instruments (<10), 2 above that + * (equities/indices), matching standard quoting convention for each magnitude. + * NOT for post-nominal dollar amounts (P&L, entry cost) — those stay at 2 decimals + * via each page's own fmtMoney. + */ +export function fmtPrice(v: number | null | undefined, decimals?: number): string { + if (v == null || Number.isNaN(v)) return '—' + const d = decimals ?? (Math.abs(v) < 10 ? 4 : 2) + return v.toFixed(d) +} + +/** + * Formats a backend UTC timestamp as a compact local "as of" label, e.g. "18/07 14:32". + * Backend timestamps come in two shapes, both naive UTC: Python's `datetime.isoformat()` + * ("2026-07-18T14:32:01.123") and sqlite's `datetime('now')` ("2026-07-18 14:32:01"). + */ +export function fmtAsOf(raw: string | null | undefined): string | null { + if (!raw) return null + let iso = raw.includes('T') ? raw : raw.replace(' ', 'T') + if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso)) iso += 'Z' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return null + return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 6298868..abc9096 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -464,7 +464,16 @@ function SaxoConnectionCard() { setInput('') } + // Guarded by isPending everywhere it's called from the UI: each call captures `symbols` + // from the current render, so firing a second mutation before the first one's + // invalidateQueries/refetch completes reads a stale list and undoes the first removal + // once it resolves — confirmed as the cause of rapid multi-click removals "coming back". const removeSymbol = (sym: string) => updateWatchlist.mutate(symbols.filter(s => s !== sym)) + const clearAllSymbols = () => { + if (symbols.length === 0) return + if (!window.confirm(`Effacer les ${symbols.length} ticker(s) de la watchlist Saxo ?`)) return + updateWatchlist.mutate([]) + } const handleExpandWatchlist = async () => { const kw = expandKeyword.trim() @@ -608,7 +617,7 @@ function SaxoConnectionCard() { ))} - @@ -631,6 +640,15 @@ function SaxoConnectionCard() { {expandMsg &&