"""Portfolio rebalancer for the autonomous trading engine. Evaluates portfolio concentration and generates rebalancing sell orders when single-stock or sector exposure exceeds configured limits. """ from __future__ import annotations from dataclasses import dataclass from services.trading.models import OpenPosition, RiskTierConfig @dataclass class RebalanceOrder: """A sell order generated by the portfolio rebalancer.""" ticker: str action: str = "sell" quantity: int = 0 reason: str = "" tag: str = "rebalance" class PortfolioRebalancer: """Evaluates portfolio concentration and generates rebalancing orders. Generates partial sell orders when: - A single stock exceeds max_position_pct of the active pool - A sector exceeds max_sector_pct of the active pool - The number of open positions exceeds the configured maximum """ def evaluate( self, positions: list[OpenPosition], risk_tier: RiskTierConfig, active_pool: float, max_positions: int = 10, ) -> list[RebalanceOrder]: """Evaluate portfolio and generate rebalancing sell orders. Args: positions: Current open positions. risk_tier: Active risk tier configuration. active_pool: Current active pool value in dollars. max_positions: Maximum allowed open positions. Returns: List of RebalanceOrder for positions that need trimming. """ orders: list[RebalanceOrder] = [] if not positions or active_pool <= 0: return orders # Track which tickers already have orders to avoid duplicates ordered_tickers: dict[str, RebalanceOrder] = {} # --- 1. Single-stock concentration check --- max_position_dollars = risk_tier.max_position_pct * active_pool for pos in positions: if pos.market_value > max_position_dollars and pos.current_price > 0: excess = pos.market_value - max_position_dollars sell_qty = int(excess / pos.current_price) if sell_qty > 0: sell_qty = min(sell_qty, pos.quantity) order = RebalanceOrder( ticker=pos.ticker, action="sell", quantity=sell_qty, reason=( f"Position {pos.ticker} market_value " f"${pos.market_value:.2f} exceeds " f"max_position_pct limit " f"${max_position_dollars:.2f}" ), tag="rebalance", ) ordered_tickers[pos.ticker] = order orders.append(order) # --- 2. Sector concentration check --- max_sector_dollars = risk_tier.max_sector_pct * active_pool # Group positions by sector sector_positions: dict[str, list[OpenPosition]] = {} for pos in positions: sector_positions.setdefault(pos.sector, []).append(pos) for sector, sector_pos in sector_positions.items(): sector_value = sum(p.market_value for p in sector_pos) if sector_value > max_sector_dollars: excess = sector_value - max_sector_dollars # Sort by confidence ascending — sell lowest confidence first sorted_pos = sorted(sector_pos, key=lambda p: p.signal_confidence) remaining_excess = excess for pos in sorted_pos: if remaining_excess <= 0: break if pos.current_price <= 0: continue # Determine how many shares to sell from this position sell_value = min(remaining_excess, pos.market_value) sell_qty = int(sell_value / pos.current_price) if sell_qty <= 0: continue sell_qty = min(sell_qty, pos.quantity) if pos.ticker in ordered_tickers: # Already have an order for this ticker — take the larger existing = ordered_tickers[pos.ticker] if sell_qty > existing.quantity: existing.quantity = sell_qty existing.reason += ( f"; also sector {sector} exposure " f"${sector_value:.2f} exceeds limit " f"${max_sector_dollars:.2f}" ) else: order = RebalanceOrder( ticker=pos.ticker, action="sell", quantity=sell_qty, reason=( f"Sector {sector} exposure " f"${sector_value:.2f} exceeds " f"max_sector_pct limit " f"${max_sector_dollars:.2f} — " f"selling lowest-confidence position" ), tag="rebalance", ) ordered_tickers[pos.ticker] = order orders.append(order) remaining_excess -= sell_qty * pos.current_price # --- 3. Maximum open positions enforcement --- if len(positions) > max_positions: excess_count = len(positions) - max_positions # Sort by confidence ascending — sell lowest confidence first sorted_all = sorted(positions, key=lambda p: p.signal_confidence) sold_count = 0 for pos in sorted_all: if sold_count >= excess_count: break if pos.ticker in ordered_tickers: # Already selling this ticker — count it toward excess existing = ordered_tickers[pos.ticker] if existing.quantity < pos.quantity: existing.quantity = pos.quantity existing.reason += "; also exceeds max open positions" sold_count += 1 else: order = RebalanceOrder( ticker=pos.ticker, action="sell", quantity=pos.quantity, reason=( f"Portfolio has {len(positions)} positions, " f"exceeding max of {max_positions} — " f"selling lowest-confidence position" ), tag="rebalance", ) ordered_tickers[pos.ticker] = order orders.append(order) sold_count += 1 return orders