diff --git a/backend/main.py b/backend/main.py index 0bfbbbe..2784830 100644 --- a/backend/main.py +++ b/backend/main.py @@ -169,6 +169,15 @@ def startup(): from services.saxo_scheduler import start_saxo_scheduler start_saxo_scheduler() + # One-time cleanup: collapse snapshot rows stored before save-time dedup existed + try: + from services.database import dedupe_saxo_snapshots + deleted = dedupe_saxo_snapshots() + if deleted: + _log.info(f"[Saxo] Startup cleanup: removed {deleted} redundant snapshot rows") + except Exception as e: + _log.warning(f"[Saxo] Startup snapshot dedupe failed: {e}") + # One-time migration: remove FXStreet-sourced rows (decimal format) — FF sync below will refill from FF try: from services.database import get_conn as _get_conn diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index f97803b..2bd6729 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from services import saxo_auth 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_snapshots, get_saxo_snapshot_symbols, dedupe_saxo_snapshots, get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows, ) @@ -148,6 +148,13 @@ def snapshot_now(symbol: str): return {"symbol": symbol.upper(), "rows_saved": len(rows)} +@router.post("/history/dedupe") +def dedupe_history(): + """Collapses consecutive stored snapshots that carry the same bid/ask/mid/IV as the row + right before them — cleans up bloat from before save-time dedup existed.""" + return {"rows_deleted": dedupe_saxo_snapshots()} + + @router.get("/quote/{symbol}") def quote(symbol: str, asset_type: str = Query("FxSpot")): """Basic (non-options) price test — isolates whether NoDataAccess is options-specific diff --git a/backend/services/database.py b/backend/services/database.py index 0d6631b..2f57502 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -70,9 +70,11 @@ def init_db(): gamma REAL, theta REAL, vega REAL, + is_synthetic INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) )""") c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_snap_symbol_date ON saxo_option_snapshots(symbol, snapshot_date)") + c.execute("CREATE INDEX IF NOT EXISTS idx_saxo_snap_contract ON saxo_option_snapshots(symbol, expiry_date, strike, option_type, created_at)") c.execute("""CREATE TABLE IF NOT EXISTS saxo_instrument_catalog ( uic INTEGER PRIMARY KEY, @@ -172,6 +174,7 @@ def init_db(): added_at TEXT DEFAULT (datetime('now')) )""", "ALTER TABLE market_watchlist ADD COLUMN asset_class TEXT DEFAULT 'custom'", + "ALTER TABLE saxo_option_snapshots ADD COLUMN is_synthetic INTEGER DEFAULT 0", # Dashboard — instruments watchlist ("radar"), independent from market_watchlist """CREATE TABLE IF NOT EXISTS instruments_watchlist ( ticker TEXT PRIMARY KEY, @@ -6089,26 +6092,83 @@ def clear_saxo_tokens(): def save_saxo_snapshot_rows(rows: List[Dict[str, Any]]): + """Inserts one row per (expiry, strike, type) — but skips contracts whose quote/IV + hasn't moved since the last stored snapshot, so an illiquid contract (bid=ask=0, + static IV) doesn't accumulate a near-duplicate row every 5 minutes forever. Greeks + drift a little from theta decay alone even when the quote is static, so they're + intentionally excluded from the comparison.""" if not rows: return import uuid conn = get_conn() + + to_insert = [] + for r in rows: + last = conn.execute(""" + SELECT bid, ask, mid, volatility_pct FROM saxo_option_snapshots + WHERE symbol=? AND expiry_date=? AND strike=? AND option_type=? + ORDER BY created_at DESC LIMIT 1 + """, (r["symbol"], r["expiry_date"], r["strike"], r["option_type"])).fetchone() + if last is not None and ( + last["bid"] == r.get("bid") and last["ask"] == r.get("ask") + and last["mid"] == r.get("mid") and last["volatility_pct"] == r.get("volatility_pct") + ): + continue + to_insert.append(r) + + if not to_insert: + conn.close() + return + conn.executemany("""INSERT INTO saxo_option_snapshots ( id, symbol, snapshot_date, spot, expiry_date, strike, option_type, - bid, ask, mid, volatility_pct, delta, gamma, theta, vega - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [ + bid, ask, mid, volatility_pct, delta, gamma, theta, vega, is_synthetic + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [ ( f"SNAP-{uuid.uuid4().hex[:12]}", r["symbol"], r["snapshot_date"], r.get("spot"), r["expiry_date"], r["strike"], r["option_type"], r.get("bid"), r.get("ask"), r.get("mid"), r.get("volatility_pct"), r.get("delta"), r.get("gamma"), r.get("theta"), r.get("vega"), + 1 if r.get("is_synthetic") else 0, ) - for r in rows + for r in to_insert ]) conn.commit() conn.close() +def dedupe_saxo_snapshots() -> int: + """One-off cleanup for rows accumulated before the save-time dedup existed: collapses + consecutive snapshots per (symbol, expiry, strike, type) that carry the same + bid/ask/mid/IV as the row immediately before them, keeping only the first of each run. + Safe to call repeatedly — a no-op once the history is already collapsed. Returns the + number of rows deleted.""" + conn = get_conn() + rows = conn.execute(""" + WITH ranked AS ( + SELECT id, bid, ask, mid, volatility_pct, + LAG(bid) OVER w AS prev_bid, + LAG(ask) OVER w AS prev_ask, + LAG(mid) OVER w AS prev_mid, + LAG(volatility_pct) OVER w AS prev_iv + FROM saxo_option_snapshots + WINDOW w AS (PARTITION BY symbol, expiry_date, strike, option_type ORDER BY created_at) + ) + SELECT id FROM ranked + WHERE prev_bid IS NOT NULL + AND bid IS prev_bid AND ask IS prev_ask AND mid IS prev_mid AND volatility_pct IS prev_iv + """).fetchall() + ids = [r["id"] for r in rows] + if not ids: + conn.close() + return 0 + placeholders = ",".join("?" for _ in ids) + conn.execute(f"DELETE FROM saxo_option_snapshots WHERE id IN ({placeholders})", ids) + conn.commit() + conn.close() + return len(ids) + + def get_saxo_snapshots(symbol: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None) -> List[Dict[str, Any]]: conn = get_conn() query = "SELECT * FROM saxo_option_snapshots WHERE 1=1" diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index a8030e6..847004c 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -16,12 +16,39 @@ from typing import Any, Dict, List, Optional import httpx +from services.options_pricer import black_scholes from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token logger = logging.getLogger(__name__) _OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption" +# Applied around a Black-Scholes theoretical price when Saxo returns no live Bid/Ask (FX +# options in particular go quiet outside FX market hours — closed over the weekend) but +# still supplies MidVolatility/Greeks from its own model, so IV/spot/strike are usable. +_SYNTHETIC_SPREAD_PCT = 0.05 +_SYNTHETIC_RATE = 0.02 + + +def _synthesize_quote( + spot: Optional[float], strike: Optional[float], expiry_date: Optional[str], + snapshot_date: str, iv_pct: Optional[float], option_type: str, +) -> tuple: + if not spot or not strike or not expiry_date or not iv_pct or iv_pct <= 0: + return None, None, None + try: + days_to_expiry = (date.fromisoformat(expiry_date) - date.fromisoformat(snapshot_date)).days + except ValueError: + return None, None, None + T = max(days_to_expiry, 1) / 365 + theo = float(black_scholes(spot, strike, T, _SYNTHETIC_RATE, iv_pct / 100.0, option_type)["price"]) + if theo <= 0: + return None, None, None + half_spread = theo * _SYNTHETIC_SPREAD_PCT / 2 + bid = round(max(0.0, theo - half_spread), 6) + ask = round(theo + half_spread, 6) + return bid, ask, round((bid + ask) / 2, 6) + # Bounded, stable catalogs worth fully caching in our own DB (StockOption/StockIndexOption # are far too large to bulk-fetch — those stay resolved on demand via Keywords search). CATALOG_ASSET_TYPES = ["FuturesOption", "FxVanillaOption"] @@ -242,7 +269,9 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, """ Returns normalized rows ready for services/database.save_saxo_snapshot_rows: {symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid, - volatility_pct, delta, gamma, theta, vega} + volatility_pct, delta, gamma, theta, vega, is_synthetic}. bid/ask/mid are + Black-Scholes-synthesized from IV (is_synthetic=True) whenever Saxo returns no live + Bid/Ask for that contract (e.g. FX options outside market hours). """ instrument = resolve_instrument(symbol) root_uic = instrument["uic"] @@ -270,23 +299,36 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, greeks = side.get("Greeks") or {} bid, ask = side.get("Bid"), side.get("Ask") mid_vol = greeks.get("MidVolatility") + option_type = "put" if side_key == "Put" else "call" + vol_pct = round(mid_vol * 100, 4) if mid_vol is not None else None + mid = round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None + + is_synthetic = False + if not bid and not ask: + syn_bid, syn_ask, syn_mid = _synthesize_quote( + spot, strike, expiry_date, snapshot_date, vol_pct, option_type, + ) + if syn_bid is not None: + bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True + rows.append({ "symbol": symbol.upper(), "snapshot_date": snapshot_date, "spot": float(spot) if spot is not None else None, "expiry_date": expiry_date, "strike": float(strike) if strike is not None else None, - "option_type": "put" if side_key == "Put" else "call", + "option_type": option_type, "bid": bid, "ask": ask, - "mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None, + "mid": mid, # MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an # actual percentage to match the volatility_pct column's name/convention. - "volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None, + "volatility_pct": vol_pct, "delta": greeks.get("Delta"), "gamma": greeks.get("Gamma"), "theta": greeks.get("Theta"), "vega": greeks.get("Vega"), + "is_synthetic": is_synthetic, }) if not rows: diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 17c6cf8..6ecbe14 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -145,7 +145,15 @@ def price_combo( net_pnl = scenario_exec - entry_ref broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec) - bounded = check_bounded_risk(legs, entry_ref, surface_now, spot_now) + # Uses surface_scenario (not surface_now): for a single-expiry combo (condor, + # butterfly, straddle...) all legs are simultaneously intrinsic at eval_days, so the + # surface is never actually consulted there and this changes nothing. For a + # calendar/diagonal spread, the far leg is still alive at the near leg's expiry and + # DOES need a vol assumption to be priced — using surface_now there silently mixed + # "today's vol" into a number sitting next to net_pnl (which uses the scenario's + # shocked vol), producing a max_gain that could be below net_pnl. Pricing both with + # the same scenario vol view keeps them consistent. + bounded = check_bounded_risk(legs, entry_ref, surface_scenario, spot_now, r) delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"] delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"] @@ -166,7 +174,7 @@ def price_combo( }) -def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float) -> Dict[str, Any]: +def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float, r: float = 0.05) -> Dict[str, Any]: """ Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long @@ -175,10 +183,15 @@ def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: An A tail is loss-bounded if moving further to that extreme does not make the P&L any worse than a point already well into that tail; symmetrically for gain-bounded. + + "At expiry" means the earliest expiry among the legs. For a single-expiry combo that's + every leg simultaneously (pure intrinsic, no vol assumption involved at all). For a + calendar/diagonal spread it's only the near leg — the far leg is still alive and needs + `surface` to be priced, so max_gain/max_loss there is only as good as that vol input. """ eval_days = min(l["days_to_expiry"] for l in legs) grid = np.geomspace(spot * 0.05, spot * 20, 300) - values = [value_at(legs, float(p), eval_days, surface, 0.05) - entry_ref for p in grid] + values = [value_at(legs, float(p), eval_days, surface, r) - entry_ref for p in grid] tail_n = max(3, len(values) // 30) tol = max(abs(entry_ref), 1.0) * 0.01 diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index e43ce5c..13692b4 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1738,7 +1738,18 @@ export type SaxoSnapshotRow = { expiry_date: string; strike: number; option_type: 'call' | 'put' bid: number | null; ask: number | null; mid: number | null; volatility_pct: number | null delta: number | null; gamma: number | null; theta: number | null; vega: number | null - created_at: string + is_synthetic?: number; created_at: string +} + +export const useDedupeSaxoHistory = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/saxo/history/dedupe').then(r => r.data as { rows_deleted: number }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['saxo-history'] }) + qc.invalidateQueries({ queryKey: ['saxo-symbols'] }) + }, + }) } export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) => diff --git a/frontend/src/pages/SaxoHistory.tsx b/frontend/src/pages/SaxoHistory.tsx index f5d751f..a6df3aa 100644 --- a/frontend/src/pages/SaxoHistory.tsx +++ b/frontend/src/pages/SaxoHistory.tsx @@ -1,12 +1,21 @@ import { useMemo, useState } from 'react' -import { History, RefreshCw } from 'lucide-react' +import { History, RefreshCw, Trash2 } from 'lucide-react' import clsx from 'clsx' -import { useSaxoSymbols, useSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi' +import { useSaxoSymbols, useSaxoHistory, useDedupeSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi' function fmt(v: number | null | undefined, digits = 2) { return v == null ? '—' : v.toFixed(digits) } +// FX option premiums are a small fraction of the underlying (e.g. 0.0050 on a 1.15 +// EURUSD) — 2 decimals rounds every real value to "0.00", indistinguishable from an +// actually-empty quote. Scale decimals to the premium's own magnitude instead. +function fmtPrice(v: number | null | undefined) { + if (v == null) return '—' + const digits = v !== 0 && Math.abs(v) < 1 ? 5 : 2 + return v.toFixed(digits) +} + function daysAgo(n: number) { const d = new Date() d.setDate(d.getDate() - n) @@ -20,6 +29,14 @@ export default function SaxoHistory() { const [dateTo, setDateTo] = useState('') const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, dateFrom || undefined, dateTo || undefined) + const dedupe = useDedupeSaxoHistory() + const [dedupeMsg, setDedupeMsg] = useState(null) + + const handleDedupe = () => { + dedupe.mutate(undefined, { + onSuccess: (data) => setDedupeMsg(`${data.rows_deleted} ligne(s) redondante(s) supprimée(s).`), + }) + } const grouped = useMemo(() => { const groups: { date: string; rows: SaxoSnapshotRow[] }[] = [] @@ -42,15 +59,27 @@ export default function SaxoHistory() { Accumulé depuis la connexion Saxo — pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.

- +
+ + +
+ {dedupeMsg &&
{dedupeMsg}
}
@@ -131,9 +160,14 @@ export default function SaxoHistory() { {r.option_type} {fmt(r.spot)} - {fmt(r.bid)} - {fmt(r.ask)} - {fmt(r.mid)} + {fmtPrice(r.bid)} + {fmtPrice(r.ask)} + + {fmtPrice(r.mid)} + {!!r.is_synthetic && ( + * + )} + {/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */} {r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'} {fmt(r.delta, 4)} diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index b978517..9dfb4d0 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -478,8 +478,8 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS Jambes Score P&L net - Max gain - Max perte + Max gain + Max perte Δ net @@ -777,8 +777,8 @@ export default function StrategyBuilder() {
Coût spread broker
{fmtMoney(priced.broker_spread_cost)}
-
-
Max gain / Max perte
+
+
Max gain / Max perte (à échéance)
{priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'} /