Backend:
- DB: add system_logs table (level/source/cycle_id/ticker/message) and
iv_watchlist table (ticker/added_by/is_active); seed builtin 18 tickers
- DBLogHandler attached at startup — all WARNING+ logs auto-persist to DB
- log_system_event() helper for structured manual events
- New router /api/logs: GET with filters (level, source, cycle_id, ticker,
date range), GET /sources, GET /cycles for dropdowns, DELETE /clear
- iv_watchlist now read from DB instead of hardcoded constant; options_vol
watchlist/refresh/bootstrap endpoints all use get_watchlist_tickers()
- New endpoints: POST/DELETE /options-vol/watchlist-tickers/{ticker} to
add/remove tickers; adding triggers background 1-year bootstrap
- auto_cycle: after log_trade_entries(), auto-detect new underlying proxies
not yet in watchlist, add them and bootstrap their IV history
Frontend:
- New page SystemLogs (/logs): log table with level/source/cycle/ticker/date
filters, color-coded rows, expandable JSON details, auto-refresh 30s
- Options Lab: WatchlistManager section — add ticker input, chip list with
builtin/auto/manual color coding, remove button for non-builtins
- Sidebar: Logs Système nav link (ScrollText icon)
- useApi: useSystemLogs, useLogSources, useLogCycles, useClearLogs,
useWatchlistTickers, useAddWatchlistTicker, useRemoveWatchlistTicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
"""
|
|
Options volatility endpoints — IV Rank, Term Structure, Skew, Options Flow.
|
|
"""
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
|
from typing import List, Optional
|
|
import logging
|
|
|
|
router = APIRouter(prefix="/api/options-vol", tags=["options-vol"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Simple in-memory cache to avoid hammering yfinance on every page load
|
|
_iv_cache: dict = {}
|
|
_CACHE_TTL_SECONDS = 3600 # 1h
|
|
|
|
|
|
def _is_stale(key: str) -> bool:
|
|
import time
|
|
entry = _iv_cache.get(key)
|
|
if not entry:
|
|
return True
|
|
return (time.time() - entry["ts"]) > _CACHE_TTL_SECONDS
|
|
|
|
|
|
def _set_cache(key: str, data: dict):
|
|
import time
|
|
_iv_cache[key] = {"data": data, "ts": time.time()}
|
|
|
|
|
|
@router.get("/snapshot/{ticker}")
|
|
def get_iv_snapshot(ticker: str, force: bool = False):
|
|
"""Full IV snapshot for one ticker: IV, Rank, Percentile, Term Structure, Skew, Flow."""
|
|
key = f"snapshot:{ticker.upper()}"
|
|
if not force and not _is_stale(key):
|
|
return _iv_cache[key]["data"]
|
|
|
|
from services.iv_engine import get_full_iv_snapshot
|
|
data = get_full_iv_snapshot(ticker)
|
|
_set_cache(key, data)
|
|
return data
|
|
|
|
|
|
@router.get("/batch")
|
|
def get_iv_batch(tickers: str = "SPY,QQQ,GLD,USO,UNG,XLE,TLT"):
|
|
"""
|
|
IV snapshot for multiple tickers (comma-separated).
|
|
Returns a dict {ticker: snapshot}.
|
|
Cached 1h per ticker.
|
|
"""
|
|
ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()][:12]
|
|
result = {}
|
|
from services.iv_engine import get_full_iv_snapshot
|
|
|
|
for ticker in ticker_list:
|
|
key = f"snapshot:{ticker}"
|
|
if not _is_stale(key):
|
|
result[ticker] = _iv_cache[key]["data"]
|
|
else:
|
|
snap = get_full_iv_snapshot(ticker)
|
|
_set_cache(key, snap)
|
|
result[ticker] = snap
|
|
|
|
return {"snapshots": result, "count": len(result)}
|
|
|
|
|
|
@router.get("/watchlist")
|
|
def get_iv_watchlist():
|
|
"""
|
|
IV for the core IV watchlist (portfolio-relevant tickers).
|
|
Returns a summary list sorted by IV Rank descending.
|
|
"""
|
|
from services.iv_engine import get_atm_iv, _resolve_ticker
|
|
from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history, get_watchlist_tickers
|
|
from datetime import date
|
|
|
|
results = []
|
|
today = date.today().isoformat()
|
|
|
|
for ticker in get_watchlist_tickers():
|
|
key = f"watchlist:{ticker}"
|
|
if not _is_stale(key):
|
|
results.append(_iv_cache[key]["data"])
|
|
continue
|
|
|
|
proxy = _resolve_ticker(ticker)
|
|
iv = get_atm_iv(ticker, 30)
|
|
live_iv = iv is not None
|
|
|
|
# Fallback: use most recent historical IV when live options chain fails
|
|
if iv is None:
|
|
recent = get_iv_history(proxy, days=5)
|
|
if recent:
|
|
iv = recent[0]["iv_current"]
|
|
|
|
if not iv:
|
|
continue
|
|
|
|
if live_iv:
|
|
save_iv_snapshot(proxy, today, iv)
|
|
rank_data = get_iv_rank_percentile(proxy, iv)
|
|
|
|
item = {
|
|
"ticker": ticker,
|
|
"iv_current_pct": round(iv * 100, 1),
|
|
"iv_rank": rank_data.get("iv_rank"),
|
|
"iv_percentile": rank_data.get("iv_percentile"),
|
|
"history_days": rank_data.get("history_days", 0),
|
|
"iv_source": "live" if live_iv else "history",
|
|
"signal": (
|
|
"sell_vol" if (rank_data.get("iv_rank") or 0) > 80
|
|
else "buy_vol" if (rank_data.get("iv_rank") or 100) < 20
|
|
else "neutral"
|
|
),
|
|
}
|
|
_set_cache(key, item)
|
|
results.append(item)
|
|
|
|
results.sort(key=lambda x: -(x.get("iv_rank") or 0))
|
|
return {"items": results, "count": len(results)}
|
|
|
|
|
|
@router.get("/history/{ticker}")
|
|
def get_iv_history_endpoint(ticker: str, days: int = 90):
|
|
"""Historical IV for a ticker (used to render IV chart)."""
|
|
from services.database import get_iv_history
|
|
from services.iv_engine import _resolve_ticker
|
|
proxy = _resolve_ticker(ticker)
|
|
history = get_iv_history(proxy, days)
|
|
return {"ticker": ticker, "proxy": proxy, "history": history, "count": len(history)}
|
|
|
|
|
|
@router.get("/watchlist-tickers")
|
|
def list_watchlist_tickers():
|
|
"""List all watchlist tickers (active + inactive) with metadata."""
|
|
from services.database import get_watchlist_entries
|
|
return {"tickers": get_watchlist_entries()}
|
|
|
|
|
|
@router.post("/watchlist-tickers/{ticker}")
|
|
def add_to_watchlist(ticker: str, background_tasks: BackgroundTasks):
|
|
"""Add a ticker to the IV watchlist and start background bootstrap."""
|
|
from services.database import add_watchlist_ticker
|
|
from services.iv_engine import _resolve_ticker
|
|
proxy = _resolve_ticker(ticker.upper())
|
|
is_new = add_watchlist_ticker(proxy, added_by="manual")
|
|
if is_new:
|
|
def _bootstrap():
|
|
from services.iv_engine import bootstrap_iv_history
|
|
bootstrap_iv_history(tickers=[proxy], min_existing=0)
|
|
logger.info(f"[Watchlist] Bootstrap done for new ticker {proxy}")
|
|
background_tasks.add_task(_bootstrap)
|
|
return {"ticker": proxy, "is_new": is_new, "bootstrap": is_new}
|
|
|
|
|
|
@router.delete("/watchlist-tickers/{ticker}")
|
|
def remove_from_watchlist(ticker: str):
|
|
"""Remove (soft-delete) a ticker from the watchlist."""
|
|
from services.database import remove_watchlist_ticker
|
|
from services.iv_engine import _resolve_ticker
|
|
proxy = _resolve_ticker(ticker.upper())
|
|
ok = remove_watchlist_ticker(proxy)
|
|
return {"ticker": proxy, "removed": ok}
|
|
|
|
|
|
@router.post("/refresh-watchlist")
|
|
def refresh_watchlist(background_tasks: BackgroundTasks):
|
|
"""Trigger a background refresh of IV data for all watchlist tickers."""
|
|
def _refresh():
|
|
from services.iv_engine import get_full_iv_snapshot
|
|
from services.database import get_watchlist_tickers
|
|
tickers = get_watchlist_tickers()
|
|
logger.info(f"[IVRefresh] Refreshing IV for {len(tickers)} tickers")
|
|
for ticker in tickers:
|
|
try:
|
|
snap = get_full_iv_snapshot(ticker)
|
|
key = f"snapshot:{ticker}"
|
|
import time
|
|
_iv_cache[key] = {"data": snap, "ts": time.time()}
|
|
logger.debug(f"[IVRefresh] {ticker}: IV={snap.get('iv_current_pct')}% IVR={snap.get('iv_rank')}")
|
|
except Exception as e:
|
|
logger.warning(f"[IVRefresh] {ticker} failed: {e}")
|
|
logger.info("[IVRefresh] Done")
|
|
|
|
from services.database import get_watchlist_tickers
|
|
count = len(get_watchlist_tickers())
|
|
background_tasks.add_task(_refresh)
|
|
return {"status": "refresh started", "tickers": count}
|
|
|
|
|
|
@router.post("/bootstrap-history")
|
|
def bootstrap_iv_history_endpoint(background_tasks: BackgroundTasks, min_existing: int = 30):
|
|
"""Bootstrap iv_history with 1 year of realized vol for all watchlist tickers."""
|
|
def _run():
|
|
from services.iv_engine import bootstrap_iv_history
|
|
from services.database import get_watchlist_tickers
|
|
tickers = get_watchlist_tickers()
|
|
logger.info(f"[IV Bootstrap] Starting for {len(tickers)} tickers")
|
|
result = bootstrap_iv_history(tickers=tickers, min_existing=min_existing)
|
|
total = sum(v.get("inserted", 0) for v in result.values() if isinstance(v, dict))
|
|
logger.info(f"[IV Bootstrap] Done — {total} rows inserted across {len(result)} tickers")
|
|
|
|
background_tasks.add_task(_run)
|
|
return {"status": "bootstrap started", "message": "Chargement historique 1 an réalisé vol — vérifier les logs dans ~60s"}
|
|
|
|
|
|
@router.get("/for-trade/{underlying}")
|
|
def get_iv_for_trade(underlying: str):
|
|
"""
|
|
IV snapshot specifically for a trade's underlying.
|
|
Used by JournalDeBord to show the IV context at trade time.
|
|
"""
|
|
from services.iv_engine import get_full_iv_snapshot
|
|
key = f"snapshot:{underlying.upper()}"
|
|
if not _is_stale(key):
|
|
return _iv_cache[key]["data"]
|
|
data = get_full_iv_snapshot(underlying)
|
|
_set_cache(key, data)
|
|
return data
|