251 lines
9.1 KiB
Python
251 lines
9.1 KiB
Python
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
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_snapshot_symbols, dedupe_saxo_snapshots,
|
|
get_snapshot_rows_asof, delete_saxo_snapshots,
|
|
get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/saxo", tags=["saxo"])
|
|
|
|
|
|
class WatchlistRequest(BaseModel):
|
|
symbols: List[str]
|
|
|
|
|
|
@router.get("/status")
|
|
def status():
|
|
return saxo_auth.get_status()
|
|
|
|
|
|
@router.post("/disconnect")
|
|
def disconnect():
|
|
saxo_auth.disconnect()
|
|
return {"disconnected": True}
|
|
|
|
|
|
@router.get("/watchlist")
|
|
def watchlist():
|
|
return {"symbols": get_watchlist()}
|
|
|
|
|
|
@router.put("/watchlist")
|
|
def update_watchlist(req: WatchlistRequest):
|
|
set_watchlist(req.symbols)
|
|
return {"symbols": get_watchlist()}
|
|
|
|
|
|
class ExpandWatchlistRequest(BaseModel):
|
|
keyword: str
|
|
|
|
|
|
@router.post("/watchlist/expand")
|
|
def expand_watchlist(req: ExpandWatchlistRequest):
|
|
"""Adds every catalog entry matching a keyword (ex. 'EURUSD' -> every weekly/monthly
|
|
FuturesOption root, not just one) — avoids hand-picking each maturity's exact ticker."""
|
|
matches = get_saxo_catalog(None, req.keyword, limit=200)
|
|
if not matches:
|
|
raise HTTPException(status_code=404, detail=f"Aucune entrée du catalogue ne correspond à '{req.keyword}' — rafraîchissez le catalogue d'abord si besoin.")
|
|
current = get_watchlist()
|
|
added = [m["symbol"] for m in matches if m["symbol"] not in current]
|
|
set_watchlist(current + added)
|
|
return {"symbols": get_watchlist(), "added": added}
|
|
|
|
|
|
@router.get("/debug-chain/{symbol}")
|
|
def debug_chain(symbol: str):
|
|
"""Raw, unparsed options-chain snapshot for one symbol — diagnostic endpoint only.
|
|
Also written to System Logs (details field) so it can be read from the Logs page
|
|
instead of requiring direct URL/API access."""
|
|
from services.saxo_client import debug_chain_raw, SaxoNotConnected, SaxoApiError
|
|
from services.database import log_system_event
|
|
try:
|
|
result = debug_chain_raw(symbol)
|
|
log_system_event(
|
|
level="INFO", source="saxo_debug",
|
|
message=f"Raw options chain snapshot for {symbol.upper()} (voir details)",
|
|
ticker=symbol, details=result,
|
|
)
|
|
return result
|
|
except SaxoNotConnected as e:
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except SaxoApiError as e:
|
|
log_system_event(level="ERROR", source="saxo_debug", message=f"debug-chain failed for {symbol.upper()}: {e}", ticker=symbol)
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
|
|
|
|
def _validate_symbol(symbol: str) -> dict:
|
|
from services.saxo_client import resolve_instrument, SaxoNotConnected
|
|
try:
|
|
info = resolve_instrument(symbol)
|
|
return {
|
|
"symbol": symbol.upper(), "valid": True, "error": None,
|
|
"uic": info["uic"], "matched_symbol": info["symbol"],
|
|
"description": info["description"], "asset_type": info["asset_type"],
|
|
}
|
|
except SaxoNotConnected as e:
|
|
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
|
|
except Exception as e:
|
|
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
|
|
|
|
|
|
@router.get("/validate")
|
|
def validate_watchlist():
|
|
"""Checks each watchlist symbol actually resolves to a real Saxo option-root instrument."""
|
|
return [_validate_symbol(s) for s in get_watchlist()]
|
|
|
|
|
|
@router.get("/validate/{symbol}")
|
|
def validate_symbol(symbol: str):
|
|
return _validate_symbol(symbol)
|
|
|
|
|
|
@router.get("/symbols")
|
|
def symbols_with_history():
|
|
"""Distinct symbols that already have recorded snapshot history (may differ from the
|
|
current watchlist — includes ad-hoc 'snapshot now' calls and previously-tracked symbols)."""
|
|
return get_saxo_snapshot_symbols()
|
|
|
|
|
|
class SettingsRequest(BaseModel):
|
|
snapshot_minutes: float
|
|
|
|
|
|
@router.get("/settings")
|
|
def get_settings():
|
|
return {"snapshot_minutes": get_snapshot_minutes()}
|
|
|
|
|
|
@router.put("/settings")
|
|
def update_settings(req: SettingsRequest):
|
|
set_snapshot_minutes(req.snapshot_minutes)
|
|
return {"snapshot_minutes": get_snapshot_minutes()}
|
|
|
|
|
|
@router.post("/snapshot-now")
|
|
def snapshot_now_all():
|
|
"""Manual immediate refresh of the whole watchlist (doesn't wait for the periodic poll)."""
|
|
if not saxo_auth.get_status().get("connected"):
|
|
raise HTTPException(status_code=401, detail="Saxo n'est pas connecté")
|
|
return {"results": run_snapshot_pass()}
|
|
|
|
|
|
@router.post("/snapshot-now/{symbol}")
|
|
def snapshot_now(symbol: str):
|
|
from services.saxo_client import snapshot_options_chain, SaxoNotConnected, SaxoApiError
|
|
from services.database import save_saxo_snapshot_rows
|
|
try:
|
|
rows = snapshot_options_chain(symbol)
|
|
except SaxoNotConnected as e:
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except (ValueError, SaxoApiError) as e:
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
save_saxo_snapshot_rows(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}")
|
|
def quote(symbol: str, asset_type: str = Query("FxSpot")):
|
|
"""Basic (non-options) price test — isolates whether NoDataAccess is options-specific
|
|
or a broader account restriction, independently of the options-chain subscription."""
|
|
from services.saxo_client import get_price_quote, SaxoNotConnected, SaxoApiError
|
|
try:
|
|
return get_price_quote(symbol, asset_type=asset_type)
|
|
except SaxoNotConnected as e:
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except (ValueError, SaxoApiError) as e:
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
|
|
|
|
@router.get("/history")
|
|
def history(
|
|
symbol: 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."),
|
|
):
|
|
"""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):
|
|
asset_types: Optional[List[str]] = None # default: CATALOG_ASSET_TYPES (FuturesOption, FxVanillaOption)
|
|
|
|
|
|
@router.post("/catalog/refresh")
|
|
def refresh_catalog(req: CatalogRefreshRequest):
|
|
from services.saxo_client import list_instruments, CATALOG_ASSET_TYPES, SaxoNotConnected, SaxoApiError
|
|
asset_types = req.asset_types or CATALOG_ASSET_TYPES
|
|
counts: Dict[str, int] = {}
|
|
try:
|
|
for asset_type in asset_types:
|
|
items = list_instruments(asset_type)
|
|
rows = [{
|
|
"uic": item.get("Identifier") or item.get("Uic"),
|
|
"symbol": item.get("Symbol"),
|
|
"asset_type": asset_type,
|
|
"description": item.get("Description"),
|
|
} for item in items if item.get("Identifier") or item.get("Uic")]
|
|
upsert_saxo_catalog_rows(rows)
|
|
counts[asset_type] = len(rows)
|
|
except SaxoNotConnected as e:
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
except SaxoApiError as e:
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
return {"refreshed": counts}
|
|
|
|
|
|
@router.get("/catalog")
|
|
def catalog(
|
|
asset_type: Optional[str] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
limit: int = Query(200),
|
|
):
|
|
return get_saxo_catalog(asset_type, q, limit)
|
|
|
|
|
|
@router.get("/catalog/summary")
|
|
def catalog_summary():
|
|
return get_saxo_catalog_summary()
|
|
|
|
|
|
# ── Saxo-only options analytics (Options Lab "Saxo" section) ─────────────────
|
|
# Computed exclusively from our own accumulated saxo_option_snapshots history — never
|
|
# blended with the yfinance-based /api/options-vol/* endpoints. Symbols come from the
|
|
# Saxo watchlist above (the same ones already being snapshotted every ~5 min).
|
|
|
|
@router.get("/iv-watchlist")
|
|
def saxo_iv_watchlist():
|
|
from services.saxo_iv_engine import get_saxo_iv_watchlist
|
|
return get_saxo_iv_watchlist()
|
|
|
|
|
|
@router.get("/iv-snapshot/{symbol}")
|
|
def saxo_iv_snapshot(symbol: str):
|
|
from services.saxo_iv_engine import get_saxo_iv_snapshot
|
|
return get_saxo_iv_snapshot(symbol)
|
|
|
|
|
|
@router.get("/iv-history/{symbol}")
|
|
def saxo_iv_history(symbol: str, days: int = Query(90, ge=1, le=730)):
|
|
from services.saxo_iv_engine import get_saxo_iv_history
|
|
return get_saxo_iv_history(symbol, days)
|