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
162 lines
6.2 KiB
Python
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
|