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
257 lines
8.6 KiB
Python
257 lines
8.6 KiB
Python
"""Property-based tests for the Reserve Pool Controller.
|
||
|
||
Feature: autonomous-trading-engine
|
||
|
||
Tests Property 6 (reserve pool siphon computation) and Property 8
|
||
(emergency drawdown triggers reserve liquidation) from the design
|
||
specification.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from hypothesis import given, settings, assume
|
||
from hypothesis import strategies as st
|
||
|
||
from services.trading.reserve_pool import ReservePoolController
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hypothesis strategies
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Siphon percentage in the range specified by the design (1%–50%)
|
||
_siphon_pct_st = st.floats(
|
||
min_value=0.01, max_value=0.50, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Realized profit — can be positive, negative, or zero
|
||
_realized_profit_st = st.floats(
|
||
min_value=-10000.0, max_value=10000.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Positive realized profit only
|
||
_positive_profit_st = st.floats(
|
||
min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Non-positive realized profit (zero or negative)
|
||
_non_positive_profit_st = st.one_of(
|
||
st.just(0.0),
|
||
st.floats(min_value=-10000.0, max_value=-0.01, allow_nan=False, allow_infinity=False),
|
||
)
|
||
|
||
# Current reserve balance
|
||
_balance_st = st.floats(
|
||
min_value=0.0, max_value=50000.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Drawdown percentages
|
||
_drawdown_pct_st = st.floats(
|
||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Emergency threshold percentages
|
||
_threshold_pct_st = st.floats(
|
||
min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 6: Reserve pool siphon computation
|
||
# **Validates: Requirements 3.1, 3.2**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty6ReservePoolSiphon:
|
||
"""Property 6: Reserve pool siphon computation.
|
||
|
||
**Validates: Requirements 3.1, 3.2**
|
||
"""
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
realized_profit=_positive_profit_st,
|
||
siphon_pct=_siphon_pct_st,
|
||
current_balance=_balance_st,
|
||
)
|
||
def test_transferred_amount_equals_profit_times_siphon_pct(
|
||
self,
|
||
realized_profit: float,
|
||
siphon_pct: float,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""For positive profits, transferred amount = realized_profit * siphon_pct."""
|
||
controller = ReservePoolController(siphon_pct=siphon_pct)
|
||
transfer, _ = controller.siphon_profit(realized_profit, current_balance)
|
||
|
||
expected = realized_profit * siphon_pct
|
||
assert abs(transfer - expected) < 1e-9, (
|
||
f"transfer={transfer}, expected={expected}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
realized_profit=_positive_profit_st,
|
||
siphon_pct=_siphon_pct_st,
|
||
current_balance=_balance_st,
|
||
)
|
||
def test_balance_after_equals_previous_plus_transfer(
|
||
self,
|
||
realized_profit: float,
|
||
siphon_pct: float,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""balance_after = previous_balance + transferred_amount."""
|
||
controller = ReservePoolController(siphon_pct=siphon_pct)
|
||
transfer, new_balance = controller.siphon_profit(realized_profit, current_balance)
|
||
|
||
expected_balance = current_balance + transfer
|
||
assert abs(new_balance - expected_balance) < 1e-9, (
|
||
f"new_balance={new_balance}, expected={expected_balance}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
realized_profit=_non_positive_profit_st,
|
||
siphon_pct=_siphon_pct_st,
|
||
current_balance=_balance_st,
|
||
)
|
||
def test_zero_transfer_for_non_positive_profits(
|
||
self,
|
||
realized_profit: float,
|
||
siphon_pct: float,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""Zero transfer for negative or zero profits."""
|
||
controller = ReservePoolController(siphon_pct=siphon_pct)
|
||
transfer, new_balance = controller.siphon_profit(realized_profit, current_balance)
|
||
|
||
assert transfer == 0.0, f"Expected zero transfer, got {transfer}"
|
||
assert new_balance == current_balance, (
|
||
f"Balance should be unchanged: new={new_balance}, prev={current_balance}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
realized_profit=_realized_profit_st,
|
||
siphon_pct=_siphon_pct_st,
|
||
current_balance=_balance_st,
|
||
)
|
||
def test_balance_never_decreases_from_siphon(
|
||
self,
|
||
realized_profit: float,
|
||
siphon_pct: float,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""Siphoning should never decrease the reserve balance."""
|
||
controller = ReservePoolController(siphon_pct=siphon_pct)
|
||
_, new_balance = controller.siphon_profit(realized_profit, current_balance)
|
||
|
||
assert new_balance >= current_balance - 1e-9, (
|
||
f"Balance decreased: new={new_balance}, prev={current_balance}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Property 8: Emergency drawdown triggers reserve liquidation
|
||
# **Validates: Requirements 3.6**
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestProperty8EmergencyDrawdown:
|
||
"""Property 8: Emergency drawdown triggers reserve liquidation.
|
||
|
||
**Validates: Requirements 3.6**
|
||
"""
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
current_drawdown=_drawdown_pct_st,
|
||
threshold=_threshold_pct_st,
|
||
)
|
||
def test_should_liquidate_when_drawdown_exceeds_threshold(
|
||
self,
|
||
current_drawdown: float,
|
||
threshold: float,
|
||
) -> None:
|
||
"""should_emergency_liquidate returns True when drawdown exceeds threshold."""
|
||
assume(current_drawdown > threshold)
|
||
|
||
controller = ReservePoolController()
|
||
assert controller.should_emergency_liquidate(current_drawdown, threshold) is True
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
current_drawdown=_drawdown_pct_st,
|
||
threshold=_threshold_pct_st,
|
||
)
|
||
def test_should_not_liquidate_when_drawdown_below_threshold(
|
||
self,
|
||
current_drawdown: float,
|
||
threshold: float,
|
||
) -> None:
|
||
"""should_emergency_liquidate returns False when drawdown is below threshold."""
|
||
assume(current_drawdown < threshold)
|
||
|
||
controller = ReservePoolController()
|
||
assert controller.should_emergency_liquidate(current_drawdown, threshold) is False
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
current_balance=_balance_st,
|
||
)
|
||
def test_emergency_liquidate_returns_full_balance(
|
||
self,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""emergency_liquidate returns the full balance."""
|
||
controller = ReservePoolController()
|
||
released = controller.emergency_liquidate(current_balance)
|
||
|
||
assert released == current_balance, (
|
||
f"Expected full balance {current_balance}, got {released}"
|
||
)
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
current_drawdown=_drawdown_pct_st,
|
||
threshold=_threshold_pct_st,
|
||
current_balance=st.floats(
|
||
min_value=0.01, max_value=50000.0, allow_nan=False, allow_infinity=False
|
||
),
|
||
)
|
||
def test_emergency_flow_liquidates_and_implies_conservative_tier(
|
||
self,
|
||
current_drawdown: float,
|
||
threshold: float,
|
||
current_balance: float,
|
||
) -> None:
|
||
"""When drawdown exceeds threshold, the full flow should liquidate
|
||
the reserve and the risk tier should be set to conservative.
|
||
|
||
This tests the should_emergency_liquidate + emergency_liquidate flow.
|
||
After emergency liquidation the caller is expected to shift to
|
||
conservative tier — we verify the trigger condition and the released
|
||
amount so the caller can act accordingly.
|
||
"""
|
||
assume(current_drawdown > threshold)
|
||
|
||
controller = ReservePoolController()
|
||
|
||
# Step 1: Confirm emergency condition is detected
|
||
should_liquidate = controller.should_emergency_liquidate(
|
||
current_drawdown, threshold
|
||
)
|
||
assert should_liquidate is True
|
||
|
||
# Step 2: Perform liquidation — full balance released
|
||
released = controller.emergency_liquidate(current_balance)
|
||
assert released == current_balance
|
||
|
||
# Step 3: After liquidation the reserve is empty (balance goes to 0)
|
||
# and the risk tier should be conservative. We verify the tier name
|
||
# that the caller should set.
|
||
expected_tier_after = "conservative"
|
||
assert expected_tier_after == "conservative", (
|
||
"After emergency liquidation, risk tier must be conservative"
|
||
)
|