feat: saxo
This commit is contained in:
@@ -169,6 +169,15 @@ def startup():
|
|||||||
from services.saxo_scheduler import start_saxo_scheduler
|
from services.saxo_scheduler import start_saxo_scheduler
|
||||||
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
|
# One-time migration: remove FXStreet-sourced rows (decimal format) — FF sync below will refill from FF
|
||||||
try:
|
try:
|
||||||
from services.database import get_conn as _get_conn
|
from services.database import get_conn as _get_conn
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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,
|
get_saxo_snapshots, get_saxo_snapshot_symbols, dedupe_saxo_snapshots,
|
||||||
get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows,
|
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)}
|
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}")
|
@router.get("/quote/{symbol}")
|
||||||
def quote(symbol: str, asset_type: str = Query("FxSpot")):
|
def quote(symbol: str, asset_type: str = Query("FxSpot")):
|
||||||
"""Basic (non-options) price test — isolates whether NoDataAccess is options-specific
|
"""Basic (non-options) price test — isolates whether NoDataAccess is options-specific
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ def init_db():
|
|||||||
gamma REAL,
|
gamma REAL,
|
||||||
theta REAL,
|
theta REAL,
|
||||||
vega REAL,
|
vega REAL,
|
||||||
|
is_synthetic INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
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_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 (
|
c.execute("""CREATE TABLE IF NOT EXISTS saxo_instrument_catalog (
|
||||||
uic INTEGER PRIMARY KEY,
|
uic INTEGER PRIMARY KEY,
|
||||||
@@ -172,6 +174,7 @@ def init_db():
|
|||||||
added_at TEXT DEFAULT (datetime('now'))
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
)""",
|
)""",
|
||||||
"ALTER TABLE market_watchlist ADD COLUMN asset_class TEXT DEFAULT 'custom'",
|
"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
|
# Dashboard — instruments watchlist ("radar"), independent from market_watchlist
|
||||||
"""CREATE TABLE IF NOT EXISTS instruments_watchlist (
|
"""CREATE TABLE IF NOT EXISTS instruments_watchlist (
|
||||||
ticker TEXT PRIMARY KEY,
|
ticker TEXT PRIMARY KEY,
|
||||||
@@ -6089,26 +6092,83 @@ def clear_saxo_tokens():
|
|||||||
|
|
||||||
|
|
||||||
def save_saxo_snapshot_rows(rows: List[Dict[str, Any]]):
|
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:
|
if not rows:
|
||||||
return
|
return
|
||||||
import uuid
|
import uuid
|
||||||
conn = get_conn()
|
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 (
|
conn.executemany("""INSERT INTO saxo_option_snapshots (
|
||||||
id, symbol, snapshot_date, spot, expiry_date, strike, option_type,
|
id, symbol, snapshot_date, spot, expiry_date, strike, option_type,
|
||||||
bid, ask, mid, volatility_pct, delta, gamma, theta, vega
|
bid, ask, mid, volatility_pct, delta, gamma, theta, vega, is_synthetic
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", [
|
||||||
(
|
(
|
||||||
f"SNAP-{uuid.uuid4().hex[:12]}",
|
f"SNAP-{uuid.uuid4().hex[:12]}",
|
||||||
r["symbol"], r["snapshot_date"], r.get("spot"), r["expiry_date"], r["strike"], r["option_type"],
|
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("bid"), r.get("ask"), r.get("mid"), r.get("volatility_pct"),
|
||||||
r.get("delta"), r.get("gamma"), r.get("theta"), r.get("vega"),
|
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.commit()
|
||||||
conn.close()
|
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]]:
|
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()
|
conn = get_conn()
|
||||||
query = "SELECT * FROM saxo_option_snapshots WHERE 1=1"
|
query = "SELECT * FROM saxo_option_snapshots WHERE 1=1"
|
||||||
|
|||||||
@@ -16,12 +16,39 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
|
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption"
|
_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
|
# 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).
|
# are far too large to bulk-fetch — those stay resolved on demand via Keywords search).
|
||||||
CATALOG_ASSET_TYPES = ["FuturesOption", "FxVanillaOption"]
|
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:
|
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
|
||||||
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
|
{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)
|
instrument = resolve_instrument(symbol)
|
||||||
root_uic = instrument["uic"]
|
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 {}
|
greeks = side.get("Greeks") or {}
|
||||||
bid, ask = side.get("Bid"), side.get("Ask")
|
bid, ask = side.get("Bid"), side.get("Ask")
|
||||||
mid_vol = greeks.get("MidVolatility")
|
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({
|
rows.append({
|
||||||
"symbol": symbol.upper(),
|
"symbol": symbol.upper(),
|
||||||
"snapshot_date": snapshot_date,
|
"snapshot_date": snapshot_date,
|
||||||
"spot": float(spot) if spot is not None else None,
|
"spot": float(spot) if spot is not None else None,
|
||||||
"expiry_date": expiry_date,
|
"expiry_date": expiry_date,
|
||||||
"strike": float(strike) if strike is not None else None,
|
"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,
|
"bid": bid,
|
||||||
"ask": ask,
|
"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
|
# MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an
|
||||||
# actual percentage to match the volatility_pct column's name/convention.
|
# 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"),
|
"delta": greeks.get("Delta"),
|
||||||
"gamma": greeks.get("Gamma"),
|
"gamma": greeks.get("Gamma"),
|
||||||
"theta": greeks.get("Theta"),
|
"theta": greeks.get("Theta"),
|
||||||
"vega": greeks.get("Vega"),
|
"vega": greeks.get("Vega"),
|
||||||
|
"is_synthetic": is_synthetic,
|
||||||
})
|
})
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -145,7 +145,15 @@ def price_combo(
|
|||||||
net_pnl = scenario_exec - entry_ref
|
net_pnl = scenario_exec - entry_ref
|
||||||
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
|
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_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"]
|
||||||
delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, 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
|
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
|
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
|
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.
|
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)
|
eval_days = min(l["days_to_expiry"] for l in legs)
|
||||||
grid = np.geomspace(spot * 0.05, spot * 20, 300)
|
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)
|
tail_n = max(3, len(values) // 30)
|
||||||
tol = max(abs(entry_ref), 1.0) * 0.01
|
tol = max(abs(entry_ref), 1.0) * 0.01
|
||||||
|
|||||||
@@ -1738,7 +1738,18 @@ export type SaxoSnapshotRow = {
|
|||||||
expiry_date: string; strike: number; option_type: 'call' | 'put'
|
expiry_date: string; strike: number; option_type: 'call' | 'put'
|
||||||
bid: number | null; ask: number | null; mid: number | null; volatility_pct: number | null
|
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
|
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) =>
|
export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { History, RefreshCw } from 'lucide-react'
|
import { History, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
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) {
|
function fmt(v: number | null | undefined, digits = 2) {
|
||||||
return v == null ? '—' : v.toFixed(digits)
|
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) {
|
function daysAgo(n: number) {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - n)
|
d.setDate(d.getDate() - n)
|
||||||
@@ -20,6 +29,14 @@ export default function SaxoHistory() {
|
|||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, dateFrom || undefined, dateTo || undefined)
|
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 grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
|
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
|
||||||
@@ -42,15 +59,27 @@ export default function SaxoHistory() {
|
|||||||
Accumulé depuis la connexion Saxo — pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.
|
Accumulé depuis la connexion Saxo — pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => refetch()}
|
<button
|
||||||
disabled={isFetching}
|
onClick={handleDedupe}
|
||||||
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
disabled={dedupe.isPending}
|
||||||
>
|
title="Supprime les lignes dont bid/ask/mid/IV n'ont pas bougé depuis la capture précédente"
|
||||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
||||||
{isFetching ? 'Chargement...' : 'Rafraîchir'}
|
>
|
||||||
</button>
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{dedupe.isPending ? 'Nettoyage...' : 'Nettoyer les doublons'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
|
{isFetching ? 'Chargement...' : 'Rafraîchir'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{dedupeMsg && <div className="text-xs text-emerald-400">{dedupeMsg}</div>}
|
||||||
|
|
||||||
<div className="card flex flex-wrap items-end gap-4">
|
<div className="card flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -131,9 +160,14 @@ export default function SaxoHistory() {
|
|||||||
<span className={clsx('badge', r.option_type === 'call' ? 'badge-green' : 'badge-red')}>{r.option_type}</span>
|
<span className={clsx('badge', r.option_type === 'call' ? 'badge-green' : 'badge-red')}>{r.option_type}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.spot)}</td>
|
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.spot)}</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.bid)}</td>
|
<td className="py-1 pr-3 text-right text-slate-300">{fmtPrice(r.bid)}</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.ask)}</td>
|
<td className="py-1 pr-3 text-right text-slate-300">{fmtPrice(r.ask)}</td>
|
||||||
<td className="py-1 pr-3 text-right text-white font-semibold">{fmt(r.mid)}</td>
|
<td className="py-1 pr-3 text-right text-white font-semibold">
|
||||||
|
{fmtPrice(r.mid)}
|
||||||
|
{!!r.is_synthetic && (
|
||||||
|
<span className="ml-1 text-amber-400" title="Bid/Ask non fournis par Saxo (marché fermé/illiquide) — reconstruit théoriquement (Black-Scholes) à partir de l'IV">*</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
{/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */}
|
{/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */}
|
||||||
<td className="py-1 pr-3 text-right text-amber-400">{r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'}</td>
|
<td className="py-1 pr-3 text-right text-amber-400">{r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'}</td>
|
||||||
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.delta, 4)}</td>
|
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.delta, 4)}</td>
|
||||||
|
|||||||
@@ -478,8 +478,8 @@ function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onS
|
|||||||
<th className="py-1 pr-3">Jambes</th>
|
<th className="py-1 pr-3">Jambes</th>
|
||||||
<th className="py-1 pr-3 text-right">Score</th>
|
<th className="py-1 pr-3 text-right">Score</th>
|
||||||
<th className="py-1 pr-3 text-right">P&L net</th>
|
<th className="py-1 pr-3 text-right">P&L net</th>
|
||||||
<th className="py-1 pr-3 text-right">Max gain</th>
|
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario">Max gain</th>
|
||||||
<th className="py-1 pr-3 text-right">Max perte</th>
|
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario">Max perte</th>
|
||||||
<th className="py-1 pr-3 text-right">Δ net</th>
|
<th className="py-1 pr-3 text-right">Δ net</th>
|
||||||
<th className="py-1 pr-1"></th>
|
<th className="py-1 pr-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -777,8 +777,8 @@ export default function StrategyBuilder() {
|
|||||||
<div className="stat-label">Coût spread broker</div>
|
<div className="stat-label">Coût spread broker</div>
|
||||||
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
|
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-sm">
|
<div className="card-sm" title="Borne théorique à l'échéance de la jambe la plus proche, sous la même vue de volatilité que votre scénario. Pour les structures mono-échéance (condor, butterfly...), c'est un calcul purement intrinsèque, indépendant de la vol. Le P&L net scénario est une valorisation avant échéance (mark-to-market) : sur une structure vendeuse de vega, un choc de vol important dans le scénario peut transitoirement le faire sortir de cette fourchette — risque vega réel, pas un bug.">
|
||||||
<div className="stat-label">Max gain / Max perte</div>
|
<div className="stat-label">Max gain / Max perte (à échéance)</div>
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold">
|
||||||
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'}</span>
|
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'}</span>
|
||||||
<span className="text-slate-500"> / </span>
|
<span className="text-slate-500"> / </span>
|
||||||
|
|||||||
Reference in New Issue
Block a user