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