- 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
246 lines
8.7 KiB
Python
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")
|