Files
polymarket-bot/bot/metrics/tracker.py
T
chemavxandClaude Fable 5 43d9577fb2
CI/CD / build-and-push (push) Successful in 10s
feat(metrics): real Sharpe ratio from daily PnL curve with minimum-sample gate
sharpe_ratio was hardcoded to 0.0 in MetricsTracker and exposed as
'or 0' in /api/summary. With only 1 resolved trade (~40 flat days plus
one +299 jump) any computed Sharpe is statistically meaningless, so:

- bot/metrics/sharpe.py: annualized Sharpe (sqrt(365)) from daily
  total_pnl closes, normalized by bankroll; sharpe_with_gate() returns
  None + status until >=30 days observed AND >=10 resolved trades.
- Database.get_daily_pnl_closes(): last metrics_daily snapshot per UTC
  day, oldest first — the return series input.
- MetricsTracker: stores the real (gated) Sharpe in the snapshot, NULL
  below the gate; log line now includes sharpe.
- /api/summary: live Sharpe + sharpe_status/days_observed/min_* fields
  explaining why it is null; resolved_count now live from COUNT(*).
- promotion_ready: requires resolved>=10, days>=30, and non-null
  win_rate/calibration/sharpe plus existing thresholds — a single lucky
  resolved trade can no longer promote.
- Dashboard Sharpe card shows the insufficient-sample explanation when
  null instead of a bare em dash.

Tests: 13 new in tests/test_sharpe_gate.py (formula, gate, API contract,
tracker snapshot); verified failing pre-fix. Suite: 62 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:12:55 +00:00

103 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Metrics Tracker — computes and persists trading performance metrics from the DB.
All metrics are derived directly from the `trades` table on every cycle call.
No in-memory trade state is kept: the tracker is stateless across pod restarts.
Metric definitions
──────────────────
unrealized_pnl_est Estimated PnL for OPEN positions: edge_net × net_cost fee.
Source: open trades with edge_net. Estimated (model signal).
realized_pnl Exact PnL for CLOSED positions: computed from resolution.
Source: closed trades with known resolution. Exact.
total_pnl unrealized_pnl_est + realized_pnl.
win_rate Fraction of resolved closed trades with close_pnl > 0.
NULL if fewer than 5 resolved trades.
calibration_score 1 AVG((final_prob resolution)²) on resolved trades.
Brier score (higher = better calibration). NULL if < 10 resolved.
sharpe_ratio Annualized Sharpe of the daily total_pnl curve (see
bot/metrics/sharpe.py). NULL until the sample gate passes:
>= 30 days observed AND >= 10 resolved trades.
"""
import logging
import os
from datetime import datetime, UTC
from bot.data.db import Database
from bot.metrics.sharpe import sharpe_with_gate
log = logging.getLogger(__name__)
class MetricsTracker:
def __init__(self, db: Database) -> None:
self._db = db
async def update_daily_summary(self) -> None:
"""Compute metrics from DB and write a metrics_daily snapshot.
Called every cycle by the trading loop. Safe after pod restarts:
reads the full trade history from DB, not from in-memory state.
"""
raw = await self._db.compute_metrics_from_db()
if not raw["total_trades"]:
return
open_count = int(raw["open_count"] or 0)
closed_count = int(raw["closed_count"] or 0)
resolved = int(raw["resolved_count"] or 0)
wins = int(raw["wins_realized"] or 0)
unrealized = float(raw["unrealized_pnl_est"] or 0)
realized = float(raw["realized_pnl"] or 0)
total_deployed = float(raw["total_deployed"] or 0)
total_fees = float(raw["total_fees"] or 0)
total_pnl = unrealized + realized
# win_rate: only over resolved closed trades; null if sample too small
win_rate = (wins / resolved) if resolved >= 5 else None
# calibration: Brier score from DB; null if sample too small
calibration = (
float(raw["calibration_score"])
if raw["calibration_score"] is not None and resolved >= 10
else None
)
avg_edge = total_pnl / total_deployed if total_deployed > 0 else 0.0
# Sharpe: real value from the daily PnL curve, NULL while the sample
# gate (>=30 days observed, >=10 resolved) is not met.
bankroll = float(os.getenv("PAPER_BANKROLL", "10000"))
daily_closes = await self._db.get_daily_pnl_closes()
sharpe, sharpe_status = sharpe_with_gate(daily_closes, bankroll, resolved)
metrics = {
"timestamp": datetime.now(UTC),
"total_trades": int(raw["total_trades"]),
"open_count": open_count,
"closed_count": closed_count,
"resolved_count": resolved,
"total_deployed": total_deployed,
"total_fees": total_fees,
"unrealized_pnl_est": unrealized,
"realized_pnl": realized,
"total_pnl": total_pnl,
"win_rate": win_rate,
"avg_edge": avg_edge,
"sharpe_ratio": sharpe, # NULL until sample gate passes
"calibration_score": calibration,
"paper_mode": True,
}
await self._db.save_daily_metrics(metrics)
log.info(
"Daily metrics | trades=%d (open=%d closed=%d resolved=%d) | "
"unrealized=$%.2f realized=$%.2f total=$%.2f | "
"win_rate=%s calibration=%s sharpe=%s",
metrics["total_trades"], open_count, closed_count, resolved,
unrealized, realized, total_pnl,
f"{win_rate:.1%}" if win_rate is not None else "n/a (<5)",
f"{calibration:.3f}" if calibration is not None else "n/a (<10)",
f"{sharpe:.2f}" if sharpe is not None else f"n/a ({sharpe_status})",
)