feat: srategy builder
This commit is contained in:
@@ -6138,6 +6138,27 @@ def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]:
|
||||
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]]):
|
||||
if not rows:
|
||||
return
|
||||
|
||||
@@ -1,114 +1,82 @@
|
||||
"""
|
||||
Real option chain fetcher for the Strategy Builder — reuses the same yfinance
|
||||
proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs).
|
||||
Option chain fetcher for the Strategy Builder — reads exclusively from our own
|
||||
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 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]:
|
||||
"""
|
||||
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.
|
||||
Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol`
|
||||
(services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries`
|
||||
expirations closest to target_days, each with calls/puts rows shaped
|
||||
{strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless
|
||||
of source, so vol_surface.py/strategy_engine.py need no changes.
|
||||
"""
|
||||
if ":" in symbol:
|
||||
from services.saxo_client import get_chain_slice_saxo
|
||||
return get_chain_slice_saxo(symbol, target_days, n_expiries)
|
||||
from services.database import get_latest_saxo_snapshot_rows
|
||||
|
||||
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
|
||||
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})")
|
||||
flat_rows = get_latest_saxo_snapshot_rows(symbol.upper())
|
||||
if not flat_rows:
|
||||
raise ValueError(
|
||||
f"Aucun historique Saxo pour '{symbol}' — ajoutez-le à la watchlist "
|
||||
f"(Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)."
|
||||
)
|
||||
|
||||
spot = next((r["spot"] for r in flat_rows if r.get("spot") is not None), None)
|
||||
today = date.today()
|
||||
dated = sorted(
|
||||
expirations,
|
||||
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - today).days - target_days),
|
||||
)[:max(1, n_expiries)]
|
||||
|
||||
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, 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 = []
|
||||
for exp in dated:
|
||||
try:
|
||||
chain = t.option_chain(exp)
|
||||
days_to_expiry = (datetime.strptime(exp, "%Y-%m-%d").date() - today).days
|
||||
expiries_out.append({
|
||||
"expiry_date": exp,
|
||||
"days_to_expiry": days_to_expiry,
|
||||
"calls": _rows_from_df(chain.calls),
|
||||
"puts": _rows_from_df(chain.puts),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"[OptionChain] {proxy} {exp}: {e}")
|
||||
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"])
|
||||
if not calls and not puts:
|
||||
continue
|
||||
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} ({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 {
|
||||
"symbol": symbol.upper(),
|
||||
"proxy": proxy,
|
||||
"spot": round(float(spot), 4),
|
||||
"proxy": symbol.upper(),
|
||||
"spot": round(float(spot), 6) if spot is not None else None,
|
||||
"expiries": expiries_out,
|
||||
}
|
||||
|
||||
|
||||
@@ -292,65 +292,3 @@ 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user