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>
107 lines
3.4 KiB
Python
107 lines
3.4 KiB
Python
"""
|
|
Tests for bug #7 — /api/summary must agree with the executor's cash model.
|
|
|
|
Regression: /api/summary computed total_trades as len() over a LIMIT-500
|
|
query (capped once history grows) and reimplemented cash as
|
|
bankroll - sum(net_cost of open trades) from that same capped query.
|
|
|
|
Fix: counts come from COUNT(*) (compute_metrics_from_db) and cash comes from
|
|
cash_available() — the same helper PaperExecutor.initialize() uses — fed by
|
|
the same source (get_open_position_data). This test runs both consumers
|
|
against one fake DB state and asserts they report identical cash.
|
|
"""
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import api.main as api_main
|
|
from bot.executor.paper import PaperExecutor, cash_available
|
|
|
|
|
|
BANKROLL = 10_000.0 # PAPER_BANKROLL default used by both bot and API
|
|
|
|
|
|
class FakeDB:
|
|
"""One DB state served to both the API endpoint and the executor."""
|
|
|
|
def __init__(self, positions: dict[str, float], total_net_cost: float,
|
|
total_trades: int, open_count: int):
|
|
self._positions = positions
|
|
self._total_net_cost = total_net_cost
|
|
self._total = total_trades
|
|
self._open = open_count
|
|
|
|
# Shared source: executor.initialize() and /api/summary both call this.
|
|
async def get_open_position_data(self):
|
|
return dict(self._positions), self._total_net_cost
|
|
|
|
# /api/summary only:
|
|
async def get_metrics_history(self, days=1):
|
|
return []
|
|
|
|
async def compute_metrics_from_db(self):
|
|
return {
|
|
"total_trades": self._total,
|
|
"open_count": self._open,
|
|
"closed_count": self._total - self._open,
|
|
"resolved_count": 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 []
|
|
|
|
|
|
def _run(db: FakeDB, monkeypatch) -> tuple[dict, PaperExecutor]:
|
|
monkeypatch.setattr(api_main, "db", db)
|
|
monkeypatch.delenv("PAPER_BANKROLL", raising=False)
|
|
|
|
async def run():
|
|
summary = await api_main.get_summary()
|
|
ex = PaperExecutor(db=db, bankroll=BANKROLL)
|
|
await ex.initialize()
|
|
return summary, ex
|
|
|
|
return asyncio.run(run())
|
|
|
|
|
|
def test_api_and_executor_report_same_cash(monkeypatch):
|
|
db = FakeDB(
|
|
positions={"m1": 100.0, "m2": 80.0},
|
|
total_net_cost=183.60, # 180 + fees
|
|
total_trades=12,
|
|
open_count=2,
|
|
)
|
|
summary, ex = _run(db, monkeypatch)
|
|
assert summary["cash_available"] == pytest.approx(ex.get_portfolio().cash)
|
|
assert summary["cash_available"] == pytest.approx(
|
|
cash_available(BANKROLL, 183.60)
|
|
)
|
|
assert summary["total_deployed"] == pytest.approx(183.60)
|
|
|
|
|
|
def test_total_trades_not_capped_by_query_limit(monkeypatch):
|
|
"""700 trades in DB: the old len(LIMIT 500) reported 500."""
|
|
db = FakeDB(
|
|
positions={"m1": 100.0},
|
|
total_net_cost=102.0,
|
|
total_trades=700,
|
|
open_count=1,
|
|
)
|
|
summary, _ = _run(db, monkeypatch)
|
|
assert summary["total_trades"] == 700
|
|
assert summary["open_trades_count"] == 1
|
|
assert summary["closed_trades_count"] == 699
|
|
|
|
|
|
def test_cash_consistency_with_no_open_positions(monkeypatch):
|
|
db = FakeDB(positions={}, total_net_cost=0.0, total_trades=0, open_count=0)
|
|
summary, ex = _run(db, monkeypatch)
|
|
assert summary["cash_available"] == pytest.approx(BANKROLL)
|
|
assert ex.get_portfolio().cash == pytest.approx(BANKROLL)
|