feat(metrics): real Sharpe ratio from daily PnL curve with minimum-sample gate
CI/CD / build-and-push (push) Successful in 10s
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:
co-authored by
Claude Fable 5
parent
1797b79f7b
commit
43d9577fb2
+49
-17
@@ -12,6 +12,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from bot.data.db import Database
|
||||
from bot.executor.paper import cash_available
|
||||
from bot.metrics.sharpe import (
|
||||
MIN_DAYS_OBSERVED,
|
||||
MIN_RESOLVED_TRADES,
|
||||
sharpe_with_gate,
|
||||
)
|
||||
|
||||
# Phase 6 format (Phase 6+): values already in log-odds space.
|
||||
# "fg_lo=+0.1200 mom_lo=+0.0000 news_lo=+0.0000 mfld_lo=-0.7483 btc_dom_lo=+0.0000"
|
||||
@@ -280,23 +285,40 @@ async def get_summary():
|
||||
PnL and performance metrics come from the latest metrics_daily snapshot,
|
||||
which is written by the bot every cycle via MetricsTracker.update_daily_summary().
|
||||
After Fix 3, that snapshot is also DB-computed — not dependent on pod restarts.
|
||||
sharpe_ratio is the exception: it is recomputed live here from the daily
|
||||
PnL-close series (same sharpe_with_gate the tracker uses), so the
|
||||
explanation fields (sharpe_status, days_observed) always match the value.
|
||||
"""
|
||||
latest_metrics, counts, position_data, inverted, legacy_count = await asyncio.gather(
|
||||
db.get_metrics_history(days=1),
|
||||
db.compute_metrics_from_db(),
|
||||
db.get_open_position_data(),
|
||||
db.get_recently_closed_inverted(hours=24),
|
||||
db.get_legacy_incomplete_count(),
|
||||
latest_metrics, counts, position_data, inverted, legacy_count, daily_closes = (
|
||||
await asyncio.gather(
|
||||
db.get_metrics_history(days=1),
|
||||
db.compute_metrics_from_db(),
|
||||
db.get_open_position_data(),
|
||||
db.get_recently_closed_inverted(hours=24),
|
||||
db.get_legacy_incomplete_count(),
|
||||
db.get_daily_pnl_closes(),
|
||||
)
|
||||
)
|
||||
|
||||
latest = latest_metrics[0] if latest_metrics else {}
|
||||
paper_bankroll = float(os.getenv("PAPER_BANKROLL", "10000"))
|
||||
total_trades = int(counts["total_trades"] or 0)
|
||||
resolved_count = int(counts.get("resolved_count") or 0)
|
||||
# Same source PaperExecutor.initialize() uses to reconstruct cash:
|
||||
# total_net_cost_open = SUM(net_cost) over open trades, uncapped.
|
||||
_, total_net_cost_open = position_data
|
||||
total_deployed = total_net_cost_open
|
||||
|
||||
# Sharpe: computed live from the daily PnL curve (same function the
|
||||
# tracker uses for the snapshot). None + status while the minimum-sample
|
||||
# gate (>=30 days observed, >=10 resolved trades) is not met — a Sharpe
|
||||
# over 1 resolved trade is statistically meaningless.
|
||||
days_observed = len(daily_closes)
|
||||
sharpe, sharpe_status = sharpe_with_gate(daily_closes, paper_bankroll, resolved_count)
|
||||
|
||||
win_rate = latest.get("win_rate")
|
||||
calibration = latest.get("calibration_score")
|
||||
|
||||
return {
|
||||
# ── Portfolio state (live from DB) ──────────────────────────────────
|
||||
"paper_mode": os.getenv("PAPER_MODE", "true") == "true",
|
||||
@@ -319,25 +341,35 @@ async def get_summary():
|
||||
"realized_pnl": latest.get("realized_pnl") or 0,
|
||||
"total_pnl": latest.get("total_pnl") or 0,
|
||||
|
||||
# ── Performance metrics (from latest metrics_daily snapshot) ─────────
|
||||
# ── Performance metrics ──────────────────────────────────────────────
|
||||
# win_rate: fraction of resolved closed trades where close_pnl > 0.
|
||||
# null if fewer than 5 resolved trades. Source: closed+resolved trades.
|
||||
# sharpe_ratio: 0.0 — requires daily-return time series (not yet tracked).
|
||||
# sharpe_ratio: annualized Sharpe of the daily total_pnl curve, computed
|
||||
# live from metrics_daily. null while the minimum-sample gate fails
|
||||
# (sharpe_status explains why). Source: bot/metrics/sharpe.py.
|
||||
# calibration_score: 1 − Brier score on resolved trades (higher = better).
|
||||
# null if fewer than 10 resolved trades. Source: closed+resolved trades.
|
||||
"win_rate": latest.get("win_rate"), # null if < 5 resolved
|
||||
"sharpe_ratio": latest.get("sharpe_ratio") or 0, # 0.0 until tracked
|
||||
"calibration_score": latest.get("calibration_score"), # null if < 10 resolved
|
||||
"win_rate": win_rate, # null if < 5 resolved
|
||||
"sharpe_ratio": sharpe, # null if gate fails
|
||||
"sharpe_status": sharpe_status, # ok | insufficient_sample | zero_variance
|
||||
"days_observed": days_observed,
|
||||
"min_days_required": MIN_DAYS_OBSERVED,
|
||||
"min_resolved_required": MIN_RESOLVED_TRADES,
|
||||
"calibration_score": calibration, # null if < 10 resolved
|
||||
|
||||
# ── Counters from snapshot ───────────────────────────────────────────
|
||||
"resolved_count": latest.get("resolved_count") or 0,
|
||||
# ── Counters (live from DB) ──────────────────────────────────────────
|
||||
"resolved_count": resolved_count,
|
||||
|
||||
# ── Promotion gate ───────────────────────────────────────────────────
|
||||
# All thresholds must pass; null metrics count as not-ready.
|
||||
# Never promote on a tiny sample: requires the resolved/days minimums
|
||||
# AND non-null metrics AND all thresholds. A single lucky resolved
|
||||
# trade must not flip this to true.
|
||||
"promotion_ready": (
|
||||
(latest.get("sharpe_ratio") or 0) >= 0.5
|
||||
and (latest.get("win_rate") or 0) >= 0.52
|
||||
and (latest.get("calibration_score") or 0) >= 0.7
|
||||
resolved_count >= MIN_RESOLVED_TRADES
|
||||
and days_observed >= MIN_DAYS_OBSERVED
|
||||
and win_rate is not None and win_rate >= 0.52
|
||||
and calibration is not None and calibration >= 0.7
|
||||
and sharpe is not None and sharpe >= 0.5
|
||||
and total_trades >= 50
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user