4ffde8cc06
- Database migration 018 with 13 tables for trading engine state - Trading engine service (services/trading/) with 12 pure computation modules: position sizer, stop-loss manager, reserve pool, circuit breaker, risk tier controller, correlation matrix, tax lots, trading window, gradual entry, notifications, micro-trading, backtester - Core TradingEngine with pre-trade evaluation pipeline and integration wiring - FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest) - Performance tracker with Sharpe ratio, drawdown, profit factor computation - 194 Python tests (165 property-based + 29 integration) - Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page - Helm chart entry, network policy, nginx proxy, ingress for trading-engine - Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""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
|