feat(metrics): real Sharpe ratio from daily PnL curve with minimum-sample gate
CI/CD / build-and-push (push) Successful in 10s

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>
This commit is contained in:
chemavx
2026-06-12 07:12:55 +00:00
co-authored by Claude Fable 5
parent 1797b79f7b
commit 43d9577fb2
7 changed files with 412 additions and 22 deletions
+14 -3
View File
@@ -15,12 +15,16 @@ 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 0.0 — requires a daily-return time series, not yet tracked.
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__)
@@ -61,6 +65,12 @@ class MetricsTracker:
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"]),
@@ -74,7 +84,7 @@ class MetricsTracker:
"total_pnl": total_pnl,
"win_rate": win_rate,
"avg_edge": avg_edge,
"sharpe_ratio": 0.0, # requires daily-return series (not yet tracked)
"sharpe_ratio": sharpe, # NULL until sample gate passes
"calibration_score": calibration,
"paper_mode": True,
}
@@ -83,9 +93,10 @@ class MetricsTracker:
log.info(
"Daily metrics | trades=%d (open=%d closed=%d resolved=%d) | "
"unrealized=$%.2f realized=$%.2f total=$%.2f | "
"win_rate=%s calibration=%s",
"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})",
)