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
+728
View File
@@ -0,0 +1,728 @@
"""Integration tests for the TradingEngine wiring and end-to-end decision flow.
Tests verify that the engine correctly delegates to sub-components and
that the full recommendation → evaluation → sizing → decision pipeline
works end-to-end with concrete values. No DB/Redis needed — all
components are pure computation.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from services.shared.config import TradingConfig
from services.trading.circuit_breaker import CircuitBreaker
from services.trading.correlation import CorrelationMatrix
from services.trading.engine import TradingEngine
from services.trading.models import (
CircuitBreakerState,
OpenPosition,
PerformanceMetrics,
PortfolioState,
RiskTierConfig,
StopLevels,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engine(**overrides) -> TradingEngine:
"""Create a TradingEngine with sensible test defaults."""
config_kwargs = {
"enabled": True,
"micro_trading_enabled": False,
}
config_kwargs.update(overrides)
config = TradingConfig(**config_kwargs)
return TradingEngine(pool=None, redis=None, config=config)
def _moderate_tier() -> RiskTierConfig:
return RiskTierConfig(
name="moderate",
min_confidence=0.55,
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,
)
def _portfolio_state(**overrides) -> PortfolioState:
defaults = {
"positions": [],
"total_value": 500.0,
"cash": 400.0,
"active_pool": 400.0,
"reserve_pool": 100.0,
"sector_exposure": {},
"portfolio_heat": 0.0,
"open_position_count": 0,
}
defaults.update(overrides)
return PortfolioState(**defaults)
def _inactive_cb() -> CircuitBreakerState:
return CircuitBreakerState(active=False)
def _within_trading_window() -> datetime:
"""Return a datetime that is within the trading window (Wed 11:00 AM ET)."""
from zoneinfo import ZoneInfo
return datetime(2025, 1, 15, 11, 0, 0, tzinfo=ZoneInfo("America/New_York"))
# ---------------------------------------------------------------------------
# Test 1: Full cycle — recommendation → evaluation → position sizing → act
# ---------------------------------------------------------------------------
class TestFullDecisionCycle:
"""Verify the complete recommendation-to-decision pipeline."""
def test_high_confidence_recommendation_produces_act_decision(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-001",
"ticker": "AAPL",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0, # cheap enough to buy at least 1 share
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert decision.decision == "act"
assert decision.ticker == "AAPL"
assert decision.computed_position_size is not None
assert decision.computed_position_size > 0
assert decision.computed_share_quantity is not None
assert decision.computed_share_quantity > 0
assert decision.risk_tier_at_decision == "moderate"
assert decision.circuit_breaker_status == "inactive"
def test_low_confidence_recommendation_produces_skip_decision(self):
engine = _make_engine()
tier = _moderate_tier()
portfolio = _portfolio_state()
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-002",
"ticker": "MSFT",
"confidence": 0.30, # below moderate min_confidence of 0.55
"sector": "Technology",
"current_price": 400.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert decision.decision == "skip"
assert "insufficient_confidence" in decision.skip_reason
def test_duplicate_recommendation_is_skipped(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
cb_state = _inactive_cb()
corr = CorrelationMatrix()
now = _within_trading_window()
rec = {
"recommendation_id": "rec-dup",
"ticker": "GOOG",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0,
"action": "buy",
}
# First evaluation should act
d1 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert d1.decision == "act"
# Second evaluation of same rec should skip as duplicate
d2 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr,
earnings_calendar={},
now=now,
)
assert d2.decision == "skip"
assert "duplicate" in d2.skip_reason
# ---------------------------------------------------------------------------
# Test 2: Stop-loss crossing → StopTrigger list
# ---------------------------------------------------------------------------
class TestStopLossCrossings:
"""Verify stop-loss crossing detection via engine wiring."""
def test_stop_loss_triggered(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="AAPL",
quantity=10,
entry_price=150.0,
current_price=140.0,
unrealized_pnl=-100.0,
market_value=1400.0,
sector="Technology",
stop_loss_price=145.0,
take_profit_price=165.0,
signal_confidence=0.75,
),
]
prices = {"AAPL": 144.0} # below stop_loss_price of 145.0
stop_levels = {
"AAPL": StopLevels(
stop_loss_price=145.0,
take_profit_price=165.0,
trailing_stop_active=False,
atr_value=3.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 1
assert triggers[0].ticker == "AAPL"
assert triggers[0].trigger_type == "stop_loss"
assert triggers[0].current_price == 144.0
def test_take_profit_triggered(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="MSFT",
quantity=5,
entry_price=400.0,
current_price=420.0,
unrealized_pnl=100.0,
market_value=2100.0,
sector="Technology",
stop_loss_price=390.0,
take_profit_price=415.0,
signal_confidence=0.80,
),
]
prices = {"MSFT": 416.0} # above take_profit_price of 415.0
stop_levels = {
"MSFT": StopLevels(
stop_loss_price=390.0,
take_profit_price=415.0,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 1
assert triggers[0].ticker == "MSFT"
assert triggers[0].trigger_type == "take_profit"
def test_no_crossing_returns_empty(self):
engine = _make_engine()
positions = [
OpenPosition(
ticker="GOOG",
quantity=3,
entry_price=170.0,
current_price=172.0,
unrealized_pnl=6.0,
market_value=516.0,
sector="Technology",
stop_loss_price=165.0,
take_profit_price=180.0,
signal_confidence=0.70,
),
]
prices = {"GOOG": 172.0} # between stop and take-profit
stop_levels = {
"GOOG": StopLevels(
stop_loss_price=165.0,
take_profit_price=180.0,
trailing_stop_active=False,
atr_value=3.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
),
}
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
assert len(triggers) == 0
# ---------------------------------------------------------------------------
# Test 3: Reserve pool siphoning on profitable close
# ---------------------------------------------------------------------------
class TestReservePoolSiphoning:
"""Verify reserve pool siphoning via engine wiring."""
def test_profitable_close_siphons_20_percent(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=100.0,
reserve_balance=50.0,
)
assert transfer == pytest.approx(20.0) # 20% of $100
assert new_balance == pytest.approx(70.0) # $50 + $20
def test_loss_does_not_siphon(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=-30.0,
reserve_balance=50.0,
)
assert transfer == 0.0
assert new_balance == 50.0
def test_zero_profit_does_not_siphon(self):
engine = _make_engine(reserve_siphon_pct=0.20)
transfer, new_balance = engine.handle_position_close(
realized_profit=0.0,
reserve_balance=50.0,
)
assert transfer == 0.0
assert new_balance == 50.0
# ---------------------------------------------------------------------------
# Test 4: Circuit breaker trigger → halt → cooldown → resume
# ---------------------------------------------------------------------------
class TestCircuitBreakerFlow:
"""Verify circuit breaker integration with the decision loop."""
def test_active_circuit_breaker_skips_recommendation(self):
engine = _make_engine()
tier = _moderate_tier()
portfolio = _portfolio_state()
now = _within_trading_window()
# Circuit breaker active with future cooldown
cb_state = CircuitBreakerState(
active=True,
trigger_type="daily_loss",
triggered_at=now - timedelta(minutes=30),
cooldown_expires=now + timedelta(hours=2),
)
rec = {
"recommendation_id": "rec-cb-1",
"ticker": "AAPL",
"confidence": 0.90,
"sector": "Technology",
"current_price": 150.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=CorrelationMatrix(),
earnings_calendar={},
now=now,
)
assert decision.decision == "skip"
assert "circuit_breaker_active" in decision.skip_reason
assert decision.circuit_breaker_status == "active"
def test_expired_circuit_breaker_allows_trading(self):
engine = _make_engine(absolute_position_cap=100.0)
tier = _moderate_tier()
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
now = _within_trading_window()
# Circuit breaker was active but cooldown has expired
cb_state = CircuitBreakerState(
active=True,
trigger_type="daily_loss",
triggered_at=now - timedelta(hours=3),
cooldown_expires=now - timedelta(hours=1), # expired
)
rec = {
"recommendation_id": "rec-cb-2",
"ticker": "AAPL",
"confidence": 0.80,
"sector": "Technology",
"current_price": 25.0,
"action": "buy",
}
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=tier,
circuit_breaker_state=cb_state,
correlation_matrix=CorrelationMatrix(),
earnings_calendar={},
now=now,
)
# Should proceed since cooldown expired
assert decision.decision == "act"
def test_circuit_breaker_daily_loss_detection(self):
"""Verify the CircuitBreaker component detects daily loss threshold."""
cb = CircuitBreaker(daily_loss_pct=0.05)
# 6% loss on $500 portfolio → should trigger
assert cb.check_daily_loss(daily_pnl=-30.0, portfolio_value=500.0) is True
# 3% loss → should not trigger
assert cb.check_daily_loss(daily_pnl=-15.0, portfolio_value=500.0) is False
def test_circuit_breaker_cooldown_computation(self):
"""Verify cooldown expiry is computed correctly."""
cb = CircuitBreaker(volatility_pause_hours=2, ticker_cooldown_hours=48)
now = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
vol_expiry = cb.compute_cooldown_expiry("volatility", now)
assert vol_expiry == now + timedelta(hours=2)
pos_expiry = cb.compute_cooldown_expiry("single_position", now)
assert pos_expiry == now + timedelta(hours=48)
# ---------------------------------------------------------------------------
# Test 5: Engine startup state
# ---------------------------------------------------------------------------
class TestEngineStartup:
"""Verify engine startup state and lifecycle."""
def test_engine_starts_not_running(self):
engine = _make_engine()
assert engine.running is False
@pytest.mark.asyncio
async def test_start_sets_running_flag(self):
engine = _make_engine()
await engine.start()
assert engine.running is True
@pytest.mark.asyncio
async def test_stop_clears_running_flag(self):
engine = _make_engine()
await engine.start()
assert engine.running is True
await engine.stop()
assert engine.running is False
def test_engine_initializes_all_sub_components(self):
engine = _make_engine()
assert engine.position_sizer is not None
assert engine.stop_loss_manager is not None
assert engine.circuit_breaker is not None
assert engine.reserve_pool_controller is not None
assert engine.risk_tier_controller is not None
assert engine.correlation_matrix is not None
assert engine.notification_service is not None
assert engine.micro_trading_module is not None
assert engine.rebalancer is not None
def test_engine_starts_with_empty_processed_ids(self):
engine = _make_engine()
assert len(engine.processed_recommendation_ids) == 0
# ---------------------------------------------------------------------------
# Test 6: Risk tier evaluation wiring
# ---------------------------------------------------------------------------
class TestRiskTierEvaluation:
"""Verify risk tier evaluation via engine wiring."""
def test_downgrade_on_poor_performance(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=500.0,
active_pool=400.0,
reserve_pool=100.0,
unrealized_pnl=-20.0,
realized_pnl=-50.0,
daily_pnl=-10.0,
win_count=3,
loss_count=7,
win_rate=0.30, # below 40% → downgrade
avg_win=10.0,
avg_loss=-8.0,
profit_factor=0.5,
sharpe_ratio=-0.5,
max_drawdown=0.20,
current_drawdown_pct=0.18, # above 15% → also triggers downgrade
portfolio_heat=0.10,
)
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
assert new_tier == "conservative"
def test_upgrade_on_strong_performance(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=600.0,
active_pool=400.0,
reserve_pool=200.0,
unrealized_pnl=30.0,
realized_pnl=100.0,
daily_pnl=5.0,
win_count=7,
loss_count=3,
win_rate=0.70, # above 55%
avg_win=15.0,
avg_loss=-5.0,
profit_factor=2.1,
sharpe_ratio=1.5,
max_drawdown=0.05,
current_drawdown_pct=0.02, # below 5%
portfolio_heat=0.08,
)
# reserve_pct > 0.20 and win_rate > 0.55 and drawdown < 0.05
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.33)
assert new_tier == "aggressive"
def test_no_change_when_metrics_are_neutral(self):
engine = _make_engine()
metrics = PerformanceMetrics(
total_portfolio_value=500.0,
active_pool=400.0,
reserve_pool=100.0,
unrealized_pnl=5.0,
realized_pnl=20.0,
daily_pnl=2.0,
win_count=5,
loss_count=5,
win_rate=0.50, # between 40% and 55%
avg_win=10.0,
avg_loss=-8.0,
profit_factor=1.2,
sharpe_ratio=0.5,
max_drawdown=0.08,
current_drawdown_pct=0.06,
portfolio_heat=0.10,
)
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
assert new_tier is None
# ---------------------------------------------------------------------------
# Test 7: Rebalancing wiring
# ---------------------------------------------------------------------------
class TestRebalancingWiring:
"""Verify portfolio rebalancing via engine wiring."""
def test_over_concentrated_position_generates_sell_order(self):
engine = _make_engine()
tier = _moderate_tier() # max_position_pct = 0.10
# market_value $200 exceeds 10% of $400 = $40 by $160
# sell_qty = int(160 / 20) = 8 shares
positions = [
OpenPosition(
ticker="AAPL",
quantity=10,
entry_price=18.0,
current_price=20.0,
unrealized_pnl=20.0,
market_value=200.0,
sector="Technology",
stop_loss_price=16.0,
take_profit_price=25.0,
signal_confidence=0.80,
),
]
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
assert len(orders) >= 1
assert orders[0].ticker == "AAPL"
assert orders[0].action == "sell"
def test_balanced_portfolio_generates_no_orders(self):
engine = _make_engine()
tier = _moderate_tier()
positions = [
OpenPosition(
ticker="AAPL",
quantity=1,
entry_price=30.0,
current_price=30.0,
unrealized_pnl=0.0,
market_value=30.0, # $30 < 10% of $400 = $40
sector="Technology",
stop_loss_price=28.0,
take_profit_price=35.0,
signal_confidence=0.80,
),
]
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
assert len(orders) == 0
# ---------------------------------------------------------------------------
# Test 8: Notification wiring
# ---------------------------------------------------------------------------
class TestNotificationWiring:
"""Verify notification creation via engine wiring."""
def test_create_alert_returns_notification_record(self):
engine = _make_engine()
record = engine.create_alert(
event_type="circuit_breaker_triggered",
details="Daily loss exceeded 5% threshold",
)
assert record.event_type == "circuit_breaker_triggered"
assert record.channel == "email"
assert "Circuit Breaker Triggered" in record.message
assert "Daily loss exceeded 5% threshold" in record.message
assert record.delivery_status == "pending"
def test_create_alert_for_risk_tier_change(self):
engine = _make_engine()
record = engine.create_alert(
event_type="risk_tier_changed",
details="moderate → conservative due to drawdown",
)
assert record.event_type == "risk_tier_changed"
assert "Risk Tier Changed" in record.message
# ---------------------------------------------------------------------------
# Test 9: Micro-trading constraint wiring
# ---------------------------------------------------------------------------
class TestMicroTradingConstraints:
"""Verify micro-trading constraint checking via engine wiring."""
def test_disabled_micro_trading_rejects(self):
engine = _make_engine(micro_trading_enabled=False)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "micro_trading_disabled"
def test_enabled_micro_trading_allows(self):
engine = _make_engine(micro_trading_enabled=True)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is True
assert reason == "ok"
def test_circuit_breaker_blocks_micro_trade(self):
engine = _make_engine(micro_trading_enabled=True)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=0,
is_within_window=True,
circuit_breaker_active=True,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "circuit_breaker_active"
def test_daily_limit_blocks_micro_trade(self):
engine = _make_engine(
micro_trading_enabled=True,
micro_trading_max_daily=10,
)
allowed, reason = engine.check_micro_trade_constraints(
daily_count=10,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "daily_limit_reached"
+227
View File
@@ -0,0 +1,227 @@
"""Property-based tests for the Backtester.
Feature: autonomous-trading-engine
Tests Property 36 (backtester produces equivalent metrics) from the
design specification. Generates random sets of historical trades and
verifies that the backtester's metric computation matches the
PerformanceComputer for the same trade data.
"""
from __future__ import annotations
from datetime import date, timedelta
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.backtester import BacktestConfig, BacktestEngine
from services.trading.models import ClosedTrade
from services.trading.performance_tracker import PerformanceComputer
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_tickers = st.sampled_from(["AAPL", "MSFT", "GOOG", "AMZN", "TSLA", "NVDA"])
_entry_price_st = st.floats(
min_value=1.0, max_value=5000.0, allow_nan=False, allow_infinity=False
)
_exit_price_st = st.floats(
min_value=1.0, max_value=5000.0, allow_nan=False, allow_infinity=False
)
_quantity_st = st.integers(min_value=1, max_value=1000)
_hold_duration_st = st.timedeltas(
min_value=timedelta(minutes=1), max_value=timedelta(days=365)
)
@st.composite
def closed_trade_st(draw: st.DrawFn) -> ClosedTrade:
"""Generate a random ClosedTrade with consistent pnl fields."""
ticker = draw(_tickers)
entry_price = draw(_entry_price_st)
exit_price = draw(_exit_price_st)
quantity = draw(_quantity_st)
hold_duration = draw(_hold_duration_st)
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=hold_duration,
)
_trades_st = st.lists(closed_trade_st(), min_size=0, max_size=50)
_daily_returns_st = st.lists(
st.floats(min_value=-0.20, max_value=0.20, allow_nan=False, allow_infinity=False),
min_size=0,
max_size=252,
)
_initial_capital_st = st.floats(
min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False
)
_risk_tier_st = st.sampled_from(["conservative", "moderate", "aggressive"])
# ---------------------------------------------------------------------------
# Property 36: Backtester produces equivalent metrics
# **Validates: Requirements 15.3**
# ---------------------------------------------------------------------------
class TestProperty36BacktesterEquivalentMetrics:
"""Property 36: Backtester produces equivalent metrics.
**Validates: Requirements 15.3**
For any set of closed trades and daily returns, the backtester's
win_rate, profit_factor, and trade_count must be identical to
those computed directly by PerformanceComputer.
"""
@settings(max_examples=100)
@given(
trades=_trades_st,
daily_returns=_daily_returns_st,
initial_capital=_initial_capital_st,
risk_tier=_risk_tier_st,
)
def test_win_rate_matches_performance_tracker(
self,
trades: list[ClosedTrade],
daily_returns: list[float],
initial_capital: float,
risk_tier: str,
) -> None:
"""win_rate from BacktestEngine matches PerformanceComputer."""
config = BacktestConfig(
start_date=date(2024, 1, 1),
end_date=date(2024, 12, 31),
initial_capital=initial_capital,
risk_tier=risk_tier,
)
engine = BacktestEngine()
result = engine.compute_result(config, trades, daily_returns, [])
perf = PerformanceComputer()
metrics = perf.compute_metrics(
closed_trades=trades,
portfolio_value=initial_capital,
active_pool=initial_capital,
reserve_pool=0.0,
daily_pnl=0.0,
unrealized_pnl=0.0,
portfolio_heat=0.0,
daily_returns=daily_returns,
)
assert result.win_rate == metrics.win_rate, (
f"win_rate mismatch: backtest={result.win_rate}, "
f"perf_tracker={metrics.win_rate}"
)
@settings(max_examples=100)
@given(
trades=_trades_st,
daily_returns=_daily_returns_st,
initial_capital=_initial_capital_st,
risk_tier=_risk_tier_st,
)
def test_profit_factor_matches_performance_tracker(
self,
trades: list[ClosedTrade],
daily_returns: list[float],
initial_capital: float,
risk_tier: str,
) -> None:
"""profit_factor from BacktestEngine matches PerformanceComputer."""
config = BacktestConfig(
start_date=date(2024, 1, 1),
end_date=date(2024, 12, 31),
initial_capital=initial_capital,
risk_tier=risk_tier,
)
engine = BacktestEngine()
result = engine.compute_result(config, trades, daily_returns, [])
perf = PerformanceComputer()
metrics = perf.compute_metrics(
closed_trades=trades,
portfolio_value=initial_capital,
active_pool=initial_capital,
reserve_pool=0.0,
daily_pnl=0.0,
unrealized_pnl=0.0,
portfolio_heat=0.0,
daily_returns=daily_returns,
)
assert result.profit_factor == metrics.profit_factor, (
f"profit_factor mismatch: backtest={result.profit_factor}, "
f"perf_tracker={metrics.profit_factor}"
)
@settings(max_examples=100)
@given(
trades=_trades_st,
daily_returns=_daily_returns_st,
initial_capital=_initial_capital_st,
risk_tier=_risk_tier_st,
)
def test_trade_count_matches_number_of_trades(
self,
trades: list[ClosedTrade],
daily_returns: list[float],
initial_capital: float,
risk_tier: str,
) -> None:
"""trade_count from BacktestEngine equals len(trades)."""
config = BacktestConfig(
start_date=date(2024, 1, 1),
end_date=date(2024, 12, 31),
initial_capital=initial_capital,
risk_tier=risk_tier,
)
engine = BacktestEngine()
result = engine.compute_result(config, trades, daily_returns, [])
# trade_count should equal the number of trades passed in,
# which is also win_count + loss_count from PerformanceComputer
perf = PerformanceComputer()
metrics = perf.compute_metrics(
closed_trades=trades,
portfolio_value=initial_capital,
active_pool=initial_capital,
reserve_pool=0.0,
daily_pnl=0.0,
unrealized_pnl=0.0,
portfolio_heat=0.0,
daily_returns=daily_returns,
)
assert result.trade_count == len(trades), (
f"trade_count mismatch: backtest={result.trade_count}, "
f"expected={len(trades)}"
)
assert result.trade_count == metrics.win_count + metrics.loss_count, (
f"trade_count={result.trade_count} != "
f"win_count({metrics.win_count}) + loss_count({metrics.loss_count})"
)
+422
View File
@@ -0,0 +1,422 @@
"""Property-based tests for the Circuit Breaker.
Feature: autonomous-trading-engine
Tests properties 13 and 14 from the design specification,
covering circuit breaker activation triggers (daily loss, single position,
volatility) and cooldown expiry behaviour.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.circuit_breaker import CircuitBreaker
from services.trading.models import CircuitBreakerState
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _circuit_breaker_strategy() -> st.SearchStrategy[CircuitBreaker]:
"""Generate CircuitBreaker instances with random but valid thresholds."""
return st.builds(
CircuitBreaker,
daily_loss_pct=st.floats(min_value=0.01, max_value=0.20, allow_nan=False, allow_infinity=False),
single_position_loss_pct=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False),
ticker_cooldown_hours=st.integers(min_value=1, max_value=168),
volatility_pause_hours=st.integers(min_value=1, max_value=24),
stop_loss_hits_threshold=st.integers(min_value=2, max_value=10),
stop_loss_window_minutes=st.integers(min_value=5, max_value=120),
)
def _aware_datetime_strategy(
min_dt: datetime | None = None,
max_dt: datetime | None = None,
) -> st.SearchStrategy[datetime]:
"""Generate timezone-aware UTC datetimes."""
_min = min_dt or datetime(2024, 1, 1, tzinfo=timezone.utc)
_max = max_dt or datetime(2025, 12, 31, tzinfo=timezone.utc)
return st.datetimes(min_value=_min.replace(tzinfo=None), max_value=_max.replace(tzinfo=None)).map(
lambda dt: dt.replace(tzinfo=timezone.utc)
)
# ---------------------------------------------------------------------------
# Property 13: Circuit breaker activation
# **Validates: Requirements 6.1, 6.2, 6.3**
# ---------------------------------------------------------------------------
class TestProperty13CircuitBreakerActivation:
"""Property 13: Circuit breaker activation.
**Validates: Requirements 6.1, 6.2, 6.3**
"""
# -- Daily loss trigger ------------------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
excess_pct=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False),
)
def test_daily_loss_triggers_when_loss_exceeds_threshold(
self, cb: CircuitBreaker, portfolio_value: float, excess_pct: float,
) -> None:
"""Daily loss circuit breaker triggers when abs(daily_pnl)/portfolio_value > daily_loss_pct."""
# Construct a daily_pnl that exceeds the threshold
loss_ratio = cb.daily_loss_pct + excess_pct
daily_pnl = -(loss_ratio * portfolio_value)
result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value)
assert result is True, (
f"Expected daily_loss trigger: loss_ratio={loss_ratio:.4f} > threshold={cb.daily_loss_pct:.4f}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
below_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_daily_loss_does_not_trigger_below_threshold(
self, cb: CircuitBreaker, portfolio_value: float, below_fraction: float,
) -> None:
"""Daily loss circuit breaker does NOT trigger when loss ratio <= threshold."""
# Loss ratio strictly below threshold
loss_ratio = cb.daily_loss_pct * below_fraction
daily_pnl = -(loss_ratio * portfolio_value)
result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value)
assert result is False, (
f"Should not trigger: loss_ratio={loss_ratio:.4f} <= threshold={cb.daily_loss_pct:.4f}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False),
profit=st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False),
)
def test_daily_loss_does_not_trigger_on_positive_pnl(
self, cb: CircuitBreaker, portfolio_value: float, profit: float,
) -> None:
"""Daily loss circuit breaker never triggers on positive P&L."""
result = cb.check_daily_loss(daily_pnl=profit, portfolio_value=portfolio_value)
assert result is False, "Should not trigger on positive daily P&L"
# -- Single position trigger -------------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
excess=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False),
)
def test_single_position_triggers_when_loss_exceeds_threshold(
self, cb: CircuitBreaker, excess: float,
) -> None:
"""Single position circuit breaker triggers when position_loss_pct > threshold."""
position_loss_pct = cb.single_position_loss_pct + excess
result = cb.check_single_position(position_loss_pct=position_loss_pct)
assert result is True, (
f"Expected single_position trigger: loss={position_loss_pct:.4f} > threshold={cb.single_position_loss_pct:.4f}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
below_fraction=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_single_position_does_not_trigger_at_or_below_threshold(
self, cb: CircuitBreaker, below_fraction: float,
) -> None:
"""Single position circuit breaker does NOT trigger when loss <= threshold."""
position_loss_pct = cb.single_position_loss_pct * below_fraction
result = cb.check_single_position(position_loss_pct=position_loss_pct)
assert result is False, (
f"Should not trigger: loss={position_loss_pct:.4f} <= threshold={cb.single_position_loss_pct:.4f}"
)
# -- Volatility trigger ------------------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
base_time=_aware_datetime_strategy(),
)
def test_volatility_triggers_when_enough_stops_within_window(
self, cb: CircuitBreaker, base_time: datetime,
) -> None:
"""Volatility circuit breaker triggers when >= threshold stop-losses fire within the window."""
# Generate exactly threshold hits within the window
window_minutes = cb.stop_loss_window_minutes
n_hits = cb.stop_loss_hits_threshold
# Space hits evenly within the window
interval = timedelta(minutes=window_minutes / n_hits)
stop_loss_hits = [base_time + interval * i for i in range(n_hits)]
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
assert result is True, (
f"Expected volatility trigger: {n_hits} hits within {window_minutes}min window"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
base_time=_aware_datetime_strategy(),
)
def test_volatility_does_not_trigger_when_too_few_stops(
self, cb: CircuitBreaker, base_time: datetime,
) -> None:
"""Volatility circuit breaker does NOT trigger with fewer than threshold hits."""
n_hits = cb.stop_loss_hits_threshold - 1
assume(n_hits >= 0)
# Even if all within the window, not enough hits
stop_loss_hits = [base_time + timedelta(minutes=i) for i in range(n_hits)]
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
assert result is False, (
f"Should not trigger: only {n_hits} hits < threshold {cb.stop_loss_hits_threshold}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
base_time=_aware_datetime_strategy(),
)
def test_volatility_does_not_trigger_when_stops_spread_outside_window(
self, cb: CircuitBreaker, base_time: datetime,
) -> None:
"""Volatility circuit breaker does NOT trigger when hits are spread beyond the window."""
n_hits = cb.stop_loss_hits_threshold
window_minutes = cb.stop_loss_window_minutes
# Space hits so that no contiguous sub-sequence of threshold size fits in the window
# Each hit is window_minutes + 1 apart, so any threshold-sized group spans
# (threshold - 1) * (window_minutes + 1) minutes > window_minutes
gap = timedelta(minutes=window_minutes + 1)
stop_loss_hits = [base_time + gap * i for i in range(n_hits)]
result = cb.check_volatility(stop_loss_hits=stop_loss_hits)
assert result is False, (
f"Should not trigger: hits spread {window_minutes + 1}min apart, "
f"window is {window_minutes}min"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
)
def test_volatility_does_not_trigger_on_empty_hits(
self, cb: CircuitBreaker,
) -> None:
"""Volatility circuit breaker does NOT trigger with no stop-loss hits."""
result = cb.check_volatility(stop_loss_hits=[])
assert result is False, "Should not trigger on empty stop-loss hits list"
# ---------------------------------------------------------------------------
# Property 14: Circuit breaker cooldown expiry
# **Validates: Requirements 6.5**
# ---------------------------------------------------------------------------
class TestProperty14CircuitBreakerCooldownExpiry:
"""Property 14: Circuit breaker cooldown expiry.
**Validates: Requirements 6.5**
"""
# -- is_active cooldown expiry -----------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
extra_seconds=st.integers(min_value=1, max_value=86400),
)
def test_is_active_returns_false_after_cooldown_expires(
self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int,
) -> None:
"""is_active returns False when current time > cooldown_expires."""
cooldown_expires = triggered_at + timedelta(hours=cb.volatility_pause_hours)
now = cooldown_expires + timedelta(seconds=extra_seconds)
state = CircuitBreakerState(
active=True,
trigger_type="daily_loss",
triggered_at=triggered_at,
cooldown_expires=cooldown_expires,
)
result = cb.is_active(state=state, now=now)
assert result is False, (
f"Expected inactive: now={now} > cooldown_expires={cooldown_expires}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_is_active_returns_true_before_cooldown_expires(
self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float,
) -> None:
"""is_active returns True when current time < cooldown_expires."""
cooldown_duration = timedelta(hours=cb.volatility_pause_hours)
cooldown_expires = triggered_at + cooldown_duration
# now is some fraction of the way through the cooldown (before expiry)
now = triggered_at + cooldown_duration * before_fraction
assume(now < cooldown_expires)
state = CircuitBreakerState(
active=True,
trigger_type="volatility",
triggered_at=triggered_at,
cooldown_expires=cooldown_expires,
)
result = cb.is_active(state=state, now=now)
assert result is True, (
f"Expected active: now={now} < cooldown_expires={cooldown_expires}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
)
def test_is_active_returns_false_when_state_not_active(
self, cb: CircuitBreaker, triggered_at: datetime,
) -> None:
"""is_active returns False when state.active is False regardless of time."""
state = CircuitBreakerState(
active=False,
trigger_type=None,
triggered_at=triggered_at,
cooldown_expires=triggered_at + timedelta(hours=24),
)
result = cb.is_active(state=state, now=triggered_at)
assert result is False, "Expected inactive when state.active is False"
# -- is_ticker_cooled_down expiry --------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
extra_seconds=st.integers(min_value=1, max_value=86400),
)
def test_ticker_cooldown_returns_false_after_expiry(
self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int,
) -> None:
"""is_ticker_cooled_down returns False when cooldown has expired."""
cooldown_expires = triggered_at + timedelta(hours=cb.ticker_cooldown_hours)
now = cooldown_expires + timedelta(seconds=extra_seconds)
ticker_cooldowns = {"AAPL": cooldown_expires}
result = cb.is_ticker_cooled_down(
ticker="AAPL",
ticker_cooldowns=ticker_cooldowns,
now=now,
)
assert result is False, (
f"Expected ticker not cooled down: now={now} > expiry={cooldown_expires}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_ticker_cooldown_returns_true_during_cooldown(
self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float,
) -> None:
"""is_ticker_cooled_down returns True when still within cooldown period."""
cooldown_duration = timedelta(hours=cb.ticker_cooldown_hours)
cooldown_expires = triggered_at + cooldown_duration
now = triggered_at + cooldown_duration * before_fraction
assume(now < cooldown_expires)
ticker_cooldowns = {"TSLA": cooldown_expires}
result = cb.is_ticker_cooled_down(
ticker="TSLA",
ticker_cooldowns=ticker_cooldowns,
now=now,
)
assert result is True, (
f"Expected ticker cooled down: now={now} < expiry={cooldown_expires}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
now=_aware_datetime_strategy(),
)
def test_ticker_cooldown_returns_false_for_unknown_ticker(
self, cb: CircuitBreaker, now: datetime,
) -> None:
"""is_ticker_cooled_down returns False for a ticker with no cooldown entry."""
result = cb.is_ticker_cooled_down(
ticker="UNKNOWN",
ticker_cooldowns={},
now=now,
)
assert result is False, "Expected no cooldown for unknown ticker"
# -- compute_cooldown_expiry -------------------------------------------
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
)
def test_cooldown_expiry_single_position_uses_ticker_cooldown_hours(
self, cb: CircuitBreaker, triggered_at: datetime,
) -> None:
"""compute_cooldown_expiry for single_position uses ticker_cooldown_hours."""
expiry = cb.compute_cooldown_expiry(
trigger_type="single_position",
triggered_at=triggered_at,
)
expected = triggered_at + timedelta(hours=cb.ticker_cooldown_hours)
assert expiry == expected, (
f"single_position expiry {expiry} != expected {expected}"
)
@settings(max_examples=100)
@given(
cb=_circuit_breaker_strategy(),
triggered_at=_aware_datetime_strategy(),
trigger_type=st.sampled_from(["daily_loss", "volatility"]),
)
def test_cooldown_expiry_daily_loss_and_volatility_use_pause_hours(
self, cb: CircuitBreaker, triggered_at: datetime, trigger_type: str,
) -> None:
"""compute_cooldown_expiry for daily_loss/volatility uses volatility_pause_hours."""
expiry = cb.compute_cooldown_expiry(
trigger_type=trigger_type,
triggered_at=triggered_at,
)
expected = triggered_at + timedelta(hours=cb.volatility_pause_hours)
assert expiry == expected, (
f"{trigger_type} expiry {expiry} != expected {expected}"
)
+670
View File
@@ -0,0 +1,670 @@
"""Property-based tests for the TradingEngine core decision loop.
Feature: autonomous-trading-engine
Tests properties 27, 28, 16, and 18 from the design specification,
covering recommendation deduplication, decision record completeness,
multiple declining positions halt, and maximum open positions enforcement.
"""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.shared.config import TradingConfig
from services.trading.correlation import CorrelationMatrix
from services.trading.engine import TradingEngine
from services.trading.models import (
CircuitBreakerState,
OpenPosition,
PortfolioState,
RiskTierConfig,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ET = ZoneInfo("America/New_York")
# A valid trading window datetime: Wednesday 10:00 AM ET
VALID_TRADING_DT = datetime(2025, 1, 8, 10, 0, 0, tzinfo=ET)
def _make_engine() -> TradingEngine:
"""Create a TradingEngine with default TradingConfig and no pool/redis."""
return TradingEngine(pool=None, redis=None, config=TradingConfig())
def _moderate_risk_tier() -> RiskTierConfig:
"""Return a moderate risk tier with reasonable defaults for testing."""
return RiskTierConfig(
name="moderate",
min_confidence=0.55,
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,
)
def _inactive_cb() -> CircuitBreakerState:
"""Return an inactive circuit breaker state."""
return CircuitBreakerState(active=False)
def _empty_correlation_matrix() -> CorrelationMatrix:
"""Return an empty correlation matrix."""
return CorrelationMatrix()
def _base_portfolio(
active_pool: float = 5000.0,
positions: list | None = None,
portfolio_heat: float = 0.0,
open_position_count: int = 0,
) -> PortfolioState:
"""Return a PortfolioState with sensible defaults for testing."""
return PortfolioState(
positions=positions or [],
total_value=active_pool + 500.0,
cash=active_pool,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure={},
portfolio_heat=portfolio_heat,
open_position_count=open_position_count,
)
def _make_recommendation(
rec_id: str = "rec-001",
ticker: str = "AAPL",
confidence: float = 0.80,
sector: str = "Technology",
current_price: float = 10.0,
action: str = "buy",
) -> dict:
"""Build a recommendation dict suitable for evaluate_recommendation."""
return {
"recommendation_id": rec_id,
"ticker": ticker,
"confidence": confidence,
"sector": sector,
"current_price": current_price,
"action": action,
}
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _recommendation_id_strategy() -> st.SearchStrategy[str]:
"""Generate random recommendation IDs."""
return st.text(
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789-"),
min_size=5,
max_size=20,
).filter(lambda s: len(s.strip()) > 0)
def _open_position_strategy(
unrealized_pnl: 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=100),
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=unrealized_pnl if unrealized_pnl is not None else st.floats(
min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False
),
market_value=st.floats(min_value=10.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
sector=st.sampled_from(["Technology", "Healthcare", "Energy", "Financials", "Consumer"]),
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=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
is_micro_trade=st.just(False),
)
# ---------------------------------------------------------------------------
# Property 27: Recommendation deduplication (idempotence)
# **Validates: Requirements 1.5**
# ---------------------------------------------------------------------------
class TestProperty27RecommendationDeduplication:
"""Property 27: Recommendation deduplication (idempotence).
**Validates: Requirements 1.5**
"""
@settings(max_examples=100)
@given(rec_id=_recommendation_id_strategy())
def test_duplicate_recommendation_produces_skip(self, rec_id: str) -> None:
"""Processing the same recommendation twice produces a skip on the second call."""
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(active_pool=5000.0)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id=rec_id,
ticker="AAPL",
confidence=0.80,
sector="Technology",
current_price=10.0,
)
# First processing — should produce an "act" decision
decision1 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert decision1.decision == "act", (
f"First processing should produce 'act', got '{decision1.decision}' "
f"with skip_reason={decision1.skip_reason}"
)
# Second processing — should produce a "skip" with duplicate reason
decision2 = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert decision2.decision == "skip"
assert decision2.skip_reason == "duplicate_recommendation"
@settings(max_examples=100)
@given(
rec_id_a=_recommendation_id_strategy(),
rec_id_b=_recommendation_id_strategy(),
)
def test_different_recommendations_not_deduplicated(
self, rec_id_a: str, rec_id_b: str,
) -> None:
"""Different recommendation IDs are processed independently."""
assume(rec_id_a != rec_id_b)
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(active_pool=5000.0)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec_a = _make_recommendation(rec_id=rec_id_a, ticker="AAPL", current_price=10.0)
rec_b = _make_recommendation(rec_id=rec_id_b, ticker="MSFT", current_price=10.0)
decision_a = engine.evaluate_recommendation(
rec=rec_a,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
decision_b = engine.evaluate_recommendation(
rec=rec_b,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
# Both should be "act" (not deduplicated against each other)
assert decision_a.decision == "act"
assert decision_b.decision == "act"
# ---------------------------------------------------------------------------
# Property 28: Trading decision record completeness and traceability
# **Validates: Requirements 1.4, 17.1, 17.2**
# ---------------------------------------------------------------------------
class TestProperty28DecisionRecordCompleteness:
"""Property 28: Trading decision record completeness and traceability.
**Validates: Requirements 1.4, 17.1, 17.2**
"""
@settings(max_examples=100)
@given(
confidence=st.floats(min_value=0.60, max_value=0.99, allow_nan=False, allow_infinity=False),
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
price=st.floats(min_value=1.0, max_value=50.0, allow_nan=False, allow_infinity=False),
)
def test_act_decision_has_all_required_fields(
self, confidence: float, ticker: str, price: float,
) -> None:
"""Act decisions have all required fields including position size."""
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(active_pool=5000.0)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id=f"rec-{ticker}",
ticker=ticker,
confidence=confidence,
current_price=price,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
# All required fields must be present and non-None
assert decision.id is not None and len(decision.id) > 0
assert decision.recommendation_id == f"rec-{ticker}"
assert decision.decision in ("act", "skip")
assert decision.ticker == ticker
assert decision.risk_tier_at_decision == "moderate"
assert decision.circuit_breaker_status in ("active", "inactive")
assert decision.decision_trace is not None
assert isinstance(decision.decision_trace, dict)
assert decision.created_at is not None
if decision.decision == "act":
assert decision.computed_position_size is not None
assert decision.computed_position_size > 0
assert decision.computed_share_quantity is not None
assert decision.computed_share_quantity > 0
assert decision.skip_reason is None
@settings(max_examples=100)
@given(
confidence=st.floats(min_value=0.01, max_value=0.50, allow_nan=False, allow_infinity=False),
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
)
def test_skip_decision_has_skip_reason(
self, confidence: float, ticker: str,
) -> None:
"""Skip decisions have skip_reason set."""
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(active_pool=5000.0)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
# Use low confidence to trigger a skip
assume(confidence < risk_tier.min_confidence)
rec = _make_recommendation(
rec_id=f"rec-{ticker}",
ticker=ticker,
confidence=confidence,
current_price=10.0,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert decision.decision == "skip"
assert decision.skip_reason is not None
assert len(decision.skip_reason) > 0
# Required fields still present on skip decisions
assert decision.id is not None
assert decision.recommendation_id is not None
assert decision.ticker == ticker
assert decision.risk_tier_at_decision == "moderate"
assert decision.circuit_breaker_status == "inactive"
assert decision.decision_trace is not None
assert decision.created_at is not None
@settings(max_examples=100)
@given(
confidence=st.floats(min_value=0.60, max_value=0.99, allow_nan=False, allow_infinity=False),
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
price=st.floats(min_value=1.0, max_value=50.0, allow_nan=False, allow_infinity=False),
)
def test_decision_trace_contains_reasoning(
self, confidence: float, ticker: str, price: float,
) -> None:
"""Decision trace contains a reasoning list for traceability."""
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(active_pool=5000.0)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id=f"rec-trace-{ticker}",
ticker=ticker,
confidence=confidence,
current_price=price,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert "reasoning" in decision.decision_trace
assert isinstance(decision.decision_trace["reasoning"], list)
assert len(decision.decision_trace["reasoning"]) > 0
# ---------------------------------------------------------------------------
# Property 16: Multiple declining positions halts new entries
# **Validates: Requirements 7.5**
# ---------------------------------------------------------------------------
class TestProperty16DecliningPositionsHalt:
"""Property 16: Multiple declining positions halts new entries.
**Validates: Requirements 7.5**
"""
@settings(max_examples=100)
@given(
total_positions=st.integers(min_value=4, max_value=20),
declining_fraction=st.floats(min_value=0.55, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_entries_halted_when_majority_declining(
self, total_positions: int, declining_fraction: float,
) -> None:
"""New entries halted when > 50% of positions have > 2% negative unrealized P&L."""
declining_count = int(total_positions * declining_fraction)
assume(declining_count > total_positions * 0.50) # strictly > 50%
non_declining_count = total_positions - declining_count
positions: list[OpenPosition] = []
# Declining positions: unrealized_pnl is negative and > 2% of entry value
for i in range(declining_count):
entry_price = 100.0
quantity = 10
entry_value = entry_price * quantity
# Loss > 2% of entry value
loss = entry_value * 0.03 # 3% loss
positions.append(OpenPosition(
ticker=f"DEC{i}",
quantity=quantity,
entry_price=entry_price,
current_price=entry_price - (loss / quantity),
unrealized_pnl=-loss,
market_value=entry_value - loss,
sector="Technology",
stop_loss_price=90.0,
take_profit_price=120.0,
signal_confidence=0.6,
))
# Non-declining positions: small positive or flat P&L
for i in range(non_declining_count):
entry_price = 100.0
quantity = 10
positions.append(OpenPosition(
ticker=f"OK{i}",
quantity=quantity,
entry_price=entry_price,
current_price=entry_price + 1.0,
unrealized_pnl=10.0,
market_value=1010.0,
sector="Healthcare",
stop_loss_price=90.0,
take_profit_price=120.0,
signal_confidence=0.7,
))
engine = _make_engine()
# Verify the check_declining_positions method returns True
assert engine.check_declining_positions(positions) is True
# Verify the full engine skips new entries
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(
active_pool=5000.0,
positions=positions,
open_position_count=total_positions,
)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id="rec-decline-test",
ticker="NEWSTOCK",
confidence=0.80,
current_price=10.0,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert decision.decision == "skip"
assert decision.skip_reason == "multiple_declining_positions"
@settings(max_examples=100)
@given(
total_positions=st.integers(min_value=4, max_value=20),
declining_fraction=st.floats(min_value=0.0, max_value=0.45, allow_nan=False, allow_infinity=False),
)
def test_entries_allowed_when_minority_declining(
self, total_positions: int, declining_fraction: float,
) -> None:
"""Entries allowed when <= 50% of positions are declining."""
declining_count = int(total_positions * declining_fraction)
assume(declining_count <= total_positions * 0.50) # at or below 50%
non_declining_count = total_positions - declining_count
positions: list[OpenPosition] = []
# Declining positions
for i in range(declining_count):
entry_price = 100.0
quantity = 10
entry_value = entry_price * quantity
loss = entry_value * 0.03
positions.append(OpenPosition(
ticker=f"DEC{i}",
quantity=quantity,
entry_price=entry_price,
current_price=entry_price - (loss / quantity),
unrealized_pnl=-loss,
market_value=entry_value - loss,
sector="Technology",
stop_loss_price=90.0,
take_profit_price=120.0,
signal_confidence=0.6,
))
# Non-declining positions
for i in range(non_declining_count):
entry_price = 100.0
quantity = 10
positions.append(OpenPosition(
ticker=f"OK{i}",
quantity=quantity,
entry_price=entry_price,
current_price=entry_price + 1.0,
unrealized_pnl=10.0,
market_value=1010.0,
sector="Healthcare",
stop_loss_price=90.0,
take_profit_price=120.0,
signal_confidence=0.7,
))
engine = _make_engine()
# Verify the check returns False (entries allowed)
assert engine.check_declining_positions(positions) is False
@settings(max_examples=100)
@given(data=st.data())
def test_empty_positions_allows_entries(self, data: st.DataObject) -> None:
"""Empty position list always allows new entries."""
engine = _make_engine()
assert engine.check_declining_positions([]) is False
# ---------------------------------------------------------------------------
# Property 18: Maximum open positions enforcement
# **Validates: Requirements 8.4**
# ---------------------------------------------------------------------------
class TestProperty18MaxOpenPositions:
"""Property 18: Maximum open positions enforcement.
**Validates: Requirements 8.4**
"""
@settings(max_examples=100)
@given(
max_positions=st.integers(min_value=1, max_value=20),
)
def test_entries_rejected_at_max_positions(self, max_positions: int) -> None:
"""New entries rejected when open_position_count >= max_open_positions."""
engine = _make_engine()
# Verify the check_max_positions method returns True at the limit
assert engine.check_max_positions(max_positions, max_positions) is True
# Also verify above the limit
assert engine.check_max_positions(max_positions + 1, max_positions) is True
@settings(max_examples=100)
@given(
max_positions=st.integers(min_value=2, max_value=20),
open_count_offset=st.integers(min_value=1, max_value=10),
)
def test_entries_allowed_below_max_positions(
self, max_positions: int, open_count_offset: int,
) -> None:
"""Entries allowed when open_position_count < max_open_positions."""
open_count = max(0, max_positions - open_count_offset)
assume(open_count < max_positions)
engine = _make_engine()
assert engine.check_max_positions(open_count, max_positions) is False
@settings(max_examples=100)
@given(
max_positions=st.integers(min_value=1, max_value=15),
)
def test_full_engine_rejects_at_max_positions(self, max_positions: int) -> None:
"""Full engine evaluation rejects new entries at max positions."""
engine = _make_engine()
# Override the config's max_open_positions via hasattr fallback
# The engine uses: self.config.max_open_positions if hasattr(...) else 10
# TradingConfig doesn't have max_open_positions, so it defaults to 10.
# We test with the default (10) by setting open_position_count = 10.
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(
active_pool=5000.0,
open_position_count=10, # default max is 10
)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id=f"rec-max-{max_positions}",
ticker="MAXTEST",
confidence=0.80,
current_price=10.0,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
assert decision.decision == "skip"
assert decision.skip_reason == "max_positions_reached"
@settings(max_examples=100)
@given(
open_count=st.integers(min_value=0, max_value=8),
)
def test_full_engine_allows_below_max_positions(self, open_count: int) -> None:
"""Full engine evaluation allows entries below max positions (default 10)."""
assume(open_count < 10) # default max is 10
engine = _make_engine()
risk_tier = _moderate_risk_tier()
portfolio = _base_portfolio(
active_pool=5000.0,
open_position_count=open_count,
)
cb_state = _inactive_cb()
corr_matrix = _empty_correlation_matrix()
rec = _make_recommendation(
rec_id=f"rec-below-{open_count}",
ticker="BELOWMAX",
confidence=0.80,
current_price=10.0,
)
decision = engine.evaluate_recommendation(
rec=rec,
portfolio_state=portfolio,
risk_tier=risk_tier,
circuit_breaker_state=cb_state,
correlation_matrix=corr_matrix,
earnings_calendar={},
now=VALID_TRADING_DT,
)
# Should not be rejected for max positions
if decision.decision == "skip":
assert decision.skip_reason != "max_positions_reached"
+361
View File
@@ -0,0 +1,361 @@
"""Property-based tests for the Micro-Trading Module.
Feature: autonomous-trading-engine
Property 31: Micro-trade parameter constraints.
Property 32: Micro-trade auto-close after max hold duration.
Property 34: Micro-trades respect all existing constraints.
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.micro_trading import MicroTradeConfig, MicroTradingModule
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _micro_config_strategy() -> st.SearchStrategy[MicroTradeConfig]:
"""Generate random MicroTradeConfig objects with valid ranges."""
return st.builds(
MicroTradeConfig,
enabled=st.just(True),
allocation_cap_pct=st.floats(
min_value=0.01, max_value=0.10, allow_nan=False, allow_infinity=False,
),
max_daily=st.integers(min_value=1, max_value=50),
max_hold_minutes=st.integers(min_value=10, max_value=480),
stop_loss_atr_multiplier=st.just(1.0),
reward_risk_ratio=st.just(1.5),
)
# ---------------------------------------------------------------------------
# Property 31: Micro-trade parameter constraints
# **Validates: Requirements 20.3, 20.4, 20.5**
# ---------------------------------------------------------------------------
class TestProperty31MicroTradeParameterConstraints:
"""Property 31: Micro-trade parameter constraints.
Verify allocation does not exceed micro_trading_allocation_cap_pct.
Verify daily count does not exceed configured maximum.
**Validates: Requirements 20.3, 20.4, 20.5**
"""
module = MicroTradingModule()
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
active_pool=st.floats(
min_value=100.0, max_value=100_000.0,
allow_nan=False, allow_infinity=False,
),
)
def test_allocation_does_not_exceed_cap(
self,
config: MicroTradeConfig,
active_pool: float,
) -> None:
"""Micro-trade allocation never exceeds allocation_cap_pct * active_pool."""
cap = self.module.compute_allocation_cap(config, active_pool)
expected = config.allocation_cap_pct * active_pool
assert abs(cap - expected) < 1e-6
assert cap <= config.allocation_cap_pct * active_pool + 1e-6
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
daily_count=st.integers(min_value=0, max_value=100),
)
def test_daily_count_enforced(
self,
config: MicroTradeConfig,
daily_count: int,
) -> None:
"""should_evaluate returns False when daily count >= max_daily."""
result = self.module.should_evaluate(config, daily_count)
if daily_count >= config.max_daily:
assert result is False
else:
assert result is True
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
)
def test_stop_loss_at_1x_atr(
self,
config: MicroTradeConfig,
) -> None:
"""Micro-trade stop-loss uses 1.0x ATR multiplier."""
assert config.stop_loss_atr_multiplier == 1.0
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
)
def test_take_profit_at_1_5x_stop_distance(
self,
config: MicroTradeConfig,
) -> None:
"""Micro-trade take-profit uses 1.5x reward-risk ratio."""
assert config.reward_risk_ratio == 1.5
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
active_pool=st.floats(
min_value=100.0, max_value=100_000.0,
allow_nan=False, allow_infinity=False,
),
)
def test_allocation_cap_scales_with_active_pool(
self,
config: MicroTradeConfig,
active_pool: float,
) -> None:
"""Allocation cap is proportional to active pool."""
cap = self.module.compute_allocation_cap(config, active_pool)
# Doubling active pool should double the cap
cap_double = self.module.compute_allocation_cap(config, active_pool * 2)
assert abs(cap_double - cap * 2) < 1e-4
# ---------------------------------------------------------------------------
# Property 32: Micro-trade auto-close after max hold duration
# **Validates: Requirements 20.6**
# ---------------------------------------------------------------------------
class TestProperty32MicroTradeAutoClose:
"""Property 32: Micro-trade auto-close after max hold duration.
Verify positions closed when hold exceeds max duration.
Verify positions not closed when hold is within duration.
**Validates: Requirements 20.6**
"""
module = MicroTradingModule()
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
hold_minutes=st.floats(
min_value=0.0, max_value=1000.0,
allow_nan=False, allow_infinity=False,
),
)
def test_auto_close_when_exceeds_max_hold(
self,
config: MicroTradeConfig,
hold_minutes: float,
) -> None:
"""Position auto-closed when hold_minutes > max_hold_minutes."""
result = self.module.should_auto_close(config, hold_minutes)
if hold_minutes > config.max_hold_minutes:
assert result is True
else:
assert result is False
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
)
def test_not_closed_at_exact_max(
self,
config: MicroTradeConfig,
) -> None:
"""Position is NOT auto-closed at exactly max_hold_minutes."""
result = self.module.should_auto_close(config, float(config.max_hold_minutes))
assert result is False
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
)
def test_closed_just_past_max(
self,
config: MicroTradeConfig,
) -> None:
"""Position IS auto-closed just past max_hold_minutes."""
result = self.module.should_auto_close(
config, float(config.max_hold_minutes) + 0.01,
)
assert result is True
# ---------------------------------------------------------------------------
# Property 34: Micro-trades respect all existing constraints
# **Validates: Requirements 20.10**
# ---------------------------------------------------------------------------
class TestProperty34MicroTradesRespectConstraints:
"""Property 34: Micro-trades respect all existing constraints.
Verify trading window, circuit breakers, portfolio heat constraints
all enforced for micro-trades.
**Validates: Requirements 20.10**
"""
module = MicroTradingModule()
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
daily_count=st.integers(min_value=0, max_value=5),
)
def test_circuit_breaker_blocks_micro_trade(
self,
config: MicroTradeConfig,
daily_count: int,
) -> None:
"""Micro-trades blocked when circuit breaker is active."""
allowed, reason = self.module.check_constraints(
config=config,
daily_count=daily_count,
is_within_window=True,
circuit_breaker_active=True,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "circuit_breaker_active"
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
daily_count=st.integers(min_value=0, max_value=5),
)
def test_outside_trading_window_blocks_micro_trade(
self,
config: MicroTradeConfig,
daily_count: int,
) -> None:
"""Micro-trades blocked outside trading window."""
allowed, reason = self.module.check_constraints(
config=config,
daily_count=daily_count,
is_within_window=False,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "outside_trading_window"
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
heat_pct=st.floats(
min_value=0.20, max_value=1.0,
allow_nan=False, allow_infinity=False,
),
)
def test_portfolio_heat_blocks_micro_trade(
self,
config: MicroTradeConfig,
heat_pct: float,
) -> None:
"""Micro-trades blocked when portfolio heat exceeds max."""
max_heat = 0.20
allowed, reason = self.module.check_constraints(
config=config,
daily_count=0,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=heat_pct,
max_heat=max_heat,
)
assert allowed is False
assert reason == "portfolio_heat_exceeded"
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
)
def test_daily_limit_blocks_micro_trade(
self,
config: MicroTradeConfig,
) -> None:
"""Micro-trades blocked when daily limit reached."""
allowed, reason = self.module.check_constraints(
config=config,
daily_count=config.max_daily,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "daily_limit_reached"
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
daily_count=st.integers(min_value=0, max_value=5),
)
def test_disabled_blocks_micro_trade(
self,
config: MicroTradeConfig,
daily_count: int,
) -> None:
"""Micro-trades blocked when module is disabled."""
disabled_config = MicroTradeConfig(
enabled=False,
allocation_cap_pct=config.allocation_cap_pct,
max_daily=config.max_daily,
max_hold_minutes=config.max_hold_minutes,
)
allowed, reason = self.module.check_constraints(
config=disabled_config,
daily_count=daily_count,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=0.05,
max_heat=0.20,
)
assert allowed is False
assert reason == "micro_trading_disabled"
@settings(max_examples=100)
@given(
config=_micro_config_strategy(),
daily_count=st.integers(min_value=0, max_value=5),
heat_pct=st.floats(
min_value=0.0, max_value=0.15,
allow_nan=False, allow_infinity=False,
),
)
def test_all_constraints_pass_allows_trade(
self,
config: MicroTradeConfig,
daily_count: int,
heat_pct: float,
) -> None:
"""Micro-trade allowed when all constraints pass."""
# Ensure daily_count is under the limit
safe_count = min(daily_count, config.max_daily - 1)
allowed, reason = self.module.check_constraints(
config=config,
daily_count=safe_count,
is_within_window=True,
circuit_breaker_active=False,
portfolio_heat_pct=heat_pct,
max_heat=0.20,
)
assert allowed is True
assert reason == "ok"
+169
View File
@@ -0,0 +1,169 @@
"""Property-based tests for the Notification Service.
Feature: autonomous-trading-engine
Property 30: Notification rate limiting.
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.notifications import NotificationService
# ---------------------------------------------------------------------------
# Property 30: Notification rate limiting
# **Validates: Requirements 19.7**
# ---------------------------------------------------------------------------
class TestProperty30NotificationRateLimiting:
"""Property 30: Notification rate limiting.
Generate random sequences of notification requests within a one-hour
window. Verify at most 10 SMS and 20 emails allowed per hour.
Verify excess notifications blocked (should_send returns False).
**Validates: Requirements 19.7**
"""
@settings(max_examples=100)
@given(
sms_limit=st.integers(min_value=1, max_value=50),
email_limit=st.integers(min_value=1, max_value=50),
sms_requests=st.integers(min_value=0, max_value=100),
email_requests=st.integers(min_value=0, max_value=100),
)
def test_sms_rate_limit_enforced(
self,
sms_limit: int,
email_limit: int,
sms_requests: int,
email_requests: int,
) -> None:
"""At most sms_limit SMS notifications are allowed per hour."""
svc = NotificationService(
sms_enabled=True,
email_enabled=True,
rate_limit_sms_per_hour=sms_limit,
rate_limit_email_per_hour=email_limit,
)
sent_sms = 0
for i in range(sms_requests):
if svc.should_send("sms", current_hour_count=i):
sent_sms += 1
assert sent_sms <= sms_limit
@settings(max_examples=100)
@given(
sms_limit=st.integers(min_value=1, max_value=50),
email_limit=st.integers(min_value=1, max_value=50),
sms_requests=st.integers(min_value=0, max_value=100),
email_requests=st.integers(min_value=0, max_value=100),
)
def test_email_rate_limit_enforced(
self,
sms_limit: int,
email_limit: int,
sms_requests: int,
email_requests: int,
) -> None:
"""At most email_limit email notifications are allowed per hour."""
svc = NotificationService(
sms_enabled=True,
email_enabled=True,
rate_limit_sms_per_hour=sms_limit,
rate_limit_email_per_hour=email_limit,
)
sent_email = 0
for i in range(email_requests):
if svc.should_send("email", current_hour_count=i):
sent_email += 1
assert sent_email <= email_limit
@settings(max_examples=100)
@given(
sms_limit=st.integers(min_value=1, max_value=50),
email_limit=st.integers(min_value=1, max_value=50),
)
def test_excess_sms_blocked(
self,
sms_limit: int,
email_limit: int,
) -> None:
"""Notifications beyond the limit are blocked."""
svc = NotificationService(
sms_enabled=True,
email_enabled=True,
rate_limit_sms_per_hour=sms_limit,
rate_limit_email_per_hour=email_limit,
)
# At the limit, should_send returns False
assert svc.should_send("sms", current_hour_count=sms_limit) is False
# One past the limit, still False
assert svc.should_send("sms", current_hour_count=sms_limit + 1) is False
@settings(max_examples=100)
@given(
sms_limit=st.integers(min_value=1, max_value=50),
email_limit=st.integers(min_value=1, max_value=50),
)
def test_excess_email_blocked(
self,
sms_limit: int,
email_limit: int,
) -> None:
"""Email notifications beyond the limit are blocked."""
svc = NotificationService(
sms_enabled=True,
email_enabled=True,
rate_limit_sms_per_hour=sms_limit,
rate_limit_email_per_hour=email_limit,
)
assert svc.should_send("email", current_hour_count=email_limit) is False
assert svc.should_send("email", current_hour_count=email_limit + 1) is False
@settings(max_examples=100)
@given(
count=st.integers(min_value=0, max_value=100),
)
def test_default_limits_10_sms_20_email(
self,
count: int,
) -> None:
"""Default limits are 10 SMS and 20 emails per hour."""
svc = NotificationService(sms_enabled=True, email_enabled=True)
sms_allowed = svc.should_send("sms", current_hour_count=count)
email_allowed = svc.should_send("email", current_hour_count=count)
if count < 10:
assert sms_allowed is True
else:
assert sms_allowed is False
if count < 20:
assert email_allowed is True
else:
assert email_allowed is False
@settings(max_examples=100)
@given(
count=st.integers(min_value=0, max_value=50),
)
def test_disabled_channel_always_blocked(
self,
count: int,
) -> None:
"""Disabled channels always return False regardless of count."""
svc = NotificationService(sms_enabled=False, email_enabled=False)
assert svc.should_send("sms", current_hour_count=count) is False
assert svc.should_send("email", current_hour_count=count) is False
+510
View File
@@ -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)
+925
View File
@@ -0,0 +1,925 @@
"""Property-based tests for the Position Sizer.
Feature: autonomous-trading-engine
Tests properties 15, 7, 19, and 24 from the design specification,
covering position sizing formula, correlation adjustment, sector exposure,
diversification bonus, Active Pool computation, earnings proximity,
portfolio heat, and Active Pool minimum enforcement.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.models import (
OpenPosition,
PortfolioState,
PositionSizeResult,
RiskTierConfig,
)
from services.trading.position_sizer import PositionSizer
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
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.02, max_value=0.50, 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.05, max_value=0.60, 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,
) -> st.SearchStrategy[OpenPosition]:
"""Generate random OpenPosition objects."""
sector_st = sector if sector is not None else st.sampled_from(
["Technology", "Healthcare", "Energy", "Financials", "Consumer"]
)
return st.builds(
OpenPosition,
ticker=st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
quantity=st.integers(min_value=1, max_value=100),
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=st.floats(min_value=10.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
sector=sector_st,
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=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
is_micro_trade=st.just(False),
)
def _portfolio_state_strategy(
positions: st.SearchStrategy[list] | None = None,
sector_exposure: st.SearchStrategy[dict] | None = None,
active_pool: st.SearchStrategy[float] | None = None,
portfolio_heat: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[PortfolioState]:
"""Generate random PortfolioState objects."""
return st.builds(
PortfolioState,
positions=positions if positions is not None else st.just([]),
total_value=st.floats(min_value=100.0, max_value=20000.0, allow_nan=False, allow_infinity=False),
cash=st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
active_pool=active_pool if active_pool is not None else st.floats(
min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False
),
reserve_pool=st.floats(min_value=0.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
sector_exposure=sector_exposure if sector_exposure is not None else st.just({}),
portfolio_heat=portfolio_heat if portfolio_heat is not None else st.just(0.0),
open_position_count=st.integers(min_value=0, max_value=10),
)
# ---------------------------------------------------------------------------
# Property 1: Position sizing formula and invariants
# **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.7**
# ---------------------------------------------------------------------------
class TestProperty1PositionSizingFormula:
"""Property 1: Position sizing formula and invariants.
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.7**
"""
sizer = PositionSizer()
@settings(max_examples=100)
@given(
confidence=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
price=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
absolute_cap=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False),
)
def test_zero_allocation_below_min_confidence(
self, confidence: float, active_pool: float, price: float,
risk_tier: RiskTierConfig, absolute_cap: float,
) -> None:
"""Confidence below min_confidence yields zero allocation."""
assume(confidence < risk_tier.min_confidence)
portfolio = PortfolioState(active_pool=active_pool)
result = self.sizer.compute(
confidence=confidence,
ticker="TEST",
sector="Technology",
current_price=price,
active_pool=active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio,
correlation_matrix={},
earnings_calendar={},
absolute_position_cap=absolute_cap,
)
assert result.rejected is True
assert result.dollar_amount == 0.0
assert result.share_quantity == 0
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
price=st.floats(min_value=1.0, max_value=500.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
absolute_cap=st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False),
)
def test_allocation_never_exceeds_max_position_pct_or_cap(
self, active_pool: float, price: float,
risk_tier: RiskTierConfig, absolute_cap: float,
) -> None:
"""Allocation never exceeds max_position_pct * active_pool or absolute cap."""
# Use confidence well above threshold to get a non-rejected result
confidence = min(risk_tier.min_confidence + 0.3, 1.0)
portfolio = PortfolioState(active_pool=active_pool)
result = self.sizer.compute(
confidence=confidence,
ticker="TEST",
sector="Technology",
current_price=price,
active_pool=active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio,
correlation_matrix={},
earnings_calendar={},
absolute_position_cap=absolute_cap,
)
if not result.rejected:
max_allowed = risk_tier.max_position_pct * active_pool
# Dollar amount (based on whole shares) should not exceed the cap or max_position_pct
assert result.dollar_amount <= max_allowed + 0.01, (
f"dollar_amount {result.dollar_amount} > max_allowed {max_allowed}"
)
assert result.dollar_amount <= absolute_cap + 0.01, (
f"dollar_amount {result.dollar_amount} > absolute_cap {absolute_cap}"
)
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=200.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
price=st.floats(min_value=1.0, max_value=50.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
absolute_cap=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
)
def test_share_quantity_rounded_down(
self, active_pool: float, price: float,
risk_tier: RiskTierConfig, absolute_cap: float,
) -> None:
"""Share quantity is always rounded down to whole shares (math.floor)."""
confidence = min(risk_tier.min_confidence + 0.3, 1.0)
portfolio = PortfolioState(active_pool=active_pool)
result = self.sizer.compute(
confidence=confidence,
ticker="TEST",
sector="Technology",
current_price=price,
active_pool=active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio,
correlation_matrix={},
earnings_calendar={},
absolute_position_cap=absolute_cap,
)
if not result.rejected:
assert result.share_quantity == int(result.share_quantity)
assert result.share_quantity >= 1
# Verify it's floor: share_quantity * price should be <= the pre-rounding dollar amount
assert result.dollar_amount == result.share_quantity * price
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=100.0, max_value=500.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_rejection_when_zero_shares(
self, active_pool: float, risk_tier: RiskTierConfig,
) -> None:
"""Trade rejected when rounded share quantity is zero (price too high)."""
confidence = min(risk_tier.min_confidence + 0.1, 1.0)
# Use a very high price so dollar_amount / price < 1
price = active_pool * 10.0
portfolio = PortfolioState(active_pool=active_pool)
result = self.sizer.compute(
confidence=confidence,
ticker="TEST",
sector="Technology",
current_price=price,
active_pool=active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio,
correlation_matrix={},
earnings_calendar={},
absolute_position_cap=50.0,
)
assert result.rejected is True
assert result.share_quantity == 0
# ---------------------------------------------------------------------------
# Property 2: Correlation-based allocation adjustment
# **Validates: Requirements 2.5, 9.2, 9.3**
# ---------------------------------------------------------------------------
class TestProperty2CorrelationAdjustment:
"""Property 2: Correlation-based allocation adjustment.
**Validates: Requirements 2.5, 9.2, 9.3**
"""
sizer = PositionSizer()
def _make_portfolio_with_position(
self, ticker: str = "EXIST", sector: str = "Technology",
market_value: float = 500.0, active_pool: float = 5000.0,
) -> PortfolioState:
"""Create a portfolio with one existing position."""
pos = OpenPosition(
ticker=ticker, quantity=10, entry_price=50.0,
current_price=50.0, unrealized_pnl=0.0,
market_value=market_value, sector=sector,
stop_loss_price=45.0, take_profit_price=60.0,
signal_confidence=0.7,
)
return PortfolioState(
positions=[pos],
total_value=active_pool + 500.0,
cash=active_pool - market_value,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure={sector: market_value},
portfolio_heat=0.0,
open_position_count=1,
)
@settings(max_examples=100)
@given(
corr=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
)
def test_allocation_reduced_when_correlation_above_half(self, corr: float) -> None:
"""Allocation reduced when weighted avg correlation > 0.5."""
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
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.50, max_portfolio_heat=0.50,
)
corr_matrix = {("NEW", "EXIST"): corr}
result_with_corr = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix=corr_matrix, earnings_calendar={},
absolute_position_cap=500.0,
)
result_no_corr = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={("NEW", "EXIST"): 0.0}, earnings_calendar={},
absolute_position_cap=500.0,
)
if not result_with_corr.rejected and not result_no_corr.rejected:
assert result_with_corr.dollar_amount <= result_no_corr.dollar_amount
@settings(max_examples=100)
@given(
corr=st.floats(min_value=0.81, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_trade_rejected_when_correlation_above_0_8(self, corr: float) -> None:
"""Trade rejected when weighted avg correlation > 0.8."""
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
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.50, max_portfolio_heat=0.50,
)
corr_matrix = {("NEW", "EXIST"): corr}
result = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix=corr_matrix, earnings_calendar={},
absolute_position_cap=500.0,
)
assert result.rejected is True
@settings(max_examples=100)
@given(
corr=st.floats(min_value=-1.0, max_value=0.5, allow_nan=False, allow_infinity=False),
)
def test_allocation_unchanged_when_correlation_at_or_below_half(self, corr: float) -> None:
"""Allocation unchanged when weighted avg correlation <= 0.5."""
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
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.50, max_portfolio_heat=0.50,
)
corr_matrix_with = {("NEW", "EXIST"): corr}
corr_matrix_zero = {("NEW", "EXIST"): 0.0}
result_with = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix=corr_matrix_with, earnings_calendar={},
absolute_position_cap=500.0,
)
result_zero = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix=corr_matrix_zero, earnings_calendar={},
absolute_position_cap=500.0,
)
# Both should produce the same dollar amount (correlation <= 0.5 has no effect)
if not result_with.rejected and not result_zero.rejected:
assert result_with.dollar_amount == result_zero.dollar_amount
@settings(max_examples=100)
@given(
corr_low=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
corr_high=st.floats(min_value=0.51, max_value=0.79, allow_nan=False, allow_infinity=False),
)
def test_monotonic_non_increase_with_higher_correlation(
self, corr_low: float, corr_high: float,
) -> None:
"""Higher correlation produces lower or equal allocation (monotonic non-increase)."""
assume(corr_low < corr_high)
portfolio = self._make_portfolio_with_position(active_pool=5000.0)
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.50, max_portfolio_heat=0.50,
)
result_low = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={("NEW", "EXIST"): corr_low},
earnings_calendar={}, absolute_position_cap=500.0,
)
result_high = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Healthcare",
current_price=10.0, active_pool=5000.0,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={("NEW", "EXIST"): corr_high},
earnings_calendar={}, absolute_position_cap=500.0,
)
# Higher correlation → lower or equal allocation
low_amount = result_low.dollar_amount if not result_low.rejected else 0.0
high_amount = result_high.dollar_amount if not result_high.rejected else 0.0
assert high_amount <= low_amount + 0.01
# ---------------------------------------------------------------------------
# Property 3: Sector exposure computation and enforcement
# **Validates: Requirements 2.6, 9.4**
# ---------------------------------------------------------------------------
class TestProperty3SectorExposure:
"""Property 3: Sector exposure computation and enforcement.
**Validates: Requirements 2.6, 9.4**
"""
sizer = PositionSizer()
@settings(max_examples=100)
@given(
max_sector_pct=st.floats(min_value=0.10, max_value=0.50, allow_nan=False, allow_infinity=False),
active_pool=st.floats(min_value=1000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
exposure_frac=st.floats(min_value=0.51, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_allocation_reduced_when_exceeding_sector_limit(
self, max_sector_pct: float, active_pool: float, exposure_frac: float,
) -> None:
"""Allocation reduced when adding position would exceed max_sector_pct."""
max_sector_dollars = max_sector_pct * active_pool
# Derive existing_exposure directly from the sector limit so it's always valid
existing_exposure = max_sector_dollars * exposure_frac
risk_tier = RiskTierConfig(
name="moderate", min_confidence=0.5, max_position_pct=0.15,
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
max_sector_pct=max_sector_pct, max_portfolio_heat=0.50,
)
pos = OpenPosition(
ticker="EXIST", quantity=10, entry_price=50.0,
current_price=50.0, unrealized_pnl=0.0,
market_value=existing_exposure, sector="Technology",
stop_loss_price=45.0, take_profit_price=60.0,
signal_confidence=0.7,
)
portfolio = PortfolioState(
positions=[pos],
total_value=active_pool + 500.0,
cash=active_pool - existing_exposure,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure={"Technology": existing_exposure},
portfolio_heat=0.0,
open_position_count=1,
)
result = self.sizer.compute(
confidence=0.9, ticker="NEW", sector="Technology",
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=max_sector_dollars,
)
if not result.rejected:
# The resulting position + existing should not exceed the sector limit
assert existing_exposure + result.dollar_amount <= max_sector_dollars + 0.01
# ---------------------------------------------------------------------------
# Property 4: Diversification bonus for under-represented sectors
# **Validates: Requirements 9.5**
# ---------------------------------------------------------------------------
class TestProperty4DiversificationBonus:
"""Property 4: Diversification bonus for under-represented sectors.
**Validates: Requirements 9.5**
"""
sizer = PositionSizer()
@settings(max_examples=100)
@given(
num_existing_sectors=st.integers(min_value=0, max_value=2),
)
def test_bonus_applied_when_fewer_than_3_sectors_and_new_sector(
self, num_existing_sectors: int,
) -> None:
"""1.2x bonus applied when portfolio has < 3 sectors and trade is in new sector."""
existing_sectors = ["Technology", "Healthcare", "Energy"][:num_existing_sectors]
sector_exposure = {s: 200.0 for s in existing_sectors}
positions = [
OpenPosition(
ticker=f"T{i}", quantity=5, entry_price=40.0,
current_price=40.0, unrealized_pnl=0.0,
market_value=200.0, sector=s,
stop_loss_price=35.0, take_profit_price=50.0,
signal_confidence=0.7,
)
for i, s in enumerate(existing_sectors)
]
active_pool = 5000.0
risk_tier = RiskTierConfig(
name="moderate", min_confidence=0.5, max_position_pct=0.15,
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
max_sector_pct=0.50, max_portfolio_heat=0.50,
)
portfolio = PortfolioState(
positions=positions,
total_value=active_pool + 500.0,
cash=active_pool - sum(200.0 for _ in existing_sectors),
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure=sector_exposure,
portfolio_heat=0.0,
open_position_count=len(positions),
)
# Trade in a new sector not in existing sectors
new_sector = "Financials"
result_new_sector = self.sizer.compute(
confidence=0.7, ticker="NEW", sector=new_sector,
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
)
# Trade in an existing sector (no bonus)
if existing_sectors:
result_existing_sector = self.sizer.compute(
confidence=0.7, ticker="NEW2", sector=existing_sectors[0],
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
)
if not result_new_sector.rejected and not result_existing_sector.rejected:
# New sector should get a bonus (higher allocation)
assert result_new_sector.dollar_amount >= result_existing_sector.dollar_amount
else:
# With 0 existing sectors, new sector should still get the bonus
if not result_new_sector.rejected:
assert result_new_sector.dollar_amount > 0
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=2000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
)
def test_no_bonus_when_3_or_more_sectors(self, active_pool: float) -> None:
"""No bonus when portfolio has >= 3 sectors."""
existing_sectors = ["Technology", "Healthcare", "Energy"]
sector_exposure = {s: 200.0 for s in existing_sectors}
positions = [
OpenPosition(
ticker=f"T{i}", quantity=5, entry_price=40.0,
current_price=40.0, unrealized_pnl=0.0,
market_value=200.0, sector=s,
stop_loss_price=35.0, take_profit_price=50.0,
signal_confidence=0.7,
)
for i, s in enumerate(existing_sectors)
]
risk_tier = RiskTierConfig(
name="moderate", min_confidence=0.5, max_position_pct=0.15,
stop_loss_atr_multiplier=2.0, reward_risk_ratio=1.5,
max_sector_pct=0.50, max_portfolio_heat=0.50,
)
portfolio = PortfolioState(
positions=positions,
total_value=active_pool + 500.0,
cash=active_pool - 600.0,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure=sector_exposure,
portfolio_heat=0.0,
open_position_count=3,
)
# Trade in a new sector — should NOT get bonus since we already have 3 sectors
result_new = self.sizer.compute(
confidence=0.7, ticker="NEW", sector="Financials",
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
)
# Trade in an existing sector
result_existing = self.sizer.compute(
confidence=0.7, ticker="NEW2", sector="Technology",
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
)
if not result_new.rejected and not result_existing.rejected:
# With >= 3 sectors, no bonus — allocations should be equal
assert result_new.dollar_amount == result_existing.dollar_amount
# ---------------------------------------------------------------------------
# Property 5: Active Pool computation invariant
# **Validates: Requirements 3.3**
# ---------------------------------------------------------------------------
class TestProperty5ActivePoolComputation:
"""Property 5: Active Pool computation invariant.
**Validates: Requirements 3.3**
"""
@settings(max_examples=100)
@given(
total_portfolio_value=st.floats(min_value=100.0, max_value=50000.0, allow_nan=False, allow_infinity=False),
reserve_pool_pct=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_active_pool_equals_total_minus_reserve(
self, total_portfolio_value: float, reserve_pool_pct: float,
) -> None:
"""Active Pool = total_portfolio_value - reserve_pool_balance."""
reserve_pool_balance = total_portfolio_value * reserve_pool_pct
active_pool = total_portfolio_value - reserve_pool_balance
assert active_pool >= -0.01, (
f"Active pool should be non-negative: {active_pool}"
)
assert abs(active_pool - (total_portfolio_value - reserve_pool_balance)) < 1e-9
# ---------------------------------------------------------------------------
# Property 19: Earnings proximity adjustments
# **Validates: Requirements 10.2, 10.3**
# ---------------------------------------------------------------------------
class TestProperty19EarningsProximity:
"""Property 19: Earnings proximity adjustments.
**Validates: Requirements 10.2, 10.3**
"""
sizer = PositionSizer()
def _base_args(self, active_pool: float = 5000.0) -> dict:
"""Common arguments for position sizer calls."""
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.50, max_portfolio_heat=0.50,
)
portfolio = PortfolioState(active_pool=active_pool)
return dict(
confidence=0.8,
ticker="EARN",
sector="Technology",
current_price=10.0,
active_pool=active_pool,
risk_tier=risk_tier,
portfolio_state=portfolio,
correlation_matrix={},
absolute_position_cap=500.0,
)
@settings(max_examples=100)
@given(
days_until=st.floats(min_value=1.01, max_value=3.0, allow_nan=False, allow_infinity=False),
)
def test_50_pct_reduction_within_3_trading_days(self, days_until: float) -> None:
"""50% reduction when earnings within 3 trading days (but > 1 day)."""
now = datetime.utcnow()
earnings_date = now + timedelta(days=days_until)
args = self._base_args()
result_with_earnings = self.sizer.compute(
**args, earnings_calendar={"EARN": earnings_date},
)
result_no_earnings = self.sizer.compute(
**args, earnings_calendar={},
)
if not result_with_earnings.rejected and not result_no_earnings.rejected:
# With earnings proximity, allocation should be ~50% of normal
ratio = result_with_earnings.dollar_amount / result_no_earnings.dollar_amount
# Allow some tolerance due to share rounding
assert ratio <= 0.6, (
f"Expected ~50% reduction, got ratio={ratio:.4f}"
)
@settings(max_examples=100)
@given(
days_until=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_rejection_within_1_trading_day(self, days_until: float) -> None:
"""Trade rejected when earnings within 1 trading day."""
now = datetime.utcnow()
earnings_date = now + timedelta(days=days_until)
args = self._base_args()
result = self.sizer.compute(
**args, earnings_calendar={"EARN": earnings_date},
)
assert result.rejected is True
@settings(max_examples=100)
@given(
days_until=st.floats(min_value=4.0, max_value=30.0, allow_nan=False, allow_infinity=False),
)
def test_normal_sizing_outside_earnings_window(self, days_until: float) -> None:
"""Normal sizing when earnings are outside the 3-day window."""
now = datetime.utcnow()
earnings_date = now + timedelta(days=days_until)
args = self._base_args()
result_with_earnings = self.sizer.compute(
**args, earnings_calendar={"EARN": earnings_date},
)
result_no_earnings = self.sizer.compute(
**args, earnings_calendar={},
)
if not result_with_earnings.rejected and not result_no_earnings.rejected:
assert result_with_earnings.dollar_amount == result_no_earnings.dollar_amount
# ---------------------------------------------------------------------------
# Property 24: Portfolio heat computation and threshold enforcement
# **Validates: Requirements 13.1, 13.2**
# ---------------------------------------------------------------------------
class TestProperty24PortfolioHeat:
"""Property 24: Portfolio heat computation and threshold enforcement.
**Validates: Requirements 13.1, 13.2**
"""
sizer = PositionSizer()
@settings(max_examples=100)
@given(
entry_prices=st.lists(
st.floats(min_value=10.0, max_value=200.0, allow_nan=False, allow_infinity=False),
min_size=1, max_size=5,
),
stop_loss_pcts=st.lists(
st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
min_size=1, max_size=5,
),
quantities=st.lists(
st.integers(min_value=1, max_value=50),
min_size=1, max_size=5,
),
)
def test_heat_computation_formula(
self, entry_prices: list[float], stop_loss_pcts: list[float],
quantities: list[int],
) -> None:
"""Heat = sum of position_value * (entry_price - stop_loss_price) / entry_price."""
n = min(len(entry_prices), len(stop_loss_pcts), len(quantities))
assume(n >= 1)
expected_heat = 0.0
for i in range(n):
entry = entry_prices[i]
stop_pct = stop_loss_pcts[i]
qty = quantities[i]
stop_loss = entry * (1.0 - stop_pct)
position_value = qty * entry
heat_contribution = position_value * (entry - stop_loss) / entry
expected_heat += heat_contribution
# Verify the formula: heat_contribution = position_value * stop_pct
recomputed = 0.0
for i in range(n):
entry = entry_prices[i]
stop_pct = stop_loss_pcts[i]
qty = quantities[i]
position_value = qty * entry
recomputed += position_value * stop_pct
assert abs(expected_heat - recomputed) < 1e-6
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=2000.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
max_heat_pct=st.floats(min_value=0.05, max_value=0.30, allow_nan=False, allow_infinity=False),
)
def test_new_entries_rejected_when_heat_exceeds_max(
self, active_pool: float, max_heat_pct: float,
) -> None:
"""New entries rejected when portfolio heat exceeds max_portfolio_heat."""
# Set portfolio heat to exceed the max
max_heat_dollars = max_heat_pct * active_pool
current_heat = max_heat_dollars * 1.1 # 10% over the limit
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.50, max_portfolio_heat=max_heat_pct,
)
portfolio = PortfolioState(
positions=[],
total_value=active_pool + 500.0,
cash=active_pool,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure={},
portfolio_heat=current_heat,
open_position_count=0,
)
result = self.sizer.compute(
confidence=0.8, ticker="NEW", sector="Technology",
current_price=10.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
)
assert result.rejected is True
# ---------------------------------------------------------------------------
# Property 7: Active Pool minimum halts new entries but allows exits
# **Validates: Requirements 3.5**
# ---------------------------------------------------------------------------
class TestProperty7ActivePoolMinimum:
"""Property 7: Active Pool minimum halts new entries but allows exits.
**Validates: Requirements 3.5**
"""
sizer = PositionSizer()
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=1.0, max_value=99.99, allow_nan=False, allow_infinity=False),
)
def test_buy_orders_rejected_when_active_pool_below_minimum(
self, active_pool: float,
) -> None:
"""Buy orders rejected when Active Pool < minimum ($100 default)."""
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,
)
portfolio = PortfolioState(
positions=[],
total_value=active_pool + 50.0,
cash=active_pool,
active_pool=active_pool,
reserve_pool=50.0,
sector_exposure={},
portfolio_heat=0.0,
open_position_count=0,
)
result = self.sizer.compute(
confidence=0.8, ticker="TEST", sector="Technology",
current_price=10.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=50.0,
active_pool_minimum=100.0,
)
assert result.rejected is True
assert "below minimum" in result.rejection_reason.lower() or "active pool" in result.rejection_reason.lower()
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=100.0, max_value=10000.0, allow_nan=False, allow_infinity=False),
)
def test_buy_orders_allowed_when_active_pool_above_minimum(
self, active_pool: float,
) -> None:
"""Buy orders allowed when Active Pool >= minimum."""
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.50, max_portfolio_heat=0.50,
)
portfolio = PortfolioState(
positions=[],
total_value=active_pool + 500.0,
cash=active_pool,
active_pool=active_pool,
reserve_pool=500.0,
sector_exposure={},
portfolio_heat=0.0,
open_position_count=0,
)
result = self.sizer.compute(
confidence=0.8, ticker="TEST", sector="Technology",
current_price=5.0, active_pool=active_pool,
risk_tier=risk_tier, portfolio_state=portfolio,
correlation_matrix={}, earnings_calendar={},
absolute_position_cap=500.0,
active_pool_minimum=100.0,
)
# Should not be rejected due to active pool minimum
# (may still be rejected for other reasons like heat, but not for active pool)
if result.rejected:
assert "active pool" not in result.rejection_reason.lower() or "below minimum" not in result.rejection_reason.lower()
+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
+256
View File
@@ -0,0 +1,256 @@
"""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"
)
+315
View File
@@ -0,0 +1,315 @@
"""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}"
)
+169
View File
@@ -0,0 +1,169 @@
"""Property-based tests for risk tier default parameters.
Feature: autonomous-trading-engine
Validates that the three risk tier defaults (conservative, moderate, aggressive)
have valid parameter ranges and correct ordering relationships.
**Validates: Requirements 5.1**
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.models import RISK_TIER_DEFAULTS, RiskTierConfig
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
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.0, max_value=1.0, allow_nan=False),
max_position_pct=st.floats(
min_value=0.01, max_value=1.0, allow_nan=False
),
stop_loss_atr_multiplier=st.floats(
min_value=0.01, max_value=10.0, allow_nan=False
),
reward_risk_ratio=st.floats(
min_value=0.01, max_value=10.0, allow_nan=False
),
max_sector_pct=st.floats(
min_value=0.01, max_value=1.0, allow_nan=False
),
max_portfolio_heat=st.floats(
min_value=0.01, max_value=1.0, allow_nan=False
),
)
# ---------------------------------------------------------------------------
# Property 29 (partial): Risk tier default parameter validation
# ---------------------------------------------------------------------------
EXPECTED_TIERS = {"conservative", "moderate", "aggressive"}
def test_all_three_tiers_exist() -> None:
"""All three risk tiers must be present in RISK_TIER_DEFAULTS.
**Validates: Requirements 5.1**
"""
assert set(RISK_TIER_DEFAULTS.keys()) == EXPECTED_TIERS
def test_each_tier_has_valid_parameter_ranges() -> None:
"""Each tier's parameters must fall within valid ranges.
**Validates: Requirements 5.1**
"""
for tier_name, cfg in RISK_TIER_DEFAULTS.items():
assert cfg.name == tier_name, (
f"Tier name mismatch: key={tier_name}, cfg.name={cfg.name}"
)
# min_confidence in [0, 1]
assert 0.0 <= cfg.min_confidence <= 1.0, (
f"{tier_name}: min_confidence={cfg.min_confidence} not in [0, 1]"
)
# max_position_pct in (0, 1]
assert 0.0 < cfg.max_position_pct <= 1.0, (
f"{tier_name}: max_position_pct={cfg.max_position_pct} not in (0, 1]"
)
# stop_loss_atr_multiplier > 0
assert cfg.stop_loss_atr_multiplier > 0.0, (
f"{tier_name}: stop_loss_atr_multiplier={cfg.stop_loss_atr_multiplier} not > 0"
)
# reward_risk_ratio > 0
assert cfg.reward_risk_ratio > 0.0, (
f"{tier_name}: reward_risk_ratio={cfg.reward_risk_ratio} not > 0"
)
# max_sector_pct in (0, 1]
assert 0.0 < cfg.max_sector_pct <= 1.0, (
f"{tier_name}: max_sector_pct={cfg.max_sector_pct} not in (0, 1]"
)
# max_portfolio_heat in (0, 1]
assert 0.0 < cfg.max_portfolio_heat <= 1.0, (
f"{tier_name}: max_portfolio_heat={cfg.max_portfolio_heat} not in (0, 1]"
)
def test_min_confidence_ordering() -> None:
"""Conservative has highest min_confidence, aggressive has lowest.
conservative.min_confidence > moderate.min_confidence > aggressive.min_confidence
**Validates: Requirements 5.1**
"""
c = RISK_TIER_DEFAULTS["conservative"]
m = RISK_TIER_DEFAULTS["moderate"]
a = RISK_TIER_DEFAULTS["aggressive"]
assert c.min_confidence > m.min_confidence > a.min_confidence, (
f"min_confidence ordering violated: "
f"conservative={c.min_confidence}, "
f"moderate={m.min_confidence}, "
f"aggressive={a.min_confidence}"
)
def test_max_position_pct_ordering() -> None:
"""Conservative has lowest max_position_pct, aggressive has highest.
conservative.max_position_pct < moderate.max_position_pct < aggressive.max_position_pct
**Validates: Requirements 5.1**
"""
c = RISK_TIER_DEFAULTS["conservative"]
m = RISK_TIER_DEFAULTS["moderate"]
a = RISK_TIER_DEFAULTS["aggressive"]
assert c.max_position_pct < m.max_position_pct < a.max_position_pct, (
f"max_position_pct ordering violated: "
f"conservative={c.max_position_pct}, "
f"moderate={m.max_position_pct}, "
f"aggressive={a.max_position_pct}"
)
def test_max_portfolio_heat_ordering() -> None:
"""Conservative has lowest max_portfolio_heat, aggressive has highest.
conservative.max_portfolio_heat < moderate.max_portfolio_heat < aggressive.max_portfolio_heat
**Validates: Requirements 5.1**
"""
c = RISK_TIER_DEFAULTS["conservative"]
m = RISK_TIER_DEFAULTS["moderate"]
a = RISK_TIER_DEFAULTS["aggressive"]
assert c.max_portfolio_heat < m.max_portfolio_heat < a.max_portfolio_heat, (
f"max_portfolio_heat ordering violated: "
f"conservative={c.max_portfolio_heat}, "
f"moderate={m.max_portfolio_heat}, "
f"aggressive={a.max_portfolio_heat}"
)
@settings(max_examples=100)
@given(cfg=_risk_tier_config_strategy())
def test_random_risk_tier_config_parameter_ranges(cfg: RiskTierConfig) -> None:
"""Any randomly generated RiskTierConfig with valid inputs satisfies range invariants.
This property test verifies that the parameter range constraints hold for
arbitrary RiskTierConfig instances, not just the defaults.
**Validates: Requirements 5.1**
"""
assert 0.0 <= cfg.min_confidence <= 1.0
assert 0.0 < cfg.max_position_pct <= 1.0
assert cfg.stop_loss_atr_multiplier > 0.0
assert cfg.reward_risk_ratio > 0.0
assert 0.0 < cfg.max_sector_pct <= 1.0
assert 0.0 < cfg.max_portfolio_heat <= 1.0
+711
View File
@@ -0,0 +1,711 @@
"""Property-based tests for the Stop-Loss Manager.
Feature: autonomous-trading-engine
Tests properties 9, 10, 11, 15, and 25 from the design specification,
covering initial stop/take-profit computation, price crossing triggers,
trailing stop activation, high-severity event tightening, and proactive
heat-based stop tightening.
"""
from __future__ import annotations
from datetime import datetime
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.models import (
OpenPosition,
RiskTierConfig,
StopLevels,
)
from services.trading.stop_loss_manager import StopLossManager
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
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.02, max_value=0.50, 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.05, max_value=0.60, 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(
ticker: st.SearchStrategy[str] | None = None,
entry_price: st.SearchStrategy[float] | None = None,
signal_confidence: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[OpenPosition]:
"""Generate random OpenPosition objects."""
return st.builds(
OpenPosition,
ticker=ticker if ticker is not None else st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
quantity=st.integers(min_value=1, max_value=100),
entry_price=entry_price if entry_price is not None else 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=st.floats(min_value=10.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
sector=st.sampled_from(["Technology", "Healthcare", "Energy", "Financials", "Consumer"]),
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 9: Stop-loss and take-profit initial computation
# **Validates: Requirements 4.1, 4.2**
# ---------------------------------------------------------------------------
class TestProperty9InitialComputation:
"""Property 9: Stop-loss and take-profit initial computation.
**Validates: Requirements 4.1, 4.2**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_stop_loss_equals_entry_minus_atr_times_multiplier(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Stop-loss = entry_price - (ATR * stop_loss_atr_multiplier)."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
expected_stop = entry_price - (atr * risk_tier.stop_loss_atr_multiplier)
assert abs(levels.stop_loss_price - expected_stop) < 1e-9, (
f"stop_loss {levels.stop_loss_price} != expected {expected_stop}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_stop_loss_always_below_entry(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Stop-loss is always below entry price (for positive ATR and multiplier)."""
assume(atr * risk_tier.stop_loss_atr_multiplier > 0)
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.stop_loss_price < entry_price, (
f"stop_loss {levels.stop_loss_price} >= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_take_profit_equals_entry_plus_stop_distance_times_ratio(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Take-profit = entry_price + (stop_distance * reward_risk_ratio)."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
stop_distance = atr * risk_tier.stop_loss_atr_multiplier
expected_tp = entry_price + (stop_distance * risk_tier.reward_risk_ratio)
assert abs(levels.take_profit_price - expected_tp) < 1e-9, (
f"take_profit {levels.take_profit_price} != expected {expected_tp}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_take_profit_always_above_entry(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Take-profit is always above entry price."""
assume(atr * risk_tier.stop_loss_atr_multiplier * risk_tier.reward_risk_ratio > 0)
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.take_profit_price > entry_price, (
f"take_profit {levels.take_profit_price} <= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_trailing_stop_initially_inactive(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Trailing stop is not active on initial computation."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.trailing_stop_active is False
# ---------------------------------------------------------------------------
# Property 10: Price crossing triggers immediate sell
# **Validates: Requirements 4.4, 4.5**
# ---------------------------------------------------------------------------
class TestProperty10PriceCrossingTriggers:
"""Property 10: Price crossing triggers immediate sell.
**Validates: Requirements 4.4, 4.5**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_at_or_below_stop_loss(
self, entry_price: float, stop_distance_pct: float, tp_distance_pct: float,
) -> None:
"""Sell triggered when current price <= stop_loss."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=stop_loss, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
)
# Price at stop_loss
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: stop_loss},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "stop_loss"
assert triggers[0].ticker == ticker
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
below_pct=st.floats(min_value=0.001, max_value=0.10, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_below_stop_loss(
self, entry_price: float, stop_distance_pct: float,
tp_distance_pct: float, below_pct: float,
) -> None:
"""Sell triggered when current price is below stop_loss."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
price_below = stop_loss * (1 - below_pct)
assume(stop_loss > 0)
assume(price_below > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=price_below, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: price_below},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "stop_loss"
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_at_or_above_take_profit(
self, entry_price: float, stop_distance_pct: float, tp_distance_pct: float,
) -> None:
"""Sell triggered when current price >= take_profit."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=take_profit, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: take_profit},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "take_profit"
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.05, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.05, max_value=0.40, allow_nan=False, allow_infinity=False),
between_pct=st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_no_trigger_when_price_between_levels(
self, entry_price: float, stop_distance_pct: float,
tp_distance_pct: float, between_pct: float,
) -> None:
"""No trigger when price is strictly between stop_loss and take_profit."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss + 0.02)
# Price strictly between stop_loss and take_profit
price_between = stop_loss + (take_profit - stop_loss) * between_pct
# Ensure strictly between (not at boundaries)
assume(price_between > stop_loss)
assume(price_between < take_profit)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=price_between, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: price_between},
stop_levels={ticker: levels},
)
assert len(triggers) == 0
# ---------------------------------------------------------------------------
# Property 11: Trailing stop activation at 50% of take-profit distance
# **Validates: Requirements 4.6**
# ---------------------------------------------------------------------------
class TestProperty11TrailingStopActivation:
"""Property 11: Trailing stop activation at 50% of take-profit distance.
**Validates: Requirements 4.6**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
move_fraction=st.floats(min_value=0.51, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_trailing_stop_activates_when_move_exceeds_50_pct(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
move_fraction: float,
) -> None:
"""Trailing stop activates when favorable move > 50% of TP distance."""
# Compute initial levels to get the TP distance
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
tp_distance = initial_levels.take_profit_price - entry_price
assume(tp_distance > 0.01)
# Current price moved favorably by more than 50% of TP distance
current_price = entry_price + (tp_distance * move_fraction)
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
)
# re_evaluate_levels returns None when no material change, or StopLevels
# Since trailing stop activation IS a material change, we expect a result
assert result is not None, "Expected re_evaluate to return updated levels"
assert result.trailing_stop_active is True
# When trailing stop is active, stop should be at least at entry (breakeven)
assert result.stop_loss_price >= entry_price - 1e-9, (
f"Trailing stop {result.stop_loss_price} should be >= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
move_fraction=st.floats(min_value=0.0, max_value=0.49, allow_nan=False, allow_infinity=False),
)
def test_trailing_stop_does_not_activate_when_move_at_or_below_50_pct(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
move_fraction: float,
) -> None:
"""Trailing stop does NOT activate when favorable move <= 50% of TP distance."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
tp_distance = initial_levels.take_profit_price - entry_price
assume(tp_distance > 0.01)
# Current price moved favorably by <= 50% of TP distance
current_price = entry_price + (tp_distance * move_fraction)
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
)
# Either None (no change) or result with trailing_stop_active=False
if result is not None:
assert result.trailing_stop_active is False, (
f"Trailing stop should not activate at {move_fraction*100:.1f}% move"
)
# ---------------------------------------------------------------------------
# Property 15: Stop tightening during high-severity events
# **Validates: Requirements 7.2**
# ---------------------------------------------------------------------------
class TestProperty15HighSeverityEventTightening:
"""Property 15: Stop tightening during high-severity events.
**Validates: Requirements 7.2**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_tightened_stop_uses_half_normal_multiplier(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""During high-severity events, stop uses 0.5x normal ATR multiplier."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
# Use a current price near entry so trailing stop doesn't activate
current_price = entry_price + 0.01
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
high_severity_event=True,
)
# High-severity event changes the multiplier, so we expect a result
assert result is not None, "Expected updated levels during high-severity event"
# The tightened stop should use 0.5x the normal multiplier
expected_tightened_multiplier = risk_tier.stop_loss_atr_multiplier * 0.5
expected_stop = entry_price - (atr * expected_tightened_multiplier)
assert abs(result.stop_loss_price - expected_stop) < 1e-9, (
f"Tightened stop {result.stop_loss_price} != expected {expected_stop} "
f"(0.5x multiplier = {expected_tightened_multiplier})"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_tightened_stop_closer_to_entry_than_normal(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Tightened stop is closer to entry price (higher) than normal stop."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
current_price = entry_price + 0.01
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
high_severity_event=True,
)
assert result is not None
# Tightened stop should be closer to entry (higher value) than normal stop
normal_stop = entry_price - (atr * risk_tier.stop_loss_atr_multiplier)
assert result.stop_loss_price >= normal_stop - 1e-9, (
f"Tightened stop {result.stop_loss_price} should be >= normal stop {normal_stop}"
)
# ---------------------------------------------------------------------------
# Property 25: Proactive stop tightening at 80% heat threshold
# **Validates: Requirements 13.3**
# ---------------------------------------------------------------------------
class TestProperty25ProactiveHeatTightening:
"""Property 25: Proactive stop tightening at 80% heat threshold.
**Validates: Requirements 13.3**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
num_positions=st.integers(min_value=2, max_value=5),
max_heat=st.floats(min_value=0.10, max_value=0.30, allow_nan=False, allow_infinity=False),
)
def test_lowest_confidence_positions_tightened_first(
self, num_positions: int, max_heat: float,
) -> None:
"""Lowest-confidence positions get stops tightened first when heat > 80% of max."""
# Create positions with distinct confidence levels
positions: list[OpenPosition] = []
stop_levels_dict: dict[str, StopLevels] = {}
for i in range(num_positions):
ticker = f"T{i}"
entry_price = 100.0
confidence = 0.3 + (i * 0.15) # ascending confidence: 0.3, 0.45, 0.6, ...
stop_loss = 90.0 # 10% below entry
take_profit = 115.0
positions.append(OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=entry_price, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=confidence,
))
stop_levels_dict[ticker] = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
)
# Set heat above 80% of max to trigger tightening
portfolio_heat = max_heat * 0.85
active_pool = 10000.0
updated = self.manager.tighten_for_heat(
positions=positions,
stop_levels=stop_levels_dict,
portfolio_heat=portfolio_heat,
max_heat=max_heat,
active_pool=active_pool,
)
if updated:
# Verify that tightened positions are ordered by confidence (lowest first)
tightened_tickers = list(updated.keys())
tightened_confidences = [
next(p.signal_confidence for p in positions if p.ticker == t)
for t in tightened_tickers
]
# The first tightened position should have the lowest confidence
min_confidence_in_portfolio = min(p.signal_confidence for p in positions)
if tightened_tickers:
first_tightened_confidence = next(
p.signal_confidence for p in positions if p.ticker == tightened_tickers[0]
)
assert first_tightened_confidence == min_confidence_in_portfolio, (
f"First tightened position confidence {first_tightened_confidence} "
f"!= min confidence {min_confidence_in_portfolio}"
)
# All tightened stops should be >= original stops (moved closer to entry)
for ticker, new_levels in updated.items():
original_stop = stop_levels_dict[ticker].stop_loss_price
assert new_levels.stop_loss_price >= original_stop - 1e-9, (
f"{ticker}: tightened stop {new_levels.stop_loss_price} "
f"< original {original_stop}"
)
@settings(max_examples=100)
@given(
max_heat=st.floats(min_value=0.10, max_value=0.30, allow_nan=False, allow_infinity=False),
heat_fraction=st.floats(min_value=0.0, max_value=0.79, allow_nan=False, allow_infinity=False),
)
def test_no_tightening_when_heat_below_80_pct_threshold(
self, max_heat: float, heat_fraction: float,
) -> None:
"""No tightening when portfolio heat <= 80% of max."""
portfolio_heat = max_heat * heat_fraction
positions = [
OpenPosition(
ticker="T0", quantity=10, entry_price=100.0,
current_price=100.0, unrealized_pnl=0.0,
market_value=1000.0, sector="Technology",
stop_loss_price=90.0, take_profit_price=115.0,
signal_confidence=0.5,
),
]
stop_levels_dict = {
"T0": StopLevels(
stop_loss_price=90.0,
take_profit_price=115.0,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.utcnow(),
),
}
updated = self.manager.tighten_for_heat(
positions=positions,
stop_levels=stop_levels_dict,
portfolio_heat=portfolio_heat,
max_heat=max_heat,
active_pool=10000.0,
)
assert updated == {}, (
f"Expected no tightening at heat {portfolio_heat} "
f"(80% threshold = {max_heat * 0.8}), got {len(updated)} updates"
)
+291
View File
@@ -0,0 +1,291 @@
"""Property-based tests for the Tax Lot Tracker.
Feature: autonomous-trading-engine
Tests properties 22 and 23 from the design specification,
covering FIFO lot ordering and wash sale detection.
"""
from __future__ import annotations
from datetime import date, timedelta
from hypothesis import given, settings, assume
from hypothesis import strategies as st
from services.trading.tax_lots import ClosedLot, TaxLot, TaxLotTracker
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _tax_lot_strategy(
ticker: str = "AAPL",
status: str = "open",
) -> st.SearchStrategy[TaxLot]:
"""Generate a random TaxLot with a fixed ticker and status."""
return st.builds(
TaxLot,
ticker=st.just(ticker),
quantity=st.integers(min_value=1, max_value=100),
cost_basis_per_share=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
acquisition_date=st.dates(
min_value=date(2023, 1, 1),
max_value=date(2025, 6, 1),
),
status=st.just(status),
)
# ---------------------------------------------------------------------------
# Property 22: Tax lot FIFO ordering
# **Validates: Requirements 12.4**
# ---------------------------------------------------------------------------
class TestProperty22TaxLotFIFOOrdering:
"""Property 22: Tax lot FIFO ordering.
For any sequence of buy transactions, closing lots SHALL follow
FIFO order — the earliest-acquired open lot is closed first.
The realized P&L for each closed lot SHALL equal
(exit_price - cost_basis_per_share) * quantity.
**Validates: Requirements 12.4**
"""
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=1,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_lots_closed_in_fifo_order(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Closed lots are ordered by acquisition_date ascending (FIFO)."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=total_available,
exit_price=exit_price,
exit_date=exit_date,
)
# Verify FIFO: each closed lot's acquisition_date is <= the next
for i in range(len(closed) - 1):
assert closed[i].acquisition_date <= closed[i + 1].acquisition_date, (
f"FIFO violated: lot {i} acquired {closed[i].acquisition_date} "
f"but lot {i+1} acquired {closed[i+1].acquisition_date}"
)
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=1,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_realized_pnl_formula(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Realized P&L = (exit_price - cost_basis_per_share) * quantity per lot."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=total_available,
exit_price=exit_price,
exit_date=exit_date,
)
for cl in closed:
expected_pnl = (cl.exit_price - cl.cost_basis_per_share) * cl.quantity
assert abs(cl.realized_pnl - expected_pnl) < 1e-6, (
f"P&L mismatch: got {cl.realized_pnl}, "
f"expected {expected_pnl} for lot acquired {cl.acquisition_date}"
)
@given(
lots=st.lists(
_tax_lot_strategy(),
min_size=2,
max_size=10,
),
exit_price=st.floats(
min_value=1.0, max_value=500.0,
allow_nan=False, allow_infinity=False,
),
exit_date=st.dates(
min_value=date(2025, 1, 1),
max_value=date(2025, 12, 31),
),
)
@settings(max_examples=100)
def test_partial_close_respects_fifo(
self,
lots: list[TaxLot],
exit_price: float,
exit_date: date,
) -> None:
"""Closing fewer shares than available still follows FIFO order."""
total_available = sum(lot.quantity for lot in lots)
assume(total_available >= 2)
# Close only half the shares
close_qty = total_available // 2
assume(close_qty > 0)
tracker = TaxLotTracker()
closed = tracker.close_lots_fifo(
lots=lots,
quantity=close_qty,
exit_price=exit_price,
exit_date=exit_date,
)
# Total closed quantity should not exceed requested
total_closed = sum(cl.quantity for cl in closed)
assert total_closed <= close_qty
# FIFO order maintained
for i in range(len(closed) - 1):
assert closed[i].acquisition_date <= closed[i + 1].acquisition_date
# ---------------------------------------------------------------------------
# Property 23: Wash sale detection within 30-day window
# **Validates: Requirements 12.2, 12.3**
# ---------------------------------------------------------------------------
class TestProperty23WashSaleDetection:
"""Property 23: Wash sale detection within 30-day window.
For any position closed at a loss, the Tax Lot Tracker SHALL flag
it as a potential wash sale if the same ticker was purchased within
30 days before or after the loss date.
**Validates: Requirements 12.2, 12.3**
"""
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
days_offset=st.integers(min_value=-30, max_value=30),
)
@settings(max_examples=100)
def test_wash_sale_flagged_within_30_days(
self,
loss_date: date,
days_offset: int,
) -> None:
"""Purchase within 30 days of loss triggers wash sale flag."""
purchase_date = loss_date + timedelta(days=days_offset)
purchase = TaxLot(
ticker="AAPL",
quantity=10,
cost_basis_per_share=100.0,
acquisition_date=purchase_date,
)
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[purchase],
)
assert result is True, (
f"Expected wash sale flag for purchase {days_offset} days "
f"from loss date {loss_date}"
)
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
days_offset=st.integers(min_value=31, max_value=365),
direction=st.sampled_from([-1, 1]),
)
@settings(max_examples=100)
def test_no_wash_sale_outside_30_days(
self,
loss_date: date,
days_offset: int,
direction: int,
) -> None:
"""Purchase outside 30-day window does not trigger wash sale."""
purchase_date = loss_date + timedelta(days=days_offset * direction)
purchase = TaxLot(
ticker="AAPL",
quantity=10,
cost_basis_per_share=100.0,
acquisition_date=purchase_date,
)
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[purchase],
)
assert result is False, (
f"Unexpected wash sale flag for purchase {days_offset * direction} "
f"days from loss date {loss_date}"
)
@given(
loss_date=st.dates(
min_value=date(2024, 6, 1),
max_value=date(2025, 6, 1),
),
)
@settings(max_examples=100)
def test_no_wash_sale_with_no_purchases(
self,
loss_date: date,
) -> None:
"""No purchases means no wash sale."""
tracker = TaxLotTracker()
result = tracker.check_wash_sale(
loss_date=loss_date,
purchases=[],
)
assert result is False
+302
View File
@@ -0,0 +1,302 @@
"""Property-based tests for Trading Engine HTTP API.
Feature: autonomous-trading-engine
Tests properties 35 and 29 from the design specification, covering
configuration change audit trail and persistence round-trip for
trading engine state via the FastAPI endpoints.
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from starlette.testclient import TestClient
from services.trading.app import app
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_RISK_TIERS = st.sampled_from(["conservative", "moderate", "aggressive"])
_CONFIG_UPDATES = st.fixed_dictionaries(
{},
optional={
"enabled": st.booleans(),
"risk_tier": _RISK_TIERS,
"reserve_siphon_pct": st.floats(
min_value=0.0, max_value=1.0,
allow_nan=False, allow_infinity=False,
),
"polling_interval_seconds": st.integers(min_value=1, max_value=3600),
"absolute_position_cap": st.floats(
min_value=1.0, max_value=10_000.0,
allow_nan=False, allow_infinity=False,
),
"active_pool_minimum": st.floats(
min_value=0.0, max_value=10_000.0,
allow_nan=False, allow_infinity=False,
),
"micro_trading_enabled": st.booleans(),
},
).filter(lambda d: len(d) > 0) # at least one field must be set
# ---------------------------------------------------------------------------
# Module-level client with lifespan
# ---------------------------------------------------------------------------
# Use a stack to manage the TestClient context manager at module scope.
_client: TestClient | None = None
def _get_client() -> TestClient:
global _client
if _client is None:
_client = TestClient(app, raise_server_exceptions=True)
_client.__enter__()
return _client
def teardown_module() -> None:
global _client
if _client is not None:
_client.__exit__(None, None, None)
_client = None
# ---------------------------------------------------------------------------
# Property 35: Configuration change audit trail
# **Validates: Requirements 16.6**
# ---------------------------------------------------------------------------
class TestProperty35ConfigurationChangeAuditTrail:
"""Property 35: Configuration change audit trail.
**Validates: Requirements 16.6**
"""
@settings(max_examples=100)
@given(update=_CONFIG_UPDATES)
def test_config_update_returns_previous_and_new(
self, update: dict,
) -> None:
"""PUT /api/trading/config returns previous and new values for every changed field."""
client = _get_client()
resp = client.put("/api/trading/config", json=update)
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
data = resp.json()
assert "previous" in data, "Response must include 'previous'"
assert "updated" in data, "Response must include 'updated'"
assert "change_source" in data, "Response must include 'change_source'"
assert "changed_at" in data, "Response must include 'changed_at'"
# Every field sent in the request must appear in both previous and updated
for field_name, new_value in update.items():
assert field_name in data["previous"], (
f"Field '{field_name}' missing from previous config"
)
assert field_name in data["updated"], (
f"Field '{field_name}' missing from updated config"
)
# The updated value must match what was sent
assert data["updated"][field_name] == new_value, (
f"Updated value for '{field_name}' should be {new_value}, "
f"got {data['updated'][field_name]}"
)
@settings(max_examples=100)
@given(update=_CONFIG_UPDATES)
def test_config_update_change_source_is_api(
self, update: dict,
) -> None:
"""PUT /api/trading/config always records change_source as 'api'."""
client = _get_client()
resp = client.put("/api/trading/config", json=update)
assert resp.status_code == 200
assert resp.json()["change_source"] == "api"
# ---------------------------------------------------------------------------
# Property 29: Persistence round-trip for trading engine state
# **Validates: Requirements 3.2, 4.7, 5.5, 6.4, 14.3, 15.4, 16.1**
# ---------------------------------------------------------------------------
class TestProperty29PersistenceRoundTrip:
"""Property 29: Persistence round-trip for trading engine state.
**Validates: Requirements 3.2, 4.7, 5.5, 6.4, 14.3, 15.4, 16.1**
"""
def test_status_returns_valid_dict_with_expected_keys(self) -> None:
"""GET /api/trading/status returns a dict with all expected keys."""
client = _get_client()
resp = client.get("/api/trading/status")
assert resp.status_code == 200
data = resp.json()
expected_keys = {
"enabled",
"paused",
"risk_tier",
"circuit_breaker_status",
"active_pool",
"reserve_pool",
"portfolio_heat",
"open_positions",
"last_decision_at",
}
assert expected_keys.issubset(data.keys()), (
f"Missing keys: {expected_keys - data.keys()}"
)
def test_pause_then_status_shows_paused(self) -> None:
"""POST /api/trading/pause followed by GET /api/trading/status shows paused=true."""
client = _get_client()
pause_resp = client.post("/api/trading/pause")
assert pause_resp.status_code == 200
assert pause_resp.json()["paused"] is True
status_resp = client.get("/api/trading/status")
assert status_resp.status_code == 200
assert status_resp.json()["paused"] is True
def test_resume_then_status_shows_not_paused(self) -> None:
"""POST /api/trading/resume followed by GET /api/trading/status shows paused=false."""
client = _get_client()
resume_resp = client.post("/api/trading/resume")
assert resume_resp.status_code == 200
assert resume_resp.json()["paused"] is False
status_resp = client.get("/api/trading/status")
assert status_resp.status_code == 200
assert status_resp.json()["paused"] is False
@settings(max_examples=100)
@given(risk_tier=_RISK_TIERS)
def test_config_round_trip_risk_tier(self, risk_tier: str) -> None:
"""Setting risk_tier via config update is reflected in status."""
client = _get_client()
resp = client.put(
"/api/trading/config",
json={"risk_tier": risk_tier},
)
assert resp.status_code == 200
assert resp.json()["updated"]["risk_tier"] == risk_tier
status = client.get("/api/trading/status").json()
assert status["risk_tier"] == risk_tier
@settings(max_examples=100)
@given(enabled=st.booleans())
def test_config_round_trip_enabled(self, enabled: bool) -> None:
"""Setting enabled via config update is reflected in status."""
client = _get_client()
resp = client.put(
"/api/trading/config",
json={"enabled": enabled},
)
assert resp.status_code == 200
assert resp.json()["updated"]["enabled"] == enabled
status = client.get("/api/trading/status").json()
assert status["enabled"] == enabled
def test_health_returns_ok(self) -> None:
"""GET /health always returns status ok."""
client = _get_client()
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
def test_ready_returns_boolean(self) -> None:
"""GET /ready returns a dict with a boolean 'ready' field."""
client = _get_client()
resp = client.get("/ready")
assert resp.status_code == 200
assert isinstance(resp.json()["ready"], bool)
def test_backtest_returns_id(self) -> None:
"""POST /api/trading/backtest returns a backtest_id string."""
client = _get_client()
resp = client.post(
"/api/trading/backtest",
json={
"start_date": "2024-01-01",
"end_date": "2024-06-30",
"initial_capital": 500.0,
"risk_tier": "moderate",
},
)
assert resp.status_code == 200
data = resp.json()
assert "backtest_id" in data
assert isinstance(data["backtest_id"], str)
assert len(data["backtest_id"]) > 0
def test_backtest_get_returns_placeholder(self) -> None:
"""GET /api/trading/backtest/{id} returns a result dict."""
client = _get_client()
test_id = "test-123"
resp = client.get(f"/api/trading/backtest/{test_id}")
assert resp.status_code == 200
data = resp.json()
assert data["backtest_id"] == test_id
def test_decisions_returns_list(self) -> None:
"""GET /api/trading/decisions returns a list."""
client = _get_client()
resp = client.get("/api/trading/decisions")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_metrics_returns_expected_keys(self) -> None:
"""GET /api/trading/metrics returns a dict with expected metric keys."""
client = _get_client()
resp = client.get("/api/trading/metrics")
assert resp.status_code == 200
data = resp.json()
expected_keys = {
"total_portfolio_value",
"active_pool",
"reserve_pool",
"unrealized_pnl",
"realized_pnl",
"daily_pnl",
"win_rate",
"profit_factor",
"sharpe_ratio",
"max_drawdown",
"portfolio_heat",
}
assert expected_keys.issubset(data.keys()), (
f"Missing keys: {expected_keys - data.keys()}"
)
def test_metrics_history_returns_list(self) -> None:
"""GET /api/trading/metrics/history returns a list."""
client = _get_client()
resp = client.get("/api/trading/metrics/history")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_notification_config_returns_dict(self) -> None:
"""GET /api/trading/notifications/config returns a dict."""
client = _get_client()
resp = client.get("/api/trading/notifications/config")
assert resp.status_code == 200
data = resp.json()
assert "sms_enabled" in data
assert "email_enabled" in data
def test_notification_history_returns_list(self) -> None:
"""GET /api/trading/notifications/history returns a list."""
client = _get_client()
resp = client.get("/api/trading/notifications/history")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
+317
View File
@@ -0,0 +1,317 @@
"""Property-based tests for Trading Window and Gradual Entry.
Feature: autonomous-trading-engine
Tests properties 20 and 21 from the design specification, covering
trading window determination (9:45 AM 3:45 PM ET on weekdays) and
gradual entry tranche splitting.
"""
from __future__ import annotations
from datetime import datetime, time
from hypothesis import given, settings
from hypothesis import strategies as st
from services.trading.gradual_entry import (
create_tranches,
should_use_gradual_entry,
split_into_tranches,
)
from services.trading.trading_window import (
ET,
MARKET_OPEN,
WINDOW_CLOSE,
WINDOW_OPEN,
is_market_open,
is_within_trading_window,
next_window_open,
)
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
_WEEKDAYS = range(0, 5) # MonFri
def _et_datetime_in_window() -> st.SearchStrategy[datetime]:
"""Generate a timezone-aware datetime that is inside the trading window."""
return (
st.dates(
min_value=datetime(2024, 1, 1).date(),
max_value=datetime(2025, 12, 31).date(),
)
.filter(lambda d: d.weekday() in _WEEKDAYS)
.flatmap(
lambda d: st.times(
min_value=WINDOW_OPEN,
max_value=time(15, 44, 59),
).map(lambda t: datetime.combine(d, t, tzinfo=ET))
)
)
def _et_datetime_outside_window_weekday() -> st.SearchStrategy[datetime]:
"""Generate a weekday datetime that is outside the trading window.
Either before 9:45 AM ET or at/after 3:45 PM ET.
"""
before_open = st.times(min_value=time(0, 0), max_value=time(9, 44, 59))
after_close = st.times(min_value=WINDOW_CLOSE, max_value=time(23, 59, 59))
return (
st.dates(
min_value=datetime(2024, 1, 1).date(),
max_value=datetime(2025, 12, 31).date(),
)
.filter(lambda d: d.weekday() in _WEEKDAYS)
.flatmap(
lambda d: st.one_of(before_open, after_close).map(
lambda t: datetime.combine(d, t, tzinfo=ET)
)
)
)
def _et_datetime_weekend() -> st.SearchStrategy[datetime]:
"""Generate a weekend datetime (Saturday or Sunday)."""
return (
st.dates(
min_value=datetime(2024, 1, 1).date(),
max_value=datetime(2025, 12, 31).date(),
)
.filter(lambda d: d.weekday() >= 5)
.flatmap(
lambda d: st.times().map(
lambda t: datetime.combine(d, t, tzinfo=ET)
)
)
)
# ---------------------------------------------------------------------------
# Property 20: Trading window determination
# **Validates: Requirements 11.1**
# ---------------------------------------------------------------------------
class TestProperty20TradingWindowDetermination:
"""Property 20: Trading window determination.
**Validates: Requirements 11.1**
"""
@settings(max_examples=100)
@given(dt=_et_datetime_in_window())
def test_within_window_on_weekday(self, dt: datetime) -> None:
"""Timestamps between 9:45 AM and 3:45 PM ET on weekdays are within the window."""
assert is_within_trading_window(dt) is True, (
f"Expected within window: {dt} (ET weekday={dt.weekday()}, time={dt.time()})"
)
@settings(max_examples=100)
@given(dt=_et_datetime_outside_window_weekday())
def test_outside_window_on_weekday(self, dt: datetime) -> None:
"""Weekday timestamps before 9:45 AM or at/after 3:45 PM ET are outside the window."""
assert is_within_trading_window(dt) is False, (
f"Expected outside window: {dt} (ET time={dt.time()})"
)
@settings(max_examples=100)
@given(dt=_et_datetime_weekend())
def test_outside_window_on_weekend(self, dt: datetime) -> None:
"""Weekend timestamps are always outside the trading window."""
assert is_within_trading_window(dt) is False, (
f"Expected outside window on weekend: {dt} (weekday={dt.weekday()})"
)
@settings(max_examples=100)
@given(dt=_et_datetime_outside_window_weekday())
def test_next_window_open_is_in_future(self, dt: datetime) -> None:
"""next_window_open always returns a time >= the input when outside the window."""
nwo = next_window_open(dt)
et_dt = dt.astimezone(ET)
et_nwo = nwo.astimezone(ET)
# If we're past today's open, next open must be a future day
if et_dt.time() >= WINDOW_OPEN:
assert et_nwo.date() > et_dt.date(), (
f"Expected future date: nwo={et_nwo}, dt={et_dt}"
)
assert et_nwo.time().hour == WINDOW_OPEN.hour
assert et_nwo.time().minute == WINDOW_OPEN.minute
@settings(max_examples=100)
@given(dt=_et_datetime_weekend())
def test_next_window_open_skips_weekends(self, dt: datetime) -> None:
"""next_window_open from a weekend returns a weekday."""
nwo = next_window_open(dt)
et_nwo = nwo.astimezone(ET)
assert et_nwo.weekday() in _WEEKDAYS, (
f"Expected weekday, got {et_nwo.weekday()} for {et_nwo}"
)
@settings(max_examples=100)
@given(
dt=st.dates(
min_value=datetime(2024, 1, 1).date(),
max_value=datetime(2025, 12, 31).date(),
)
.filter(lambda d: d.weekday() in _WEEKDAYS)
.flatmap(
lambda d: st.times(
min_value=MARKET_OPEN,
max_value=time(15, 59, 59),
).map(lambda t: datetime.combine(d, t, tzinfo=ET))
),
)
def test_is_market_open_during_market_hours(self, dt: datetime) -> None:
"""Timestamps between 9:30 AM and 4:00 PM ET on weekdays are market-open."""
assert is_market_open(dt) is True, (
f"Expected market open: {dt} (time={dt.time()})"
)
@settings(max_examples=100)
@given(dt=_et_datetime_weekend())
def test_is_market_closed_on_weekends(self, dt: datetime) -> None:
"""Weekend timestamps always have market closed."""
assert is_market_open(dt) is False
# ---------------------------------------------------------------------------
# Property 21: Gradual entry tranche splitting
# **Validates: Requirements 11.3, 11.5**
# ---------------------------------------------------------------------------
class TestProperty21GradualEntryTrancheSplitting:
"""Property 21: Gradual entry tranche splitting.
**Validates: Requirements 11.3, 11.5**
"""
# -- should_use_gradual_entry ------------------------------------------
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=100.0, max_value=100_000.0, allow_nan=False, allow_infinity=False),
threshold_dollars=st.floats(min_value=1.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
excess=st.floats(min_value=0.01, max_value=1000.0, allow_nan=False, allow_infinity=False),
)
def test_gradual_entry_triggered_above_threshold(
self, active_pool: float, threshold_dollars: float, excess: float,
) -> None:
"""Gradual entry is used when position size exceeds min(threshold, 5% of pool)."""
effective = min(threshold_dollars, 0.05 * active_pool)
position_size = effective + excess
assert should_use_gradual_entry(position_size, active_pool, threshold_dollars) is True
@settings(max_examples=100)
@given(
active_pool=st.floats(min_value=100.0, max_value=100_000.0, allow_nan=False, allow_infinity=False),
threshold_dollars=st.floats(min_value=1.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
fraction=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
)
def test_gradual_entry_not_triggered_at_or_below_threshold(
self, active_pool: float, threshold_dollars: float, fraction: float,
) -> None:
"""Gradual entry is NOT used when position size <= effective threshold."""
effective = min(threshold_dollars, 0.05 * active_pool)
position_size = effective * fraction
assert should_use_gradual_entry(position_size, active_pool, threshold_dollars) is False
# -- split_into_tranches -----------------------------------------------
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
num_tranches=st.integers(min_value=1, max_value=20),
)
def test_tranche_sum_equals_total(
self, total_quantity: int, num_tranches: int,
) -> None:
"""Sum of all tranches must equal the original total quantity."""
tranches = split_into_tranches(total_quantity, num_tranches)
assert sum(tranches) == total_quantity
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
num_tranches=st.integers(min_value=1, max_value=20),
)
def test_tranche_sizes_approximately_equal(
self, total_quantity: int, num_tranches: int,
) -> None:
"""All tranche sizes differ by at most 1."""
tranches = split_into_tranches(total_quantity, num_tranches)
assert max(tranches) - min(tranches) <= 1
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
num_tranches=st.integers(min_value=1, max_value=20),
)
def test_tranche_count_matches_requested(
self, total_quantity: int, num_tranches: int,
) -> None:
"""Number of tranches returned matches the requested count."""
tranches = split_into_tranches(total_quantity, num_tranches)
assert len(tranches) == num_tranches
# -- create_tranches ---------------------------------------------------
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
parent_id=st.text(min_size=1, max_size=36, alphabet=st.characters(whitelist_categories=("L", "N", "Pd"))),
num_tranches=st.integers(min_value=1, max_value=10),
)
def test_all_tranches_reference_same_parent_decision_id(
self, total_quantity: int, parent_id: str, num_tranches: int,
) -> None:
"""Every tranche references the same parent decision ID."""
tranches = create_tranches(total_quantity, parent_id, num_tranches)
for t in tranches:
assert t.parent_decision_id == parent_id
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
parent_id=st.text(min_size=1, max_size=36, alphabet=st.characters(whitelist_categories=("L", "N", "Pd"))),
num_tranches=st.integers(min_value=1, max_value=10),
)
def test_create_tranches_quantity_sum(
self, total_quantity: int, parent_id: str, num_tranches: int,
) -> None:
"""Sum of tranche quantities from create_tranches equals total."""
tranches = create_tranches(total_quantity, parent_id, num_tranches)
assert sum(t.quantity for t in tranches) == total_quantity
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
parent_id=st.text(min_size=1, max_size=36, alphabet=st.characters(whitelist_categories=("L", "N", "Pd"))),
num_tranches=st.integers(min_value=1, max_value=10),
)
def test_create_tranches_indices_sequential(
self, total_quantity: int, parent_id: str, num_tranches: int,
) -> None:
"""Tranche indices are sequential starting from 0."""
tranches = create_tranches(total_quantity, parent_id, num_tranches)
for i, t in enumerate(tranches):
assert t.tranche_index == i
@settings(max_examples=100)
@given(
total_quantity=st.integers(min_value=1, max_value=10_000),
parent_id=st.text(min_size=1, max_size=36, alphabet=st.characters(whitelist_categories=("L", "N", "Pd"))),
num_tranches=st.integers(min_value=1, max_value=10),
)
def test_create_tranches_default_status_pending(
self, total_quantity: int, parent_id: str, num_tranches: int,
) -> None:
"""All tranches start with status 'pending'."""
tranches = create_tranches(total_quantity, parent_id, num_tranches)
for t in tranches:
assert t.status == "pending"