galaxis-po/backend/tests/unit/test_optimizer.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

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