diff --git a/backend/routers/saxo.py b/backend/routers/saxo.py index 2bd6729..8881410 100644 --- a/backend/routers/saxo.py +++ b/backend/routers/saxo.py @@ -6,7 +6,8 @@ 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, dedupe_saxo_snapshots, + get_saxo_snapshot_symbols, dedupe_saxo_snapshots, + get_snapshot_rows_asof, delete_saxo_snapshots, get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows, ) @@ -171,10 +172,18 @@ def quote(symbol: str, asset_type: str = Query("FxSpot")): @router.get("/history") def history( symbol: Optional[str] = Query(None), - date_from: Optional[str] = Query(None), - date_to: Optional[str] = Query(None), + as_of: Optional[str] = Query(None, description="ISO datetime — replay the chain as it stood at/before this moment. Omit for the latest capture."), ): - return get_saxo_snapshots(symbol, date_from, date_to) + """One row per (symbol, expiry, strike, type) — the latest capture, or the latest at/ + before `as_of` to replay a past moment. The full 5-min history is retained underneath + for future backtest use; this endpoint only ever surfaces one row per contract.""" + return get_snapshot_rows_asof(symbol, as_of) + + +@router.delete("/history/{symbol}") +def delete_history(symbol: str): + """Wipes the full accumulated snapshot history for one symbol.""" + return {"symbol": symbol.upper(), "rows_deleted": delete_saxo_snapshots(symbol.upper())} class CatalogRefreshRequest(BaseModel): diff --git a/backend/services/database.py b/backend/services/database.py index 2f57502..42be86d 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -6219,6 +6219,49 @@ def get_latest_saxo_snapshot_rows(symbol: str) -> List[Dict[str, Any]]: return [dict(r) for r in rows] +def get_snapshot_rows_asof(symbol: Optional[str] = None, as_of: Optional[str] = None) -> List[Dict[str, Any]]: + """One row per (symbol, expiry_date, strike, option_type) — the freshest capture at or + before `as_of` (an ISO datetime string), or simply the freshest capture overall when + as_of is omitted. Powers the Saxo History page's default 'current chain' view and its + 'replay a past moment' filter — the full granular history stays in the table underneath + for later backtest use, this just picks one row per contract.""" + conn = get_conn() + where_symbol = "AND symbol = ?" if symbol else "" + where_asof = "AND created_at <= ?" if as_of else "" + params: List[Any] = [] + if symbol: + params.append(symbol) + if as_of: + params.append(as_of) + rows = conn.execute(f""" + SELECT s.* FROM saxo_option_snapshots s + JOIN ( + SELECT symbol, expiry_date, strike, option_type, MAX(created_at) AS max_created + FROM saxo_option_snapshots + WHERE 1=1 {where_symbol} {where_asof} + GROUP BY symbol, expiry_date, strike, option_type + ) latest + ON s.symbol = latest.symbol AND s.expiry_date = latest.expiry_date + AND s.strike = latest.strike AND s.option_type = latest.option_type + AND s.created_at = latest.max_created + ORDER BY s.symbol, s.expiry_date, s.strike + """, params).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def delete_saxo_snapshots(symbol: str) -> int: + """Wipes the full accumulated history for one symbol (all expiries/strikes/dates) — + the raw history table is otherwise never pruned, so this is the user-triggered escape + hatch for a ticker they no longer want tracked.""" + conn = get_conn() + cur = conn.execute("DELETE FROM saxo_option_snapshots WHERE symbol = ?", (symbol,)) + deleted = cur.rowcount + conn.commit() + conn.close() + return deleted + + def upsert_saxo_catalog_rows(rows: List[Dict[str, Any]]): if not rows: return diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 847004c..8038bbd 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -26,7 +26,14 @@ _OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOptio # 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. +# +# Calibrated against a real Saxo EURUSD chain: the quoted spread there sits at a near- +# constant ~9-10 pips (~0.0009-0.0010) in ABSOLUTE terms across every strike/expiry — +# 40%+ of the premium for a cheap OTM contract, ~8% for an expensive one. A pure +# percentage-of-premium spread badly underprices the OTM end, so the wider of the two +# (percentage floor vs. a spot-scaled absolute floor) is used. _SYNTHETIC_SPREAD_PCT = 0.05 +_SYNTHETIC_SPREAD_FLOOR_PCT_OF_SPOT = 0.0008 _SYNTHETIC_RATE = 0.02 @@ -44,7 +51,8 @@ def _synthesize_quote( 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 + full_spread = max(theo * _SYNTHETIC_SPREAD_PCT, spot * _SYNTHETIC_SPREAD_FLOOR_PCT_OF_SPOT) + half_spread = full_spread / 2 bid = round(max(0.0, theo - half_spread), 6) ask = round(theo + half_spread, 6) return bid, ask, round((bid + ask) / 2, 6) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 13692b4..81d6bee 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1752,14 +1752,25 @@ export const useDedupeSaxoHistory = () => { }) } -export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) => +export const useSaxoHistory = (symbol?: string, asOf?: string) => useQuery({ - queryKey: ['saxo-history', symbol, dateFrom, dateTo], + queryKey: ['saxo-history', symbol, asOf], queryFn: () => api.get('/saxo/history', { - params: { ...(symbol ? { symbol } : {}), ...(dateFrom ? { date_from: dateFrom } : {}), ...(dateTo ? { date_to: dateTo } : {}) }, + params: { ...(symbol ? { symbol } : {}), ...(asOf ? { as_of: asOf } : {}) }, }).then(r => r.data), }) +export const useDeleteSaxoHistory = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (symbol: string) => api.delete(`/saxo/history/${encodeURIComponent(symbol)}`).then(r => r.data as { symbol: string; rows_deleted: number }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['saxo-history'] }) + qc.invalidateQueries({ queryKey: ['saxo-symbols'] }) + }, + }) +} + export type SaxoSymbolSummary = { symbol: string; rows_count: number; first_date: string; last_date: string } export const useSaxoSymbols = () => diff --git a/frontend/src/pages/SaxoHistory.tsx b/frontend/src/pages/SaxoHistory.tsx index a6df3aa..4726dd1 100644 --- a/frontend/src/pages/SaxoHistory.tsx +++ b/frontend/src/pages/SaxoHistory.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' -import { History, RefreshCw, Trash2 } from 'lucide-react' +import { History, RefreshCw, Trash2, X } from 'lucide-react' import clsx from 'clsx' -import { useSaxoSymbols, useSaxoHistory, useDedupeSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi' +import { useSaxoSymbols, useSaxoHistory, useDedupeSaxoHistory, useDeleteSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi' function fmt(v: number | null | undefined, digits = 2) { return v == null ? '—' : v.toFixed(digits) @@ -16,34 +16,48 @@ function fmtPrice(v: number | null | undefined) { return v.toFixed(digits) } -function daysAgo(n: number) { - const d = new Date() - d.setDate(d.getDate() - n) - return d.toISOString().slice(0, 10) +// has no timezone suffix, so the browser parses it as local +// time — reformat to the same "YYYY-MM-DD HH:MM:SS" UTC shape SQLite's datetime('now') +// writes into created_at, so a plain string comparison on the backend lines up correctly. +function toSqliteUtc(datetimeLocal: string): string { + return new Date(datetimeLocal).toISOString().slice(0, 19).replace('T', ' ') } export default function SaxoHistory() { const { data: symbolSummaries = [] } = useSaxoSymbols() const [symbol, setSymbol] = useState('') - const [dateFrom, setDateFrom] = useState(daysAgo(30)) - const [dateTo, setDateTo] = useState('') + const [asOfLocal, setAsOfLocal] = useState('') + + const asOf = asOfLocal ? toSqliteUtc(asOfLocal) : undefined + const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, asOf) - 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 deleteHistory = useDeleteSaxoHistory() + const handleDelete = () => { + if (!symbol) return + if (!window.confirm(`Effacer tout l'historique de snapshots de "${symbol}" ? Cette action est irréversible (les données ne seront plus disponibles pour les backtests).`)) return + deleteHistory.mutate(symbol, { + onSuccess: (data) => { + setDedupeMsg(`${data.rows_deleted} ligne(s) supprimée(s) pour ${data.symbol}.`) + setSymbol('') + }, + }) + } + const grouped = useMemo(() => { - const groups: { date: string; rows: SaxoSnapshotRow[] }[] = [] + const groups: { key: string; symbol: string; expiry: string; rows: SaxoSnapshotRow[] }[] = [] for (const r of rows) { + const key = `${r.symbol}||${r.expiry_date}` const last = groups[groups.length - 1] - if (last && last.date === r.snapshot_date) last.rows.push(r) - else groups.push({ date: r.snapshot_date, rows: [r] }) + if (last && last.key === key) last.rows.push(r) + else groups.push({ key, symbol: r.symbol, expiry: r.expiry_date, rows: [r] }) } return groups }, [rows]) @@ -56,7 +70,7 @@ export default function SaxoHistory() { Saxo — Historique des snapshots

- Accumulé depuis la connexion Saxo — pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes. + Une ligne par contrat (ticker/maturité/strike/type) — la capture la plus récente, ou celle en vigueur à la date/heure choisie ci-dessous.

@@ -94,16 +108,27 @@ export default function SaxoHistory() {
- - setDateFrom(e.target.value)} - className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" /> + +
+ setAsOfLocal(e.target.value)} + className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" /> + {asOfLocal && ( + + )} +
-
- - setDateTo(e.target.value)} - className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" /> -
-
{rows.length} lignes
+ +
{rows.length} contrat(s)
{symbolSummaries.length > 0 && ( @@ -143,13 +168,14 @@ export default function SaxoHistory() { Gamma Theta Vega + Capturé le {grouped.map(group => ( <> - - {group.date} + + {group.symbol} — {group.expiry} {group.rows.map(r => ( @@ -174,6 +200,7 @@ export default function SaxoHistory() { {fmt(r.gamma, 4)} {fmt(r.theta, 4)} {fmt(r.vega, 4)} + {r.created_at} ))}