mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +08:00
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:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user