galaxis-po/backend/tests/unit/test_position_sizing.py
머니페니 12d235a1f1 feat: add 9 new modules - notification alerts, trading journal, position sizing, pension allocation, drawdown monitoring, benchmark dashboard, tax simulation, correlation analysis, parameter optimizer
Phase 1:
- Real-time signal alerts (Discord/Telegram webhook)
- Trading journal with entry/exit tracking
- Position sizing calculator (Fixed/Kelly/ATR)

Phase 2:
- Pension asset allocation (DC/IRP 70% risk limit)
- Drawdown monitoring with SVG gauge
- Benchmark dashboard (portfolio vs KOSPI vs deposit)

Phase 3:
- Tax benefit simulation (Korean pension tax rules)
- Correlation matrix heatmap
- Parameter optimizer with grid search + overfit detection
2026-03-29 10:03:08 +09:00

162 lines
6.2 KiB
Python

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