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:
@@ -0,0 +1,510 @@
|
||||
"""Property-based tests for the Performance Tracker.
|
||||
|
||||
Feature: autonomous-trading-engine
|
||||
|
||||
Property 26: Performance metrics computation.
|
||||
Property 33: Micro-trade metrics tracked separately.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import timedelta
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.trading.models import ClosedTrade
|
||||
from services.trading.performance_tracker import PerformanceComputer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _closed_trade_strategy(
|
||||
is_micro: st.SearchStrategy[bool] | None = None,
|
||||
) -> st.SearchStrategy[ClosedTrade]:
|
||||
"""Generate random ClosedTrade objects with consistent pnl fields."""
|
||||
return st.builds(
|
||||
_make_closed_trade,
|
||||
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
|
||||
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||||
exit_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
|
||||
quantity=st.integers(min_value=1, max_value=100),
|
||||
hold_days=st.integers(min_value=1, max_value=90),
|
||||
is_micro_trade=is_micro if is_micro is not None else st.booleans(),
|
||||
)
|
||||
|
||||
|
||||
def _make_closed_trade(
|
||||
ticker: str,
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
quantity: int,
|
||||
hold_days: int,
|
||||
is_micro_trade: bool,
|
||||
) -> ClosedTrade:
|
||||
"""Create a ClosedTrade with consistent pnl fields."""
|
||||
pnl = (exit_price - entry_price) * quantity
|
||||
pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0
|
||||
return ClosedTrade(
|
||||
ticker=ticker,
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
quantity=quantity,
|
||||
pnl=pnl,
|
||||
pnl_pct=pnl_pct,
|
||||
hold_duration=timedelta(days=hold_days),
|
||||
recommendation_id=None,
|
||||
is_micro_trade=is_micro_trade,
|
||||
)
|
||||
|
||||
|
||||
def _daily_returns_strategy(
|
||||
min_size: int = 2,
|
||||
max_size: int = 60,
|
||||
) -> st.SearchStrategy[list[float]]:
|
||||
"""Generate random daily return sequences."""
|
||||
return st.lists(
|
||||
st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False),
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 26: Performance metrics computation
|
||||
# **Validates: Requirements 14.1, 14.2**
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProperty26PerformanceMetrics:
|
||||
"""Property 26: Performance metrics computation.
|
||||
|
||||
**Validates: Requirements 14.1, 14.2**
|
||||
"""
|
||||
|
||||
computer = PerformanceComputer()
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=30,
|
||||
),
|
||||
)
|
||||
def test_win_rate_equals_wins_over_total(self, trades: list[ClosedTrade]) -> None:
|
||||
"""win_rate = wins / total_trades."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=trades,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
total = len(trades)
|
||||
wins = sum(1 for t in trades if t.pnl > 0)
|
||||
expected_win_rate = wins / total if total > 0 else 0.0
|
||||
|
||||
assert abs(metrics.win_rate - expected_win_rate) < 1e-9, (
|
||||
f"Expected win_rate={expected_win_rate}, got {metrics.win_rate}"
|
||||
)
|
||||
assert metrics.win_count == wins
|
||||
assert metrics.loss_count == total - wins
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=30,
|
||||
),
|
||||
)
|
||||
def test_profit_factor_equals_gross_profits_over_losses(
|
||||
self, trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""profit_factor = gross_profits / gross_losses (inf if no losses)."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=trades,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
gross_profits = sum(t.pnl for t in trades if t.pnl > 0)
|
||||
gross_losses = abs(sum(t.pnl for t in trades if t.pnl <= 0))
|
||||
|
||||
if gross_losses > 0:
|
||||
expected_pf = gross_profits / gross_losses
|
||||
assert abs(metrics.profit_factor - expected_pf) < 1e-6, (
|
||||
f"Expected profit_factor={expected_pf}, got {metrics.profit_factor}"
|
||||
)
|
||||
else:
|
||||
if gross_profits > 0:
|
||||
assert metrics.profit_factor == float("inf")
|
||||
else:
|
||||
assert metrics.profit_factor == 0.0
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
daily_returns=_daily_returns_strategy(min_size=2, max_size=60),
|
||||
)
|
||||
def test_sharpe_ratio_formula_consistency(
|
||||
self, daily_returns: list[float],
|
||||
) -> None:
|
||||
"""Sharpe ratio = (mean / std) * sqrt(252), 0 if std=0 or <2 points."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=daily_returns,
|
||||
)
|
||||
|
||||
n = len(daily_returns)
|
||||
mean_r = sum(daily_returns) / n
|
||||
variance = sum((r - mean_r) ** 2 for r in daily_returns) / (n - 1)
|
||||
std_r = math.sqrt(variance)
|
||||
|
||||
if std_r < 1e-12:
|
||||
assert metrics.sharpe_ratio == 0.0
|
||||
else:
|
||||
expected = (mean_r / std_r) * math.sqrt(252)
|
||||
assert abs(metrics.sharpe_ratio - expected) < 1e-6, (
|
||||
f"Expected sharpe={expected}, got {metrics.sharpe_ratio}"
|
||||
)
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
daily_returns=st.just([0.05, 0.05, 0.05]),
|
||||
)
|
||||
def test_sharpe_zero_when_std_is_zero(self, daily_returns: list[float]) -> None:
|
||||
"""Sharpe ratio is 0.0 when all daily returns are identical (std=0)."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=daily_returns,
|
||||
)
|
||||
assert metrics.sharpe_ratio == 0.0
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
daily_returns=st.just([0.01]),
|
||||
)
|
||||
def test_sharpe_zero_when_fewer_than_2_points(
|
||||
self, daily_returns: list[float],
|
||||
) -> None:
|
||||
"""Sharpe ratio is 0.0 when fewer than 2 data points."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=daily_returns,
|
||||
)
|
||||
assert metrics.sharpe_ratio == 0.0
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=0,
|
||||
max_size=20,
|
||||
),
|
||||
)
|
||||
def test_empty_trades_gives_zero_metrics(self, trades: list[ClosedTrade]) -> None:
|
||||
"""With no trades, win_rate=0, profit_factor=0, counts=0."""
|
||||
if len(trades) > 0:
|
||||
return # Only test empty case
|
||||
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
assert metrics.win_count == 0
|
||||
assert metrics.loss_count == 0
|
||||
assert metrics.win_rate == 0.0
|
||||
assert metrics.profit_factor == 0.0
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=30,
|
||||
),
|
||||
)
|
||||
def test_realized_pnl_equals_sum_of_trade_pnls(
|
||||
self, trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""realized_pnl equals the sum of all trade P&Ls."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=trades,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
expected_pnl = sum(t.pnl for t in trades)
|
||||
assert abs(metrics.realized_pnl - expected_pnl) < 1e-6
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
daily_returns=_daily_returns_strategy(min_size=3, max_size=60),
|
||||
)
|
||||
def test_max_drawdown_non_negative(self, daily_returns: list[float]) -> None:
|
||||
"""Max drawdown is always non-negative."""
|
||||
metrics = self.computer.compute_metrics(
|
||||
closed_trades=[],
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=daily_returns,
|
||||
)
|
||||
|
||||
assert metrics.max_drawdown >= 0.0
|
||||
assert metrics.current_drawdown_pct >= 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 33: Micro-trade metrics tracked separately
|
||||
# **Validates: Requirements 20.7**
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProperty33MicroTradeMetrics:
|
||||
"""Property 33: Micro-trade metrics tracked separately.
|
||||
|
||||
**Validates: Requirements 20.7**
|
||||
"""
|
||||
|
||||
computer = PerformanceComputer()
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
standard_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
micro_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(True)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
)
|
||||
def test_micro_trade_filter_returns_only_micro(
|
||||
self,
|
||||
standard_trades: list[ClosedTrade],
|
||||
micro_trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""filter_by_micro_trade(is_micro=True) returns only micro-trades."""
|
||||
all_trades = standard_trades + micro_trades
|
||||
filtered = self.computer.filter_by_micro_trade(all_trades, is_micro=True)
|
||||
|
||||
assert len(filtered) == len(micro_trades)
|
||||
for t in filtered:
|
||||
assert t.is_micro_trade is True
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
standard_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
micro_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(True)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
)
|
||||
def test_standard_trade_filter_returns_only_standard(
|
||||
self,
|
||||
standard_trades: list[ClosedTrade],
|
||||
micro_trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""filter_by_micro_trade(is_micro=False) returns only standard trades."""
|
||||
all_trades = standard_trades + micro_trades
|
||||
filtered = self.computer.filter_by_micro_trade(all_trades, is_micro=False)
|
||||
|
||||
assert len(filtered) == len(standard_trades)
|
||||
for t in filtered:
|
||||
assert t.is_micro_trade is False
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
standard_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
micro_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(True)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
)
|
||||
def test_micro_metrics_independent_from_standard(
|
||||
self,
|
||||
standard_trades: list[ClosedTrade],
|
||||
micro_trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""Micro-trade metrics are computed independently from standard metrics."""
|
||||
all_trades = standard_trades + micro_trades
|
||||
|
||||
# Compute metrics for micro-trades only
|
||||
micro_only = self.computer.filter_by_micro_trade(all_trades, is_micro=True)
|
||||
micro_metrics = self.computer.compute_metrics(
|
||||
closed_trades=micro_only,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
# Compute metrics for standard trades only
|
||||
standard_only = self.computer.filter_by_micro_trade(all_trades, is_micro=False)
|
||||
standard_metrics = self.computer.compute_metrics(
|
||||
closed_trades=standard_only,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
# Verify counts are independent
|
||||
assert micro_metrics.win_count + micro_metrics.loss_count == len(micro_trades)
|
||||
assert standard_metrics.win_count + standard_metrics.loss_count == len(standard_trades)
|
||||
|
||||
# Verify win rates are computed independently
|
||||
micro_wins = sum(1 for t in micro_trades if t.pnl > 0)
|
||||
expected_micro_wr = micro_wins / len(micro_trades) if micro_trades else 0.0
|
||||
assert abs(micro_metrics.win_rate - expected_micro_wr) < 1e-9
|
||||
|
||||
standard_wins = sum(1 for t in standard_trades if t.pnl > 0)
|
||||
expected_std_wr = standard_wins / len(standard_trades) if standard_trades else 0.0
|
||||
assert abs(standard_metrics.win_rate - expected_std_wr) < 1e-9
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
standard_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
micro_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(True)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
)
|
||||
def test_standard_metrics_not_contaminated_by_micro(
|
||||
self,
|
||||
standard_trades: list[ClosedTrade],
|
||||
micro_trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""Standard trade metrics are not contaminated by micro-trades."""
|
||||
# Compute standard metrics from standard trades only
|
||||
standard_metrics = self.computer.compute_metrics(
|
||||
closed_trades=standard_trades,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
# Compute standard metrics after filtering from mixed set
|
||||
all_trades = standard_trades + micro_trades
|
||||
filtered_standard = self.computer.filter_by_micro_trade(all_trades, is_micro=False)
|
||||
filtered_metrics = self.computer.compute_metrics(
|
||||
closed_trades=filtered_standard,
|
||||
portfolio_value=10000.0,
|
||||
active_pool=8000.0,
|
||||
reserve_pool=2000.0,
|
||||
daily_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
portfolio_heat=0.0,
|
||||
daily_returns=[],
|
||||
)
|
||||
|
||||
# Metrics should be identical
|
||||
assert standard_metrics.win_count == filtered_metrics.win_count
|
||||
assert standard_metrics.loss_count == filtered_metrics.loss_count
|
||||
assert abs(standard_metrics.win_rate - filtered_metrics.win_rate) < 1e-9
|
||||
assert abs(standard_metrics.realized_pnl - filtered_metrics.realized_pnl) < 1e-6
|
||||
# Handle inf == inf case (inf - inf = nan)
|
||||
if math.isinf(standard_metrics.profit_factor) and math.isinf(filtered_metrics.profit_factor):
|
||||
pass # Both infinity — equal
|
||||
else:
|
||||
assert abs(standard_metrics.profit_factor - filtered_metrics.profit_factor) < 1e-6
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
standard_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(False)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
micro_trades=st.lists(
|
||||
_closed_trade_strategy(is_micro=st.just(True)),
|
||||
min_size=1,
|
||||
max_size=15,
|
||||
),
|
||||
)
|
||||
def test_filter_preserves_all_trades(
|
||||
self,
|
||||
standard_trades: list[ClosedTrade],
|
||||
micro_trades: list[ClosedTrade],
|
||||
) -> None:
|
||||
"""Filtering micro + standard covers all trades with no overlap."""
|
||||
all_trades = standard_trades + micro_trades
|
||||
|
||||
micro_filtered = self.computer.filter_by_micro_trade(all_trades, is_micro=True)
|
||||
standard_filtered = self.computer.filter_by_micro_trade(all_trades, is_micro=False)
|
||||
|
||||
assert len(micro_filtered) + len(standard_filtered) == len(all_trades)
|
||||
Reference in New Issue
Block a user