feat: saxo history
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1752,14 +1752,25 @@ export const useDedupeSaxoHistory = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>
|
||||
export const useSaxoHistory = (symbol?: string, asOf?: string) =>
|
||||
useQuery<SaxoSnapshotRow[]>({
|
||||
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 = () =>
|
||||
|
||||
@@ -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)
|
||||
// <input type="datetime-local"> 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<string>('')
|
||||
const [dateFrom, setDateFrom] = useState(daysAgo(30))
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [asOfLocal, setAsOfLocal] = useState<string>('')
|
||||
|
||||
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<string | null>(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() {
|
||||
<History className="w-5 h-5 text-blue-400" /> Saxo — Historique des snapshots
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -94,16 +108,27 @@ export default function SaxoHistory() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="stat-label block mb-1">Du</label>
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
||||
<label className="stat-label block mb-1">Voir au (vide = actuel)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="datetime-local" value={asOfLocal} onChange={e => setAsOfLocal(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
||||
{asOfLocal && (
|
||||
<button onClick={() => setAsOfLocal('')} title="Revenir à l'actuel" className="text-slate-500 hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="stat-label block mb-1">Au</label>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 ml-auto">{rows.length} lignes</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!symbol || deleteHistory.isPending}
|
||||
title={symbol ? `Effacer tout l'historique de ${symbol}` : 'Choisissez un symbole précis pour effacer son historique'}
|
||||
className="flex items-center gap-1.5 text-xs border border-red-700/50 text-red-400 hover:text-red-300 hover:border-red-500 px-3 py-1.5 rounded transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{deleteHistory.isPending ? 'Suppression...' : `Effacer l'historique${symbol ? ` de ${symbol}` : ''}`}
|
||||
</button>
|
||||
<div className="text-xs text-slate-500 ml-auto">{rows.length} contrat(s)</div>
|
||||
</div>
|
||||
|
||||
{symbolSummaries.length > 0 && (
|
||||
@@ -143,13 +168,14 @@ export default function SaxoHistory() {
|
||||
<th className="py-1 pr-3 text-right">Gamma</th>
|
||||
<th className="py-1 pr-3 text-right">Theta</th>
|
||||
<th className="py-1 pr-3 text-right">Vega</th>
|
||||
<th className="py-1 pr-3">Capturé le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grouped.map(group => (
|
||||
<>
|
||||
<tr key={`h-${group.date}`} className="border-t border-slate-700/40">
|
||||
<td colSpan={13} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.date}</td>
|
||||
<tr key={`h-${group.key}`} className="border-t border-slate-700/40">
|
||||
<td colSpan={14} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.symbol} — {group.expiry}</td>
|
||||
</tr>
|
||||
{group.rows.map(r => (
|
||||
<tr key={r.id} className="border-t border-slate-800/60 hover:bg-dark-700/30">
|
||||
@@ -174,6 +200,7 @@ export default function SaxoHistory() {
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.gamma, 4)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.theta, 4)}</td>
|
||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.vega, 4)}</td>
|
||||
<td className="py-1 pr-3 text-slate-500">{r.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user