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)
|
||||
|
||||
Reference in New Issue
Block a user