Add unit tests for ORBTradingEngine._compute_sizing_capital
14 tests covering daily_budget_reset, drawdown governor (no-op, partial, full), and streak sizing (win/loss/capped/floored/direction correctness). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
190f7a47fa
commit
91efa04d4e
@ -0,0 +1,184 @@
|
||||
"""Unit tests for ORBTradingEngine._compute_sizing_capital."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.orb_trader.engine import ORBTradingEngine
|
||||
|
||||
|
||||
def _make_engine(
|
||||
*,
|
||||
initial_equity: float = 10_000.0,
|
||||
daily_budget_reset: bool = True,
|
||||
drawdown_governor_threshold: float | None = None,
|
||||
drawdown_governor_min_scale: float = 0.30,
|
||||
streak_sizing_win_bonus: float | None = None,
|
||||
streak_sizing_loss_penalty: float | None = None,
|
||||
streak_sizing_max: float = 2.5,
|
||||
streak_sizing_min: float = 0.5,
|
||||
peak_equity: float | None = None,
|
||||
trades: list[dict] | None = None,
|
||||
) -> ORBTradingEngine:
|
||||
params = SimpleNamespace(
|
||||
daily_budget_reset=daily_budget_reset,
|
||||
drawdown_governor_threshold=drawdown_governor_threshold,
|
||||
drawdown_governor_min_scale=drawdown_governor_min_scale,
|
||||
streak_sizing_win_bonus=streak_sizing_win_bonus,
|
||||
streak_sizing_loss_penalty=streak_sizing_loss_penalty,
|
||||
streak_sizing_max=streak_sizing_max,
|
||||
streak_sizing_min=streak_sizing_min,
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
session_id="test-session",
|
||||
initial_equity=initial_equity,
|
||||
)
|
||||
state = MagicMock()
|
||||
state.get_peak_equity.return_value = peak_equity if peak_equity is not None else initial_equity
|
||||
state.list_trades.return_value = trades or []
|
||||
|
||||
engine = object.__new__(ORBTradingEngine)
|
||||
engine._params = params
|
||||
engine._session = session
|
||||
engine._state = state
|
||||
return engine
|
||||
|
||||
|
||||
# ── daily_budget_reset ────────────────────────────────────────────────────────
|
||||
|
||||
class TestDailyBudgetReset:
|
||||
def test_reset_true_uses_initial_equity(self):
|
||||
eng = _make_engine(initial_equity=10_000, daily_budget_reset=True)
|
||||
assert eng._compute_sizing_capital(15_000) == pytest.approx(10_000)
|
||||
|
||||
def test_reset_false_uses_current_equity(self):
|
||||
eng = _make_engine(initial_equity=10_000, daily_budget_reset=False)
|
||||
assert eng._compute_sizing_capital(15_000) == pytest.approx(15_000)
|
||||
|
||||
|
||||
# ── Drawdown governor ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestDrawdownGovernor:
|
||||
def test_no_drawdown_no_scaling(self):
|
||||
eng = _make_engine(
|
||||
drawdown_governor_threshold=0.025,
|
||||
drawdown_governor_min_scale=0.30,
|
||||
peak_equity=10_000,
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000)
|
||||
|
||||
def test_drawdown_below_threshold_no_scaling(self):
|
||||
# 2% DD, threshold 2.5% → no scaling
|
||||
eng = _make_engine(
|
||||
drawdown_governor_threshold=0.025,
|
||||
peak_equity=10_000,
|
||||
)
|
||||
result = eng._compute_sizing_capital(9_800)
|
||||
assert result == pytest.approx(10_000)
|
||||
|
||||
def test_drawdown_at_full_governor(self):
|
||||
# DD = 5% = 2 * threshold(2.5%) → excess = 1x threshold → scale = min_scale
|
||||
eng = _make_engine(
|
||||
drawdown_governor_threshold=0.025,
|
||||
drawdown_governor_min_scale=0.30,
|
||||
peak_equity=10_000,
|
||||
)
|
||||
result = eng._compute_sizing_capital(9_500)
|
||||
assert result == pytest.approx(10_000 * 0.30)
|
||||
|
||||
def test_drawdown_partial_governor(self):
|
||||
# DD = 3.75% → excess = 1.25% = 0.5 * threshold(2.5%)
|
||||
# scale = max(0.30, 1.0 - 0.70 * 0.5) = max(0.30, 0.65) = 0.65
|
||||
eng = _make_engine(
|
||||
drawdown_governor_threshold=0.025,
|
||||
drawdown_governor_min_scale=0.30,
|
||||
peak_equity=10_000,
|
||||
)
|
||||
result = eng._compute_sizing_capital(9_625)
|
||||
expected_scale = max(0.30, 1.0 - 0.70 * 0.5)
|
||||
assert result == pytest.approx(10_000 * expected_scale, rel=1e-4)
|
||||
|
||||
|
||||
# ── Streak sizing ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStreakSizing:
|
||||
def _win(self, pnl=100.0) -> dict:
|
||||
return {"pnl": pnl}
|
||||
|
||||
def _loss(self, pnl=-100.0) -> dict:
|
||||
return {"pnl": pnl}
|
||||
|
||||
def test_no_trades_no_multiplier(self):
|
||||
eng = _make_engine(streak_sizing_win_bonus=0.70, trades=[])
|
||||
assert eng._compute_sizing_capital(10_000) == pytest.approx(10_000)
|
||||
|
||||
def test_single_win_streak_1(self):
|
||||
# streak_len=1 win → mult = 1 + 1*0.70 = 1.70
|
||||
eng = _make_engine(
|
||||
streak_sizing_win_bonus=0.70,
|
||||
trades=[self._win()], # newest first
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 1.70)
|
||||
|
||||
def test_two_wins_streak_2(self):
|
||||
# streak_len=2 → mult = 1 + 2*0.70 = 2.40
|
||||
eng = _make_engine(
|
||||
streak_sizing_win_bonus=0.70,
|
||||
trades=[self._win(), self._win()],
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 2.40)
|
||||
|
||||
def test_win_streak_capped_at_max(self):
|
||||
# streak_len=5 → mult = 1 + 5*0.70 = 4.50 → capped at streak_max=2.5
|
||||
eng = _make_engine(
|
||||
streak_sizing_win_bonus=0.70,
|
||||
streak_sizing_max=2.5,
|
||||
trades=[self._win()] * 5,
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 2.5)
|
||||
|
||||
def test_loss_streak_reduces_sizing(self):
|
||||
# streak_len=2 loss, loss_penalty=0.20 → mult = 1 - 2*0.20 = 0.60
|
||||
eng = _make_engine(
|
||||
streak_sizing_loss_penalty=0.20,
|
||||
streak_sizing_min=0.5,
|
||||
trades=[self._loss(), self._loss()],
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 0.60)
|
||||
|
||||
def test_loss_streak_floored_at_min(self):
|
||||
eng = _make_engine(
|
||||
streak_sizing_loss_penalty=0.20,
|
||||
streak_sizing_min=0.5,
|
||||
trades=[self._loss()] * 10,
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 0.5)
|
||||
|
||||
def test_streak_direction_newest_first(self):
|
||||
# trades list DESC (newest first): [win, loss, loss]
|
||||
# The most recent is a win → streak_len=1 → mult=1.70
|
||||
eng = _make_engine(
|
||||
streak_sizing_win_bonus=0.70,
|
||||
trades=[self._win(), self._loss(), self._loss()],
|
||||
)
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 1.70)
|
||||
|
||||
def test_streak_direction_oldest_not_used(self):
|
||||
# trades list DESC: [loss, win, win]
|
||||
# Most recent = loss, streak_len=1 → no win_bonus applies (only loss_penalty)
|
||||
eng = _make_engine(
|
||||
streak_sizing_win_bonus=0.70,
|
||||
trades=[self._loss(), self._win(), self._win()],
|
||||
)
|
||||
# No loss_penalty configured, so streak_mult = 1.0
|
||||
result = eng._compute_sizing_capital(10_000)
|
||||
assert result == pytest.approx(10_000 * 1.0)
|
||||
Loading…
Reference in New Issue