feat: srategy builder

This commit is contained in:
OpenSquared
2026-07-18 23:25:13 +02:00
parent 8fe18a8ff9
commit ca30e37942
3 changed files with 81 additions and 154 deletions

View File

@@ -6138,6 +6138,27 @@ def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_latest_saxo_snapshot_rows(symbol: str) -> List[Dict[str, Any]]:
"""One row per (expiry_date, strike, option_type) — the freshest of the accumulated
5-min snapshots for that contract, not the whole history. Powers the Strategy Builder,
which reads exclusively from this accumulated history (no live yfinance/Saxo call)."""
conn = get_conn()
rows = conn.execute("""
SELECT s.* FROM saxo_option_snapshots s
JOIN (
SELECT expiry_date, strike, option_type, MAX(created_at) AS max_created
FROM saxo_option_snapshots WHERE symbol = ?
GROUP BY expiry_date, strike, option_type
) latest
ON 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
WHERE s.symbol = ?
ORDER BY s.expiry_date, s.strike
""", (symbol, symbol)).fetchall()
conn.close()
return [dict(r) for r in rows]
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

View File

@@ -1,114 +1,82 @@
""" """
Real option chain fetcher for the Strategy Builder — reuses the same yfinance Option chain fetcher for the Strategy Builder — reads exclusively from our own
proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs). accumulated Saxo history (saxo_option_snapshots, refreshed every ~5 min by
services/saxo_scheduler.py). No live yfinance/Saxo call here: by the time the
Strategy Builder needs a chain, it's already been fetched and parsed correctly
by the periodic snapshot poller, so this is a fast, reliable DB read instead of
repeating the whole resolve/subscribe/parse dance per pricing request.
""" """
import logging
import math
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import yfinance as yf
from services.iv_engine import _resolve_ticker, _get_current_price
logger = logging.getLogger(__name__)
def _num(v: Any, default: float = 0.0) -> float:
try:
f = float(v)
return default if math.isnan(f) else f
except (TypeError, ValueError):
return default
def _rows_from_df(df) -> List[Dict[str, Any]]:
rows = []
for _, r in df.iterrows():
bid = _num(r.get("bid"))
ask = _num(r.get("ask"))
rows.append({
"strike": _num(r.get("strike")),
"bid": bid,
"ask": ask,
"mid": round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else _num(r.get("lastPrice")),
"last": _num(r.get("lastPrice")),
"iv": _num(r.get("impliedVolatility")),
"open_interest": int(_num(r.get("openInterest"))),
"volume": int(_num(r.get("volume"))),
})
return sorted(rows, key=lambda x: x["strike"])
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> 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 Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol`
Saxo fallback for instruments yfinance can't handle (FX/futures options). (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries`
expirations closest to target_days, each with calls/puts rows shaped
Dispatch: a Saxo-formatted symbol (exchange suffix, e.g. "OG:xcme") goes straight to {strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless
Saxo; otherwise yfinance is tried first (unchanged, proven path for stocks/ETFs), and of source, so vol_surface.py/strategy_engine.py need no changes.
only falls back to Saxo if yfinance fails AND a Saxo connection is available.
""" """
if ":" in symbol: from services.database import get_latest_saxo_snapshot_rows
from services.saxo_client import get_chain_slice_saxo
return get_chain_slice_saxo(symbol, target_days, n_expiries)
try: flat_rows = get_latest_saxo_snapshot_rows(symbol.upper())
return _get_chain_slice_yfinance(symbol, target_days, n_expiries) if not flat_rows:
except ValueError: raise ValueError(
from services import saxo_auth f"Aucun historique Saxo pour '{symbol}' — ajoutez-le à la watchlist "
from services.saxo_client import get_chain_slice_saxo f"(Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)."
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
normalized calls/puts rows (strike, bid, ask, mid, last, iv, open_interest, volume).
"""
proxy = _resolve_ticker(symbol)
t = yf.Ticker(proxy)
spot = _get_current_price(t)
if not spot:
raise ValueError(f"Impossible d'obtenir le prix spot pour {symbol} ({proxy})")
expirations = t.options
if not expirations:
raise ValueError(f"Aucune chaîne d'options disponible pour {symbol} ({proxy})")
spot = next((r["spot"] for r in flat_rows if r.get("spot") is not None), None)
today = date.today() today = date.today()
dated = sorted(
expirations, by_expiry: Dict[str, List[Dict[str, Any]]] = {}
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - today).days - target_days), for r in flat_rows:
)[:max(1, n_expiries)] 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, 6) 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),
"iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0,
"open_interest": 0,
"volume": 0,
}
expiries_out = [] expiries_out = []
for exp in dated: for expiry_date in sorted(selected, key=_days_to):
try: rows = by_expiry[expiry_date]
chain = t.option_chain(exp) calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"])
days_to_expiry = (datetime.strptime(exp, "%Y-%m-%d").date() - today).days puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"])
expiries_out.append({ if not calls and not puts:
"expiry_date": exp, continue
"days_to_expiry": days_to_expiry, expiries_out.append({
"calls": _rows_from_df(chain.calls), "expiry_date": expiry_date,
"puts": _rows_from_df(chain.puts), "days_to_expiry": _days_to(expiry_date),
}) "calls": calls,
except Exception as e: "puts": puts,
logger.debug(f"[OptionChain] {proxy} {exp}: {e}") })
if not expiries_out: if not expiries_out:
raise ValueError(f"Aucune chaîne exploitable pour {symbol} ({proxy})") raise ValueError(f"Historique Saxo présent pour '{symbol}' mais aucune échéance exploitable (pas de cotation Call/Put dans la fenêtre active de Saxo).")
return { return {
"symbol": symbol.upper(), "symbol": symbol.upper(),
"proxy": proxy, "proxy": symbol.upper(),
"spot": round(float(spot), 4), "spot": round(float(spot), 6) if spot is not None else None,
"expiries": expiries_out, "expiries": expiries_out,
} }

View File

@@ -292,65 +292,3 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
if not rows: if not rows:
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})") raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
return rows 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,
}