"""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")