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,
|
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 = {
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user