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
282 lines
8.9 KiB
Python
282 lines
8.9 KiB
Python
"""
|
|
Tests for the strategy optimizer service and schemas.
|
|
"""
|
|
import pytest
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from app.schemas.optimizer import (
|
|
DEFAULT_GRIDS,
|
|
KJB_DEFAULT_GRID,
|
|
STRATEGY_TYPES,
|
|
OptimizeRequest,
|
|
OptimizeResponse,
|
|
OptimizeResultItem,
|
|
)
|
|
from app.services.optimizer import (
|
|
OptimizerService,
|
|
_expand_grid,
|
|
_build_strategy_params,
|
|
)
|
|
|
|
|
|
# --- Unit tests for grid expansion ---
|
|
|
|
|
|
class TestExpandGrid:
|
|
def test_single_param(self):
|
|
grid = {"a": [1, 2, 3]}
|
|
result = _expand_grid(grid)
|
|
assert len(result) == 3
|
|
assert result[0] == {"a": 1}
|
|
assert result[2] == {"a": 3}
|
|
|
|
def test_two_params(self):
|
|
grid = {"a": [1, 2], "b": [10, 20]}
|
|
result = _expand_grid(grid)
|
|
assert len(result) == 4
|
|
assert {"a": 1, "b": 10} in result
|
|
assert {"a": 2, "b": 20} in result
|
|
|
|
def test_empty_grid(self):
|
|
result = _expand_grid({})
|
|
assert len(result) == 1 # single empty combo
|
|
assert result[0] == {}
|
|
|
|
def test_kjb_default_grid_size(self):
|
|
result = _expand_grid(KJB_DEFAULT_GRID)
|
|
# 3 * 3 * 3 = 27
|
|
assert len(result) == 27
|
|
|
|
|
|
class TestBuildStrategyParams:
|
|
def test_flat_params(self):
|
|
result = _build_strategy_params("kjb", {
|
|
"stop_loss_pct": 0.05,
|
|
"target1_pct": 0.07,
|
|
})
|
|
assert result == {"stop_loss_pct": 0.05, "target1_pct": 0.07}
|
|
|
|
def test_nested_params(self):
|
|
result = _build_strategy_params("multi_factor", {
|
|
"weights.value": 0.3,
|
|
"weights.quality": 0.2,
|
|
})
|
|
assert result == {"weights": {"value": 0.3, "quality": 0.2}}
|
|
|
|
def test_deeply_nested(self):
|
|
result = _build_strategy_params("test", {
|
|
"a.b.c": 1,
|
|
})
|
|
assert result == {"a": {"b": {"c": 1}}}
|
|
|
|
|
|
# --- Schema tests ---
|
|
|
|
|
|
class TestOptimizeRequest:
|
|
def test_defaults(self):
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
)
|
|
assert req.initial_capital == Decimal("100000000")
|
|
assert req.commission_rate == Decimal("0.00015")
|
|
assert req.slippage_rate == Decimal("0.001")
|
|
assert req.benchmark == "KOSPI"
|
|
assert req.top_n == 30
|
|
assert req.rank_by == "sharpe_ratio"
|
|
assert req.param_grid is None
|
|
|
|
def test_custom_grid(self):
|
|
custom = {"stop_loss_pct": [0.02, 0.04]}
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
param_grid=custom,
|
|
)
|
|
assert req.param_grid == custom
|
|
|
|
def test_all_strategy_types_valid(self):
|
|
for st in STRATEGY_TYPES:
|
|
req = OptimizeRequest(
|
|
strategy_type=st,
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
)
|
|
assert req.strategy_type == st
|
|
|
|
|
|
class TestOptimizeResponse:
|
|
def test_response_serialization(self):
|
|
item = OptimizeResultItem(
|
|
rank=1,
|
|
params={"stop_loss_pct": 0.05},
|
|
total_return=Decimal("15.5"),
|
|
cagr=Decimal("12.3"),
|
|
mdd=Decimal("-8.2"),
|
|
sharpe_ratio=Decimal("1.45"),
|
|
volatility=Decimal("18.7"),
|
|
benchmark_return=Decimal("10.0"),
|
|
excess_return=Decimal("5.5"),
|
|
)
|
|
resp = OptimizeResponse(
|
|
strategy_type="kjb",
|
|
total_combinations=27,
|
|
results=[item],
|
|
best_params={"stop_loss_pct": 0.05},
|
|
)
|
|
data = resp.model_dump(mode="json")
|
|
assert data["total_combinations"] == 27
|
|
assert data["results"][0]["sharpe_ratio"] == 1.45
|
|
assert isinstance(data["results"][0]["sharpe_ratio"], float)
|
|
|
|
|
|
# --- Default grids ---
|
|
|
|
|
|
class TestDefaultGrids:
|
|
def test_all_strategy_types_have_grids(self):
|
|
for st in STRATEGY_TYPES:
|
|
assert st in DEFAULT_GRIDS
|
|
|
|
def test_kjb_grid_keys(self):
|
|
assert "stop_loss_pct" in KJB_DEFAULT_GRID
|
|
assert "target1_pct" in KJB_DEFAULT_GRID
|
|
assert "rs_lookback" in KJB_DEFAULT_GRID
|
|
|
|
|
|
# --- OptimizerService tests with mocked DB ---
|
|
|
|
|
|
class TestOptimizerService:
|
|
def _make_service(self):
|
|
db = MagicMock()
|
|
return OptimizerService(db)
|
|
|
|
def test_optimize_no_grid_raises_for_unknown_type(self):
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="unknown_type",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
)
|
|
with pytest.raises(ValueError, match="No parameter grid"):
|
|
service.optimize(req)
|
|
|
|
@patch.object(OptimizerService, "_run_single")
|
|
def test_optimize_uses_default_grid(self, mock_run):
|
|
mock_run.return_value = {
|
|
"total_return": 10.0,
|
|
"cagr": 8.0,
|
|
"mdd": -5.0,
|
|
"sharpe_ratio": 1.2,
|
|
"volatility": 15.0,
|
|
"benchmark_return": 7.0,
|
|
"excess_return": 3.0,
|
|
}
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
)
|
|
result = service.optimize(req)
|
|
assert result.strategy_type == "kjb"
|
|
assert result.total_combinations == 27 # 3*3*3
|
|
assert len(result.results) == 27
|
|
assert result.results[0].rank == 1
|
|
|
|
@patch.object(OptimizerService, "_run_single")
|
|
def test_optimize_ranks_by_sharpe(self, mock_run):
|
|
mock_run.side_effect = [
|
|
{
|
|
"total_return": 10.0, "cagr": 8.0, "mdd": -5.0,
|
|
"sharpe_ratio": 0.5, "volatility": 15.0,
|
|
"benchmark_return": 7.0, "excess_return": 3.0,
|
|
},
|
|
{
|
|
"total_return": 20.0, "cagr": 15.0, "mdd": -10.0,
|
|
"sharpe_ratio": 2.0, "volatility": 20.0,
|
|
"benchmark_return": 7.0, "excess_return": 13.0,
|
|
},
|
|
]
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
param_grid={"stop_loss_pct": [0.03, 0.05]},
|
|
rank_by="sharpe_ratio",
|
|
)
|
|
result = service.optimize(req)
|
|
assert result.total_combinations == 2
|
|
assert result.results[0].sharpe_ratio == Decimal("2.0")
|
|
assert result.results[1].sharpe_ratio == Decimal("0.5")
|
|
assert result.best_params == {"stop_loss_pct": 0.05}
|
|
|
|
@patch.object(OptimizerService, "_run_single")
|
|
def test_optimize_handles_failures_gracefully(self, mock_run):
|
|
mock_run.side_effect = [
|
|
Exception("data error"),
|
|
{
|
|
"total_return": 10.0, "cagr": 8.0, "mdd": -5.0,
|
|
"sharpe_ratio": 1.0, "volatility": 15.0,
|
|
"benchmark_return": 7.0, "excess_return": 3.0,
|
|
},
|
|
]
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
param_grid={"stop_loss_pct": [0.03, 0.05]},
|
|
)
|
|
result = service.optimize(req)
|
|
assert result.total_combinations == 2
|
|
assert len(result.results) == 1
|
|
|
|
@patch.object(OptimizerService, "_run_single")
|
|
def test_optimize_all_fail_returns_empty(self, mock_run):
|
|
mock_run.side_effect = Exception("fail")
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="kjb",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
param_grid={"stop_loss_pct": [0.03]},
|
|
)
|
|
result = service.optimize(req)
|
|
assert result.total_combinations == 1
|
|
assert len(result.results) == 0
|
|
assert result.best_params == {}
|
|
|
|
@patch.object(OptimizerService, "_run_single")
|
|
def test_optimize_rank_by_cagr(self, mock_run):
|
|
mock_run.side_effect = [
|
|
{
|
|
"total_return": 30.0, "cagr": 25.0, "mdd": -15.0,
|
|
"sharpe_ratio": 0.8, "volatility": 25.0,
|
|
"benchmark_return": 7.0, "excess_return": 23.0,
|
|
},
|
|
{
|
|
"total_return": 15.0, "cagr": 12.0, "mdd": -5.0,
|
|
"sharpe_ratio": 1.5, "volatility": 10.0,
|
|
"benchmark_return": 7.0, "excess_return": 8.0,
|
|
},
|
|
]
|
|
service = self._make_service()
|
|
req = OptimizeRequest(
|
|
strategy_type="quality",
|
|
start_date=date(2024, 1, 1),
|
|
end_date=date(2024, 12, 31),
|
|
param_grid={"min_fscore": [6, 7]},
|
|
rank_by="cagr",
|
|
)
|
|
result = service.optimize(req)
|
|
assert result.results[0].cagr == Decimal("25.0")
|
|
assert result.results[1].cagr == Decimal("12.0")
|