""" Tests for position sizing module. """ import pytest from app.services.position_sizing import fixed_ratio, kelly_criterion, atr_based class TestFixedRatio: """Tests for fixed_ratio position sizing (quant.md default).""" def test_basic_calculation(self): result = fixed_ratio(capital=10_000_000, num_positions=10, cash_ratio=0.3) # 10M * 0.7 (invest portion) / 10 positions = 700,000 assert result["position_size"] == 700_000 assert result["method"] == "fixed" assert result["risk_amount"] == pytest.approx(700_000 * 0.03) # -3% max loss per position def test_default_cash_ratio(self): result = fixed_ratio(capital=10_000_000, num_positions=10) assert result["position_size"] == 700_000 def test_custom_cash_ratio(self): result = fixed_ratio(capital=10_000_000, num_positions=5, cash_ratio=0.5) # 10M * 0.5 / 5 = 1,000,000 assert result["position_size"] == 1_000_000 def test_single_position(self): result = fixed_ratio(capital=1_000_000, num_positions=1, cash_ratio=0.0) assert result["position_size"] == 1_000_000 def test_zero_capital(self): with pytest.raises(ValueError, match="capital"): fixed_ratio(capital=0, num_positions=10) def test_negative_capital(self): with pytest.raises(ValueError, match="capital"): fixed_ratio(capital=-1_000_000, num_positions=10) def test_zero_positions(self): with pytest.raises(ValueError, match="num_positions"): fixed_ratio(capital=10_000_000, num_positions=0) def test_invalid_cash_ratio(self): with pytest.raises(ValueError, match="cash_ratio"): fixed_ratio(capital=10_000_000, num_positions=10, cash_ratio=1.5) def test_cash_ratio_one(self): """cash_ratio=1.0 means 100% cash, 0 investable.""" with pytest.raises(ValueError, match="cash_ratio"): fixed_ratio(capital=10_000_000, num_positions=10, cash_ratio=1.0) def test_notes_included(self): result = fixed_ratio(capital=10_000_000, num_positions=10) assert "notes" in result assert isinstance(result["notes"], str) class TestKellyCriterion: """Tests for Kelly criterion position sizing.""" def test_basic_calculation(self): # Kelly = W - (1-W)/R where W=win_rate, R=avg_win/avg_loss # Kelly = 0.6 - (0.4)/(5/3) = 0.6 - 0.24 = 0.36 # Quarter Kelly = 0.36 * 0.25 = 0.09 result = kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.03, fraction=0.25) assert result["method"] == "kelly" assert result["position_size"] == pytest.approx(0.09, abs=1e-6) def test_default_quarter_kelly(self): result = kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.03) assert result["position_size"] == pytest.approx(0.09, abs=1e-6) def test_full_kelly(self): result = kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.03, fraction=1.0) assert result["position_size"] == pytest.approx(0.36, abs=1e-6) def test_negative_kelly_returns_zero(self): """Negative Kelly means don't bet - should clamp to 0.""" result = kelly_criterion(win_rate=0.3, avg_win=0.02, avg_loss=0.05) assert result["position_size"] == 0.0 def test_win_rate_zero(self): with pytest.raises(ValueError, match="win_rate"): kelly_criterion(win_rate=0.0, avg_win=0.05, avg_loss=0.03) def test_win_rate_above_one(self): with pytest.raises(ValueError, match="win_rate"): kelly_criterion(win_rate=1.1, avg_win=0.05, avg_loss=0.03) def test_negative_avg_win(self): with pytest.raises(ValueError, match="avg_win"): kelly_criterion(win_rate=0.6, avg_win=-0.05, avg_loss=0.03) def test_zero_avg_loss(self): with pytest.raises(ValueError, match="avg_loss"): kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.0) def test_negative_win_rate(self): with pytest.raises(ValueError, match="win_rate"): kelly_criterion(win_rate=-0.1, avg_win=0.05, avg_loss=0.03) def test_risk_amount_in_result(self): result = kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.03) assert "risk_amount" in result def test_notes_included(self): result = kelly_criterion(win_rate=0.6, avg_win=0.05, avg_loss=0.03) assert "notes" in result class TestATRBased: """Tests for ATR-based volatility sizing.""" def test_basic_calculation(self): # position_size = (capital * risk_pct) / atr # = (10M * 0.02) / 1000 = 200 result = atr_based(capital=10_000_000, atr=1000, risk_pct=0.02) assert result["method"] == "atr" assert result["shares"] == 200 assert result["risk_amount"] == pytest.approx(10_000_000 * 0.02) def test_default_risk_pct(self): result = atr_based(capital=10_000_000, atr=1000) assert result["shares"] == 200 # 2% default def test_custom_risk_pct(self): result = atr_based(capital=10_000_000, atr=500, risk_pct=0.01) # (10M * 0.01) / 500 = 200 assert result["shares"] == 200 def test_shares_truncated_to_int(self): # (10M * 0.02) / 3000 = 66.666... -> 66 result = atr_based(capital=10_000_000, atr=3000, risk_pct=0.02) assert result["shares"] == 66 assert isinstance(result["shares"], int) def test_zero_capital(self): with pytest.raises(ValueError, match="capital"): atr_based(capital=0, atr=1000) def test_zero_atr(self): with pytest.raises(ValueError, match="atr"): atr_based(capital=10_000_000, atr=0) def test_negative_atr(self): with pytest.raises(ValueError, match="atr"): atr_based(capital=10_000_000, atr=-100) def test_risk_pct_too_high(self): with pytest.raises(ValueError, match="risk_pct"): atr_based(capital=10_000_000, atr=1000, risk_pct=1.5) def test_position_size_in_result(self): result = atr_based(capital=10_000_000, atr=1000, risk_pct=0.02) assert "position_size" in result assert result["position_size"] == result["risk_amount"] def test_notes_included(self): result = atr_based(capital=10_000_000, atr=1000) assert "notes" in result