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