Add centralized event logging + Logs/Health UI with PEAD/ORB tabs

- New EventsStore (SQLite WAL) captures all structlog + stdlib events
- ORB engine: 13 _emit() calls for orders, errors, kill-switch, circuit breaker
- ORB daemon: configures structlog sink so engine emits reach events.db
- ORB scheduler: phase lifecycle events (phase_started/completed) with job_run_id
- PEAD scheduler: same lifecycle pattern, pipeline stdout capture improved
- New /api/events + /api/health endpoints
- Logs/Health page: All / PEAD / ORB tabs, health cards, event table, detail panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent bd26e7ab43
commit 1d24893326

@ -33,6 +33,20 @@ def main() -> None:
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logging.root.addHandler(file_handler) logging.root.addHandler(file_handler)
logging.root.setLevel(logging.INFO) logging.root.setLevel(logging.INFO)
# Mirror all ORB daemon logs to events.db so the Logs/Health UI can surface them.
# Two channels:
# 1. structlog sink processor — captures structured engine emits (orb_engine_*)
# 2. EventsLoggingHandler — bridges plain stdlib log.info(...) lines
try:
from apps.web.services.events_store import EventsStore, EventsLoggingHandler
from libs.common.logging import configure_logging
EventsStore.get().ensure_schema()
configure_logging(enable_events_sink=True)
_events_handler = EventsLoggingHandler()
_events_handler.setLevel(logging.INFO)
logging.root.addHandler(_events_handler)
except Exception:
pass # events.db mirror is best-effort; don't break the daemon
sessions = [s.strip() for s in args.sessions.split(",") if s.strip()] sessions = [s.strip() for s in args.sessions.split(",") if s.strip()]
trigger_dir = Path(args.db_path).parent trigger_dir = Path(args.db_path).parent

File diff suppressed because it is too large Load Diff

@ -43,7 +43,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apps.web.routers import backtest, docs, experiments, intraday, leaderboard, orb_trading, paper_trading, runs, sqs from apps.web.routers import advisor, backtest, docs, experiments, intraday, leaderboard, orb_trading, paper_trading, runs, sqs
from apps.web.routers.events import router as events_router, health_router
_STATIC_DIR = Path(__file__).parent / "static" _STATIC_DIR = Path(__file__).parent / "static"
@ -56,6 +57,16 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg]
project_root = Path(__file__).parent.parent.parent project_root = Path(__file__).parent.parent.parent
os.chdir(project_root) os.chdir(project_root)
# ── Centralized event store: schema first, then sink + stdlib bridge ─────
from apps.web.services.events_store import EventsStore, EventsLoggingHandler
_events_store = EventsStore.get()
_events_store.ensure_schema() # must precede configure_logging so table exists before first write
from libs.common.logging import configure_logging
configure_logging(enable_events_sink=True)
_events_handler = EventsLoggingHandler()
_events_handler.setLevel(_logging.INFO)
_logging.root.addHandler(_events_handler)
# Auto-restart PEAD auto scheduler if it was running before last shutdown # Auto-restart PEAD auto scheduler if it was running before last shutdown
from apps.web.paper_trading_service import auto_scheduler, load_saved_state from apps.web.paper_trading_service import auto_scheduler, load_saved_state
_db_env = os.environ.get("PAPER_TRADER_DB", "paper_trading.db") _db_env = os.environ.get("PAPER_TRADER_DB", "paper_trading.db")
@ -91,13 +102,15 @@ async def lifespan(app: FastAPI): # type: ignore[type-arg]
if _orb_sessions: if _orb_sessions:
try: try:
from apps.orb_trader.state import ORBStateManager as _ORBState from apps.orb_trader.state import ORBStateManager as _ORBState
_orb_state = _ORBState(_orb_db_path)
_existing = [ _existing = [
s for s in _orb_sessions s for s in _orb_sessions
if _ORBState(_orb_db_path).get_session(s) is not None if (session := _orb_state.get_session(s)) is not None
and session.status == "active"
] ]
if not _existing: if not _existing:
_logging.getLogger(__name__).warning( _logging.getLogger(__name__).warning(
"ORB auto-restart: saved sessions %s not found in DB; " "ORB auto-restart: saved sessions %s not active in DB; "
"will use all active sessions instead", _orb_sessions "will use all active sessions instead", _orb_sessions
) )
_orb_sessions = [] _orb_sessions = []
@ -160,6 +173,9 @@ def create_app() -> FastAPI:
app.include_router(paper_trading.router, prefix=api_prefix) app.include_router(paper_trading.router, prefix=api_prefix)
app.include_router(sqs.router, prefix=api_prefix) app.include_router(sqs.router, prefix=api_prefix)
app.include_router(docs.router, prefix=api_prefix) app.include_router(docs.router, prefix=api_prefix)
app.include_router(advisor.router, prefix=api_prefix)
app.include_router(events_router, prefix=api_prefix)
app.include_router(health_router, prefix=api_prefix)
# Serve built frontend (production) # Serve built frontend (production)
if _STATIC_DIR.exists(): if _STATIC_DIR.exists():

