feat: saxo history
This commit is contained in:
@@ -6,7 +6,8 @@ from pydantic import BaseModel
|
|||||||
from services import saxo_auth
|
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.saxo_scheduler import get_watchlist, set_watchlist, get_snapshot_minutes, set_snapshot_minutes, run_snapshot_pass
|
||||||
from services.database import (
|
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,
|
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")
|
@router.get("/history")
|
||||||
def history(
|
def history(
|
||||||
symbol: Optional[str] = Query(None),
|
symbol: Optional[str] = Query(None),
|
||||||
date_from: 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."),
|
||||||
date_to: Optional[str] = Query(None),
|
|
||||||
):
|
):
|
||||||
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):
|
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]
|
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]]):
|
def upsert_saxo_catalog_rows(rows: List[Dict[str, Any]]):
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
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
|
# 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
|
# 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.
|
# 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_PCT = 0.05
|
||||||
|
_SYNTHETIC_SPREAD_FLOOR_PCT_OF_SPOT = 0.0008
|
||||||
_SYNTHETIC_RATE = 0.02
|
_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"])
|
theo = float(black_scholes(spot, strike, T, _SYNTHETIC_RATE, iv_pct / 100.0, option_type)["price"])
|
||||||
if theo <= 0:
|
if theo <= 0:
|
||||||
return None, None, None
|
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)
|
bid = round(max(0.0, theo - half_spread), 6)
|
||||||
ask = round(theo + half_spread, 6)
|
ask = round(theo + half_spread, 6)
|
||||||
return bid, ask, round((bid + ask) / 2, 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[]>({
|
useQuery<SaxoSnapshotRow[]>({
|
||||||
queryKey: ['saxo-history', symbol, dateFrom, dateTo],
|
queryKey: ['saxo-history', symbol, asOf],
|
||||||
queryFn: () => api.get('/saxo/history', {
|
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),
|
}).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 type SaxoSymbolSummary = { symbol: string; rows_count: number; first_date: string; last_date: string }
|
||||||
|
|
||||||
export const useSaxoSymbols = () =>
|
export const useSaxoSymbols = () =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 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) {
|
function fmt(v: number | null | undefined, digits = 2) {
|
||||||
return v == null ? '—' : v.toFixed(digits)
|
return v == null ? '—' : v.toFixed(digits)
|
||||||
@@ -16,34 +16,48 @@ function fmtPrice(v: number | null | undefined) {
|
|||||||
return v.toFixed(digits)
|
return v.toFixed(digits)
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysAgo(n: number) {
|
// <input type="datetime-local"> has no timezone suffix, so the browser parses it as local
|
||||||
const d = new Date()
|
// time — reformat to the same "YYYY-MM-DD HH:MM:SS" UTC shape SQLite's datetime('now')
|
||||||
d.setDate(d.getDate() - n)
|
// writes into created_at, so a plain string comparison on the backend lines up correctly.
|
||||||
return d.toISOString().slice(0, 10)
|
function toSqliteUtc(datetimeLocal: string): string {
|
||||||
|
return new Date(datetimeLocal).toISOString().slice(0, 19).replace('T', ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SaxoHistory() {
|
export default function SaxoHistory() {
|
||||||
const { data: symbolSummaries = [] } = useSaxoSymbols()
|
const { data: symbolSummaries = [] } = useSaxoSymbols()
|
||||||
const [symbol, setSymbol] = useState<string>('')
|
const [symbol, setSymbol] = useState<string>('')
|
||||||
const [dateFrom, setDateFrom] = useState(daysAgo(30))
|
const [asOfLocal, setAsOfLocal] = useState<string>('')
|
||||||
const [dateTo, setDateTo] = 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 dedupe = useDedupeSaxoHistory()
|
||||||
const [dedupeMsg, setDedupeMsg] = useState<string | null>(null)
|
const [dedupeMsg, setDedupeMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleDedupe = () => {
|
const handleDedupe = () => {
|
||||||
dedupe.mutate(undefined, {
|
dedupe.mutate(undefined, {
|
||||||
onSuccess: (data) => setDedupeMsg(`${data.rows_deleted} ligne(s) redondante(s) supprimée(s).`),
|
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 grouped = useMemo(() => {
|
||||||
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
|
const groups: { key: string; symbol: string; expiry: string; rows: SaxoSnapshotRow[] }[] = []
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
const key = `${r.symbol}||${r.expiry_date}`
|
||||||
const last = groups[groups.length - 1]
|
const last = groups[groups.length - 1]
|
||||||
if (last && last.date === r.snapshot_date) last.rows.push(r)
|
if (last && last.key === key) last.rows.push(r)
|
||||||
else groups.push({ date: r.snapshot_date, rows: [r] })
|
else groups.push({ key, symbol: r.symbol, expiry: r.expiry_date, rows: [r] })
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}, [rows])
|
}, [rows])
|
||||||
@@ -56,7 +70,7 @@ export default function SaxoHistory() {
|
|||||||
<History className="w-5 h-5 text-blue-400" /> Saxo — Historique des snapshots
|
<History className="w-5 h-5 text-blue-400" /> Saxo — Historique des snapshots
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -94,16 +108,27 @@ export default function SaxoHistory() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="stat-label block mb-1">Du</label>
|
<label className="stat-label block mb-1">Voir au (vide = actuel)</label>
|
||||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
<div className="flex items-center gap-1.5">
|
||||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
<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>
|
||||||
<div>
|
<button
|
||||||
<label className="stat-label block mb-1">Au</label>
|
onClick={handleDelete}
|
||||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
disabled={!symbol || deleteHistory.isPending}
|
||||||
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
|
title={symbol ? `Effacer tout l'historique de ${symbol}` : 'Choisissez un symbole précis pour effacer son historique'}
|
||||||
</div>
|
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"
|
||||||
<div className="text-xs text-slate-500 ml-auto">{rows.length} lignes</div>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{symbolSummaries.length > 0 && (
|
{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">Gamma</th>
|
||||||
<th className="py-1 pr-3 text-right">Theta</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 text-right">Vega</th>
|
||||||
|
<th className="py-1 pr-3">Capturé le</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{grouped.map(group => (
|
{grouped.map(group => (
|
||||||
<>
|
<>
|
||||||
<tr key={`h-${group.date}`} className="border-t border-slate-700/40">
|
<tr key={`h-${group.key}`} 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>
|
<td colSpan={14} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.symbol} — {group.expiry}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{group.rows.map(r => (
|
{group.rows.map(r => (
|
||||||
<tr key={r.id} className="border-t border-slate-800/60 hover:bg-dark-700/30">
|
<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.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.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-right text-slate-400">{fmt(r.vega, 4)}</td>
|
||||||
|
<td className="py-1 pr-3 text-slate-500">{r.created_at}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user