Files
CryptoTrading/tests/test_risk.py
bnair123 e17c3bf508 Add Supertrend strategy and Risk Engine (Phase 2 Milestones 2.2, 2.3)
- Implement SupertrendStrategy with pandas-ta indicator, ATR-based stops
- Add RiskEngine with position sizing, risk limits, portfolio heat tracking
- Add comprehensive tests for both modules (32 new tests)
- Update AGENTS.md with accurate project structure and py312 target
2025-12-27 18:24:20 +04:00

246 lines
8.7 KiB
Python

"""Tests for risk sizing, limits, and portfolio exposure tracking."""
from decimal import Decimal
import pytest
from tradefinder.core.risk import PortfolioRisk, RiskEngine
class TestPortfolioRisk:
"""Verify portfolio exposure tracking and heat calculation."""
def test_add_exposure_updates_totals_and_heat(self) -> None:
equity = Decimal("10000")
portfolio_risk = PortfolioRisk()
amount = Decimal("250")
portfolio_risk.add_exposure("trend", amount, equity)
assert portfolio_risk.total_exposure == amount
assert portfolio_risk.per_strategy_exposure["trend"] == amount
assert portfolio_risk.portfolio_heat == Decimal("2.5")
def test_remove_exposure_reduces_totals(self) -> None:
equity = Decimal("10000")
portfolio_risk = PortfolioRisk()
portfolio_risk.add_exposure("trend", Decimal("400"), equity)
portfolio_risk.remove_exposure("trend", Decimal("150"), equity)
assert portfolio_risk.total_exposure == Decimal("250")
assert portfolio_risk.per_strategy_exposure["trend"] == Decimal("250")
assert portfolio_risk.portfolio_heat == Decimal("2.5")
def test_heat_zero_equity_ignored(self) -> None:
portfolio_risk = PortfolioRisk()
portfolio_risk.add_exposure("trend", Decimal("100"), Decimal("0"))
assert portfolio_risk.portfolio_heat == Decimal("0")
assert portfolio_risk.total_exposure == Decimal("100")
class TestRiskEngine:
"""Unit tests for sizing, risk calculations, and allocations."""
def test_calculate_position_size_normal(self) -> None:
engine = RiskEngine()
position = engine.calculate_position_size(
equity=Decimal("3000"),
entry_price=Decimal("50000"),
stop_loss=Decimal("48000"),
risk_pct=Decimal("2"),
)
assert position == Decimal("0.03")
def test_calculate_position_size_clamps_bounds(self) -> None:
engine = RiskEngine()
# Risk pct 0.5% clamps to minimum 1%
minimum = engine.calculate_position_size(
equity=Decimal("3000"),
entry_price=Decimal("50000"),
stop_loss=Decimal("49000"),
risk_pct=Decimal("0.5"),
)
# Risk pct 5% clamps to maximum 3%
maximum = engine.calculate_position_size(
equity=Decimal("3000"),
entry_price=Decimal("50000"),
stop_loss=Decimal("49000"),
risk_pct=Decimal("5"),
)
# 1% of 3000 = 30, / 1000 stop distance = 0.03
assert minimum == Decimal("0.03")
# 3% of 3000 = 90, / 1000 stop distance = 0.09
assert maximum == Decimal("0.09")
def test_calculate_position_size_zero_equity_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Equity must be positive"):
engine.calculate_position_size(
equity=Decimal("0"),
entry_price=Decimal("50000"),
stop_loss=Decimal("48000"),
risk_pct=Decimal("2"),
)
def test_calculate_position_size_zero_stop_distance_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Stop loss must differ from entry price"):
engine.calculate_position_size(
equity=Decimal("3000"),
entry_price=Decimal("50000"),
stop_loss=Decimal("50000"),
risk_pct=Decimal("2"),
)
def test_calculate_position_size_negative_entry_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Entry and stop must be positive values"):
engine.calculate_position_size(
equity=Decimal("3000"),
entry_price=Decimal("-1"),
stop_loss=Decimal("48000"),
risk_pct=Decimal("2"),
)
def test_validate_risk_limits_within_limits(self) -> None:
engine = RiskEngine()
# 0.001 BTC * 50000 = 50 notional, 3% of 3000 = 90 max -> within limits
assert engine.validate_risk_limits(
position_size=Decimal("0.001"),
entry_price=Decimal("50000"),
max_per_trade_pct=Decimal("3"),
equity=Decimal("3000"),
)
def test_validate_risk_limits_exceeds_threshold(self) -> None:
engine = RiskEngine()
assert not engine.validate_risk_limits(
position_size=Decimal("0.03"),
entry_price=Decimal("50000"),
max_per_trade_pct=Decimal("1"),
equity=Decimal("3000"),
)
def test_validate_risk_limits_zero_equity_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Equity must be positive"):
engine.validate_risk_limits(
position_size=Decimal("0.03"),
entry_price=Decimal("50000"),
max_per_trade_pct=Decimal("3"),
equity=Decimal("0"),
)
def test_validate_risk_limits_zero_entry_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Position size and entry price must be positive"):
engine.validate_risk_limits(
position_size=Decimal("0.03"),
entry_price=Decimal("0"),
max_per_trade_pct=Decimal("3"),
equity=Decimal("3000"),
)
def test_calculate_risk_amount_normal(self) -> None:
engine = RiskEngine()
amount = engine.calculate_risk_amount(
position_size=Decimal("0.03"),
entry_price=Decimal("50000"),
stop_loss=Decimal("48000"),
)
assert amount == Decimal("60")
def test_calculate_risk_amount_zero_position_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Position size must be positive"):
engine.calculate_risk_amount(
position_size=Decimal("0"),
entry_price=Decimal("50000"),
stop_loss=Decimal("48000"),
)
def test_calculate_risk_amount_negative_stop_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Entry and stop prices must be positive"):
engine.calculate_risk_amount(
position_size=Decimal("0.01"),
entry_price=Decimal("50000"),
stop_loss=Decimal("-1"),
)
def test_calculate_risk_amount_zero_distance_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Stop loss distance must be non-zero"):
engine.calculate_risk_amount(
position_size=Decimal("0.01"),
entry_price=Decimal("50000"),
stop_loss=Decimal("50000"),
)
def test_can_allocate_strategy_within_limits(self) -> None:
portfolio_risk = PortfolioRisk()
engine = RiskEngine(portfolio_risk)
equity = Decimal("1000")
risk_amount = Decimal("200")
assert engine.can_allocate_strategy("trend", risk_amount, equity)
assert portfolio_risk.per_strategy_exposure["trend"] == risk_amount
assert portfolio_risk.total_exposure == risk_amount
def test_can_allocate_strategy_exceeds_strategy_limit(self) -> None:
portfolio_risk = PortfolioRisk()
engine = RiskEngine(portfolio_risk)
assert not engine.can_allocate_strategy(
"trend",
Decimal("300"),
Decimal("1000"),
max_per_strategy_pct=Decimal("20"),
)
assert portfolio_risk.total_exposure == Decimal("0")
def test_can_allocate_strategy_exceeds_total_limit(self) -> None:
portfolio_risk = PortfolioRisk()
engine = RiskEngine(portfolio_risk)
equity = Decimal("1000")
portfolio_risk.add_exposure("other", Decimal("400"), equity)
assert not engine.can_allocate_strategy(
"trend",
Decimal("200"),
equity,
max_total_exposure_pct=Decimal("50"),
)
assert portfolio_risk.per_strategy_exposure.get("trend", Decimal("0")) == Decimal("0")
assert portfolio_risk.total_exposure == Decimal("400")
def test_can_allocate_strategy_zero_equity_raises(self) -> None:
engine = RiskEngine()
with pytest.raises(ValueError, match="Equity must be positive"):
engine.can_allocate_strategy("trend", Decimal("1"), Decimal("0"))
def test_can_allocate_strategy_zero_risk_returns_false(self) -> None:
engine = RiskEngine()
assert not engine.can_allocate_strategy("trend", Decimal("0"), Decimal("1000"))
assert engine._portfolio_risk.total_exposure == Decimal("0")