""" Tests for the real Sharpe ratio with minimum-sample gate. Regression: sharpe_ratio was hardcoded to 0.0 in MetricsTracker and exposed as `latest.get("sharpe_ratio") or 0` in /api/summary, and promotion_ready could in principle flip on a statistically meaningless sample (e.g. 1 resolved trade over ~40 days of flat PnL plus a single +299 jump). Fix: bot/metrics/sharpe.py computes an annualized Sharpe from the daily total_pnl close series, gated to None ("insufficient_sample") below 30 days observed / 10 resolved trades. /api/summary exposes the value plus an explanation (sharpe_status, days_observed, min_* fields), and promotion_ready additionally requires the sample minimums and non-null metrics. """ import asyncio from statistics import mean, stdev import pytest import api.main as api_main from bot.metrics.sharpe import ( MIN_DAYS_OBSERVED, MIN_RESOLVED_TRADES, SHARPE_INSUFFICIENT, SHARPE_OK, SHARPE_ZERO_VARIANCE, compute_sharpe, daily_returns, sharpe_with_gate, ) from bot.metrics.tracker import MetricsTracker BANKROLL = 10_000.0 def _closes_from_deltas(deltas: list[float], start: float = 0.0) -> list[float]: closes = [start] for d in deltas: closes.append(closes[-1] + d) return closes # ── Pure computation ───────────────────────────────────────────────────────── def test_daily_returns_are_bankroll_normalized_deltas(): closes = [0.0, 100.0, 50.0, 50.0] assert daily_returns(closes, BANKROLL) == pytest.approx([0.01, -0.005, 0.0]) def test_compute_sharpe_matches_manual_formula(): deltas = [10.0, 14.0, 8.0, 12.0, 6.0, 13.0, 9.0] closes = _closes_from_deltas(deltas) rets = [d / BANKROLL for d in deltas] expected = mean(rets) / stdev(rets) * 365 ** 0.5 assert compute_sharpe(closes, BANKROLL) == pytest.approx(expected) assert compute_sharpe(closes, BANKROLL) > 0 def test_compute_sharpe_undefined_cases_return_none(): assert compute_sharpe([], BANKROLL) is None assert compute_sharpe([0.0], BANKROLL) is None assert compute_sharpe([0.0, 50.0], BANKROLL) is None # only 1 return assert compute_sharpe([0.0] * 40, BANKROLL) is None # zero variance # ── Minimum-sample gate ─────────────────────────────────────────────────────── def test_gate_blocks_current_situation_one_resolved_trade(): """~40 flat days plus a single +299 jump, 1 resolved trade → no Sharpe.""" closes = [0.0] * 35 + [299.06] * 5 sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=1) assert sharpe is None assert status == SHARPE_INSUFFICIENT # The raw (ungated) value would exist and be wildly misleading: assert compute_sharpe(closes, BANKROLL) is not None def test_gate_blocks_too_few_days_even_with_enough_resolved(): closes = _closes_from_deltas([10.0, -5.0] * 10) # 21 days < 30 sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=15) assert sharpe is None assert status == SHARPE_INSUFFICIENT def test_gate_passes_with_sufficient_sample(): deltas = [10.0, 14.0, 8.0, 12.0, 6.0] * 8 # 40 returns → 41 days closes = _closes_from_deltas(deltas) sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=MIN_RESOLVED_TRADES) assert status == SHARPE_OK assert sharpe == pytest.approx(compute_sharpe(closes, BANKROLL)) def test_gate_flat_curve_with_sufficient_sample_is_zero_variance(): sharpe, status = sharpe_with_gate([0.0] * 40, BANKROLL, resolved_count=12) assert sharpe is None assert status == SHARPE_ZERO_VARIANCE # ── /api/summary ───────────────────────────────────────────────────────────── class FakeDB: def __init__(self, daily_closes, resolved_count, total_trades=60, win_rate=0.6, calibration=0.8): self._closes = daily_closes self._resolved = resolved_count self._total = total_trades self._win_rate = win_rate self._calibration = calibration async def get_metrics_history(self, days=1): return [{ "win_rate": self._win_rate, "calibration_score": self._calibration, "unrealized_pnl_est": 0.0, "realized_pnl": 299.06, "total_pnl": 299.06, }] async def compute_metrics_from_db(self): return { "total_trades": self._total, "open_count": self._total - self._resolved, "closed_count": self._resolved, "resolved_count": self._resolved, } async def get_open_position_data(self): return {}, 0.0 async def get_recently_closed_inverted(self, hours=24): return set() async def get_legacy_incomplete_count(self): return 0 async def get_daily_pnl_closes(self): return list(self._closes) def _summary(db, monkeypatch) -> dict: monkeypatch.setattr(api_main, "db", db) monkeypatch.delenv("PAPER_BANKROLL", raising=False) return asyncio.run(api_main.get_summary()) def test_api_insufficient_sample_returns_null_with_explanation(monkeypatch): """Current prod situation: 1 resolved, ~40 days → null Sharpe, not ready.""" db = FakeDB(daily_closes=[0.0] * 35 + [299.06] * 5, resolved_count=1) s = _summary(db, monkeypatch) assert s["sharpe_ratio"] is None assert s["sharpe_status"] == SHARPE_INSUFFICIENT assert s["resolved_count"] == 1 assert s["min_resolved_required"] == MIN_RESOLVED_TRADES == 10 assert s["days_observed"] == 40 assert s["min_days_required"] == MIN_DAYS_OBSERVED == 30 # One lucky resolved trade must never promote, even with perfect # win_rate/calibration and 50+ trades. assert s["promotion_ready"] is False def test_api_sharpe_appears_with_sufficient_sample(monkeypatch): deltas = [10.0, 14.0, 8.0, 12.0, 6.0] * 8 db = FakeDB(daily_closes=_closes_from_deltas(deltas), resolved_count=12) s = _summary(db, monkeypatch) assert s["sharpe_status"] == SHARPE_OK assert s["sharpe_ratio"] == pytest.approx( compute_sharpe(_closes_from_deltas(deltas), BANKROLL) ) assert s["sharpe_ratio"] >= 0.5 assert s["promotion_ready"] is True def test_api_not_ready_when_sharpe_below_threshold(monkeypatch): # Zero-drift curve: mean return ~0 → Sharpe ≈ 0 < 0.5 deltas = [50.0, -50.0] * 20 db = FakeDB(daily_closes=_closes_from_deltas(deltas), resolved_count=12) s = _summary(db, monkeypatch) assert s["sharpe_status"] == SHARPE_OK assert s["sharpe_ratio"] < 0.5 assert s["promotion_ready"] is False def test_api_not_ready_when_metrics_null(monkeypatch): db = FakeDB( daily_closes=_closes_from_deltas([10.0, 14.0, 8.0, 12.0, 6.0] * 8), resolved_count=12, win_rate=None, calibration=None, ) s = _summary(db, monkeypatch) assert s["sharpe_status"] == SHARPE_OK assert s["promotion_ready"] is False # ── MetricsTracker: no hardcoded 0.0 in the snapshot ───────────────────────── class FakeTrackerDB: def __init__(self, daily_closes, resolved_count): self._closes = daily_closes self._resolved = resolved_count self.saved = None async def compute_metrics_from_db(self): return { "total_trades": 60, "open_count": 40, "closed_count": 20, "resolved_count": self._resolved, "wins_realized": self._resolved, "unrealized_pnl_est": 0.0, "realized_pnl": 100.0, "total_deployed": 1000.0, "total_fees": 20.0, "calibration_score": 0.8, } async def get_daily_pnl_closes(self): return list(self._closes) async def save_daily_metrics(self, metrics): self.saved = metrics def test_tracker_stores_null_sharpe_below_gate(monkeypatch): monkeypatch.delenv("PAPER_BANKROLL", raising=False) db = FakeTrackerDB(daily_closes=[0.0] * 35 + [299.06] * 5, resolved_count=1) asyncio.run(MetricsTracker(db).update_daily_summary()) assert db.saved is not None assert db.saved["sharpe_ratio"] is None def test_tracker_stores_real_sharpe_above_gate(monkeypatch): monkeypatch.delenv("PAPER_BANKROLL", raising=False) closes = _closes_from_deltas([10.0, 14.0, 8.0, 12.0, 6.0] * 8) db = FakeTrackerDB(daily_closes=closes, resolved_count=12) asyncio.run(MetricsTracker(db).update_daily_summary()) assert db.saved["sharpe_ratio"] == pytest.approx( compute_sharpe(closes, BANKROLL) ) assert db.saved["sharpe_ratio"] != 0.0