feat: option

This commit is contained in:
OpenSquared
2026-07-18 22:04:43 +02:00
parent 6db2705466
commit b0b7f9bfda
9 changed files with 300 additions and 24 deletions

View File

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from services import saxo_auth
from services.saxo_scheduler import get_watchlist, set_watchlist
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,
get_saxo_catalog, get_saxo_catalog_summary, upsert_saxo_catalog_rows,
@@ -72,6 +72,29 @@ def symbols_with_history():
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

View File

@@ -30,6 +30,13 @@ def saxo_callback(code: str = Query(...), state: str = Query(...)):
except Exception as e:
logger.error(f"[Saxo OAuth] Token exchange failed: {e}")
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_error=1")
# Snapshot the whole watchlist right away — don't make the user wait for the next
# scheduled poll after connecting. Runs in the background so the redirect isn't delayed.
import threading
from services.saxo_scheduler import run_snapshot_pass
threading.Thread(target=run_snapshot_pass, daemon=True, name="saxo-connect-snapshot").start()
return RedirectResponse(f"{FRONTEND_CONFIG_URL}?saxo_connected=1")

View File

@@ -41,6 +41,32 @@ def _rows_from_df(df) -> List[Dict[str, Any]]:
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
"""
Fetch the real option chain for `symbol` — yfinance by default, with an automatic
Saxo fallback for instruments yfinance can't handle (FX/futures options).
Dispatch: a Saxo-formatted symbol (exchange suffix, e.g. "OG:xcme") goes straight to
Saxo; otherwise yfinance is tried first (unchanged, proven path for stocks/ETFs), and
only falls back to Saxo if yfinance fails AND a Saxo connection is available.
"""
if ":" in symbol:
from services.saxo_client import get_chain_slice_saxo
return get_chain_slice_saxo(symbol, target_days, n_expiries)
try:
return _get_chain_slice_yfinance(symbol, target_days, n_expiries)
except ValueError:
from services import saxo_auth
from services.saxo_client import get_chain_slice_saxo
if saxo_auth.get_status().get("connected"):
try:
return get_chain_slice_saxo(symbol, target_days, n_expiries)
except Exception as e:
logger.debug(f"[OptionChain] Saxo fallback failed for {symbol}: {e}")
raise
def _get_chain_slice_yfinance(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
"""
Fetch the real option chain for `symbol` around a target horizon (days).
Returns the `n_expiries` expirations closest to target_days, each with

View File

@@ -291,3 +291,65 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
if not rows:
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
return rows
def get_chain_slice_saxo(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
"""
Saxo-backed equivalent of services/option_chain.get_chain_slice — same output shape
({symbol, proxy, spot, expiries: [{expiry_date, days_to_expiry, calls, puts}]}, each row
{strike, bid, ask, mid, last, iv, open_interest, volume}) so vol_surface.py/strategy_engine.py
work unchanged regardless of data source. Reuses snapshot_options_chain (already flat,
already bid/ask/mid/greeks per contract) and reshapes/filters it down to n_expiries.
"""
instrument = resolve_instrument(symbol)
flat_rows = snapshot_options_chain(symbol)
spot = flat_rows[0]["spot"] if flat_rows else None
today = date.today()
by_expiry: Dict[str, List[Dict[str, Any]]] = {}
for r in flat_rows:
if r.get("expiry_date"):
by_expiry.setdefault(r["expiry_date"], []).append(r)
def _days_to(expiry_date: str) -> int:
return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days
selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)]
def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]:
bid = r.get("bid") or 0.0
ask = r.get("ask") or 0.0
mid = r.get("mid") or (round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else 0.0)
vol_pct = r.get("volatility_pct")
return {
"strike": float(r["strike"]),
"bid": float(bid),
"ask": float(ask),
"mid": float(mid),
"last": float(mid), # Saxo's chain snapshot has no separate last-traded field
"iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0,
"open_interest": 0,
"volume": 0,
}
expiries_out = []
for expiry_date in sorted(selected, key=_days_to):
rows = by_expiry[expiry_date]
calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"])
puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"])
expiries_out.append({
"expiry_date": expiry_date,
"days_to_expiry": _days_to(expiry_date),
"calls": calls,
"puts": puts,
})
if not expiries_out:
raise ValueError(f"Aucune chaîne exploitable pour '{symbol}' via Saxo")
return {
"symbol": symbol.upper(),
"proxy": instrument["symbol"] or symbol.upper(),
"spot": round(float(spot), 4) if spot is not None else None,
"expiries": expiries_out,
}

View File

@@ -61,28 +61,47 @@ def _refresh_loop(stop: threading.Event):
stop.wait(timeout=REFRESH_INTERVAL_SECONDS)
def get_snapshot_minutes() -> float:
from .database import get_config
return float(get_config("saxo_snapshot_minutes") or "5")
def set_snapshot_minutes(minutes: float):
from .database import set_config
set_config("saxo_snapshot_minutes", str(minutes))
def run_snapshot_pass() -> dict[str, int]:
"""Snapshot every watchlist symbol once — shared by the periodic loop, the
'connect now' trigger (saxo_oauth.py callback), and the manual refresh button."""
from .saxo_client import snapshot_options_chain
from .database import save_saxo_snapshot_rows
results: dict[str, int] = {}
for symbol in get_watchlist():
try:
rows = snapshot_options_chain(symbol)
save_saxo_snapshot_rows(rows)
results[symbol] = len(rows)
logger.info(f"[Saxo Scheduler] Snapshot saved: {symbol} ({len(rows)} rows)")
except Exception as e:
results[symbol] = 0
logger.warning(f"[Saxo Scheduler] Snapshot failed for {symbol}: {e}")
return results
def _snapshot_loop(stop: threading.Event):
while not stop.wait(0):
try:
from .database import get_config
from .saxo_auth import get_status
minutes = float(get_config("saxo_snapshot_minutes") or "20")
minutes = get_snapshot_minutes()
if get_status().get("connected"):
from .saxo_client import snapshot_options_chain
from .database import save_saxo_snapshot_rows
for symbol in get_watchlist():
try:
rows = snapshot_options_chain(symbol)
save_saxo_snapshot_rows(rows)
logger.info(f"[Saxo Scheduler] Snapshot saved: {symbol} ({len(rows)} rows)")
except Exception as e:
logger.warning(f"[Saxo Scheduler] Snapshot failed for {symbol}: {e}")
run_snapshot_pass()
else:
minutes = max(minutes, 5) # don't busy-loop while disconnected
except Exception as e:
logger.error(f"[Saxo Scheduler] Snapshot loop error: {e}")
minutes = 20
minutes = 5
stop.wait(timeout=minutes * 60)