@ -37,6 +37,43 @@ from zoneinfo import ZoneInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Mirror scheduler activity into journal/events.db so the Logs/Health UI can
# group ORB phases by job_run_id alongside PEAD events. We write directly to
# EventsStore rather than via structlog because the scheduler runs in the
# daemon process where source inference from a PrintLogger is unreliable.
try:
from libs.common.logging import bind_job_run_id as _bind_job_run_id
except Exception: # pragma: no cover
_bind_job_run_id = None # type: ignore[assignment]
def _scheduler_emit(event: str, level: str = "INFO", **fields: Any) -> None:
try:
from apps.web.services.events_store import EventsStore
payload: dict[str, Any] = {
"ts_utc": datetime.now(timezone.utc).isoformat(),
"source": "auto_scheduler",
"level": level,
"event": event,
"scheduler": "orb",
}
payload.update(fields)
EventsStore.get().write(payload)
except Exception:
pass
# kind → public phase name (distinct from PEAD's run_open/run_close so health
# card rows do not collide).
_PHASE_NAME_MAP = {
"pre_screen": "orb_pre_screen",
"orb_detect": "orb_detect",
"breakout": "orb_breakout",
"stop_check": "orb_stop",
"eod_exit": "orb_eod",
"post_close": "orb_post_close",
}
_TZ_ET = ZoneInfo("America/New_York") _TZ_ET = ZoneInfo("America/New_York")
_TZ_PHOENIX = ZoneInfo("America/Phoenix") # UTC-7 always (no DST) _TZ_PHOENIX = ZoneInfo("America/Phoenix") # UTC-7 always (no DST)
_DEFAULT_DB = "data/paper/orb.db" _DEFAULT_DB = "data/paper/orb.db"
@ -98,12 +135,13 @@ def build_schedule(
"et_dt": t, "et_dt": t,
}) })
# ── ORB detection at window close (fetch bars + rank candidates) ────────── # ── ORB detection: one bar after window close (bars are published ~5 min late)
detect_dt = orb_end + dt.timedelta(minutes=5)
events.append({ events.append({
"name": "orb_detect", "name": "orb_detect",
"label": f"ORB 감지 ({orb_end.strftime('%H:%M')} ET)", "label": f"ORB 감지 ({detect_dt.strftime('%H:%M')} ET)",
"kind": "orb_detect", "kind": "orb_detect",
"et_dt": orb_end, "et_dt": detect_dt,
}) })
# ── Breakout checks: every sim_bar_minutes from first bar close to timeout ── # ── Breakout checks: every sim_bar_minutes from first bar close to timeout ──
@ -151,12 +189,12 @@ def _load_session_params(db_path: str, session_name: str) -> dict[str, Any]:
""" """
defaults = {"orb_minutes": 10, "sim_bar_minutes": 90, "order_timeout_minutes": 45} defaults = {"orb_minutes": 10, "sim_bar_minutes": 90, "order_timeout_minutes": 45}
try: try:
import yaml
from apps.orb_trader.state import ORBStateManager from apps.orb_trader.state import ORBStateManager
from apps.intraday_bt.run import _load_config_yaml
session = ORBStateManager(db_path).get_session(session_name) session = ORBStateManager(db_path).get_session(session_name)
if session is None: if session is None:
return defaults return defaults
raw = yaml.safe_load(Path(session.config_path).read_text()) or {} raw = _load_config_yaml(Path(session.config_path)) or {}
orb = raw.get("orb_strategy", {}) orb = raw.get("orb_strategy", {})
return { return {
"orb_minutes": orb.get("orb_minutes", defaults["orb_minutes"]), "orb_minutes": orb.get("orb_minutes", defaults["orb_minutes"]),
@ -380,6 +418,9 @@ class ORBAutoScheduler:
f.write(line + "\n") f.write(line + "\n")
except Exception: except Exception:
pass pass
# Mirror to events.db with source=auto_scheduler so the Logs/Health UI
# can show scheduler activity inline with engine events.
_scheduler_emit("scheduler_log", message=msg)
def _now_et(self) -> dt.datetime: def _now_et(self) -> dt.datetime:
return dt.datetime.now(tz=_TZ_ET) return dt.datetime.now(tz=_TZ_ET)
@ -423,6 +464,13 @@ class ORBAutoScheduler:
except Exception: except Exception:
return [] return []
def _mark_past_events_completed(self, now_et: dt.datetime) -> None:
"""Mark already-past events as completed after a schedule rebuild."""
for ev in self._today_schedule:
if ev["et_dt"] <= now_et and ev["name"] not in self._completed:
self._completed.add(ev["name"])
self._log(f"Past (skipped): {ev['label']}")
def _build_today_schedule(self, date: dt.date, sessions: list[str]) -> list[dict[str, Any]]: def _build_today_schedule(self, date: dt.date, sessions: list[str]) -> list[dict[str, Any]]:
"""Build per-session schedules and merge into one sorted timeline. """Build per-session schedules and merge into one sorted timeline.
@ -513,7 +561,25 @@ class ORBAutoScheduler:
if engine is None: if engine is None:
continue continue
# Bind a fresh job_run_id per (session, kind) so every engine event
# in this run groups into a single timeline in the Logs/Health UI.
phase_name = _PHASE_NAME_MAP.get(kind, kind)
phase_run_id = str(uuid.uuid4())
if _bind_job_run_id is not None:
try:
_bind_job_run_id(phase_run_id)
except Exception:
pass
phase_started = time.monotonic()
_scheduler_emit(
"phase_started",
job_run_id=phase_run_id,
phase=phase_name, session_id=session.session_id,
session_name=session_name, category="lifecycle",
)
self._log(f"{kind}{session_name}") self._log(f"{kind}{session_name}")
phase_status = "success"
try: try:
def _run_sync(e=engine, k=kind, d=date_str) -> dict[str, Any]: def _run_sync(e=engine, k=kind, d=date_str) -> dict[str, Any]:
import asyncio as _asyncio import asyncio as _asyncio
@ -540,8 +606,28 @@ class ORBAutoScheduler:
self._log(f"{session_name}: {summary}") self._log(f"{session_name}: {summary}")
except Exception as exc: except Exception as exc:
tb = traceback.format_exc() tb = traceback.format_exc()
phase_status = "failed"
self._log(f" ERROR {session_name}: {exc}") self._log(f" ERROR {session_name}: {exc}")
log.error("ORB engine error: %s\n%s", exc, tb) log.error("ORB engine error: %s\n%s", exc, tb)
_scheduler_emit(
"phase_completed",
level="ERROR",
job_run_id=phase_run_id,
phase=phase_name, status="failed",
session_id=session.session_id, session_name=session_name,
category="lifecycle",
duration_s=round(time.monotonic() - phase_started, 3),
error=str(exc),
)
continue
_scheduler_emit(
"phase_completed",
job_run_id=phase_run_id,
phase=phase_name, status=phase_status,
session_id=session.session_id, session_name=session_name,
category="lifecycle",
duration_s=round(time.monotonic() - phase_started, 3),
)
# ── Run-now: manually trigger detection for a late-added session ───────── # ── Run-now: manually trigger detection for a late-added session ─────────
@ -665,10 +751,7 @@ class ORBAutoScheduler:
else: else:
self._today_schedule = self._build_today_schedule(today, resolved) self._today_schedule = self._build_today_schedule(today, resolved)
# Skip events already past # Skip events already past
for ev in self._today_schedule: self._mark_past_events_completed(now_et)
if ev["et_dt"] <= now_et:
self._completed.add(ev["name"])
self._log(f"Past (skipped): {ev['label']}")
self._save_schedule_state() self._save_schedule_state()
if not self._is_trading_day(today): if not self._is_trading_day(today):
@ -679,6 +762,23 @@ class ORBAutoScheduler:
await asyncio.sleep(300) await asyncio.sleep(300)
continue continue
# If the daemon was started with an explicit but now-stale session
# list, do not spend the whole day scheduling inactive/deleted
# sessions. This commonly happens when a user creates/replaces an
# ORB session while the long-lived daemon is already running.
if self._sessions:
active_now = self._get_active_sessions()
active_resolved = [s for s in resolved if s in active_now]
if not active_resolved and active_now:
self._log(
"Configured ORB sessions are no longer active; "
f"refreshing to active sessions: {', '.join(active_now)}"
)
resolved = active_now
self._today_schedule = self._build_today_schedule(today, resolved)
self._mark_past_events_completed(now_et)
self._save_schedule_state()
pending = [ev for ev in self._today_schedule if ev["name"] not in self._completed] pending = [ev for ev in self._today_schedule if ev["name"] not in self._completed]
if not pending: if not pending:
@ -795,7 +895,25 @@ class ORBDaemonController:
def start(self, sessions: list[str], db_path: str, dry_run: bool = False) -> None: def start(self, sessions: list[str], db_path: str, dry_run: bool = False) -> None:
self._db_path = db_path # set FIRST so _pid_file() uses the right dir self._db_path = db_path # set FIRST so _pid_file() uses the right dir
if self.running: if self.running:
return # daemon already alive — no-op (web server may have restarted) requested_sessions = [s.strip() for s in sessions if s and s.strip()]
saved = load_orb_saved_state(self._db_path) or {}
current_sessions = [
str(s).strip()
for s in saved.get("sessions", [])
if str(s).strip()
]
current_dry_run = bool(saved.get("dry_run", False))
if current_sessions == requested_sessions and current_dry_run == dry_run:
return # daemon already alive with the requested configuration
# A no-op here leaves the old daemon pinned to stale sessions. Restart
# so "start selected sessions" from the UI actually updates trading.
self.stop()
deadline = time.time() + 5.0
while self.running and time.time() < deadline:
time.sleep(0.1)
if self.running:
raise RuntimeError("ORB daemon is still running after stop request")
cmd = [ cmd = [
sys.executable, "-m", "apps.orb_trader.daemon", sys.executable, "-m", "apps.orb_trader.daemon",

@ -0,0 +1,69 @@
"""Events and health monitoring router.
Provides endpoints for the centralized trading event log and live health summary.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Query
router = APIRouter(prefix="/events", tags=["events"])
def _get_store():
from apps.web.services.events_store import EventsStore
return EventsStore.get()
@router.get("")
def list_events(
source: str | None = Query(None),
level: str | None = Query(None),
category: str | None = Query(None),
session_id: str | None = Query(None),
job_run_id: str | None = Query(None),
event_name: str | None = Query(None),
q: str | None = Query(None),
since: str | None = Query(None),
until: str | None = Query(None),
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
system: str | None = Query(None, description="'pead' or 'orb' to scope to one system"),
) -> dict[str, Any]:
store = _get_store()
rows, total = store.query(
source=source, level=level, category=category,
session_id=session_id, job_run_id=job_run_id, event_name=event_name,
q=q, since=since, until=until,
limit=limit, offset=offset,
system=system,
)
next_offset = offset + limit if offset + limit < total else None
return {"rows": rows, "total": total, "offset": offset, "next_offset": next_offset}
@router.get("/runs")
def list_runs(limit: int = Query(20, ge=1, le=100)) -> list[dict[str, Any]]:
return _get_store().recent_runs(limit=limit)
@router.get("/sources")
def list_sources() -> list[str]:
return _get_store().distinct_sources()
@router.delete("")
def purge_events(before: str = Query(..., description="ISO-8601 datetime; delete events older than this")) -> dict[str, Any]:
deleted = _get_store().purge_before(before)
return {"deleted": deleted}
# ── Health endpoint (mounted at /health to match plan, kept in events router) ──
health_router = APIRouter(tags=["health"])
@health_router.get("/health")
def get_health() -> dict[str, Any]:
return _get_store().health_summary()

@ -0,0 +1,576 @@
"""Centralized structured event store for live trading monitoring.
All structlog events from the PEAD engine, ORB engine, pipeline subprocesses,
and AutoScheduler flow into a single SQLite table here. This gives the user
a queryable log of every fallback, timeout, error, and phase lifecycle event
without touching any engine emit sites.
"""
from __future__ import annotations
import json
import logging
import queue
import sqlite3
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_DB_PATH = "journal/events.db"
_SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_utc TEXT NOT NULL,
job_run_id TEXT,
source TEXT NOT NULL,
level TEXT NOT NULL,
category TEXT,
event_name TEXT NOT NULL,
session_id TEXT,
ticker TEXT,
message TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts_utc DESC);
CREATE INDEX IF NOT EXISTS idx_events_run ON events(job_run_id, ts_utc DESC);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts_utc DESC);
CREATE INDEX IF NOT EXISTS idx_events_cat_lvl ON events(category, level, ts_utc DESC);
CREATE INDEX IF NOT EXISTS idx_events_name ON events(event_name, ts_utc DESC);
"""
# event_name suffix → level promotion to WARN
_WARN_SUFFIXES = (
"_fallback", "_skipped", "_timeout", "_stale", "_failed",
"_unavailable", "_not_found", "_soft_fallback",
)
# event_name prefix → category
_CATEGORY_MAP: list[tuple[str, str]] = [
("paper_engine_macro_", "macro"),
("paper_engine_clock_", "health"),
("paper_engine_kill_switch_", "kill_switch"),
("paper_engine_buy_", "order"),
("paper_engine_close_", "order"),
("paper_engine_no_bar", "order"),
("paper_engine_", "engine"),
("orb_engine_buy_", "order"),
("orb_engine_close_", "order"),
("orb_engine_kill_switch_", "kill_switch"),
("orb_engine_circuit_breaker", "kill_switch"),
("orb_engine_oracle_", "broker"),
("orb_engine_bars_", "broker"),
("orb_engine_no_", "health"),
("orb_engine_", "engine"),
("snapshot_store_", "snapshot"),
("incremental_update_canonical_", "snapshot"),
("event_detector_", "snapshot"),
("oracle_", "broker"),
("multi_daily_bars", "broker"),
("multi_intraday_bars", "broker"),
("phase_started", "lifecycle"),
("phase_completed", "lifecycle"),
("scheduler_", "lifecycle"),
("pipeline_", "pipeline"),
]
def _infer_category(event_name: str, explicit: str | None) -> str | None:
if explicit:
return explicit
for prefix, cat in _CATEGORY_MAP:
if event_name.startswith(prefix):
return cat
return None
def _promote_level(level: str, event_name: str, category: str | None) -> str:
"""Promote INFO → WARN for known-fallback event names."""
if level in ("warning", "warn", "WARN", "WARNING"):
return "WARN"
if level in ("error", "critical", "ERROR", "CRITICAL"):
return "ERROR"
low = event_name.lower()
if any(low.endswith(s) for s in _WARN_SUFFIXES):
return "WARN"
# fallback-category items that are INFO → WARN
if category in ("kill_switch",) and level in ("info", "INFO"):
return "WARN"
return "INFO"
class EventsStore:
"""Thread-safe event store backed by SQLite.
Uses a background writer thread and a bounded queue so that SQLite I/O
never blocks the trading hot-path. The writer thread is resilient to
transient SQLite errors (locked / disk full) it drops the failing row
and counts the drop rather than dying.
"""
_instance: "EventsStore | None" = None
_lock = threading.Lock()
@classmethod
def get(cls) -> "EventsStore":
"""Return the process-level singleton, creating it on first call."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls(_DB_PATH)
return cls._instance
def __init__(self, db_path: str = _DB_PATH) -> None:
self._db_path = str(Path(db_path))
self._queue: queue.Queue[dict[str, Any] | None] = queue.Queue(maxsize=10_000)
self._drop_count = 0
self._schema_done = False
self._writer = threading.Thread(target=self._writer_loop, daemon=True, name="events-writer")
self._writer.start()
def ensure_schema(self) -> None:
"""Create DB tables if they don't exist. Safe to call multiple times."""
if self._schema_done:
return
try:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self._db_path, timeout=10)
conn.executescript(_SCHEMA)
conn.commit()
conn.close()
self._schema_done = True
except Exception as exc:
logging.getLogger(__name__).error("events_store schema init failed: %s", exc)
def write(self, record: dict[str, Any]) -> None:
"""Enqueue a record for background writing. Non-blocking; drops on full queue."""
try:
self._queue.put_nowait(record)
except queue.Full:
self._drop_count += 1
if self._drop_count % 100 == 1:
print(f"[events_store] queue full, {self._drop_count} drops", flush=True, file=__import__("sys").stderr)
def _writer_loop(self) -> None:
conn: sqlite3.Connection | None = None
BATCH = 50
FLUSH_EVERY = 2.0 # seconds
def _connect() -> sqlite3.Connection | None:
try:
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
c = sqlite3.connect(self._db_path, timeout=15, check_same_thread=False)
c.execute("PRAGMA journal_mode=WAL")
return c
except Exception as exc:
print(f"[events_store] connect failed: {exc}", file=__import__("sys").stderr, flush=True)
return None
pending: list[dict[str, Any]] = []
last_flush = time.monotonic()
while True:
# Drain up to BATCH items from the queue
try:
record = self._queue.get(timeout=FLUSH_EVERY)
if record is None: # poison pill
break
pending.append(record)
# Drain additional items without waiting
while len(pending) < BATCH:
try:
r = self._queue.get_nowait()
if r is None:
break
pending.append(r)
except queue.Empty:
break
except queue.Empty:
pass
now = time.monotonic()
if not pending and now - last_flush < FLUSH_EVERY:
continue
if not pending:
last_flush = now
continue
if conn is None:
conn = _connect()
if conn is None:
pending.clear()
continue
rows_to_insert = []
for rec in pending:
try:
rows_to_insert.append(_build_row(rec))
except Exception:
pass
pending.clear()
if not rows_to_insert:
last_flush = now
continue
try:
conn.executemany(
"INSERT INTO events (ts_utc,job_run_id,source,level,category,"
"event_name,session_id,ticker,message,details) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
rows_to_insert,
)
conn.commit()
except Exception as exc:
self._drop_count += len(rows_to_insert)
print(f"[events_store] write failed ({len(rows_to_insert)} rows dropped): {exc}",
file=__import__("sys").stderr, flush=True)
try:
conn.close()
except Exception:
pass
conn = None
last_flush = time.monotonic()
def query(
self,
source: str | None = None,
level: str | None = None,
category: str | None = None,
session_id: str | None = None,
job_run_id: str | None = None,
event_name: str | None = None,
q: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int = 200,
offset: int = 0,
system: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""Return (rows, total_count) matching the given filters."""
clauses: list[str] = []
params: list[Any] = []
if system == "pead":
srcs = ("paper_engine", "snapshot_store", "event_detector", "oracle", "pipeline")
ph = ",".join("?" * len(srcs))
clauses.append(f"(source IN ({ph}) OR (source='auto_scheduler' AND details NOT LIKE ?))")
params.extend(srcs)
params.append('%"scheduler": "orb"%')
elif system == "orb":
clauses.append("(source IN (?) OR (source='auto_scheduler' AND details LIKE ?))")
params.append("orb_engine")
params.append('%"scheduler": "orb"%')
if source:
clauses.append("source = ?")
params.append(source)
if level:
clauses.append("level = ?")
params.append(level.upper())
if category:
clauses.append("category = ?")
params.append(category)
if session_id:
clauses.append("session_id = ?")
params.append(session_id)
if job_run_id:
clauses.append("job_run_id = ?")
params.append(job_run_id)
if event_name:
clauses.append("event_name LIKE ?")
params.append(f"%{event_name}%")
if q:
clauses.append("(message LIKE ? OR event_name LIKE ? OR details LIKE ?)")
params += [f"%{q}%", f"%{q}%", f"%{q}%"]
if since:
clauses.append("ts_utc >= ?")
params.append(since)
if until:
clauses.append("ts_utc <= ?")
params.append(until)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
try:
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
total = conn.execute(f"SELECT COUNT(*) FROM events {where}", params).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM events {where} ORDER BY ts_utc DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
conn.close()
return [dict(r) for r in rows], total
except Exception as exc:
logging.getLogger(__name__).warning("events_store query failed: %s", exc)
return [], 0
def recent_runs(self, limit: int = 20) -> list[dict[str, Any]]:
"""Return recent job_run_id summaries ordered by start time."""
sql = """
SELECT
job_run_id,
MIN(ts_utc) AS started_at,
MAX(ts_utc) AS ended_at,
COUNT(*) AS event_count,
SUM(CASE WHEN level='ERROR' THEN 1 ELSE 0 END) AS error_count,
SUM(CASE WHEN level='WARN' THEN 1 ELSE 0 END) AS warn_count,
GROUP_CONCAT(DISTINCT source) AS sources,
MAX(CASE WHEN event_name='phase_started' THEN json_extract(details,'$.phase') END) AS phase
FROM events
WHERE job_run_id IS NOT NULL AND job_run_id != ''
GROUP BY job_run_id
ORDER BY started_at DESC
LIMIT ?
"""
try:
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
rows = conn.execute(sql, [limit]).fetchall()
conn.close()
return [dict(r) for r in rows]
except Exception as exc:
logging.getLogger(__name__).warning("events_store recent_runs failed: %s", exc)
return []
def health_summary(self) -> dict[str, Any]:
"""Aggregate today's events into a health snapshot for the UI."""
today_start = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00")
phase_sql = """
SELECT
json_extract(details,'$.phase') AS phase,
json_extract(details,'$.status') AS status,
MAX(ts_utc) AS last_ts
FROM events
WHERE event_name='phase_completed' AND ts_utc >= ?
GROUP BY phase, status
"""
fallback_sql = """
SELECT category, level, COUNT(*) AS cnt
FROM events
WHERE ts_utc >= ?
AND (
level IN ('WARN','ERROR')
OR category IN ('fallback','snapshot','macro','regime','broker','kill_switch')
)
AND category NOT IN ('lifecycle','pipeline')
GROUP BY category, level
"""
recent_errors_sql = """
SELECT id, ts_utc, source, event_name, message, session_id, job_run_id
FROM events
WHERE level='ERROR' AND ts_utc >= ?
ORDER BY ts_utc DESC
LIMIT 5
"""
snapshot_sql = """
SELECT session_id,
json_extract(details,'$.snapshot_id') AS snapshot_id,
MAX(ts_utc) AS last_update
FROM events
WHERE event_name LIKE 'incremental_update_canonical%done'
AND ts_utc >= date('now','-7 days')
GROUP BY session_id, snapshot_id
"""
try:
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
phases = [dict(r) for r in conn.execute(phase_sql, [today_start]).fetchall()]
fallbacks = [dict(r) for r in conn.execute(fallback_sql, [today_start]).fetchall()]
recent_errors = [dict(r) for r in conn.execute(recent_errors_sql, [today_start]).fetchall()]
snapshots = [dict(r) for r in conn.execute(snapshot_sql, []).fetchall()]
conn.close()
except Exception as exc:
logging.getLogger(__name__).warning("events_store health_summary failed: %s", exc)
phases, fallbacks, recent_errors, snapshots = [], [], [], []
phase_map: dict[str, dict[str, Any]] = {}
for row in phases:
ph = row["phase"] or "unknown"
if ph not in phase_map or row["last_ts"] > phase_map[ph].get("last_ts", ""):
phase_map[ph] = row
fallback_map: dict[str, dict[str, Any]] = {}
for row in fallbacks:
cat = row["category"] or "other"
if cat not in fallback_map:
fallback_map[cat] = {"warn": 0, "error": 0}
if row["level"] == "ERROR":
fallback_map[cat]["error"] += row["cnt"]
else:
fallback_map[cat]["warn"] += row["cnt"]
return {
"phases": phase_map,
"fallbacks_today": fallback_map,
"recent_errors": recent_errors,
"snapshot_freshness": snapshots,
"drop_count": self._drop_count,
}
def distinct_sources(self) -> list[str]:
try:
conn = sqlite3.connect(self._db_path, timeout=10)
rows = conn.execute(
"SELECT DISTINCT source FROM events ORDER BY source"
).fetchall()
conn.close()
return [r[0] for r in rows]
except Exception:
return []
def purge_before(self, before_iso: str) -> int:
try:
conn = sqlite3.connect(self._db_path, timeout=10)
cur = conn.execute("DELETE FROM events WHERE ts_utc < ?", [before_iso])
conn.commit()
deleted = cur.rowcount
conn.execute("VACUUM")
conn.close()
return deleted
except Exception as exc:
logging.getLogger(__name__).warning("events_store purge failed: %s", exc)
return 0
def _build_row(rec: dict[str, Any]) -> tuple:
"""Convert a structlog event_dict or freeform dict into a DB row tuple."""
ts = rec.get("timestamp") or rec.get("ts_utc") or datetime.now(timezone.utc).isoformat()
event_name = str(rec.get("event") or rec.get("event_name") or "")
raw_level = str(rec.get("level") or "info").lower()
explicit_cat = rec.get("category")
category = _infer_category(event_name, explicit_cat)
level = _promote_level(raw_level, event_name, category)
# "fallback" refinement: any event_name containing "fallback" → category fallback
if "fallback" in event_name.lower() and category not in ("lifecycle",):
category = "fallback"
message = rec.get("message") or rec.get("msg") or event_name
details_dict = {k: v for k, v in rec.items()
if k not in ("timestamp", "level", "event", "event_name",
"message", "msg", "ts_utc", "source",
"job_run_id", "session_id", "ticker", "category")}
try:
details = json.dumps(details_dict, default=str)
except Exception:
details = str(details_dict)
return (
ts,
rec.get("job_run_id") or "",
str(rec.get("source") or "unknown"),
level,
category,
event_name,
rec.get("session_id"),
rec.get("ticker"),
message,
details,
)
# ── structlog processor ───────────────────────────────────────────────────────
def structlog_sink_processor(logger: Any, method: str, event_dict: dict[str, Any]) -> dict[str, Any]:
"""structlog processor that tees events to EventsStore without blocking."""
try:
store = EventsStore.get()
# Infer source from logger name if not already set
source = event_dict.get("source")
if not source:
logger_name = str(logger.name if hasattr(logger, "name") else "")
source = _infer_source(logger_name, event_dict.get("event", ""))
record = dict(event_dict)
record["source"] = source
store.write(record)
except Exception:
pass
return event_dict
def _infer_source(logger_name: str, event_name: str) -> str:
if "paper_trader" in logger_name or event_name.startswith("paper_engine_"):
return "paper_engine"
if "orb_trader" in logger_name or event_name.startswith("orb_"):
return "orb_engine"
if "snapshot_store" in logger_name or event_name.startswith("snapshot_store_"):
return "snapshot_store"
if "event_detector" in logger_name or event_name.startswith("event_detector_"):
return "event_detector"
if "oracle" in logger_name or event_name.startswith(("oracle_", "multi_")):
return "oracle"
if "canonical_snapshot" in logger_name or event_name.startswith("incremental_update"):
return "snapshot_store"
return logger_name.split(".")[-1] if logger_name else "unknown"
# ── stdlib logging bridge ─────────────────────────────────────────────────────
_STDLIB_LEVEL_MAP = {
logging.DEBUG: "INFO",
logging.INFO: "INFO",
logging.WARNING: "WARN",
logging.ERROR: "ERROR",
logging.CRITICAL: "ERROR",
}
class EventsLoggingHandler(logging.Handler):
"""stdlib logging.Handler that writes to EventsStore.
Captures ORB engine (stdlib), oracle client (stdlib), and web.main
auto-restart warnings that structlog doesn't see.
"""
# Loggers to skip (already handled by structlog or too noisy)
_SKIP_LOGGERS = {
"uvicorn", "uvicorn.error", "uvicorn.access",
"fastapi", "asyncio", "multiprocessing",
"sqlalchemy",
}
def emit(self, record: logging.LogRecord) -> None:
logger_name = record.name or ""
# Skip loggers that are too noisy or already handled by structlog
root = logger_name.split(".")[0]
if root in self._SKIP_LOGGERS:
return
# Skip DEBUG unless it's a known important logger
if record.levelno < logging.WARNING and record.levelno == logging.DEBUG:
return
try:
store = EventsStore.get()
event_name = f"stdlib_{logger_name.replace('.', '_')}"
message = record.getMessage()
level = _STDLIB_LEVEL_MAP.get(record.levelno, "INFO")
# Infer a better event_name from known patterns in the message
low_msg = message.lower()
if "fallback" in low_msg:
event_name = "stdlib_fallback"
elif "failed" in low_msg or "error" in low_msg:
event_name = f"stdlib_error_{root}"
elif "auto-restart" in low_msg or "auto_restart" in low_msg:
event_name = "scheduler_auto_restart"
store.write({
"ts_utc": datetime.now(timezone.utc).isoformat(),
"source": _infer_source(logger_name, event_name),
"level": level,
"event": event_name,
"message": message,
"logger": logger_name,
})
except Exception:
pass

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fithia2</title> <title>Fithia2</title>
<script type="module" crossorigin src="/assets/index-BdTCIlkw.js"></script> <script type="module" crossorigin src="/assets/index-BpOBWdYW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVDbFxMk.css"> <link rel="stylesheet" crossorigin href="/assets/index-AVDbFxMk.css">
</head> </head>
<body> <body>

@ -14,6 +14,8 @@ import { CashParkingPage } from './pages/CashParking';
import { ComposeStrategyPage } from './pages/ComposeStrategy'; import { ComposeStrategyPage } from './pages/ComposeStrategy';
import { IntradayBacktestPage, IntradayTaskDetailPage } from './pages/IntradayBacktest'; import { IntradayBacktestPage, IntradayTaskDetailPage } from './pages/IntradayBacktest';
import { OrbTradingPage } from './pages/OrbTrading'; import { OrbTradingPage } from './pages/OrbTrading';
import { AdvisorPage } from './pages/Advisor';
import { LogsPage } from './pages/Logs';
const qc = new QueryClient({ const qc = new QueryClient({
defaultOptions: { defaultOptions: {
@ -46,6 +48,8 @@ export default function App() {
<Route path="/intraday" element={<IntradayBacktestPage />} /> <Route path="/intraday" element={<IntradayBacktestPage />} />
<Route path="/intraday/tasks/:taskId" element={<IntradayTaskDetailPage />} /> <Route path="/intraday/tasks/:taskId" element={<IntradayTaskDetailPage />} />
<Route path="/orb-trading" element={<OrbTradingPage />} /> <Route path="/orb-trading" element={<OrbTradingPage />} />
<Route path="/advisor" element={<AdvisorPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/docs/cash-parking" element={<CashParkingPage />} /> <Route path="/docs/cash-parking" element={<CashParkingPage />} />
</Routes> </Routes>
</main> </main>

@ -428,6 +428,10 @@ export interface IntradayStrategy {
risk_per_trade_pct?: number | null; risk_per_trade_pct?: number | null;
max_position_pct?: number | null; max_position_pct?: number | null;
atr_stop_multiplier?: number | null; atr_stop_multiplier?: number | null;
min_price?: number | null;
min_avg_dollar_volume?: number | null;
min_premarket_dollar_vol?: number | null;
min_breakout_rel_vol?: number | null;
min_rvol?: number | null; min_rvol?: number | null;
max_candidates?: number | null; max_candidates?: number | null;
daily_max_loss_pct?: number | null; daily_max_loss_pct?: number | null;
@ -802,6 +806,7 @@ export interface OrbPosition {
bid: number | null; bid: number | null;
ask: number | null; ask: number | null;
change_pct: number | null; change_pct: number | null;
day_change_pct?: number | null;
unrealized_pnl: number; unrealized_pnl: number;
r_multiple: number; r_multiple: number;
} }
@ -993,3 +998,128 @@ export const sqsApi = {
`/sqs/pipeline/${pipelineId}` `/sqs/pipeline/${pipelineId}`
), ),
}; };
// --- Advisor (single-ticker BUY/SELL/HOLD) ---
export type AdvisorSignal = 'BUY' | 'SELL' | 'HOLD' | 'NO_SIGNAL' | 'ERROR';
export interface HeldPosition {
strategy: 'pead' | 'orb';
entry_date: string; // YYYY-MM-DD
entry_price: number;
}
export interface AdvisorRequest {
ticker: string;
asof?: string | null; // YYYY-MM-DD; null = today ET
held?: HeldPosition | null;
}
export interface StrategyVerdict {
signal: AdvisorSignal;
score: number | null;
reason: string;
details: Record<string, unknown>;
warning: string | null;
}
export interface AdvisorResponse {
ticker: string;
asof: string;
evaluated_at: string;
pead: StrategyVerdict;
orb: StrategyVerdict;
}
export const advisorApi = {
evaluate: (req: AdvisorRequest) =>
request<AdvisorResponse>('/advisor/evaluate', {
method: 'POST',
body: JSON.stringify(req),
}),
champions: () => request<{ pead: string; orb: string }>('/advisor/champions'),
};
// --- Events / Health ---
export interface TradeEvent {
id: number;
ts_utc: string;
job_run_id: string | null;
source: string;
level: 'INFO' | 'WARN' | 'ERROR';
category: string | null;
event_name: string;
session_id: string | null;
ticker: string | null;
message: string | null;
details: string | null;
}
export interface EventsResponse {
rows: TradeEvent[];
total: number;
offset: number;
next_offset: number | null;
}
export interface RunSummary {
job_run_id: string;
started_at: string;
ended_at: string;
event_count: number;
error_count: number;
warn_count: number;
sources: string | null;
phase: string | null;
}
export interface HealthSummary {
phases: Record<string, { phase: string; status: string; last_ts: string }>;
fallbacks_today: Record<string, { warn: number; error: number }>;
recent_errors: Array<{
id: number;
ts_utc: string;
source: string;
event_name: string;
message: string | null;
session_id: string | null;
job_run_id: string | null;
}>;
snapshot_freshness: Array<{
session_id: string | null;
snapshot_id: string | null;
last_update: string;
}>;
drop_count: number;
}
export interface EventsQuery {
source?: string;
level?: string;
category?: string;
session_id?: string;
job_run_id?: string;
event_name?: string;
q?: string;
since?: string;
until?: string;
limit?: number;
offset?: number;
system?: string;
}
export const eventsApi = {
list: (params: EventsQuery = {}) => {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
});
const q = qs.toString();
return request<EventsResponse>(`/events${q ? '?' + q : ''}`);
},
runs: (limit = 20) => request<RunSummary[]>(`/events/runs?limit=${limit}`),
sources: () => request<string[]>('/events/sources'),
health: () => request<HealthSummary>('/health'),
purge: (before: string) =>
request<{ deleted: number }>(`/events?before=${encodeURIComponent(before)}`, { method: 'DELETE' }),
};

@ -10,6 +10,8 @@ import {
PanelLeftClose, PanelLeftClose,
TrendingUp, TrendingUp,
Zap, Zap,
Compass,
ScrollText,
} from 'lucide-react'; } from 'lucide-react';
const NAV = [ const NAV = [
@ -20,6 +22,8 @@ const NAV = [
{ to: '/intraday', icon: TrendingUp, label: 'Intraday ORB' }, { to: '/intraday', icon: TrendingUp, label: 'Intraday ORB' },
{ to: '/orb-trading', icon: Zap, label: 'ORB Trading' }, { to: '/orb-trading', icon: Zap, label: 'ORB Trading' },
{ to: '/paper', icon: LineChart, label: 'Paper Trading' }, { to: '/paper', icon: LineChart, label: 'Paper Trading' },
{ to: '/advisor', icon: Compass, label: 'Advisor' },
{ to: '/logs', icon: ScrollText, label: 'Logs / Health' },
]; ];
const DOCS_NAV = [ const DOCS_NAV = [

@ -0,0 +1,606 @@
import { useState, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { CheckCircle, XCircle, Clock, RefreshCw, Activity } from 'lucide-react';
import { eventsApi, type TradeEvent, type HealthSummary } from '../api/client';
// ── colour helpers ────────────────────────────────────────────────────────────
const LEVEL_STYLE: Record<string, { color: string; bg: string }> = {
ERROR: { color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
WARN: { color: 'var(--gold)', bg: 'var(--gold-dim)' },
INFO: { color: 'var(--text3)', bg: 'rgba(0,0,0,0.04)' },
};
const CAT_STYLE: Record<string, { color: string; bg: string }> = {
fallback: { color: '#f97316', bg: 'rgba(249,115,22,0.10)' },
snapshot: { color: 'var(--cyan)', bg: 'var(--cyan-dim)' },
macro: { color: '#8b5cf6', bg: 'rgba(139,92,246,0.10)' },
kill_switch: { color: '#ef4444', bg: 'rgba(239,68,68,0.10)' },
broker: { color: 'var(--gold)', bg: 'var(--gold-dim)' },
lifecycle: { color: 'var(--green)', bg: 'rgba(16,185,129,0.10)' },
order: { color: 'var(--cyan)', bg: 'var(--cyan-dim)' },
engine: { color: 'var(--text2)', bg: 'rgba(0,0,0,0.05)' },
pipeline: { color: 'var(--text3)', bg: 'rgba(0,0,0,0.04)' },
};
function LevelPill({ level }: { level: string }) {
const s = LEVEL_STYLE[level] ?? LEVEL_STYLE.INFO;
return (
<span style={{
display: 'inline-block', padding: '1px 7px', borderRadius: 4, fontSize: 11,
fontFamily: 'var(--font-mono)', fontWeight: 600, letterSpacing: '0.05em',
color: s.color, background: s.bg, border: '1px solid var(--border)',
}}>
{level}
</span>
);
}
function CatPill({ cat }: { cat: string | null }) {
if (!cat) return null;
const s = CAT_STYLE[cat] ?? { color: 'var(--text3)', bg: 'rgba(0,0,0,0.04)' };
return (
<span style={{
display: 'inline-block', padding: '1px 7px', borderRadius: 4, fontSize: 11,
fontFamily: 'var(--font-mono)', fontWeight: 500,
color: s.color, background: s.bg, border: '1px solid var(--border)',
}}>
{cat}
</span>
);
}
// ── timestamp formatting ──────────────────────────────────────────────────────
function fmtTs(iso: string) {
try {
const d = new Date(iso);
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' });
} catch {
return iso.slice(11, 19);
}
}
function fmtDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch { return iso.slice(0, 10); }
}
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return fmtDate(iso);
}
// ── Phase definitions ─────────────────────────────────────────────────────────
const PEAD_PHASES = [
{ key: 'pipeline_pre', label: 'pipeline_pre' },
{ key: 'run_open', label: 'run_open' },
{ key: 'run_close', label: 'run_close' },
{ key: 'pipeline_post', label: 'pipeline_post' },
];
const ORB_PHASES = [
{ key: 'orb_pre_screen', label: 'pre_screen' },
{ key: 'orb_detect', label: 'detect' },
{ key: 'orb_breakout', label: 'breakout' },
{ key: 'orb_stop', label: 'stop_check' },
{ key: 'orb_eod', label: 'eod_exit' },
{ key: 'orb_post_close', label: 'post_close' },
];
// ── Health Cards ──────────────────────────────────────────────────────────────
function PhaseRow({ label, phase, data }: { label: string; phase: string; data: HealthSummary['phases'] }) {
const info = data[phase];
const ok = info?.status === 'success';
const color = !info ? 'var(--text3)' : ok ? 'var(--green)' : '#ef4444';
const Icon = !info ? Clock : ok ? CheckCircle : XCircle;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
<Icon size={14} style={{ color, flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text2)', minWidth: 100 }}>{label}</span>
{info ? (
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color }}>
{ok ? '✓' : '✗'} {timeAgo(info.last_ts)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>not yet today</span>
)}
</div>
);
}
function PhaseCard({ title, phases, data }: {
title: string;
phases: { key: string; label: string }[];
data: HealthSummary['phases'];
}) {
return (
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 10 }}>
{title}
</div>
{phases.map(p => (
<PhaseRow key={p.key} label={p.label} phase={p.key} data={data} />
))}
</div>
);
}
function HealthCards({ data, system }: { data: HealthSummary; system: string }) {
const FALLBACK_CATS = ['snapshot', 'macro', 'broker', 'kill_switch', 'fallback', 'regime'];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14, marginBottom: 20 }}>
{/* Phase status cards */}
{system !== 'orb' && (
<PhaseCard title="PEAD Phases" phases={PEAD_PHASES} data={data.phases} />
)}
{system !== 'pead' && (
<PhaseCard title="ORB Phases" phases={ORB_PHASES} data={data.phases} />
)}
{/* Fallback counts */}
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 10 }}>
Fallbacks Today
</div>
{FALLBACK_CATS.map(cat => {
const counts = data.fallbacks_today[cat];
const errCnt = counts?.error ?? 0;
const warnCnt = counts?.warn ?? 0;
const total = errCnt + warnCnt;
const color = errCnt > 0 ? '#ef4444' : warnCnt > 0 ? 'var(--gold)' : 'var(--green)';
return (
<div key={cat} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{cat}</span>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color, fontWeight: 600 }}>
{total === 0 ? '—' : `${errCnt > 0 ? errCnt + ' ERR' : ''}${errCnt > 0 && warnCnt > 0 ? ' · ' : ''}${warnCnt > 0 ? warnCnt + ' WARN' : ''}`}
</span>
</div>
);
})}
</div>
{/* Recent errors */}
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 10 }}>
Recent Errors
</div>
{data.recent_errors.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--green)', display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
<CheckCircle size={13} /> No errors today
</div>
) : (
data.recent_errors.map(e => (
<div key={e.id} style={{ padding: '4px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: '#ef4444' }}>{e.event_name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{e.message?.slice(0, 60) ?? '—'}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 1 }}>{timeAgo(e.ts_utc)}</div>
</div>
))
)}
</div>
{/* Snapshot freshness — PEAD only */}
{system !== 'orb' && data.snapshot_freshness.length > 0 && (
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 10 }}>
Snapshot Freshness
</div>
{data.snapshot_freshness.map((sf, i) => {
const ageMs = Date.now() - new Date(sf.last_update).getTime();
const ageH = ageMs / 3_600_000;
const color = ageH < 25 ? 'var(--green)' : ageH < 49 ? 'var(--gold)' : '#ef4444';
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text2)', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 130 }}>
{sf.snapshot_id ?? sf.session_id ?? '?'}
</span>
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color }}>{timeAgo(sf.last_update)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Detail side panel ─────────────────────────────────────────────────────────
function DetailPanel({ event, onClose, relatedEvents }: {
event: TradeEvent;
onClose: () => void;
relatedEvents: TradeEvent[];
}) {
let parsedDetails: Record<string, unknown> | null = null;
try {
if (event.details) parsedDetails = JSON.parse(event.details);
} catch {}
return (
<div style={{
width: 380, flexShrink: 0, background: 'var(--bg1)', borderLeft: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)' }}>Event Detail</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}>×</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
<LevelPill level={event.level} />
<CatPill cat={event.category} />
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 4, wordBreak: 'break-all' }}>
{event.event_name}
</div>
{event.message && (
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>{event.message}</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
<span>Time: {fmtTs(event.ts_utc)}</span>
<span>Source: {event.source}</span>
{event.session_id && <span style={{ gridColumn: '1/-1' }}>Session: {event.session_id}</span>}
{event.ticker && <span>Ticker: {event.ticker}</span>}
</div>
{event.job_run_id && (
<div style={{ marginTop: 6, fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)', wordBreak: 'break-all' }}>
Run: {event.job_run_id.slice(0, 16)}
</div>
)}
</div>
{parsedDetails && Object.keys(parsedDetails).length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>Details</div>
<pre style={{
margin: 0, padding: 10, background: 'var(--bg2)', borderRadius: 6,
fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text2)',
overflowX: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all',
border: '1px solid var(--border)',
}}>
{JSON.stringify(parsedDetails, null, 2)}
</pre>
</div>
)}
{event.job_run_id && relatedEvents.length > 1 && (
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>
Run Timeline ({relatedEvents.length} events)
</div>
<div style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
{[...relatedEvents].sort((a, b) => a.ts_utc.localeCompare(b.ts_utc)).map(e => (
<div key={e.id} style={{
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '3px 0',
opacity: e.id === event.id ? 1 : 0.65,
}}>
<div style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0, marginTop: 3,
background: e.level === 'ERROR' ? '#ef4444' : e.level === 'WARN' ? 'var(--gold)' : 'var(--text3)',
}} />
<div>
<div style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
{e.ts_utc.slice(11, 19)}
</div>
<div style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>
{e.event_name.length > 40 ? e.event_name.slice(0, 40) + '…' : e.event_name}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
// ── Filter bar ────────────────────────────────────────────────────────────────
const LEVEL_OPTIONS = ['', 'ERROR', 'WARN', 'INFO'];
const CAT_OPTIONS = ['', 'fallback', 'snapshot', 'macro', 'broker', 'kill_switch', 'lifecycle', 'order', 'engine', 'pipeline'];
const TIME_OPTIONS = [
{ label: 'Today', since: () => new Date().toISOString().slice(0, 10) + 'T00:00:00' },
{ label: '24h', since: () => new Date(Date.now() - 86_400_000).toISOString() },
{ label: '7d', since: () => new Date(Date.now() - 7 * 86_400_000).toISOString() },
{ label: 'All', since: () => '' },
];
function sel() {
return {
padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--bg2)', color: 'var(--text2)', fontSize: 12,
fontFamily: 'var(--font-mono)', cursor: 'pointer', outline: 'none',
} as React.CSSProperties;
}
// ── Tab bar ───────────────────────────────────────────────────────────────────
const TABS = [
{ key: 'all', label: 'All' },
{ key: 'pead', label: 'PEAD' },
{ key: 'orb', label: 'ORB' },
] as const;
type TabKey = typeof TABS[number]['key'];
// ── Main page ─────────────────────────────────────────────────────────────────
export function LogsPage() {
const [sp, setSP] = useSearchParams();
const [selected, setSelected] = useState<TradeEvent | null>(null);
const [relatedEvents, setRelatedEvents] = useState<TradeEvent[]>([]);
const [timeLabel, setTimeLabel] = useState('Today');
// Active tab from URL
const activeTab = ((sp.get('system') || 'all') as TabKey);
// Filters from URL querystring for deep-linking
const filters = {
level: sp.get('level') ?? '',
category: sp.get('category') ?? '',
source: sp.get('source') ?? '',
session_id: sp.get('session_id') ?? '',
job_run_id: sp.get('job_run_id') ?? '',
q: sp.get('q') ?? '',
since: sp.get('since') ?? TIME_OPTIONS[0].since(),
system: sp.get('system') ?? '',
};
const setFilter = useCallback((key: string, val: string) => {
setSP(prev => {
const next = new URLSearchParams(prev);
if (val) next.set(key, val);
else next.delete(key);
return next;
});
}, [setSP]);
const { data: health, refetch: refetchHealth } = useQuery({
queryKey: ['health'],
queryFn: eventsApi.health,
refetchInterval: 5_000,
});
const { data: sources } = useQuery({
queryKey: ['event-sources'],
queryFn: eventsApi.sources,
staleTime: 60_000,
});
const queryKey = ['events', filters];
const { data, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => eventsApi.list({
level: filters.level || undefined,
category: filters.category || undefined,
source: filters.source || undefined,
session_id: filters.session_id || undefined,
job_run_id: filters.job_run_id || undefined,
q: filters.q || undefined,
since: filters.since || undefined,
limit: 200,
system: filters.system || undefined,
}),
refetchInterval: 3_000,
});
// Load run timeline when an event with job_run_id is selected
useEffect(() => {
if (!selected?.job_run_id) { setRelatedEvents([]); return; }
eventsApi.list({ job_run_id: selected.job_run_id, limit: 1000 }).then(r => setRelatedEvents(r.rows)).catch(() => {});
}, [selected?.job_run_id]);
const rows = data?.rows ?? [];
const total = data?.total ?? 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg2)' }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 14px', background: 'var(--bg1)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<Activity size={18} style={{ color: 'var(--cyan)' }} />
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: 'var(--text1)' }}>Logs / Health</h1>
{isFetching && <RefreshCw size={13} style={{ color: 'var(--text3)', animation: 'spin 1s linear infinite' }} />}
</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
Live event feed from PEAD engine, ORB engine, pipeline, and AutoScheduler
</div>
</div>
{/* ── Tab bar ── */}
<div style={{
display: 'flex', gap: 0,
background: 'var(--bg1)', borderBottom: '1px solid var(--border)',
padding: '0 24px', flexShrink: 0,
}}>
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setFilter('system', tab.key === 'all' ? '' : tab.key)}
style={{
padding: '10px 18px', fontSize: 13, fontWeight: 600,
border: 'none',
borderBottom: activeTab === tab.key
? '2px solid var(--cyan)'
: '2px solid transparent',
background: 'transparent', cursor: 'pointer',
color: activeTab === tab.key ? 'var(--cyan)' : 'var(--text3)',
fontFamily: 'var(--font-mono)', letterSpacing: '0.04em',
transition: 'color 0.15s, border-color 0.15s',
}}
>
{tab.label}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* ── Health Cards ── */}
{health && (
<div style={{ padding: '16px 24px 0', flexShrink: 0 }}>
<HealthCards data={health} system={activeTab} />
</div>
)}
{/* ── Filter bar ── */}
<div style={{
padding: '10px 24px', background: 'var(--bg1)', borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', flexShrink: 0,
}}>
{/* Time range */}
<div style={{ display: 'flex', gap: 4 }}>
{TIME_OPTIONS.map(opt => (
<button
key={opt.label}
onClick={() => { setTimeLabel(opt.label); setFilter('since', opt.since()); }}
style={{
padding: '4px 10px', borderRadius: 6, fontSize: 12, fontFamily: 'var(--font-mono)',
border: '1px solid var(--border)', cursor: 'pointer',
background: timeLabel === opt.label ? 'var(--cyan)' : 'var(--bg2)',
color: timeLabel === opt.label ? '#fff' : 'var(--text2)',
}}
>
{opt.label}
</button>
))}
</div>
<div style={{ width: 1, height: 20, background: 'var(--border)', flexShrink: 0 }} />
<select value={filters.level} onChange={e => setFilter('level', e.target.value)} style={sel()}>
{LEVEL_OPTIONS.map(l => <option key={l} value={l}>{l || 'All Levels'}</option>)}
</select>
<select value={filters.category} onChange={e => setFilter('category', e.target.value)} style={sel()}>
{CAT_OPTIONS.map(c => <option key={c} value={c}>{c || 'All Categories'}</option>)}
</select>
<select value={filters.source} onChange={e => setFilter('source', e.target.value)} style={sel()}>
<option value="">All Sources</option>
{(sources ?? []).map(s => <option key={s} value={s}>{s}</option>)}
</select>
<input
placeholder="Search…"
value={filters.q}
onChange={e => setFilter('q', e.target.value)}
style={{
padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)',
background: 'var(--bg2)', color: 'var(--text2)', fontSize: 12,
fontFamily: 'var(--font-mono)', outline: 'none', minWidth: 140,
}}
/>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
{total.toLocaleString()} events
</span>
<button
onClick={() => { refetch(); refetchHealth(); }}
title="Refresh"
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg2)',
cursor: 'pointer', color: 'var(--text2)', fontSize: 12,
}}
>
<RefreshCw size={12} /> Refresh
</button>
</div>
</div>
{/* ── Table + Detail ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', minHeight: 0 }}>
{/* Event table */}
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'auto', minHeight: 0 }}>
{rows.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
{isFetching ? 'Loading…' : 'No events match the current filters'}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: 'var(--bg1)', borderBottom: '1px solid var(--border)' }}>
{['Time', 'Level', 'Category', 'Source', 'Event', 'Message', 'Session'].map(h => (
<th key={h} style={{
padding: '8px 12px', textAlign: 'left', fontSize: 11,
fontFamily: 'var(--font-mono)', color: 'var(--text3)',
letterSpacing: '0.06em', textTransform: 'uppercase',
position: 'sticky', top: 0, background: 'var(--bg1)',
borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr
key={row.id}
onClick={() => setSelected(row.id === selected?.id ? null : row)}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: row.id === selected?.id
? 'var(--cyan-dim)'
: row.level === 'ERROR' ? 'rgba(239,68,68,0.04)'
: row.level === 'WARN' ? 'rgba(234,179,8,0.04)'
: 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (row.id !== selected?.id) (e.currentTarget as HTMLElement).style.background = 'var(--bg1)'; }}
onMouseLeave={e => { if (row.id !== selected?.id) (e.currentTarget as HTMLElement).style.background = row.level === 'ERROR' ? 'rgba(239,68,68,0.04)' : row.level === 'WARN' ? 'rgba(234,179,8,0.04)' : 'transparent'; }}
>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)', whiteSpace: 'nowrap' }}>
{fmtTs(row.ts_utc)}
</td>
<td style={{ padding: '7px 12px' }}>
<LevelPill level={row.level} />
</td>
<td style={{ padding: '7px 12px' }}>
<CatPill cat={row.category} />
</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)', fontSize: 11 }}>
{row.source}
</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text2)', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.event_name}
</td>
<td style={{ padding: '7px 12px', color: 'var(--text2)', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.message}
</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)', fontSize: 11, whiteSpace: 'nowrap' }}>
{row.session_id ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Detail panel */}
{selected && (
<DetailPanel
event={selected}
onClose={() => setSelected(null)}
relatedEvents={relatedEvents}
/>
)}
</div>
</div>
</div>
);
}
Loading…
Cancel
Save