|
16 | 16 | - ``GET /api/risk`` — full risk engine summary from backtest |
17 | 17 | - ``GET /api/drift`` — latest feature drift report |
18 | 18 | - ``GET /api/bot`` — bot state (last trade, shutdown reason, etc.) |
| 19 | +- ``GET /api/logs`` — latest bot log lines (?lines=N, default 80, max 500) |
19 | 20 |
|
20 | 21 | Public API (unchanged): |
21 | 22 | - ``create_dashboard(...)`` — standalone backtest-only app |
|
41 | 42 |
|
42 | 43 | RESULTS_DIR = Path("data/processed") |
43 | 44 | STATE_FILE = Path("data/bot_state.json") |
| 45 | +BOT_LOG_FILE = Path(os.getenv("LOG_DIR", "/var/log/signum")) / "bot.log" |
| 46 | +# Fallback: check for local log file when not on VPS |
| 47 | +_LOCAL_LOG = Path("live_bot.log") |
44 | 48 |
|
45 | 49 | # ═══════════════════════════════════════════════════════════════════ |
46 | 50 | # Design Tokens |
@@ -1022,6 +1026,35 @@ def _build_live_tab() -> html.Div: |
1022 | 1026 | else: |
1023 | 1027 | children.append(_panel(_empty_state("No trading history yet."))) |
1024 | 1028 |
|
| 1029 | + # ── Bot Log ── |
| 1030 | + children.append(html.Div(style={"height": _SP_5})) |
| 1031 | + children.append(_section_label("Bot Log")) |
| 1032 | + |
| 1033 | + log_text = _read_log_tail(80) |
| 1034 | + children.append( |
| 1035 | + _panel( |
| 1036 | + html.Pre( |
| 1037 | + log_text, |
| 1038 | + style={ |
| 1039 | + "fontFamily": _FONT_MONO, |
| 1040 | + "fontSize": "11px", |
| 1041 | + "lineHeight": "1.6", |
| 1042 | + "color": _TEXT_SECONDARY, |
| 1043 | + "backgroundColor": _INSET, |
| 1044 | + "padding": _SP_4, |
| 1045 | + "borderRadius": _RADIUS_SM, |
| 1046 | + "border": f"1px solid {_BORDER}", |
| 1047 | + "margin": "0", |
| 1048 | + "maxHeight": "500px", |
| 1049 | + "overflowY": "auto", |
| 1050 | + "whiteSpace": "pre-wrap", |
| 1051 | + "wordBreak": "break-all", |
| 1052 | + }, |
| 1053 | + ), |
| 1054 | + padding=_SP_3, |
| 1055 | + ) |
| 1056 | + ) |
| 1057 | + |
1025 | 1058 | return html.Div(children) |
1026 | 1059 |
|
1027 | 1060 |
|
@@ -1340,6 +1373,29 @@ def _load_equity_history() -> pd.Series | None: |
1340 | 1373 | return None |
1341 | 1374 |
|
1342 | 1375 |
|
| 1376 | +def _read_log_tail(n_lines: int = 80) -> str: |
| 1377 | + """Read the last N lines from the bot log file. |
| 1378 | +
|
| 1379 | + Checks the systemd log path first, then the local log fallback. |
| 1380 | + """ |
| 1381 | + for log_path in [BOT_LOG_FILE, _LOCAL_LOG]: |
| 1382 | + if log_path.exists(): |
| 1383 | + try: |
| 1384 | + with open(log_path, "rb") as f: |
| 1385 | + # Seek from end to find last N lines efficiently |
| 1386 | + f.seek(0, 2) |
| 1387 | + size = f.tell() |
| 1388 | + # Read last 64KB max (enough for ~80 lines) |
| 1389 | + chunk_size = min(size, 65536) |
| 1390 | + f.seek(max(0, size - chunk_size)) |
| 1391 | + data = f.read().decode("utf-8", errors="replace") |
| 1392 | + lines = data.splitlines() |
| 1393 | + return "\n".join(lines[-n_lines:]) |
| 1394 | + except Exception as e: |
| 1395 | + logger.warning(f"Could not read log {log_path}: {e}") |
| 1396 | + return "No log file found. Bot may not have started yet." |
| 1397 | + |
| 1398 | + |
1343 | 1399 | def _fetch_regime_state() -> dict | None: |
1344 | 1400 | """Fetch current market regime (VIX + SPY drawdown).""" |
1345 | 1401 | try: |
@@ -1384,6 +1440,7 @@ def _fetch_regime_state() -> dict | None: |
1384 | 1440 | "/api/risk": "Full risk engine summary (Sharpe, VaR, drawdowns, etc.)", |
1385 | 1441 | "/api/drift": "Latest ML feature drift report (KS stat, PSI per feature)", |
1386 | 1442 | "/api/bot": "Bot state (last trade date, shutdown reason, positions count)", |
| 1443 | + "/api/logs": "Latest bot log lines (default 80, ?lines=N to customize)", |
1387 | 1444 | } |
1388 | 1445 |
|
1389 | 1446 |
|
@@ -1605,6 +1662,22 @@ def api_bot(): |
1605 | 1662 | ) |
1606 | 1663 | return _json_response({"timestamp": datetime.now().isoformat(), "bot_state": state}) |
1607 | 1664 |
|
| 1665 | + # ── GET /api/logs — bot log tail ── |
| 1666 | + @server.route("/api/logs") |
| 1667 | + def api_logs(): |
| 1668 | + from flask import request |
| 1669 | + |
| 1670 | + n_lines = min(int(request.args.get("lines", 80)), 500) |
| 1671 | + log_text = _read_log_tail(n_lines) |
| 1672 | + lines = log_text.splitlines() |
| 1673 | + return _json_response( |
| 1674 | + { |
| 1675 | + "timestamp": datetime.now().isoformat(), |
| 1676 | + "n_lines": len(lines), |
| 1677 | + "log": lines, |
| 1678 | + } |
| 1679 | + ) |
| 1680 | + |
1608 | 1681 |
|
1609 | 1682 | def _fetch_account_json() -> dict | None: |
1610 | 1683 | """Fetch Alpaca account data as a dict. Returns None on failure.""" |
|
0 commit comments