diff --git a/services/adapters/broker_service.py b/services/adapters/broker_service.py index 6fdad31..eba4012 100644 --- a/services/adapters/broker_service.py +++ b/services/adapters/broker_service.py @@ -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 = { diff --git a/services/risk/engine.py b/services/risk/engine.py index bfbc5cf..a602d30 100644 --- a/services/risk/engine.py +++ b/services/risk/engine.py @@ -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) # --------------------------------------------------------------------------- diff --git a/services/shared/metrics.py b/services/shared/metrics.py index 46d47da..2d8896d 100644 --- a/services/shared/metrics.py +++ b/services/shared/metrics.py @@ -251,6 +251,11 @@ RISK_CHECK_FAILURES = Counter( ["check_name"], ) +ORDERS_CLAMPED = Counter( + "stonks_orders_clamped_total", + "Orders auto-clamped to fit within position limits", +) + POSITIONS_SYNCED = Counter( "stonks_positions_synced_total", "Position sync operations completed",