feat: auto-clamp buy orders to fit within position limits instead of hard-rejecting
This commit is contained in:
@@ -12,6 +12,7 @@ Design: Section 4.8 - Risk Engine
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
@@ -265,6 +266,74 @@ class ProposedOrder(BaseModel):
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Order clamping — auto-scale to fit within position limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def clamp_order_to_position_limits(
|
||||
order: ProposedOrder,
|
||||
config: PortfolioRiskConfig,
|
||||
state: AccountRiskState,
|
||||
) -> ProposedOrder:
|
||||
"""Clamp a buy order's quantity/value to fit within position limits.
|
||||
|
||||
Instead of hard-rejecting orders that exceed max_position_pct or
|
||||
max_position_value, this function computes the maximum allowed
|
||||
order size and returns a new ProposedOrder scaled down to fit.
|
||||
|
||||
Sell orders are returned unchanged (they reduce exposure).
|
||||
If the order already fits, it is returned unchanged.
|
||||
If the clamped quantity rounds to zero, the order is returned with
|
||||
quantity=0 and estimated_value=0 so the caller can reject it.
|
||||
"""
|
||||
if order.action == "sell" or order.quantity <= 0:
|
||||
return order
|
||||
|
||||
limits = config.position_limits
|
||||
existing_value = state.positions_by_symbol.get(order.ticker, 0.0)
|
||||
|
||||
# Compute per-share price from the order
|
||||
price_per_share = (
|
||||
order.estimated_value / order.quantity
|
||||
if order.quantity > 0 and order.estimated_value > 0
|
||||
else 0.0
|
||||
)
|
||||
if price_per_share <= 0:
|
||||
return order # Can't clamp without a price; let risk checks handle it
|
||||
|
||||
# Compute the maximum additional value we can add to this position
|
||||
max_allowed_value = limits.max_position_value - existing_value
|
||||
|
||||
# Also enforce max_position_pct if portfolio value is known
|
||||
if state.portfolio_value > 0:
|
||||
max_pct_value = (limits.max_position_pct * state.portfolio_value) - existing_value
|
||||
max_allowed_value = min(max_allowed_value, max_pct_value)
|
||||
|
||||
# If already at or over the limit, clamp to zero
|
||||
if max_allowed_value <= 0:
|
||||
return order.model_copy(update={"quantity": 0.0, "estimated_value": 0.0})
|
||||
|
||||
# If the order already fits, return unchanged
|
||||
if order.estimated_value <= max_allowed_value:
|
||||
return order
|
||||
|
||||
# Clamp: compute the maximum whole shares that fit
|
||||
clamped_shares = math.floor(max_allowed_value / price_per_share)
|
||||
|
||||
# Also respect max_shares_per_order
|
||||
clamped_shares = min(clamped_shares, int(limits.max_shares_per_order))
|
||||
|
||||
if clamped_shares <= 0:
|
||||
return order.model_copy(update={"quantity": 0.0, "estimated_value": 0.0})
|
||||
|
||||
clamped_value = clamped_shares * price_per_share
|
||||
return order.model_copy(update={
|
||||
"quantity": float(clamped_shares),
|
||||
"estimated_value": clamped_value,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual risk checks (Requirement 8.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user