From 6c22b3f55229e32d1b48f6932e36711e9ba85074 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Tue, 21 Jul 2026 10:18:15 +0200 Subject: [PATCH] feat: strategy builder --- backend/routers/options_vol.py | 4 +++- backend/services/database.py | 16 +++++++++++++ backend/services/iv_engine.py | 5 +++- backend/services/option_chain.py | 2 ++ frontend/src/hooks/useApi.ts | 2 +- frontend/src/lib/format.ts | 26 +++++++++++++++++++++ frontend/src/pages/Config.tsx | 24 +++++++++++++++++-- frontend/src/pages/Dashboard.tsx | 17 +++++++++++--- frontend/src/pages/InstrumentDashboard.tsx | 9 ++++---- frontend/src/pages/OptionsLab.tsx | 13 +++++++++++ frontend/src/pages/StrategyBuilder.tsx | 27 ++++++++++++++++++---- 11 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/format.ts 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 &&
{expandMsg}
} + {symbols.length > 0 && ( +
+ +
+ )}
{symbols.map(sym => { const check = validation?.find(v => v.symbol === sym.toUpperCase()) @@ -646,7 +664,9 @@ function SaxoConnectionCard() { - + ) })} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 288be3e..19aaf99 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -22,6 +22,7 @@ import { import { scoreColor } from '../components/TradeIdeas' import { ASSET_CLASS_COLORS } from '../constants/assetColors' import TradeRankList from '../components/TradeRankList' +import { fmtPrice, fmtAsOf } from '../lib/format' const riskGauge = (score: number) => { if (score < 25) return { color: 'text-emerald-400', bg: 'bg-emerald-500', label: 'LOW' } @@ -113,7 +114,7 @@ function QuoteRow({ q }: { q: Quote }) {
{q.name || q.symbol}
-
{q.price.toFixed(2)}
+
{fmtPrice(q.price)}
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
@@ -403,7 +404,7 @@ export default function Dashboard() {
{it.ticker}
- {it.price != null ? it.price.toFixed(2) : '—'} + {fmtPrice(it.price)} = 0 ? 'text-emerald-400' : 'text-red-400')}> {it.change_pct != null ? `${it.change_pct >= 0 ? '+' : ''}${it.change_pct.toFixed(2)}%` : '—'} @@ -923,12 +924,16 @@ export default function Dashboard() { .map((w: any) => snapshots[w.ticker]) .filter(Boolean) .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 (
🧪 Options Lab - +
+ {asOf && MAJ {asOf}} + +
{watchlist.length === 0 ? (
@@ -940,6 +945,7 @@ export default function Dashboard() { const rank = h.iv_rank const skewPct = h.skew?.skew_pct const ivCur = h.iv_current_pct + const ivChg = h.iv_change_1d_pct return (
@@ -947,6 +953,11 @@ export default function Dashboard() { {h.ticker} IVR {rank != null ? rank.toFixed(0) : '—'} {ivCur != null && · IV {ivCur.toFixed(1)}%} + {ivChg != null && Math.abs(ivChg) >= 0.1 && ( + 0 ? 'text-orange-400' : 'text-blue-400')}> + {ivChg >= 0 ? '+' : ''}{ivChg.toFixed(1)}pt + + )} {skewPct != null && Math.abs(skewPct) > 3 && ( 0 ? 'text-orange-400' : 'text-blue-400')}> skew {skewPct >= 0 ? '+' : ''}{skewPct.toFixed(1)} diff --git a/frontend/src/pages/InstrumentDashboard.tsx b/frontend/src/pages/InstrumentDashboard.tsx index bc2c4d3..81f296d 100644 --- a/frontend/src/pages/InstrumentDashboard.tsx +++ b/frontend/src/pages/InstrumentDashboard.tsx @@ -8,6 +8,7 @@ import axios from 'axios' import clsx from 'clsx' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import InstrumentChart, { TheoPoint } from '../components/InstrumentChart' +import { fmtPrice } from '../lib/format' const api = axios.create({ baseURL: '/api' }) @@ -448,7 +449,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
Prix - {trend.current_price?.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} + {fmtPrice(trend.current_price)}
@@ -492,7 +493,7 @@ function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: strin
- {trend.low_52w?.toFixed(2)}{trend.high_52w?.toFixed(2)} + {fmtPrice(trend.low_52w)}{fmtPrice(trend.high_52w)}
)} @@ -1906,11 +1907,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i {displayPrice !== undefined && (
- {displayPrice.toLocaleString('fr-FR', { maximumFractionDigits: 4 })} + {fmtPrice(displayPrice)} {isLastDate && snapshot && ( = 0 ? 'text-emerald-400' : 'text-red-400')}> - {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_abs?.toFixed(2)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%) + {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%) )}
diff --git a/frontend/src/pages/OptionsLab.tsx b/frontend/src/pages/OptionsLab.tsx index aaf9e9d..251b873 100644 --- a/frontend/src/pages/OptionsLab.tsx +++ b/frontend/src/pages/OptionsLab.tsx @@ -174,6 +174,14 @@ function TickerDetail({ ticker }: { ticker: string }) { min {snap.iv_min_52w_pct}% {' · '}max {snap.iv_max_52w_pct}% {' · '}{snap.history_days}d of history + {snap.iv_change_1d_pct != null && ( + <> + {' · '}vs veille{' '} + 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 + + + )}
)} @@ -203,6 +211,11 @@ function WatchlistRow({ item }: { item: any }) { {item.iv_current_pct != null ? `${item.iv_source === 'history' ? '~' : ''}${item.iv_current_pct}%` : '—'} + {item.iv_change_1d_pct != null && Math.abs(item.iv_change_1d_pct) >= 0.1 && ( + 0 ? 'text-orange-400' : 'text-blue-400')}> + {item.iv_change_1d_pct >= 0 ? '+' : ''}{item.iv_change_1d_pct.toFixed(1)} + + )}
{item.iv_source === 'history' ? 'Estimated IV' : 'Current IV'}
diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index a9df6ed..b49f9d1 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -8,10 +8,11 @@ import { useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, - useWatchlistTickers, useSaxoCatalog, + useWatchlistTickers, useSaxoCatalog, useIvForTrade, type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, } from '../hooks/useApi' +import { fmtPrice, fmtAsOf } from '../lib/format' type WatchlistEntry = { ticker: string; is_active: number | boolean; added_by: string } @@ -321,7 +322,7 @@ function VolSurfaceHeatmap({ chain, spot }: { chain: any; spot: number }) { const { bg, fg } = ivCellColor(cell.iv, min, max) return ( + title={`${exp.expiry_date} · ${pct}% (${fmtPrice(spot * pct / 100)}) · IV ${(cell.iv * 100).toFixed(1)}%`}> {(cell.iv * 100).toFixed(1)} ) @@ -375,7 +376,7 @@ function LegRow({ onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })} > {rows.map((r: any) => ( - + ))}