diff options
Diffstat (limited to 'services/order-executor')
| -rw-r--r-- | services/order-executor/src/order_executor/risk_manager.py | 30 | ||||
| -rw-r--r-- | services/order-executor/tests/test_risk_manager.py | 108 |
2 files changed, 116 insertions, 22 deletions
diff --git a/services/order-executor/src/order_executor/risk_manager.py b/services/order-executor/src/order_executor/risk_manager.py index 94d15c2..5a05746 100644 --- a/services/order-executor/src/order_executor/risk_manager.py +++ b/services/order-executor/src/order_executor/risk_manager.py @@ -212,8 +212,16 @@ class RiskManager: prices_a = prices_a[-min_len:] prices_b = prices_b[-min_len:] - returns_a = [(prices_a[i] - prices_a[i-1]) / prices_a[i-1] for i in range(1, len(prices_a)) if prices_a[i-1] != 0] - returns_b = [(prices_b[i] - prices_b[i-1]) / prices_b[i-1] for i in range(1, len(prices_b)) if prices_b[i-1] != 0] + returns_a = [ + (prices_a[i] - prices_a[i - 1]) / prices_a[i - 1] + for i in range(1, len(prices_a)) + if prices_a[i - 1] != 0 + ] + returns_b = [ + (prices_b[i] - prices_b[i - 1]) / prices_b[i - 1] + for i in range(1, len(prices_b)) + if prices_b[i - 1] != 0 + ] if len(returns_a) < 3 or len(returns_b) < 3: return None @@ -225,7 +233,9 @@ class RiskManager: mean_a = sum(returns_a) / len(returns_a) mean_b = sum(returns_b) / len(returns_b) - cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len(returns_a) + cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len( + returns_a + ) std_a = math.sqrt(sum((a - mean_a) ** 2 for a in returns_a) / len(returns_a)) std_b = math.sqrt(sum((b - mean_b) ** 2 for b in returns_b) / len(returns_b)) @@ -253,7 +263,11 @@ class RiskManager: if not hist or len(hist) < 5: continue prices = list(hist) - returns = [(prices[i] - prices[i-1]) / prices[i-1] for i in range(1, len(prices)) if prices[i-1] != 0] + returns = [ + (prices[i] - prices[i - 1]) / prices[i - 1] + for i in range(1, len(prices)) + if prices[i - 1] != 0 + ] if returns: all_returns.append(returns) weight = float(pos.quantity * pos.current_price / balance) @@ -280,15 +294,15 @@ class RiskManager: return abs(var_return) * 100 # As percentage - def check_portfolio_exposure(self, positions: dict[str, Position], balance: Decimal) -> RiskCheckResult: + def check_portfolio_exposure( + self, positions: dict[str, Position], balance: Decimal + ) -> RiskCheckResult: """Check total portfolio exposure.""" if balance <= 0: return RiskCheckResult(allowed=True, reason="OK") total_exposure = sum( - pos.quantity * pos.current_price - for pos in positions.values() - if pos.quantity > 0 + pos.quantity * pos.current_price for pos in positions.values() if pos.quantity > 0 ) exposure_ratio = total_exposure / balance diff --git a/services/order-executor/tests/test_risk_manager.py b/services/order-executor/tests/test_risk_manager.py index a8fe37f..00a9ab4 100644 --- a/services/order-executor/tests/test_risk_manager.py +++ b/services/order-executor/tests/test_risk_manager.py @@ -204,21 +204,49 @@ def test_position_size_without_scaling(): def test_portfolio_exposure_check_passes(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.8) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("0.01"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))} + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_portfolio_exposure=0.8, + ) + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("0.01"), + avg_entry_price=Decimal("50000"), + current_price=Decimal("50000"), + ) + } result = rm.check_portfolio_exposure(positions, Decimal("10000")) assert result.allowed # 500/10000 = 5% < 80% def test_portfolio_exposure_check_rejects(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.3) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))} + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_portfolio_exposure=0.3, + ) + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("1"), + avg_entry_price=Decimal("50000"), + current_price=Decimal("50000"), + ) + } result = rm.check_portfolio_exposure(positions, Decimal("10000")) assert not result.allowed # 50000/10000 = 500% > 30% def test_correlation_calculation(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10")) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) # Feed identical price histories — correlation should be ~1.0 for i in range(20): rm.update_price("A", Decimal(str(100 + i))) @@ -229,10 +257,21 @@ def test_correlation_calculation(): def test_var_calculation(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10")) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + ) for i in range(30): rm.update_price("BTCUSDT", Decimal(str(100 + (i % 5) - 2))) - positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("100"), current_price=Decimal("100"))} + positions = { + "BTCUSDT": Position( + symbol="BTCUSDT", + quantity=Decimal("1"), + avg_entry_price=Decimal("100"), + current_price=Decimal("100"), + ) + } var = rm.calculate_portfolio_var(positions, Decimal("10000")) assert var >= 0 # Non-negative @@ -241,28 +280,52 @@ def test_var_calculation(): def test_drawdown_position_scale_full(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("10000")) assert scale == 1.0 # No drawdown def test_drawdown_position_scale_reduced(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("8500")) # 15% drawdown (between 10% and 20%) assert 0.25 < scale < 1.0 def test_drawdown_halt(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_reduction_threshold=0.1, + drawdown_halt_threshold=0.2, + ) rm.update_balance(Decimal("10000")) scale = rm.get_position_scale(Decimal("7500")) # 25% drawdown assert scale == 0.0 def test_consecutive_losses_pause(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3, loss_pause_minutes=60) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_consecutive_losses=3, + loss_pause_minutes=60, + ) rm.record_trade_result(False) rm.record_trade_result(False) assert not rm.is_paused() @@ -271,7 +334,12 @@ def test_consecutive_losses_pause(): def test_consecutive_losses_reset_on_win(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + max_consecutive_losses=3, + ) rm.record_trade_result(False) rm.record_trade_result(False) rm.record_trade_result(True) # Win resets counter @@ -280,9 +348,21 @@ def test_consecutive_losses_reset_on_win(): def test_drawdown_check_rejects_in_check(): - rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_halt_threshold=0.15) + rm = RiskManager( + max_position_size=Decimal("0.5"), + stop_loss_pct=Decimal("5"), + daily_loss_limit_pct=Decimal("10"), + drawdown_halt_threshold=0.15, + ) rm.update_balance(Decimal("10000")) - signal = Signal(strategy="test", symbol="BTC/USDT", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), reason="test") + signal = Signal( + strategy="test", + symbol="BTC/USDT", + side=OrderSide.BUY, + price=Decimal("50000"), + quantity=Decimal("0.01"), + reason="test", + ) result = rm.check(signal, Decimal("8000"), {}, Decimal("0")) # 20% dd > 15% assert not result.allowed assert "halted" in result.reason.lower() |
