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
This commit is contained in:
Celes Renata
2026-04-15 16:12:22 +00:00
parent da86132f0c
commit 4ffde8cc06
58 changed files with 14168 additions and 1 deletions
+383
View File
@@ -0,0 +1,383 @@
"""Property-based tests for the Portfolio Rebalancer.
Feature: autonomous-trading-engine
Property 17: Portfolio rebalancing generates correct sell orders.
"""
from __future__ import annotations
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.models import OpenPosition, RiskTierConfig
from services.trading.rebalancer import PortfolioRebalancer, RebalanceOrder
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
SECTORS = ["Technology", "Healthcare", "Energy", "Financials", "Consumer"]
def _risk_tier_config_strategy() -> st.SearchStrategy[RiskTierConfig]:
"""Generate random RiskTierConfig objects with valid parameter ranges."""
return st.builds(
RiskTierConfig,
name=st.sampled_from(["conservative", "moderate", "aggressive"]),
min_confidence=st.floats(min_value=0.10, max_value=0.95, allow_nan=False, allow_infinity=False),
max_position_pct=st.floats(min_value=0.05, max_value=0.30, allow_nan=False, allow_infinity=False),
stop_loss_atr_multiplier=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
reward_risk_ratio=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
max_sector_pct=st.floats(min_value=0.10, max_value=0.50, allow_nan=False, allow_infinity=False),
max_portfolio_heat=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False),
)
def _open_position_strategy(
sector: st.SearchStrategy[str] | None = None,
market_value: st.SearchStrategy[float] | None = None,
signal_confidence: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[OpenPosition]:
"""Generate random OpenPosition objects."""
return st.builds(
OpenPosition,
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
quantity=st.integers(min_value=1, max_value=200),
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
current_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
unrealized_pnl=st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
market_value=market_value if market_value is not None else st.floats(
min_value=50.0, max_value=5000.0, allow_nan=False, allow_infinity=False,
),
sector=sector if sector is not None else st.sampled_from(SECTORS),
stop_loss_price=st.floats(min_value=1.0, max_value=400.0, allow_nan=False, allow_infinity=False),
take_profit_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
signal_confidence=signal_confidence if signal_confidence is not None else st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
),
is_micro_trade=st.just(False),
)
# ---------------------------------------------------------------------------
# Property 17: Portfolio rebalancing generates correct sell orders
# **Validates: Requirements 8.2, 8.3**
# ---------------------------------------------------------------------------
class TestProperty17PortfolioRebalancing:
"""Property 17: Portfolio rebalancing generates correct sell orders.
**Validates: Requirements 8.2, 8.3**
"""
rebalancer = PortfolioRebalancer()
@settings(max_examples=100)
@given(
risk_tier=_risk_tier_config_strategy(),
active_pool=st.floats(min_value=1000.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
excess_factor=st.floats(min_value=1.1, max_value=3.0, allow_nan=False, allow_infinity=False),
current_price=st.floats(min_value=5.0, max_value=200.0, allow_nan=False, allow_infinity=False),
)
def test_sell_order_generated_for_over_concentrated_position(
self,
risk_tier: RiskTierConfig,
active_pool: float,
excess_factor: float,
current_price: float,
) -> None:
"""A sell order is generated when a single stock exceeds max_position_pct."""
max_dollars = risk_tier.max_position_pct * active_pool
over_value = max_dollars * excess_factor
quantity = max(1, int(over_value / current_price))
actual_market_value = quantity * current_price
# Only test when the position actually exceeds the limit
assume(actual_market_value > max_dollars)
# Ensure we have enough shares to sell at least 1
assume(int((actual_market_value - max_dollars) / current_price) >= 1)
pos = OpenPosition(
ticker="OVER",
quantity=quantity,
entry_price=current_price,
current_price=current_price,
unrealized_pnl=0.0,
market_value=actual_market_value,
sector="Technology",
stop_loss_price=current_price * 0.9,
take_profit_price=current_price * 1.2,
signal_confidence=0.7,
)
orders = self.rebalancer.evaluate(
positions=[pos],
risk_tier=risk_tier,
active_pool=active_pool,
)
assert len(orders) >= 1
over_order = next(o for o in orders if o.ticker == "OVER")
assert over_order.action == "sell"
assert over_order.quantity >= 1
assert over_order.tag == "rebalance"
# After selling, the remaining value should be within the limit
remaining_value = actual_market_value - (over_order.quantity * current_price)
assert remaining_value <= max_dollars + current_price, (
f"Remaining value {remaining_value} still exceeds limit {max_dollars}"
)
@settings(max_examples=100)
@given(
risk_tier=_risk_tier_config_strategy(),
active_pool=st.floats(min_value=1000.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
within_factor=st.floats(min_value=0.1, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_no_sell_order_when_within_limits(
self,
risk_tier: RiskTierConfig,
active_pool: float,
within_factor: float,
) -> None:
"""No sell orders when all positions are within limits."""
max_dollars = risk_tier.max_position_pct * active_pool
position_value = max_dollars * within_factor
current_price = 10.0
quantity = max(1, int(position_value / current_price))
pos = OpenPosition(
ticker="OK",
quantity=quantity,
entry_price=current_price,
current_price=current_price,
unrealized_pnl=0.0,
market_value=quantity * current_price,
sector="Technology",
stop_loss_price=9.0,
take_profit_price=12.0,
signal_confidence=0.7,
)
# Ensure the position is actually within limits
assume(pos.market_value <= max_dollars)
# Also ensure sector is within limits
max_sector = risk_tier.max_sector_pct * active_pool
assume(pos.market_value <= max_sector)
orders = self.rebalancer.evaluate(
positions=[pos],
risk_tier=risk_tier,
active_pool=active_pool,
)
assert len(orders) == 0
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=5000.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
conf_low=st.floats(min_value=0.1, max_value=0.4, allow_nan=False, allow_infinity=False),
conf_high=st.floats(min_value=0.6, max_value=0.9, allow_nan=False, allow_infinity=False),
)
def test_lowest_confidence_sold_first_for_sector_rebalancing(
self,
active_pool: float,
conf_low: float,
conf_high: float,
) -> None:
"""Lowest-confidence positions are targeted first for sector rebalancing."""
assume(conf_low < conf_high)
risk_tier = RiskTierConfig(
name="moderate",
min_confidence=0.5,
max_position_pct=0.50, # High so single-stock check doesn't trigger
stop_loss_atr_multiplier=2.0,
reward_risk_ratio=1.5,
max_sector_pct=0.20, # Low sector limit to trigger rebalancing
max_portfolio_heat=0.50,
)
max_sector_dollars = risk_tier.max_sector_pct * active_pool
# Each position is 60% of the sector limit, so two together exceed it
per_position_value = max_sector_dollars * 0.6
current_price = 50.0
quantity = max(1, int(per_position_value / current_price))
actual_value = quantity * current_price
# Ensure two positions together exceed the sector limit
assume(actual_value * 2 > max_sector_dollars)
# Ensure each position alone is within the single-stock limit
assume(actual_value <= risk_tier.max_position_pct * active_pool)
pos_low = OpenPosition(
ticker="LOW",
quantity=quantity,
entry_price=current_price,
current_price=current_price,
unrealized_pnl=0.0,
market_value=actual_value,
sector="Technology",
stop_loss_price=45.0,
take_profit_price=60.0,
signal_confidence=conf_low,
)
pos_high = OpenPosition(
ticker="HIGH",
quantity=quantity,
entry_price=current_price,
current_price=current_price,
unrealized_pnl=0.0,
market_value=actual_value,
sector="Technology",
stop_loss_price=45.0,
take_profit_price=60.0,
signal_confidence=conf_high,
)
orders = self.rebalancer.evaluate(
positions=[pos_low, pos_high],
risk_tier=risk_tier,
active_pool=active_pool,
)
# Should have at least one order
assert len(orders) >= 1
# The first order should target the lowest-confidence position
tickers_ordered = [o.ticker for o in orders]
if "LOW" in tickers_ordered and "HIGH" in tickers_ordered:
# If both are being sold, LOW should have more shares sold
low_order = next(o for o in orders if o.ticker == "LOW")
high_order = next(o for o in orders if o.ticker == "HIGH")
assert low_order.quantity >= high_order.quantity
elif len(tickers_ordered) == 1:
# If only one is being sold, it should be the low-confidence one
assert "LOW" in tickers_ordered
@settings(max_examples=100)
@given(
num_positions=st.integers(min_value=11, max_value=15),
max_positions=st.integers(min_value=5, max_value=10),
)
def test_excess_positions_sold_lowest_confidence_first(
self,
num_positions: int,
max_positions: int,
) -> None:
"""When exceeding max positions, lowest-confidence positions are sold first."""
assume(num_positions > max_positions)
active_pool = 100000.0 # Large pool so no single-stock/sector triggers
risk_tier = RiskTierConfig(
name="moderate",
min_confidence=0.5,
max_position_pct=0.50,
stop_loss_atr_multiplier=2.0,
reward_risk_ratio=1.5,
max_sector_pct=0.90,
max_portfolio_heat=0.50,
)
positions = []
for i in range(num_positions):
conf = (i + 1) / (num_positions + 1) # Increasing confidence
pos = OpenPosition(
ticker=f"T{i:02d}",
quantity=10,
entry_price=50.0,
current_price=50.0,
unrealized_pnl=0.0,
market_value=500.0,
sector=SECTORS[i % len(SECTORS)],
stop_loss_price=45.0,
take_profit_price=60.0,
signal_confidence=conf,
)
positions.append(pos)
orders = self.rebalancer.evaluate(
positions=positions,
risk_tier=risk_tier,
active_pool=active_pool,
max_positions=max_positions,
)
assert len(orders) >= num_positions - max_positions
# Verify that the sold positions have lower confidence than the kept ones
sold_tickers = {o.ticker for o in orders}
sold_confs = [p.signal_confidence for p in positions if p.ticker in sold_tickers]
kept_confs = [p.signal_confidence for p in positions if p.ticker not in sold_tickers]
if sold_confs and kept_confs:
assert max(sold_confs) <= max(kept_confs), (
f"Sold max conf {max(sold_confs)} > kept max conf {max(kept_confs)}"
)
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=1000.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
)
def test_empty_portfolio_returns_no_orders(self, active_pool: float) -> None:
"""Empty portfolio produces no rebalancing orders."""
risk_tier = RiskTierConfig(
name="moderate",
min_confidence=0.5,
max_position_pct=0.10,
stop_loss_atr_multiplier=2.0,
reward_risk_ratio=1.5,
max_sector_pct=0.30,
max_portfolio_heat=0.20,
)
orders = self.rebalancer.evaluate(
positions=[],
risk_tier=risk_tier,
active_pool=active_pool,
)
assert orders == []
@settings(max_examples=100)
@given(
risk_tier=_risk_tier_config_strategy(),
)
def test_all_orders_are_sell_with_rebalance_tag(
self,
risk_tier: RiskTierConfig,
) -> None:
"""All rebalancing orders have action='sell' and tag='rebalance'."""
active_pool = 5000.0
max_dollars = risk_tier.max_position_pct * active_pool
# Create an over-concentrated position
over_value = max_dollars * 2.0
current_price = 50.0
quantity = max(1, int(over_value / current_price))
pos = OpenPosition(
ticker="BIG",
quantity=quantity,
entry_price=current_price,
current_price=current_price,
unrealized_pnl=0.0,
market_value=quantity * current_price,
sector="Technology",
stop_loss_price=45.0,
take_profit_price=60.0,
signal_confidence=0.5,
)
orders = self.rebalancer.evaluate(
positions=[pos],
risk_tier=risk_tier,
active_pool=active_pool,
)
for order in orders:
assert order.action == "sell"
assert order.tag == "rebalance"
assert order.quantity >= 1
assert len(order.reason) > 0