feat(portfolio): add optimizer, risk model, and rebalance engine

- WeightOptimizer base class with registry (equal, factor_weighted, risk_parity, mean_variance)
- RiskModel with shrinkage covariance estimation and portfolio risk metrics
- RebalanceEngine for multi-period backtesting with commission/slippage
- 20 unit tests covering all components

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
GitHub
2026-06-12 20:22:31 +08:00
co-authored by Claude
parent 6b6b7c66a0
commit e6a69d51e4
8 changed files with 594 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
"""Test portfolio optimizers."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.portfolio.optimizer import (
EqualWeightOptimizer,
FactorWeightedOptimizer,
RiskParityOptimizer,
get_optimizer,
)
def _make_scores(n: int = 20, seed: int = 42) -> pd.DataFrame:
rng = np.random.default_rng(seed)
return pd.DataFrame({
"code": [f"{i:06d}" for i in range(n)],
"score": rng.normal(0.02, 0.05, n),
})
class TestEqualWeight:
def test_weights_sum_to_one(self):
w = EqualWeightOptimizer().optimize(_make_scores(), n_stocks=10)
assert abs(sum(w.values()) - 1.0) < 1e-8
def test_n_stocks_selected(self):
w = EqualWeightOptimizer().optimize(_make_scores(), n_stocks=5)
assert len(w) == 5
def test_all_equal(self):
w = EqualWeightOptimizer().optimize(_make_scores(), n_stocks=10)
vals = list(w.values())
assert all(abs(v - vals[0]) < 1e-8 for v in vals)
def test_empty_input(self):
w = EqualWeightOptimizer().optimize(pd.DataFrame(columns=["code", "score"]), n_stocks=5)
assert len(w) == 0
class TestFactorWeighted:
def test_weights_sum_to_one(self):
w = FactorWeightedOptimizer().optimize(_make_scores(), n_stocks=10)
assert abs(sum(w.values()) - 1.0) < 1e-6
def test_higher_score_higher_weight(self):
scores = pd.DataFrame({"code": ["A", "B", "C"], "score": [3.0, 2.0, 1.0]})
w = FactorWeightedOptimizer().optimize(scores, n_stocks=3)
assert w["A"] > w["C"]
class TestRiskParity:
def test_weights_sum_to_one(self):
w = RiskParityOptimizer().optimize(_make_scores(), n_stocks=10)
assert abs(sum(w.values()) - 1.0) < 1e-6
def test_with_volatility_column(self):
scores = pd.DataFrame({"code": ["A", "B", "C"], "score": [1.0, 1.0, 1.0], "volatility": [0.1, 0.2, 0.4]})
w = RiskParityOptimizer().optimize(scores, n_stocks=3)
assert w["A"] > w["C"]
class TestRegistry:
def test_get_optimizer(self):
assert isinstance(get_optimizer("equal"), EqualWeightOptimizer)
def test_unknown_raises(self):
with pytest.raises(ValueError, match="未知优化器"):
get_optimizer("nonexistent")
+55
View File
@@ -0,0 +1,55 @@
"""Test RebalanceEngine."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.portfolio.optimizer import EqualWeightOptimizer, FactorWeightedOptimizer
from easy_tdx.portfolio.rebalance import RebalanceEngine
def _make_market(n_stocks: int = 10, n_days: int = 120, seed: int = 42) -> dict[str, pd.DataFrame]:
rng = np.random.default_rng(seed)
data = {}
for i in range(n_stocks):
close = 10.0 + np.cumsum(rng.normal(0.01, 0.5, n_days))
close = np.maximum(close, 1.0)
data[f"{i:06d}"] = pd.DataFrame({
"datetime": pd.date_range("2024-01-01", periods=n_days, freq="D"),
"open": close, "high": close + 0.3, "low": close - 0.3,
"close": close,
"vol": rng.integers(1e5, 1e7, n_days).astype(float),
"amount": close * 1e6,
})
return data
class TestRebalanceEngine:
def test_basic_run(self):
engine = RebalanceEngine(optimizer=EqualWeightOptimizer(), factor_name="momentum_20d", n_stocks=5, rebalance_freq="M", cash=1_000_000)
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
assert len(result.states) > 0
assert len(result.rebalance_dates) > 0
assert len(result.equity_curve) > 0
assert "total_return" in result.performance
def test_with_factor_weighted(self):
engine = RebalanceEngine(optimizer=FactorWeightedOptimizer(), factor_name="momentum_20d", n_stocks=5)
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
assert len(result.states) > 0
def test_empty_data(self):
result = RebalanceEngine(optimizer=EqualWeightOptimizer()).run({})
assert result.performance["total_return"] == 0.0
def test_equity_curve_dates_sorted(self):
result = RebalanceEngine(optimizer=EqualWeightOptimizer(), rebalance_freq="M").run(_make_market(), start_date=20240101, end_date=20240430)
dates = result.equity_curve["datetime"].tolist()
assert dates == sorted(dates)
def test_trades_recorded(self):
engine = RebalanceEngine(optimizer=EqualWeightOptimizer(), n_stocks=3, rebalance_freq="M")
result = engine.run(_make_market(), start_date=20240101, end_date=20240430)
assert len(result.trades) > 0
assert "BUY" in result.trades["direction"].values
+44
View File
@@ -0,0 +1,44 @@
"""Test RiskModel."""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.portfolio.risk import RiskModel
def _make_returns(n_dates: int = 100, n_stocks: int = 5, seed: int = 42) -> pd.DataFrame:
rng = np.random.default_rng(seed)
codes = [f"{i:06d}" for i in range(n_stocks)]
return pd.DataFrame(rng.normal(0.001, 0.02, (n_dates, n_stocks)), columns=codes)
class TestCovarianceEstimation:
def test_shape(self):
cov = RiskModel().estimate_covariance(_make_returns())
assert cov.shape == (5, 5)
def test_symmetric(self):
cov = RiskModel().estimate_covariance(_make_returns())
assert np.allclose(cov.to_numpy(), cov.to_numpy().T)
def test_shrinkage_reduces_offdiag(self):
rm = RiskModel()
ret = _make_returns()
shrunk = rm.estimate_covariance(ret, method="shrinkage")
sample = rm.estimate_covariance(ret, method="sample")
off_shrunk = shrunk.values[~np.eye(5, dtype=bool)]
off_sample = sample.values[~np.eye(5, dtype=bool)]
assert np.abs(off_shrunk).mean() <= np.abs(off_sample).mean()
class TestPortfolioRisk:
def test_total_volatility(self):
cov = RiskModel().estimate_covariance(_make_returns())
risk = RiskModel().portfolio_risk({"000000": 0.5, "000001": 0.5}, cov)
assert risk["total_volatility"] > 0
assert risk["n_positions"] == 2
def test_empty_weights(self):
risk = RiskModel().portfolio_risk({}, pd.DataFrame())
assert risk["total_volatility"] == 0.0