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
167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
"""
|
|
Unit tests for benchmark service.
|
|
"""
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from app.services.benchmark import BenchmarkService
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def service(db):
|
|
return BenchmarkService(db)
|
|
|
|
|
|
class TestGetDepositRate:
|
|
def test_returns_fixed_rate(self, service):
|
|
rate = service.get_deposit_rate()
|
|
assert rate == 3.5
|
|
|
|
|
|
class TestGetBenchmarkData:
|
|
@patch("app.services.benchmark.pykrx_stock")
|
|
def test_returns_kospi_time_series(self, mock_pykrx, service):
|
|
dates = pd.date_range("2025-01-02", periods=3, freq="B")
|
|
mock_pykrx.get_index_ohlcv.return_value = pd.DataFrame(
|
|
{"시가": [2800, 2810, 2820], "종가": [2810, 2820, 2830]},
|
|
index=dates,
|
|
)
|
|
|
|
result = service.get_benchmark_data(
|
|
"kospi", date(2025, 1, 2), date(2025, 1, 6)
|
|
)
|
|
|
|
assert len(result) == 3
|
|
assert result[0]["date"] == dates[0].date()
|
|
assert result[0]["close"] == 2810
|
|
|
|
@patch("app.services.benchmark.pykrx_stock")
|
|
def test_empty_data_returns_empty_list(self, mock_pykrx, service):
|
|
mock_pykrx.get_index_ohlcv.return_value = pd.DataFrame()
|
|
|
|
result = service.get_benchmark_data(
|
|
"kospi", date(2025, 1, 2), date(2025, 1, 6)
|
|
)
|
|
|
|
assert result == []
|
|
|
|
|
|
class TestCalculateMetrics:
|
|
def test_cumulative_return(self, service):
|
|
returns = [0.01, 0.02, -0.005, 0.015]
|
|
metrics = service._calculate_metrics(returns, num_days=120)
|
|
|
|
expected_cum = ((1.01) * (1.02) * (0.995) * (1.015) - 1) * 100
|
|
assert abs(metrics.cumulative_return - expected_cum) < 0.01
|
|
|
|
def test_max_drawdown(self, service):
|
|
returns = [0.10, -0.20, 0.05]
|
|
metrics = service._calculate_metrics(returns, num_days=90)
|
|
|
|
assert metrics.max_drawdown < 0
|
|
|
|
def test_sharpe_ratio_with_zero_std(self, service):
|
|
returns = [0.01, 0.01, 0.01]
|
|
metrics = service._calculate_metrics(returns, num_days=90)
|
|
|
|
assert metrics.sharpe_ratio is None
|
|
|
|
def test_empty_returns(self, service):
|
|
metrics = service._calculate_metrics([], num_days=0)
|
|
|
|
assert metrics.cumulative_return == 0.0
|
|
assert metrics.annualized_return == 0.0
|
|
assert metrics.max_drawdown == 0.0
|
|
|
|
|
|
class TestCompareWithBenchmark:
|
|
def _make_snapshot(self, snapshot_date, total_value):
|
|
snap = MagicMock()
|
|
snap.snapshot_date = snapshot_date
|
|
snap.total_value = Decimal(str(total_value))
|
|
return snap
|
|
|
|
@patch("app.services.benchmark.pykrx_stock")
|
|
def test_compare_basic(self, mock_pykrx, service, db):
|
|
base = date(2025, 1, 2)
|
|
snapshots = [
|
|
self._make_snapshot(base, 10000),
|
|
self._make_snapshot(base + timedelta(days=30), 10500),
|
|
self._make_snapshot(base + timedelta(days=60), 10800),
|
|
]
|
|
|
|
portfolio = MagicMock()
|
|
portfolio.id = 1
|
|
portfolio.name = "Test Portfolio"
|
|
portfolio.user_id = 1
|
|
|
|
db.query.return_value.filter.return_value.first.return_value = portfolio
|
|
db.query.return_value.filter.return_value.order_by.return_value.all.return_value = snapshots
|
|
|
|
dates = pd.date_range(base.strftime("%Y%m%d"), periods=3, freq="30D")
|
|
mock_pykrx.get_index_ohlcv.return_value = pd.DataFrame(
|
|
{"시가": [2800, 2850, 2900], "종가": [2810, 2860, 2910]},
|
|
index=dates,
|
|
)
|
|
|
|
result = service.compare_with_benchmark(
|
|
portfolio_id=1, benchmark_type="kospi", period="all", user_id=1
|
|
)
|
|
|
|
assert result.portfolio_name == "Test Portfolio"
|
|
assert result.benchmark_type == "kospi"
|
|
assert result.alpha is not None
|
|
assert len(result.time_series) > 0
|
|
|
|
@patch("app.services.benchmark.pykrx_stock")
|
|
def test_compare_not_found(self, mock_pykrx, service, db):
|
|
db.query.return_value.filter.return_value.first.return_value = None
|
|
|
|
with pytest.raises(ValueError, match="Portfolio not found"):
|
|
service.compare_with_benchmark(
|
|
portfolio_id=999, benchmark_type="kospi", period="1y", user_id=1
|
|
)
|
|
|
|
@patch("app.services.benchmark.pykrx_stock")
|
|
def test_compare_no_snapshots(self, mock_pykrx, service, db):
|
|
portfolio = MagicMock()
|
|
portfolio.id = 1
|
|
portfolio.name = "Empty"
|
|
portfolio.user_id = 1
|
|
|
|
db.query.return_value.filter.return_value.first.return_value = portfolio
|
|
db.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
|
|
|
with pytest.raises(ValueError, match="스냅샷 데이터가 없습니다"):
|
|
service.compare_with_benchmark(
|
|
portfolio_id=1, benchmark_type="kospi", period="1y", user_id=1
|
|
)
|
|
|
|
|
|
class TestCalculateInformationRatio:
|
|
def test_positive_tracking_error(self, service):
|
|
portfolio_returns = [0.02, 0.03, -0.01, 0.04]
|
|
benchmark_returns = [0.01, 0.02, -0.005, 0.02]
|
|
|
|
ir = service._calculate_information_ratio(portfolio_returns, benchmark_returns)
|
|
assert ir is not None
|
|
assert isinstance(ir, float)
|
|
|
|
def test_zero_tracking_error(self, service):
|
|
same_returns = [0.01, 0.02, 0.03]
|
|
ir = service._calculate_information_ratio(same_returns, same_returns)
|
|
assert ir is None
|
|
|
|
def test_empty_returns(self, service):
|
|
ir = service._calculate_information_ratio([], [])
|
|
assert ir is None
|