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.
This commit is contained in:
+29
-12
@@ -302,16 +302,24 @@ def _check_max_position_size(
|
|||||||
new_total_value = max(existing_value - order.estimated_value, 0.0)
|
new_total_value = max(existing_value - order.estimated_value, 0.0)
|
||||||
else:
|
else:
|
||||||
new_total_value = existing_value + order.estimated_value
|
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(
|
checks.append(RiskCheckDetail(
|
||||||
check_name="max_position_value",
|
check_name="max_position_value",
|
||||||
result=(
|
result=value_result,
|
||||||
RiskCheckResult.PASS
|
|
||||||
if new_total_value <= limits.max_position_value
|
|
||||||
else RiskCheckResult.FAIL
|
|
||||||
),
|
|
||||||
message=(
|
message=(
|
||||||
f"Position value {new_total_value:.2f} "
|
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}"
|
f"limit {limits.max_position_value:.2f}"
|
||||||
),
|
),
|
||||||
threshold=limits.max_position_value,
|
threshold=limits.max_position_value,
|
||||||
@@ -323,16 +331,25 @@ def _check_max_position_size(
|
|||||||
position_pct = new_total_value / state.portfolio_value
|
position_pct = new_total_value / state.portfolio_value
|
||||||
else:
|
else:
|
||||||
position_pct = 1.0 if new_total_value > 0 else 0.0
|
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(
|
checks.append(RiskCheckDetail(
|
||||||
check_name="max_position_pct",
|
check_name="max_position_pct",
|
||||||
result=(
|
result=pct_result,
|
||||||
RiskCheckResult.PASS
|
|
||||||
if position_pct <= limits.max_position_pct
|
|
||||||
else RiskCheckResult.FAIL
|
|
||||||
),
|
|
||||||
message=(
|
message=(
|
||||||
f"Position {position_pct:.4f} of portfolio "
|
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}"
|
f"limit {limits.max_position_pct:.4f}"
|
||||||
),
|
),
|
||||||
threshold=limits.max_position_pct,
|
threshold=limits.max_position_pct,
|
||||||
|
|||||||
@@ -728,6 +728,7 @@ class TradingEngine:
|
|||||||
"side": "sell",
|
"side": "sell",
|
||||||
"quantity": sell_qty,
|
"quantity": sell_qty,
|
||||||
"order_type": "market",
|
"order_type": "market",
|
||||||
|
"estimated_value": estimated_proceeds,
|
||||||
"source": "trading_engine",
|
"source": "trading_engine",
|
||||||
}
|
}
|
||||||
if self.redis is not None:
|
if self.redis is not None:
|
||||||
@@ -1813,14 +1814,17 @@ class TradingEngine:
|
|||||||
self.portfolio_state.active_pool += current * qty
|
self.portfolio_state.active_pool += current * qty
|
||||||
|
|
||||||
async def _submit_sell_order(
|
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:
|
) -> None:
|
||||||
"""Push a sell order to the broker queue via Redis."""
|
"""Push a sell order to the broker queue via Redis."""
|
||||||
order_job = {
|
order_job = {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
|
"side": "sell",
|
||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"order_type": "market",
|
"order_type": "market",
|
||||||
|
"estimated_value": estimated_value,
|
||||||
"source": "trading_engine",
|
"source": "trading_engine",
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user