feat: auto-clamp buy orders to fit within position limits instead of hard-rejecting

This commit is contained in:
Celes Renata
2026-04-28 14:20:44 +00:00
parent e360b66c3e
commit 226d799eb2
3 changed files with 123 additions and 0 deletions
+49
View File
@@ -52,6 +52,7 @@ from services.risk.engine import (
AccountRiskState, AccountRiskState,
PortfolioRiskConfig, PortfolioRiskConfig,
ProposedOrder, ProposedOrder,
clamp_order_to_position_limits,
evaluate_order, evaluate_order,
) )
from services.shared.audit import ( from services.shared.audit import (
@@ -66,6 +67,7 @@ from services.shared.config import load_config
from services.shared.db import get_pg_pool, get_redis from services.shared.db import get_pg_pool, get_redis
from services.shared.logging import setup_logging from services.shared.logging import setup_logging
from services.shared.metrics import ( from services.shared.metrics import (
ORDERS_CLAMPED,
ORDERS_DUPLICATES_PREVENTED, ORDERS_DUPLICATES_PREVENTED,
ORDERS_FILLED, ORDERS_FILLED,
ORDERS_REJECTED, ORDERS_REJECTED,
@@ -288,6 +290,25 @@ async def load_risk_config(pool: asyncpg.Pool) -> PortfolioRiskConfig:
return PortfolioRiskConfig() return PortfolioRiskConfig()
async def _estimate_share_price(
adapter: AlpacaBrokerAdapter,
ticker: str,
) -> float:
"""Estimate the current per-share price for a ticker.
Checks existing Alpaca positions first (free, no API call).
Returns 0.0 if no price can be determined.
"""
try:
positions = await adapter.get_positions()
for pos in positions:
if pos.ticker == ticker and pos.current_price > 0:
return pos.current_price
except Exception as e:
logger.debug("Could not fetch positions for price estimate: %s", e)
return 0.0
async def load_account_risk_state( async def load_account_risk_state(
pool: asyncpg.Pool, pool: asyncpg.Pool,
adapter: AlpacaBrokerAdapter, adapter: AlpacaBrokerAdapter,
@@ -534,6 +555,34 @@ async def process_order_job(
risk_config = await load_risk_config(pool) risk_config = await load_risk_config(pool)
risk_state = await load_account_risk_state(pool, adapter, account_uuid) risk_state = await load_account_risk_state(pool, adapter, account_uuid)
proposed = build_proposed_order(job) proposed = build_proposed_order(job)
# If estimated_value is missing, derive it from Alpaca positions or
# a fresh quote so that position-limit clamping can work.
if proposed.estimated_value <= 0 and proposed.quantity > 0:
price_per_share = await _estimate_share_price(adapter, proposed.ticker)
if price_per_share > 0:
proposed = proposed.model_copy(update={
"estimated_value": proposed.quantity * price_per_share,
})
job["estimated_value"] = proposed.estimated_value
# Auto-clamp buy orders to fit within position limits instead of
# hard-rejecting. If the clamped quantity is zero the normal risk
# evaluation will still reject the order with a clear reason.
original_qty = proposed.quantity
proposed = clamp_order_to_position_limits(proposed, risk_config, risk_state)
if proposed.quantity != original_qty:
logger.info(
"Order for %s clamped from %.0f to %.0f shares "
"(value %.2f%.2f) to fit position limits",
ticker, original_qty, proposed.quantity,
float(job.get("estimated_value", 0)), proposed.estimated_value,
)
ORDERS_CLAMPED.inc()
# Update the job dict so build_order_request picks up the clamped qty
job["quantity"] = proposed.quantity
job["estimated_value"] = proposed.estimated_value
evaluation = evaluate_order(proposed, risk_config, risk_state) evaluation = evaluate_order(proposed, risk_config, risk_state)
risk_eval_dict = { risk_eval_dict = {
+69
View File
@@ -12,6 +12,7 @@ Design: Section 4.8 - Risk Engine
""" """
from __future__ import annotations from __future__ import annotations
import math
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
@@ -265,6 +266,74 @@ class ProposedOrder(BaseModel):
confidence: float = 0.0 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) # Individual risk checks (Requirement 8.4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5
View File
@@ -251,6 +251,11 @@ RISK_CHECK_FAILURES = Counter(
["check_name"], ["check_name"],
) )
ORDERS_CLAMPED = Counter(
"stonks_orders_clamped_total",
"Orders auto-clamped to fit within position limits",
)
POSITIONS_SYNCED = Counter( POSITIONS_SYNCED = Counter(
"stonks_positions_synced_total", "stonks_positions_synced_total",
"Position sync operations completed", "Position sync operations completed",