Files
quant_engine/tests/test_portfolio_decomp.py
T
George BerkshireandMavis c04acf0ab6 feat: quant_engine 独立量化引擎(v1.2.0 重构 bootstrap)
从 research_results 抽出纯回测核心能力,形成独立成员仓。

## 模块(10 个核心)

| 模块 | 内容 |
|---|---|
| alpha_factors | 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158)+ JSONB 工具 |
| execution | 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解 |
| indicators | 50+ 技术指标(MACD/KDJ/布林/ATR/ADX/等) |
| data_adapter | 桥接 qtdb_pro 长表与新模块(rename/long-wide/复权/vwap 代理) |
| backtest | weight-based 多日仿真 |
| metrics / perf_stats | 绩效指标 |
| factor_library | 通用方法(turnover/IC/winsorize/OLS) |
| portfolio_decomp / risk | 组合分解 + 风险指标 |
| logging | 统一 logger(标准库 + 可选 loguru) |

## 设计原则

- 零重型依赖(numpy/pandas/scipy)
- mypy strict 0 errors(12 source files)
- 394 tests passed(从 research_results 复制 + 适配)
- ruff clean

## 与 research_results 的关系

- research_results 通过 re-export wrapper 保持向后兼容(src.shared.X → quant_engine.X)
- 47 proj 的 import 路径暂时不变,后续逐步迁移
- 本仓角色:researchhub_workspace 引擎层

Co-Authored-By: Mavis <noreply@mavis.local>
2026-08-19 15:50:01 +08:00

