Files
stonks-oracle/tests/test_pbt_risk_tier_controller.py
T
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

316 lines
10 KiB
Python

"""Property-based tests for the Risk Tier Controller.
Feature: autonomous-trading-engine
Tests Property 12 from the design specification: risk tier auto-adjustment
conditions. Verifies downgrade, upgrade, and no-change behaviour across all
three starting tiers with randomly generated performance metrics.
**Validates: Requirements 5.3, 5.4**
"""
from __future__ import annotations
from datetime import datetime
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.risk_tier_controller import RiskTierController, TIER_ORDER
from services.trading.models import PerformanceMetrics
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_metrics(
*,
win_rate: float,
current_drawdown_pct: float,
) -> PerformanceMetrics:
"""Build a PerformanceMetrics instance with the given win_rate and drawdown.
All other fields are set to neutral defaults that do not affect tier
evaluation logic.
"""
return PerformanceMetrics(
total_portfolio_value=10_000.0,
active_pool=8_000.0,
reserve_pool=2_000.0,
unrealized_pnl=0.0,
realized_pnl=0.0,
daily_pnl=0.0,
win_count=0,
loss_count=0,
win_rate=win_rate,
avg_win=0.0,
avg_loss=0.0,
profit_factor=1.0,
sharpe_ratio=0.0,
max_drawdown=0.0,
current_drawdown_pct=current_drawdown_pct,
portfolio_heat=0.0,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_win_rate_st = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
_drawdown_st = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
_reserve_pct_st = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
_tier_st = st.sampled_from(TIER_ORDER)
# ---------------------------------------------------------------------------
# Property 12: Risk tier auto-adjustment conditions
# ---------------------------------------------------------------------------
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=st.floats(min_value=0.0, max_value=0.39, allow_nan=False, allow_infinity=False),
drawdown=st.floats(min_value=0.0, max_value=0.15, allow_nan=False, allow_infinity=False),
reserve_pct=_reserve_pct_st,
)
def test_downgrade_on_low_win_rate(
current_tier: str,
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""Tier downgrades when win rate < 40% (regardless of drawdown).
**Validates: Requirements 5.3**
"""
assume(win_rate < 0.40)
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
current_index = TIER_ORDER.index(current_tier)
if current_index > 0:
assert result == TIER_ORDER[current_index - 1], (
f"Expected downgrade from {current_tier} to {TIER_ORDER[current_index - 1]}, "
f"got {result} (win_rate={win_rate})"
)
else:
# Already at conservative — no change possible
assert result is None, (
f"Expected None (already at conservative), got {result}"
)
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=_win_rate_st,
drawdown=st.floats(min_value=0.151, max_value=1.0, allow_nan=False, allow_infinity=False),
reserve_pct=_reserve_pct_st,
)
def test_downgrade_on_high_drawdown(
current_tier: str,
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""Tier downgrades when drawdown > 15% (regardless of win rate).
**Validates: Requirements 5.3**
"""
assume(drawdown > 0.15)
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
current_index = TIER_ORDER.index(current_tier)
if current_index > 0:
assert result == TIER_ORDER[current_index - 1], (
f"Expected downgrade from {current_tier} to {TIER_ORDER[current_index - 1]}, "
f"got {result} (drawdown={drawdown})"
)
else:
assert result is None, (
f"Expected None (already at conservative), got {result}"
)
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=st.floats(min_value=0.551, max_value=1.0, allow_nan=False, allow_infinity=False),
reserve_pct=st.floats(min_value=0.201, max_value=1.0, allow_nan=False, allow_infinity=False),
drawdown=st.floats(min_value=0.0, max_value=0.049, allow_nan=False, allow_infinity=False),
)
def test_upgrade_when_all_conditions_met(
current_tier: str,
win_rate: float,
reserve_pct: float,
drawdown: float,
) -> None:
"""Tier upgrades when win rate > 55% AND reserve > 20% AND drawdown < 5%.
**Validates: Requirements 5.4**
"""
assume(win_rate > 0.55)
assume(reserve_pct > 0.20)
assume(drawdown < 0.05)
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
current_index = TIER_ORDER.index(current_tier)
if current_index < len(TIER_ORDER) - 1:
assert result == TIER_ORDER[current_index + 1], (
f"Expected upgrade from {current_tier} to {TIER_ORDER[current_index + 1]}, "
f"got {result} (win_rate={win_rate}, reserve_pct={reserve_pct}, drawdown={drawdown})"
)
else:
# Already at aggressive — no change possible
assert result is None, (
f"Expected None (already at aggressive), got {result}"
)
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=st.floats(min_value=0.40, max_value=0.55, allow_nan=False, allow_infinity=False),
drawdown=st.floats(min_value=0.0, max_value=0.15, allow_nan=False, allow_infinity=False),
reserve_pct=_reserve_pct_st,
)
def test_no_change_when_neither_condition_met(
current_tier: str,
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""Tier stays the same when neither downgrade nor upgrade conditions are met.
The "neutral zone" is: win_rate in [0.40, 0.55] AND drawdown in [0.0, 0.15].
In this zone, upgrade conditions cannot all be satisfied (win_rate <= 0.55),
and downgrade conditions are not met (win_rate >= 0.40 AND drawdown <= 0.15).
**Validates: Requirements 5.3, 5.4**
"""
assume(win_rate >= 0.40)
assume(win_rate <= 0.55)
assume(drawdown <= 0.15)
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
assert result is None, (
f"Expected no change (None) for {current_tier}, "
f"got {result} (win_rate={win_rate}, drawdown={drawdown}, reserve_pct={reserve_pct})"
)
@settings(max_examples=100)
@given(
win_rate=_win_rate_st,
drawdown=_drawdown_st,
reserve_pct=_reserve_pct_st,
)
def test_tier_never_below_conservative(
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""Starting from conservative, the tier never goes below conservative.
**Validates: Requirements 5.3, 5.4**
"""
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate("conservative", metrics, reserve_pct)
if result is not None:
assert result in TIER_ORDER, f"Unknown tier: {result}"
assert TIER_ORDER.index(result) >= 0, (
f"Tier went below conservative: {result}"
)
@settings(max_examples=100)
@given(
win_rate=_win_rate_st,
drawdown=_drawdown_st,
reserve_pct=_reserve_pct_st,
)
def test_tier_never_above_aggressive(
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""Starting from aggressive, the tier never goes above aggressive.
**Validates: Requirements 5.3, 5.4**
"""
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate("aggressive", metrics, reserve_pct)
if result is not None:
assert result in TIER_ORDER, f"Unknown tier: {result}"
assert TIER_ORDER.index(result) <= len(TIER_ORDER) - 1, (
f"Tier went above aggressive: {result}"
)
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=_win_rate_st,
drawdown=_drawdown_st,
reserve_pct=_reserve_pct_st,
)
def test_result_is_always_valid_tier_or_none(
current_tier: str,
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""The evaluate result is always None or a valid tier name from TIER_ORDER.
**Validates: Requirements 5.3, 5.4**
"""
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
assert result is None or result in TIER_ORDER, (
f"Invalid result: {result} (expected None or one of {TIER_ORDER})"
)
@settings(max_examples=100)
@given(
current_tier=_tier_st,
win_rate=_win_rate_st,
drawdown=_drawdown_st,
reserve_pct=_reserve_pct_st,
)
def test_tier_changes_by_at_most_one_level(
current_tier: str,
win_rate: float,
drawdown: float,
reserve_pct: float,
) -> None:
"""A single evaluation can only move the tier by at most one level.
**Validates: Requirements 5.3, 5.4**
"""
controller = RiskTierController()
metrics = _build_metrics(win_rate=win_rate, current_drawdown_pct=drawdown)
result = controller.evaluate(current_tier, metrics, reserve_pct)
if result is not None:
current_index = TIER_ORDER.index(current_tier)
new_index = TIER_ORDER.index(result)
assert abs(new_index - current_index) == 1, (
f"Tier jumped more than one level: {current_tier}{result}"
)