From f251c53f92c1175f2483ac8c0af0569cff88bc16 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Wed, 22 Apr 2026 02:07:24 +0000 Subject: [PATCH] fix: risk engine blocking sell orders on over-concentrated positions Two bugs: (1) trading engine omitted estimated_value from sell order jobs, causing risk engine to compute 0 reduction; (2) risk engine applied position size limits to sells, trapping users in positions they couldn't exit. Sells now always pass position value/pct checks. --- services/risk/engine.py | 41 +++++++++++++++++++++++++++----------- services/trading/engine.py | 6 +++++- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/services/risk/engine.py b/services/risk/engine.py index 25d6532..bfbc5cf 100644 --- a/services/risk/engine.py +++ b/services/risk/engine.py @@ -302,16 +302,24 @@ def _check_max_position_size( new_total_value = max(existing_value - order.estimated_value, 0.0) else: new_total_value = existing_value + order.estimated_value + + # Sell orders always pass position value check — they reduce exposure + if order.action == "sell": + value_result = RiskCheckResult.PASS + value_verb = "within (sell reduces exposure)" + elif new_total_value <= limits.max_position_value: + value_result = RiskCheckResult.PASS + value_verb = "within" + else: + value_result = RiskCheckResult.FAIL + value_verb = "exceeds" + checks.append(RiskCheckDetail( check_name="max_position_value", - result=( - RiskCheckResult.PASS - if new_total_value <= limits.max_position_value - else RiskCheckResult.FAIL - ), + result=value_result, message=( f"Position value {new_total_value:.2f} " - f"{'within' if new_total_value <= limits.max_position_value else 'exceeds'} " + f"{value_verb} " f"limit {limits.max_position_value:.2f}" ), threshold=limits.max_position_value, @@ -323,16 +331,25 @@ def _check_max_position_size( position_pct = new_total_value / state.portfolio_value else: position_pct = 1.0 if new_total_value > 0 else 0.0 + + # Sell orders that reduce concentration should always pass — blocking a + # sell on an over-concentrated position prevents the user from fixing it. + if order.action == "sell": + pct_result = RiskCheckResult.PASS + pct_verb = "within (sell reduces exposure)" + elif position_pct <= limits.max_position_pct: + pct_result = RiskCheckResult.PASS + pct_verb = "within" + else: + pct_result = RiskCheckResult.FAIL + pct_verb = "exceeds" + checks.append(RiskCheckDetail( check_name="max_position_pct", - result=( - RiskCheckResult.PASS - if position_pct <= limits.max_position_pct - else RiskCheckResult.FAIL - ), + result=pct_result, message=( f"Position {position_pct:.4f} of portfolio " - f"{'within' if position_pct <= limits.max_position_pct else 'exceeds'} " + f"{pct_verb} " f"limit {limits.max_position_pct:.4f}" ), threshold=limits.max_position_pct, diff --git a/services/trading/engine.py b/services/trading/engine.py index 9a1e4e6..5c9001b 100644 --- a/services/trading/engine.py +++ b/services/trading/engine.py @@ -728,6 +728,7 @@ class TradingEngine: "side": "sell", "quantity": sell_qty, "order_type": "market", + "estimated_value": estimated_proceeds, "source": "trading_engine", } if self.redis is not None: @@ -1813,14 +1814,17 @@ class TradingEngine: self.portfolio_state.active_pool += current * qty async def _submit_sell_order( - self, ticker: str, quantity: int, reason: str + self, ticker: str, quantity: int, reason: str, + estimated_value: float = 0.0, ) -> None: """Push a sell order to the broker queue via Redis.""" order_job = { "ticker": ticker, "action": "sell", + "side": "sell", "quantity": quantity, "order_type": "market", + "estimated_value": estimated_value, "source": "trading_engine", "reason": reason, }