185 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""src/shared/portfolio_decomp.py 单测(v0.18.1 加,提升覆盖率 40% → 80%+)。"""
from __future__ import annotations
import numpy as np
import pytest
# ── l1_rebalance_decompose ────────────────────────────
def test_l1_rebalance_basic():
"""l1_rebalance_decompose 单资产案例:y == X[0] → β ≈ [1, 0, ..., 0]。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(0)
n = 50
X = np.random.randn(n, 3)
# y = X[:, 0](纯资产 0)
y = X[:, 0]
beta = l1_rebalance_decompose(y, X)
assert len(beta) == 3
assert abs(beta.sum() - 1.0) < 1e-4
assert beta[0] > 0.9 # 主要权重在资产 0
def test_l1_rebalance_y_x_shape_mismatch():
"""l1_rebalance_decompose y 与 X 行数不一致 → ValueError。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = np.array([1.0, 2.0, 3.0])
X = np.array([[1.0, 2.0], [3.0, 4.0]]) # 2 行
with pytest.raises(ValueError, match="行数不一致"):
l1_rebalance_decompose(y, X)
def test_l1_rebalance_bounds_length_mismatch():
"""l1_rebalance_decompose bounds 长度不匹配 → ValueError。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = np.array([1.0, 2.0])
X = np.array([[1.0, 2.0], [3.0, 4.0]])
bounds = [(0.0, 1.0)] # 长度 1,资产数 2
with pytest.raises(ValueError, match="bounds 长度"):
l1_rebalance_decompose(y, X, bounds=bounds)
def test_l1_rebalance_with_custom_bounds():
"""l1_rebalance_decompose 自定义 bounds → 权重在 bounds 内。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(0)
X = np.random.randn(50, 3)
y = X[:, 0]
bounds = [(0.0, 0.5), (0.0, 1.0), (0.0, 1.0)]
beta = l1_rebalance_decompose(y, X, bounds=bounds)
assert all(b <= 0.5 + 1e-4 for b in beta)
def test_l1_rebalance_list_input():
"""l1_rebalance_decompose 支持 list 输入(自动转 ndarray)。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = [1.0, 2.0, 3.0, 4.0, 5.0]
X = [[1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0], [5.0, 0.0]]
beta = l1_rebalance_decompose(y, X)
assert len(beta) == 2
# y 完全等于 X[:, 0],所以 β ≈ [1, 0]
assert beta[0] > 0.9
def test_l1_rebalance_result_in_bounds():
"""l1_rebalance_decompose 输出 β 在 [0, 1] 内。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(42)
X = np.random.randn(30, 4)
y = X.mean(axis=1)
beta = l1_rebalance_decompose(y, X)
assert all(0.0 <= b <= 1.0 + 1e-9 for b in beta)
assert abs(beta.sum() - 1.0) < 1e-4
# ── risk_parity_weights ────────────────────────────
def test_risk_parity_weights_basic():
"""risk_parity_weights 简单 2 资产协方差 → 权重合理。"""
from quant_engine.portfolio_decomp import risk_parity_weights
# 资产 1 风险 = 1,资产 2 风险 = 2(独立)→ 风险平价权重应偏向资产 1
cov = np.array(
[
[1.0, 0.0],
[0.0, 4.0], # 高波动
]
)
w = risk_parity_weights(cov)
assert len(w) == 2
assert abs(w.sum() - 1.0) < 1e-4
# 低波动资产应有更高权重
assert w[0] > w[1]
def test_risk_parity_weights_correlated():
"""risk_parity_weights 相关资产。"""
from quant_engine.portfolio_decomp import risk_parity_weights
np.random.seed(0)
A = np.random.randn(100, 3)
cov = np.cov(A.T)
w = risk_parity_weights(cov)
assert abs(w.sum() - 1.0) < 1e-3
assert all(w > 0)
def test_risk_parity_weights_zero_variance():
"""risk_parity_weights 全 0 方差 → 退化路径(返回等权 fallback)。"""
from quant_engine.portfolio_decomp import risk_parity_weights
cov = np.zeros((3, 3))
w = risk_parity_weights(cov)
assert len(w) == 3
# 全 0 时退化,结果可能异常但函数不应崩
assert abs(w.sum() - 1.0) < 0.5 # 容忍大误差
# ── mean_variance_weights ────────────────────────────
def test_mean_variance_weights_basic():
"""mean_variance_weights 单调收益 + 差异协方差 → 权重偏向高收益。
v1.2.0 修复:原 risk_aversion=1.0 时 SLSQP 把资产 1/2 卡在下边界 1e-4,
导致 w[1] > w[2] 因浮点噪声 flaky。改用 risk_aversion=100(解析解
[0.951, 0.045, 0.004] 在内部,稳定收敛)。
"""
from quant_engine.portfolio_decomp import mean_variance_weights
# mu = [0.10, 0.05, 0.02],cov 不等(资产 1 风险最高)
mu = np.array([0.10, 0.05, 0.02])
cov = np.diag([0.001, 0.01, 0.04]) # 资产 0 风险最低
w = mean_variance_weights(mu, cov, risk_aversion=100.0)
assert len(w) == 3
assert abs(w.sum() - 1.0) < 1e-3
# 资产 0:低风险高收益 → 应有最高权重(解析解 [0.951, 0.045, 0.004])
assert w[0] > w[1] > w[2]
# 与解析解一致(允许优化误差)
assert w[0] == pytest.approx(0.951, abs=0.01)
def test_mean_variance_weights_high_aversion():
"""mean_variance_weights 高风险厌恶 → 权重接近等权(偏好分散)。"""
from quant_engine.portfolio_decomp import mean_variance_weights
mu = np.array([0.1, 0.05, 0.02])
cov = np.diag([0.01, 0.01, 0.01])
w_low = mean_variance_weights(mu, cov, risk_aversion=0.5)
w_high = mean_variance_weights(mu, cov, risk_aversion=10.0)
# 高厌恶 → 权重差异小(更接近等权 1/3)
diff_low = w_low.max() - w_low.min()
diff_high = w_high.max() - w_high.min()
assert diff_high < diff_low
def test_mean_variance_weights_list_input():
"""mean_variance_weights 支持 list 输入。"""
from quant_engine.portfolio_decomp import mean_variance_weights
w = mean_variance_weights([0.1, 0.05], [[0.01, 0.001], [0.001, 0.01]])
assert len(w) == 2
assert abs(w.sum() - 1.0) < 1e-3
def test_mean_variance_weights_zero_mu():
"""mean_variance_weights 全 0 收益 → 等权。"""
from quant_engine.portfolio_decomp import mean_variance_weights
mu = np.zeros(3)
cov = np.eye(3) * 0.01
w = mean_variance_weights(mu, cov)
# 全 0 收益 → 等权
for wi in w:
assert abs(wi - 1.0 / 3) < 0.01