diff options
Diffstat (limited to 'services/order-executor/tests/test_risk_manager.py')
| -rw-r--r-- | services/order-executor/tests/test_risk_manager.py | 132 |
1 files changed, 131 insertions, 1 deletions
diff --git a/services/order-executor/tests/test_risk_manager.py b/services/order-executor/tests/test_risk_manager.py index a122d16..efabe73 100644 --- a/services/order-executor/tests/test_risk_manager.py +++ b/services/order-executor/tests/test_risk_manager.py @@ -3,7 +3,7 @@ from decimal import Decimal -from shared.models import OrderSide, Signal +from shared.models import OrderSide, Position, Signal from order_executor.risk_manager import RiskManager @@ -22,11 +22,28 @@ def make_risk_manager( max_position_size: str = "0.1", stop_loss_pct: str = "5.0", daily_loss_limit_pct: str = "10.0", + trailing_stop_pct: str = "0", + max_open_positions: int = 10, + volatility_lookback: int = 20, + volatility_scale: bool = False, ) -> RiskManager: return RiskManager( max_position_size=Decimal(max_position_size), stop_loss_pct=Decimal(stop_loss_pct), daily_loss_limit_pct=Decimal(daily_loss_limit_pct), + trailing_stop_pct=Decimal(trailing_stop_pct), + max_open_positions=max_open_positions, + volatility_lookback=volatility_lookback, + volatility_scale=volatility_scale, + ) + + +def make_position(symbol: str, quantity: str, avg_entry: str, current: str) -> Position: + return Position( + symbol=symbol, + quantity=Decimal(quantity), + avg_entry_price=Decimal(avg_entry), + current_price=Decimal(current), ) @@ -68,3 +85,116 @@ def test_risk_check_rejects_insufficient_balance(): result = rm.check(signal, balance=Decimal("100"), positions={}, daily_pnl=Decimal("0")) assert result.allowed is False assert result.reason == "Insufficient balance" + + +# --- Trailing stop tests --- + + +def test_trailing_stop_set_and_trigger(): + """Trailing stop should trigger when price drops below stop level.""" + rm = make_risk_manager(trailing_stop_pct="5") + rm.set_trailing_stop("BTC/USDT", Decimal("100")) + + signal = make_signal(side=OrderSide.BUY, price="94", quantity="0.01") + result = rm.check(signal, balance=Decimal("10000"), positions={}, daily_pnl=Decimal("0")) + assert result.allowed is False + assert "Trailing stop triggered" in result.reason + + +def test_trailing_stop_updates_highest_price(): + """Trailing stop should track the highest price seen.""" + rm = make_risk_manager(trailing_stop_pct="5") + rm.set_trailing_stop("BTC/USDT", Decimal("100")) + + # Price rises to 120 => stop at 114 + rm.update_price("BTC/USDT", Decimal("120")) + + # Price at 115 is above stop (114), should be allowed + signal = make_signal(side=OrderSide.BUY, price="115", quantity="0.01") + result = rm.check(signal, balance=Decimal("10000"), positions={}, daily_pnl=Decimal("0")) + assert result.allowed is True + + # Price at 113 is below stop (114), should be rejected + signal = make_signal(side=OrderSide.BUY, price="113", quantity="0.01") + result = rm.check(signal, balance=Decimal("10000"), positions={}, daily_pnl=Decimal("0")) + assert result.allowed is False + assert "Trailing stop triggered" in result.reason + + +def test_trailing_stop_not_triggered_above_stop(): + """Trailing stop should not trigger when price is above stop level.""" + rm = make_risk_manager(trailing_stop_pct="5") + rm.set_trailing_stop("BTC/USDT", Decimal("100")) + + # Price at 96 is above stop (95), should be allowed + signal = make_signal(side=OrderSide.BUY, price="96", quantity="0.01") + result = rm.check(signal, balance=Decimal("10000"), positions={}, daily_pnl=Decimal("0")) + assert result.allowed is True + + +# --- Max open positions test --- + + +def test_max_open_positions_check(): + """Should reject new BUY when max open positions is reached.""" + rm = make_risk_manager(max_open_positions=2) + + positions = { + "BTC/USDT": make_position("BTC/USDT", "1", "100", "100"), + "ETH/USDT": make_position("ETH/USDT", "10", "50", "50"), + } + + signal = make_signal(side=OrderSide.BUY, price="10", quantity="1", symbol="SOL/USDT") + result = rm.check(signal, balance=Decimal("10000"), positions=positions, daily_pnl=Decimal("0")) + assert result.allowed is False + assert result.reason == "Max open positions reached" + + +# --- Volatility tests --- + + +def test_volatility_calculation(): + """Volatility should be calculated from price history.""" + rm = make_risk_manager(volatility_lookback=5) + + # No history yet + assert rm.get_volatility("BTC/USDT") is None + + # Feed prices + prices = [100, 102, 98, 105, 101] + for p in prices: + rm.update_price("BTC/USDT", Decimal(str(p))) + + vol = rm.get_volatility("BTC/USDT") + assert vol is not None + assert vol > 0 + + +def test_position_size_with_volatility_scaling(): + """High volatility should reduce position size.""" + rm = make_risk_manager(volatility_scale=True, volatility_lookback=5) + + # Feed volatile prices + prices = [100, 120, 80, 130, 70] + for p in prices: + rm.update_price("BTC/USDT", Decimal(str(p))) + + size = rm.calculate_position_size("BTC/USDT", Decimal("10000")) + base = Decimal("10000") * Decimal("0.1") + + # High volatility should reduce size below base + assert size < base + + +def test_position_size_without_scaling(): + """Without scaling, position size should be base size regardless of volatility.""" + rm = make_risk_manager(volatility_scale=False, volatility_lookback=5) + + prices = [100, 120, 80, 130, 70] + for p in prices: + rm.update_price("BTC/USDT", Decimal(str(p))) + + size = rm.calculate_position_size("BTC/USDT", Decimal("10000")) + base = Decimal("10000") * Decimal("0.1") + + assert size == base |
