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>
This commit is contained in:
George Berkshire
2026-08-19 15:50:01 +08:00
co-authored by Mavis
commit c04acf0ab6
29 changed files with 9814 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
"""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