feat: system logs page + dynamic IV watchlist with auto-add from cycle
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>
This commit is contained in:
75
backend/routers/logs.py
Normal file
75
backend/routers/logs.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
System logs endpoint — exposes structured log events stored in system_logs table.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_logs(
|
||||
level: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
cycle_id: Optional[str] = None,
|
||||
ticker: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = 300,
|
||||
):
|
||||
"""
|
||||
Query system logs with optional filters.
|
||||
- level: INFO | WARNING | ERROR | CRITICAL
|
||||
- source: logger name substring (e.g. 'auto_cycle', 'iv_engine')
|
||||
- cycle_id: exact run_id UUID
|
||||
- ticker: exact ticker symbol
|
||||
- date_from / date_to: ISO date strings YYYY-MM-DD
|
||||
- limit: max rows returned (default 300, max 1000)
|
||||
"""
|
||||
from services.database import get_system_logs
|
||||
limit = min(limit, 1000)
|
||||
logs = get_system_logs(
|
||||
level=level,
|
||||
source=source,
|
||||
cycle_id=cycle_id,
|
||||
ticker=ticker,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
)
|
||||
return {"logs": logs, "count": len(logs)}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
def get_log_sources():
|
||||
"""List distinct source names present in system_logs (for filter dropdown)."""
|
||||
from services.database import get_conn
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT source FROM system_logs WHERE source IS NOT NULL ORDER BY source"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {"sources": [r["source"] for r in rows]}
|
||||
|
||||
|
||||
@router.get("/cycles")
|
||||
def get_log_cycles(limit: int = 50):
|
||||
"""List distinct cycle_ids (most recent first) for filter dropdown."""
|
||||
from services.database import get_conn
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT cycle_id, MIN(ts) as first_ts
|
||||
FROM system_logs WHERE cycle_id IS NOT NULL
|
||||
GROUP BY cycle_id ORDER BY first_ts DESC LIMIT ?""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {"cycles": [{"cycle_id": r["cycle_id"], "first_ts": r["first_ts"]} for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/clear")
|
||||
def clear_old_logs(older_than_days: int = 30):
|
||||
"""Delete logs older than N days."""
|
||||
from services.database import clear_system_logs
|
||||
deleted = clear_system_logs(older_than_days)
|
||||
return {"deleted": deleted, "older_than_days": older_than_days}
|
||||
@@ -68,14 +68,14 @@ 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 IV_WATCHLIST, get_atm_iv, _resolve_ticker
|
||||
from services.database import get_iv_rank_percentile, save_iv_snapshot, get_iv_history
|
||||
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 IV_WATCHLIST:
|
||||
for ticker in get_watchlist_tickers():
|
||||
key = f"watchlist:{ticker}"
|
||||
if not _is_stale(key):
|
||||
results.append(_iv_cache[key]["data"])
|
||||
@@ -128,16 +128,48 @@ def get_iv_history_endpoint(ticker: str, days: int = 90):
|
||||
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.
|
||||
Call this once daily to build up the 52-week IV history.
|
||||
"""
|
||||
"""Trigger a background refresh of IV data for all watchlist tickers."""
|
||||
def _refresh():
|
||||
from services.iv_engine import IV_WATCHLIST, get_full_iv_snapshot
|
||||
logger.info(f"[IVRefresh] Refreshing IV for {len(IV_WATCHLIST)} tickers")
|
||||
for ticker in IV_WATCHLIST:
|
||||
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}"
|
||||
@@ -148,21 +180,21 @@ def refresh_watchlist(background_tasks: BackgroundTasks):
|
||||
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": len(__import__("services.iv_engine", fromlist=["IV_WATCHLIST"]).IV_WATCHLIST)}
|
||||
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 volatility for all watchlist tickers.
|
||||
Only fills missing dates — safe to call multiple times.
|
||||
Runs in background (takes ~30-60s for 18 tickers).
|
||||
"""
|
||||
"""Bootstrap iv_history with 1 year of realized vol for all watchlist tickers."""
|
||||
def _run():
|
||||
from services.iv_engine import bootstrap_iv_history, IV_WATCHLIST
|
||||
logger.info(f"[IV Bootstrap] Starting for {len(IV_WATCHLIST)} tickers")
|
||||
result = bootstrap_iv_history(tickers=IV_WATCHLIST, min_existing=min_existing)
|
||||
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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user