"""TDD for :mod:`cerbero_bite.core.kelly_recalibration`. Spec: ``docs/03-algorithms.md §7``. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from uuid import uuid4 import pytest from cerbero_bite.config import StrategyConfig, golden_config from cerbero_bite.core.kelly_recalibration import TradeRecord, recalibrate @pytest.fixture def cfg() -> StrategyConfig: return golden_config() @pytest.fixture def now() -> datetime: return datetime(2026, 4, 27, 14, 0, tzinfo=UTC) def _trade( *, pnl_usd: str, risk_usd: str = "100", days_ago: int = 30, outcome: str = "CLOSE_PROFIT", now_dt: datetime = datetime(2026, 4, 27, 14, 0, tzinfo=UTC), ) -> TradeRecord: return TradeRecord( proposal_id=uuid4(), pnl_usd=Decimal(pnl_usd), risk_usd=Decimal(risk_usd), closed_at=now_dt - timedelta(days=days_ago), outcome=outcome, ) # --------------------------------------------------------------------------- # Confidence bands and recommended fraction # --------------------------------------------------------------------------- def test_empty_history_returns_low_confidence_with_cfg_kelly( cfg: StrategyConfig, now: datetime ) -> None: res = recalibrate(trades=[], now=now, cfg=cfg) assert res.confidence == "low" assert res.sample_size == 0 assert res.recommended_fraction == cfg.sizing.kelly_fraction assert res.win_rate == Decimal("0") assert res.full_kelly_pct == Decimal("0") assert res.quarter_kelly_pct == Decimal("0") def test_low_sample_keeps_cfg_kelly_fraction(cfg: StrategyConfig, now: datetime) -> None: trades = [_trade(pnl_usd="10", now_dt=now) for _ in range(20)] trades += [_trade(pnl_usd="-15", now_dt=now) for _ in range(5)] res = recalibrate(trades=trades, now=now, cfg=cfg) assert res.sample_size == 25 assert res.confidence == "low" assert res.recommended_fraction == cfg.sizing.kelly_fraction def test_medium_sample_blends_quarter_kelly_with_cfg( cfg: StrategyConfig, now: datetime ) -> None: # 60 trades, 70% wins (avg_win=10/100=0.10), 30% losses (avg_loss=15/100=0.15) # b = 0.10 / 0.15 = 0.667 # full_kelly = (0.7 × 0.667 - 0.3) / 0.667 = (0.467 - 0.3) / 0.667 = 0.25 # quarter_kelly = 0.0625 # blended = 0.5 × 0.0625 + 0.5 × 0.13 = 0.09625 wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(42)] losses = [_trade(pnl_usd="-15", now_dt=now) for _ in range(18)] res = recalibrate(trades=wins + losses, now=now, cfg=cfg) assert res.sample_size == 60 assert res.confidence == "medium" assert res.win_rate == Decimal("0.7") expected = Decimal("0.5") * res.quarter_kelly_pct + Decimal("0.5") * cfg.sizing.kelly_fraction assert res.recommended_fraction == expected def test_high_sample_adopts_quarter_kelly(cfg: StrategyConfig, now: datetime) -> None: wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(120)] losses = [_trade(pnl_usd="-15", now_dt=now) for _ in range(40)] res = recalibrate(trades=wins + losses, now=now, cfg=cfg) assert res.sample_size == 160 assert res.confidence == "high" assert res.recommended_fraction == res.quarter_kelly_pct # --------------------------------------------------------------------------- # Lookback filter # --------------------------------------------------------------------------- def test_trades_older_than_lookback_are_ignored( cfg: StrategyConfig, now: datetime ) -> None: fresh = [_trade(pnl_usd="10", days_ago=30, now_dt=now) for _ in range(40)] stale = [_trade(pnl_usd="-50", days_ago=400, now_dt=now) for _ in range(60)] res = recalibrate(trades=fresh + stale, now=now, cfg=cfg) # Only 40 trades in window; all wins → win_rate = 1.0 assert res.sample_size == 40 assert res.win_rate == Decimal("1") # --------------------------------------------------------------------------- # Edge cases for full_kelly arithmetic # --------------------------------------------------------------------------- def test_all_losses_clip_full_kelly_to_zero(cfg: StrategyConfig, now: datetime) -> None: trades = [_trade(pnl_usd="-20", now_dt=now) for _ in range(40)] res = recalibrate(trades=trades, now=now, cfg=cfg) assert res.win_rate == Decimal("0") assert res.full_kelly_pct == Decimal("0") assert res.quarter_kelly_pct == Decimal("0") def test_all_wins_yield_full_kelly_equal_to_win_rate( cfg: StrategyConfig, now: datetime ) -> None: # No losses → division by zero. Convention: full_kelly = win_rate (= 1.0). trades = [_trade(pnl_usd="20", now_dt=now) for _ in range(40)] res = recalibrate(trades=trades, now=now, cfg=cfg) assert res.win_rate == Decimal("1") assert res.avg_loss_pct_risk == Decimal("0") assert res.full_kelly_pct == Decimal("1") assert res.quarter_kelly_pct == Decimal("0.25") def test_avg_loss_uses_absolute_value(cfg: StrategyConfig, now: datetime) -> None: wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(30)] losses = [_trade(pnl_usd="-20", now_dt=now) for _ in range(30)] res = recalibrate(trades=wins + losses, now=now, cfg=cfg) # avg_win_pct_risk = 10/100 = 0.10; avg_loss_pct_risk = 20/100 = 0.20 (positive) assert res.avg_win_pct_risk == Decimal("0.1") assert res.avg_loss_pct_risk == Decimal("0.2") def test_zero_pnl_counts_as_loss_for_winrate(cfg: StrategyConfig, now: datetime) -> None: # Spec: "trade con pnl > 0" → wins. 0 is not a win. trades = [ *[_trade(pnl_usd="10", now_dt=now) for _ in range(20)], *[_trade(pnl_usd="0", now_dt=now) for _ in range(20)], ] res = recalibrate(trades=trades, now=now, cfg=cfg) assert res.win_rate == Decimal("0.5")