feat: option lab

This commit is contained in:
OpenSquared
2026-07-21 17:23:55 +02:00
parent b6e9b96dc4
commit 78eda311f8
7 changed files with 275 additions and 82 deletions

View File

@@ -26,14 +26,16 @@ def list_watchlist():
@router.get("/quotes")
def watchlist_quotes():
from services.database import get_instruments_watchlist
from services.data_fetcher import get_quote
from services.data_fetcher import get_quote_with_volatility
items = []
for row in get_instruments_watchlist():
q = get_quote(row["ticker"]) or {}
q = get_quote_with_volatility(row["ticker"]) or {}
items.append({
**row,
"price": q.get("price"),
"change_pct": q.get("change_pct"),
"volatility_pct": q.get("volatility_pct"),
"volatility_change_pct": q.get("volatility_change_pct"),
})
return {"items": items}
@@ -78,3 +80,19 @@ def reorder(body: ReorderBody):
from services.database import reorder_instruments_watchlist
reorder_instruments_watchlist(body.tickers)
return {"ok": True}
class SaxoLinkBody(BaseModel):
saxo_symbol: str | None = None
@router.put("/{ticker}/saxo-link")
def set_saxo_link(ticker: str, body: SaxoLinkBody):
"""Link this tracked instrument to a Saxo watchlist symbol (or pass null to unlink)
so Options Lab's Saxo section shows broker data for it. Adds the symbol to
services.saxo_scheduler's watchlist automatically if it wasn't already there."""
from services.database import set_instrument_watchlist_saxo_symbol
ok = set_instrument_watchlist_saxo_symbol(ticker, body.saxo_symbol)
if not ok:
raise HTTPException(404, f"'{ticker}' is not in the instruments watchlist")
return {"ticker": ticker.strip().upper(), "saxo_symbol": (body.saxo_symbol or "").strip().upper() or None}

View File

@@ -132,6 +132,60 @@ def get_quote(symbol: str) -> Optional[Dict[str, Any]]:
return {"symbol": symbol, "price": None, "error": "no data"}
def get_quote_with_volatility(symbol: str, vol_window: int = 20) -> Optional[Dict[str, Any]]:
"""Like get_quote(), plus a realized volatility overlay: annualized %, rolling
`vol_window`-day stddev of log returns — same formula as the Instrument Analysis
chart's volatility overlay (services.instrument_service). Needs more history than
get_quote()'s 5d/1mo window, so it's kept as a separate function rather than slowing
down get_quote()'s many other callers that don't need volatility."""
import numpy as np
for period in ("3mo", "6mo"):
try:
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period, interval="1d", auto_adjust=True)
if hist.empty:
continue
hist = hist.dropna(subset=["Close"])
if len(hist) < vol_window + 2:
continue
close = hist["Close"]
price = float(close.iloc[-1])
last_date = hist.index[-1].date()
prior_rows = hist[hist.index.date < last_date]
prev = float(prior_rows["Close"].iloc[-1]) if not prior_rows.empty else price
change = price - prev
change_pct = (change / prev * 100) if prev else 0
log_ret = np.log(close / close.shift(1))
vol_series = (log_ret.rolling(vol_window).std() * np.sqrt(252) * 100).dropna()
if vol_series.empty:
continue
volatility_pct = float(vol_series.iloc[-1])
# D-1 vol: same explicit date-comparison approach as the price above, not
# just "the point before last" (guards the same near-24h double-row case).
volatility_change_pct = None
prior_vol = vol_series[vol_series.index.date < last_date]
if not prior_vol.empty:
volatility_change_pct = round(volatility_pct - float(prior_vol.iloc[-1]), 2)
return {
"symbol": symbol,
"price": round(price, 4),
"change": round(change, 4),
"change_pct": round(change_pct, 2),
"volatility_pct": round(volatility_pct, 2),
"volatility_change_pct": volatility_change_pct,
"volume": int(hist["Volume"].iloc[-1]) if "Volume" in hist.columns else 0,
"timestamp": datetime.utcnow().isoformat(),
}
except Exception:
continue
return {"symbol": symbol, "price": None, "error": "no data"}
def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
result = {}
for asset_class, assets in WATCHLIST.items():

View File

@@ -183,6 +183,10 @@ def init_db():
sort_order INTEGER DEFAULT 0,
added_at TEXT DEFAULT (datetime('now'))
)""",
# Optional link to a Saxo watchlist symbol (services.saxo_scheduler) — lets Options
# Lab's Saxo section show only broker data for instruments the user actually tracks
# here, instead of an independently-managed Saxo symbol list.
"ALTER TABLE instruments_watchlist ADD COLUMN saxo_symbol TEXT",
# Wavelets — saved optimization/simulation runs (ported from InstrumentSimulator's
# WaveletOptimizationRun: form/results are free-form JSON blobs, not modeled relationally)
"""CREATE TABLE IF NOT EXISTS wavelet_simulations (
@@ -3270,13 +3274,40 @@ def remove_market_custom_ticker(ticker: str) -> bool:
def get_instruments_watchlist() -> List[Dict]:
conn = get_conn()
rows = conn.execute(
"SELECT ticker, name, asset_class, sort_order, added_at "
"SELECT ticker, name, asset_class, sort_order, added_at, saxo_symbol "
"FROM instruments_watchlist ORDER BY sort_order ASC, added_at ASC"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def set_instrument_watchlist_saxo_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool:
"""Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to a Saxo
watchlist symbol. Linking also adds that symbol to services.saxo_scheduler's own
watchlist if it isn't there yet, so it actually starts getting snapshotted. Unlinking
does NOT remove it from the Saxo watchlist — it may still be wanted directly, or by
another linked instrument; remove it from Config -> Saxo if it's truly no longer needed."""
from services.saxo_scheduler import get_watchlist, set_watchlist
ticker = ticker.upper()
saxo_symbol = (saxo_symbol or "").strip().upper() or None
conn = get_conn()
row = conn.execute("SELECT ticker FROM instruments_watchlist WHERE ticker = ?", (ticker,)).fetchone()
if row is None:
conn.close()
return False
conn.execute("UPDATE instruments_watchlist SET saxo_symbol = ? WHERE ticker = ?", (saxo_symbol, ticker))
conn.commit()
conn.close()
if saxo_symbol:
current = get_watchlist()
if saxo_symbol not in current:
set_watchlist(current + [saxo_symbol])
return True
def add_instrument_watchlist(ticker: str, name: str = "", asset_class: str = "unknown") -> bool:
conn = get_conn()
try: