Files
crypto_trading/backend/app/metrics.py
T
matitos ac92dd5ac4 Observability: Prometheus metrics, log panel, runbook
- GET /api/metrics renders Prometheus text (equity, P&L, per-strategy
  equity/pnl/allocation/halted, info) with no client library.
- In-memory ring log handler (app/logs.py) exposed at GET /api/logs and a
  dashboard "Server logs" panel; Runtime mirrors bot events into the logger.
- docs/runbook.md covers deploys, the kill switch, resetting paper state and
  going live. Adds tests for metrics rendering and the log ring.
2026-09-22 17:10:38 +00:00

97 lines
3.6 KiB
Python

from __future__ import annotations
from app.schemas import MultiStatusOut
# Rendered as Prometheus text exposition format (no client library needed).
# The route serves this at GET /api/metrics for the existing Prometheus scrape.
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _metric(
name: str, help_text: str, value: float | int, labels: dict[str, str] | None = None
) -> str:
lines = [f"# HELP {name} {help_text}", f"# TYPE {name} gauge"]
if labels:
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"{name}{{{rendered}}} {value}")
else:
lines.append(f"{name} {value}")
return "\n".join(lines)
def render_metrics(status: MultiStatusOut, mode: str, exchange: str) -> str:
"""Render the current portfolio state as Prometheus metrics."""
parts = [
_metric("crypto_running", "1 when the bot is running", int(status.running)),
_metric("crypto_equity", "Total account equity", status.equity),
_metric("crypto_starting_balance", "Starting balance", status.starting_balance),
_metric("crypto_total_pnl", "Total profit and loss", status.total_pnl),
_metric(
"crypto_open_positions",
"Number of open positions across strategies",
sum(s.open_positions for s in status.strategies),
),
_metric(
"crypto_strategies",
"Number of configured strategies",
len(status.strategies),
),
]
if status.price is not None:
parts.append(_metric("crypto_price", "Latest mark price", status.price))
# Common labels shared by the per-strategy families.
lines = [
"# HELP crypto_strategy_equity Equity per strategy",
"# TYPE crypto_strategy_equity gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_equity{{{rendered}}} {s.equity}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_pnl Total P&L per strategy",
"# TYPE crypto_strategy_pnl gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_pnl{{{rendered}}} {s.total_pnl}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_allocation Fraction of the balance per strategy",
"# TYPE crypto_strategy_allocation gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_allocation{{{rendered}}} {s.allocation}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_halted 1 when a strategy is halted",
"# TYPE crypto_strategy_halted gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_halted{{{rendered}}} {int(s.halted)}")
parts.append("\n".join(lines))
info_labels = {"mode": mode, "exchange": exchange}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in info_labels.items())
parts.append(
"# HELP crypto_info Build information\n"
"# TYPE crypto_info gauge\n"
f"crypto_info{{{rendered}}} 1"
)
return "\n".join(parts) + "\n"