feat: option lab
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user