feat: auto-clamp buy orders to fit within position limits instead of hard-rejecting
This commit is contained in:
@@ -52,6 +52,7 @@ from services.risk.engine import (
|
||||
AccountRiskState,
|
||||
PortfolioRiskConfig,
|
||||
ProposedOrder,
|
||||
clamp_order_to_position_limits,
|
||||
evaluate_order,
|
||||
)
|
||||
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.logging import setup_logging
|
||||
from services.shared.metrics import (
|
||||
ORDERS_CLAMPED,
|
||||
ORDERS_DUPLICATES_PREVENTED,
|
||||
ORDERS_FILLED,
|
||||
ORDERS_REJECTED,
|
||||
@@ -288,6 +290,25 @@ async def load_risk_config(pool: asyncpg.Pool) -> 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(
|
||||
pool: asyncpg.Pool,
|
||||
adapter: AlpacaBrokerAdapter,
|
||||
@@ -534,6 +555,34 @@ async def process_order_job(
|
||||
risk_config = await load_risk_config(pool)
|
||||
risk_state = await load_account_risk_state(pool, adapter, account_uuid)
|
||||
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)
|
||||
|
||||
risk_eval_dict = {
|
||||
|
||||
Reference in New Issue
Block a user