Files
Celes Renata 4ffde8cc06 feat: autonomous trading engine — full implementation
- 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
2026-04-15 16:12:22 +00:00

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