mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
docs: add quantitative guide, update README + CHANGELOG, bump v1.11.1
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -534,6 +534,41 @@ class MyStrategy(Strategy):
|
||||
|
||||
完整 API 参考:[docs/backtest_usage.md](docs/backtest_usage.md)
|
||||
|
||||
### 量化因子与组合管理
|
||||
|
||||
新增三大模块:**因子引擎**(19 个内置因子 + 自定义扩展)、**因子分析**(IC/分层/衰减)、**组合管理**(4 种优化器 + 再平衡引擎)。加上**高级回测增强**:可插拔滑点模型(方根冲击/成交量比例)、执行仿真(TWAP/VWAP/限价单)、归因分析(Brinson + 因子归因)。
|
||||
|
||||
```python
|
||||
from easy_tdx.factor import FactorEngine, FactorAnalyzer, preprocess
|
||||
from easy_tdx.portfolio import RebalanceEngine, FactorWeightedOptimizer
|
||||
from easy_tdx.backtest import BacktestEngine
|
||||
from easy_tdx.backtest.slippage import SquareRootSlippage
|
||||
from easy_tdx.backtest.execution import TWAPExecution
|
||||
|
||||
# 因子研究
|
||||
engine = FactorEngine()
|
||||
factor_data = engine.compute_cross_section(data, ["momentum_20d", "rsi_14"])
|
||||
clean = preprocess(factor_data, ["momentum_20d", "rsi_14"])
|
||||
forward_returns = engine.compute_forward_returns(data, period=5)
|
||||
report = FactorAnalyzer(clean, forward_returns).full_report("momentum_20d")
|
||||
print(f"IC均值={report.mean_ic:.4f} ICIR={report.icir:.4f}")
|
||||
|
||||
# 组合回测
|
||||
result = RebalanceEngine(
|
||||
FactorWeightedOptimizer(), factor_name="momentum_20d", n_stocks=50, cash=1_000_000,
|
||||
).run(data, start_date=20230101, end_date=20240101)
|
||||
print(f"年化={result.performance['annual_return']:.2%}")
|
||||
|
||||
# 高级回测(滑点 + 执行仿真)
|
||||
engine = BacktestEngine(
|
||||
MyStrategy, cash=1_000_000,
|
||||
slippage_model=SquareRootSlippage(impact_coeff=0.1),
|
||||
execution_model=TWAPExecution(n_bars=3),
|
||||
)
|
||||
```
|
||||
|
||||
详细用法和完整工作流示例:**[docs/quantitative-guide.md](docs/quantitative-guide.md)**
|
||||
|
||||
### 策略选股扫描(screen)
|
||||
|
||||
把策略翻转成选股器:给定一个策略,扫描全市场找出今天触发买入信号的股票,再对这些信号做历史回测排名。**纯离线数据**,读取本地通达信 `.day` 文件,全市场约 30-60 秒。
|
||||
@@ -910,6 +945,9 @@ uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
| `indicator-list` | 列出可用技术指标 |
|
||||
| `backtest` | 回测引擎(加载策略文件,输出绩效报告) |
|
||||
| `portfolio` | 多标的组合回测(共享资金池,均等分配,汇总绩效) |
|
||||
| `factor list` | 列出所有内置因子 |
|
||||
| `factor analyze` | 因子分析(IC/分层/衰减) |
|
||||
| `pfactor backtest` | 组合因子选股回测 |
|
||||
| `run-all` | 批量运行所有策略并排名(绩效排名 + 综合评分 + 可选图表) |
|
||||
| `screen scan` | 策略选股扫描(纯离线,全市场信号扫描) |
|
||||
| `screen rank` | 扫描结果回测排名(按夏普/回撤等指标排序) |
|
||||
@@ -1439,7 +1477,9 @@ src/easy_tdx/
|
||||
├── commands/ # 标准协议命令(无 IO)
|
||||
├── codec/ # price / volume / datetime / frame / bitmap 编解码
|
||||
├── chanlun/ # 缠论技术分析(K线合并/分型/笔/线段/中枢/买卖点/背驰)
|
||||
├── backtest/ # 回测引擎(Strategy基类/向量化引擎/多因子组合/组合回测/绩效分析)
|
||||
├── factor/ # 因子引擎(Factor ABC/19内置因子/截面计算/因子分析/预处理管道)
|
||||
├── portfolio/ # 组合管理(4优化器/风险模型/再平衡引擎)
|
||||
├── backtest/ # 回测引擎(Strategy基类/向量化引擎/多因子组合/滑点模型/执行仿真/归因分析)
|
||||
├── screen/ # 策略选股扫描(scan信号扫描/rank回测排名/并发扫描/增量缓存)
|
||||
├── realtime/ # 实时数据推送框架(EventBus/事件驱动/asyncio)
|
||||
├── web/ # Web API(FastAPI REST + WebSocket)
|
||||
@@ -1471,6 +1511,34 @@ ruff format --check src/ tests/ # format check
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.11.1 (2026-06-12)
|
||||
|
||||
**量化因子引擎 + 组合管理 + 高级回测增强** — 三大新模块,补齐从因子研究到组合执行的完整量化链路。
|
||||
|
||||
**因子引擎(factor/)**:
|
||||
- `Factor` ABC + 注册表模式(`@register_factor` 装饰器),19 个内置因子
|
||||
- `FactorEngine`:单股多因子 / 截面批量 / 远期收益计算
|
||||
- 因子类别:动量、波动率、质量、成交量、技术(桥接 MyTT)、缠论(桥接 ChanlunAnalyser)、价值(占位)
|
||||
- 因子预处理管道:去极值(MAD)、标准化、排名归一化、填充缺失、正交化
|
||||
- `FactorAnalyzer`:IC(Spearman)、分层收益(5 组)、换手率、衰减分析、完整报告
|
||||
|
||||
**组合管理(portfolio/)**:
|
||||
- 4 种权重优化器:等权、因子加权、风险平价(逆波动率)、均值方差(scipy 可选)
|
||||
- 风险模型:Ledoit-Wolf 收缩协方差、组合风险分解
|
||||
- `RebalanceEngine`:多期调仓回测(周/月/季),100 股整手、佣金+印花税
|
||||
|
||||
**高级回测增强(backtest/)**:
|
||||
- 4 种滑点模型:Fixed、Percent、SquareRoot(Almgren-Chriss)、Volume
|
||||
- 4 种执行仿真:Immediate、TWAP、VWAP、Limit(限价单 + TTL)
|
||||
- `AttributionAnalyzer`:成本归因、Brinson 归因(配置/选股/交叉)、因子归因
|
||||
- 完全向后兼容(`BacktestEngine` 新增 `slippage_model` / `execution_model` 可选参数)
|
||||
|
||||
**CLI**:
|
||||
- `easy-tdx factor list` / `factor analyze` — 因子列表和分析
|
||||
- `easy-tdx pfactor backtest` — 组合因子选股回测
|
||||
|
||||
**测试**:556 passed, 0 failed(+176 新增)
|
||||
|
||||
### 1.10.5 (2026-06-12)
|
||||
|
||||
**Web API 全面补齐 + 稳定性修复** — 新增 18 个 REST 端点,Web API 与 CLI 接口覆盖对齐,修复多个生产环境问题。
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
# 量化因子与组合管理 — 使用指南
|
||||
|
||||
> 本文档覆盖 easy-tdx v1.11.1 新增的量化计算能力:因子研究、因子分析、组合管理、高级回测(滑点建模/执行仿真/归因分析)。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 因子引擎](#1-因子引擎)
|
||||
- [1.1 内置因子一览](#11-内置因子一览)
|
||||
- [1.2 单股多因子计算](#12-单股多因子计算)
|
||||
- [1.3 截面因子计算](#13-截面因子计算)
|
||||
- [1.4 远期收益计算](#14-远期收益计算)
|
||||
- [1.5 自定义因子](#15-自定义因子)
|
||||
- [2. 因子预处理](#2-因子预处理)
|
||||
- [3. 因子分析](#3-因子分析)
|
||||
- [4. 组合管理](#4-组合管理)
|
||||
- [4.1 权重优化器](#41-权重优化器)
|
||||
- [4.2 风险模型](#42-风险模型)
|
||||
- [4.3 再平衡引擎](#43-再平衡引擎)
|
||||
- [5. 高级回测](#5-高级回测)
|
||||
- [5.1 滑点模型](#51-滑点模型)
|
||||
- [5.2 执行仿真](#52-执行仿真)
|
||||
- [5.3 归因分析](#53-归因分析)
|
||||
- [6. CLI 命令](#6-cli-命令)
|
||||
- [7. 完整工作流示例](#7-完整工作流示例)
|
||||
|
||||
---
|
||||
|
||||
## 1. 因子引擎
|
||||
|
||||
因子引擎(`FactorEngine`)支持单股和多股截面两种计算模式,内置 19 个因子。
|
||||
|
||||
### 1.1 内置因子一览
|
||||
|
||||
| 类别 | 因子名 | 说明 |
|
||||
|------|--------|------|
|
||||
| **动量** | `momentum_20d` | 20 日收益率 |
|
||||
| | `momentum_60d` | 60 日收益率 |
|
||||
| | `reversal_5d` | 5 日反转(负收益) |
|
||||
| **波动率** | `volatility_20d` | 20 日年化波动率 |
|
||||
| | `atr_14d` | 14 日平均真实波幅 |
|
||||
| | `turnover_rate` | 换手率(需 vol 列) |
|
||||
| **质量** | `sharpe_20d` | 20 日夏普比率 |
|
||||
| | `max_drawdown_20d` | 20 日最大回撤 |
|
||||
| | `win_rate_20d` | 20 日上涨天数占比 |
|
||||
| **成交量** | `obv_trend` | OBV 趋势斜率 |
|
||||
| | `vol_surge` | 成交量突增倍数 |
|
||||
| | `amount_ma_ratio` | 成交额 / MA5 比值 |
|
||||
| **技术** | `macd_hist_signal` | MACD 柱状信号 |
|
||||
| | `rsi_14` | 14 日 RSI |
|
||||
| | `boll_position` | 布林带位置(0~1) |
|
||||
| **缠论** | `chanlun_bi_dir` | 当前笔方向(+1/-1) |
|
||||
| | `chanlun_mmd` | 最近买卖点(+2/+1/-1/-2) |
|
||||
| **价值** | `pe_ratio` | 市盈率(占位,返回 NaN) |
|
||||
| | `pb_ratio` | 市净率(占位,返回 NaN) |
|
||||
|
||||
### 1.2 单股多因子计算
|
||||
|
||||
```python
|
||||
from easy_tdx import TdxClient
|
||||
from easy_tdx.factor import FactorEngine
|
||||
|
||||
client = TdxClient()
|
||||
df = client.get_security_bars(Market.SH, "600519", KlineCategory.DAY, 0, 300)
|
||||
|
||||
engine = FactorEngine()
|
||||
|
||||
# 计算多个因子
|
||||
result = engine.compute_single(df, ["momentum_20d", "volatility_20d", "rsi_14"])
|
||||
print(result.tail())
|
||||
|
||||
# 计算所有内置因子
|
||||
result = engine.compute_single(df) # 不传因子名 = 全部
|
||||
print(result.columns.tolist())
|
||||
```
|
||||
|
||||
输出 DataFrame 在原始列基础上追加因子列(以因子名命名,前缀 `NaN` 行因窗口不足为 `NaN`)。
|
||||
|
||||
### 1.3 截面因子计算
|
||||
|
||||
```python
|
||||
from easy_tdx import TdxClient
|
||||
from easy_tdx.factor import FactorEngine
|
||||
|
||||
client = TdxClient()
|
||||
|
||||
# 准备多只股票数据
|
||||
stock_pool = ["000001", "000858", "600519", "600036", "601318"]
|
||||
data = {}
|
||||
for code in stock_pool:
|
||||
market = Market.SH if code.startswith("6") else Market.SZ
|
||||
data[code] = client.get_security_bars(market, code, KlineCategory.DAY, 0, 300)
|
||||
|
||||
engine = FactorEngine()
|
||||
|
||||
# 截面计算:返回 long format(date, code, factor_name...)
|
||||
factor_data = engine.compute_cross_section(
|
||||
data,
|
||||
["momentum_20d", "volatility_20d", "rsi_14"],
|
||||
)
|
||||
print(factor_data.head(10))
|
||||
|
||||
# 指定日期:只计算某一天的截面
|
||||
factor_data = engine.compute_cross_section(
|
||||
data, ["momentum_20d"], date=20240601,
|
||||
)
|
||||
```
|
||||
|
||||
### 1.4 远期收益计算
|
||||
|
||||
```python
|
||||
# 计算未来 5 日收益率(用于因子分析)
|
||||
forward_returns = engine.compute_forward_returns(data, period=5)
|
||||
print(forward_returns.head())
|
||||
```
|
||||
|
||||
### 1.5 自定义因子
|
||||
|
||||
继承 `Factor` 基类,用 `@register_factor` 注册即可自动发现:
|
||||
|
||||
```python
|
||||
from easy_tdx.factor import Factor, register_factor
|
||||
|
||||
@register_factor
|
||||
class MyMomentum(Factor):
|
||||
name = "my_momentum"
|
||||
description = "自定义动量因子"
|
||||
window = 20
|
||||
|
||||
def compute(self, df):
|
||||
return df["close"].pct_change(self.window)
|
||||
```
|
||||
|
||||
注册后直接用名字引用:
|
||||
|
||||
```python
|
||||
result = engine.compute_single(df, ["my_momentum"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 因子预处理
|
||||
|
||||
6 个纯函数,组合成管道:
|
||||
|
||||
```python
|
||||
from easy_tdx.factor import preprocess
|
||||
|
||||
# 单因子预处理管道
|
||||
clean = preprocess(
|
||||
factor_data,
|
||||
factor_names=["momentum_20d"],
|
||||
steps=["winsorize", "zscore", "fill_missing"],
|
||||
)
|
||||
```
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `winsorize(df, factor_names, n_sigma=3)` | MAD 去极值 |
|
||||
| `zscore(df, factor_names)` | 截面标准化 |
|
||||
| `rank_normalize(df, factor_names)` | 排名归一化 |
|
||||
| `fill_missing(df, factor_names)` | 填充缺失值 |
|
||||
| `orthogonalize(df, factor_names, by="market_cap")` | 正交化(去除市值暴露) |
|
||||
| `preprocess(df, factor_names, steps)` | 组合管道 |
|
||||
|
||||
所有函数自动检测截面数据(有 `date` 列时按日期分组处理)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 因子分析
|
||||
|
||||
```python
|
||||
from easy_tdx.factor import FactorEngine, FactorAnalyzer, preprocess
|
||||
|
||||
# 1. 计算截面因子
|
||||
factor_data = engine.compute_cross_section(data, ["momentum_20d", "rsi_14"])
|
||||
|
||||
# 2. 预处理
|
||||
clean = preprocess(factor_data, ["momentum_20d", "rsi_14"])
|
||||
|
||||
# 3. 计算远期收益
|
||||
forward_returns = engine.compute_forward_returns(data, period=5)
|
||||
|
||||
# 4. 分析
|
||||
analyzer = FactorAnalyzer(clean, forward_returns)
|
||||
|
||||
# IC 分析(Spearman 秩相关)
|
||||
ic_series = analyzer.compute_ic("momentum_20d")
|
||||
print(f"均值 IC: {ic_series.mean():.4f}, ICIR: {ic_series.mean()/ic_series.std():.4f}")
|
||||
|
||||
# 分层收益(5 组)
|
||||
quantile_returns = analyzer.compute_quantile_returns("momentum_20d", n_groups=5)
|
||||
print(quantile_returns.head())
|
||||
|
||||
# 因子衰减(IC 自相关)
|
||||
decay = analyzer.compute_decay("momentum_20d", max_lag=10)
|
||||
print(decay)
|
||||
|
||||
# 完整报告
|
||||
report = analyzer.full_report("momentum_20d")
|
||||
print(f"IC均值={report.mean_ic:.4f} ICIR={report.icir:.4f}")
|
||||
print(f"多头年化={report.long_only_annual:.2%} 空头年化={report.short_only_annual:.2%}")
|
||||
print(f"多空夏普={report.long_short_sharpe:.4f} 换手率={report.turnover:.4f}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 组合管理
|
||||
|
||||
### 4.1 权重优化器
|
||||
|
||||
4 种内置优化器:
|
||||
|
||||
```python
|
||||
from easy_tdx.portfolio import (
|
||||
EqualWeightOptimizer,
|
||||
FactorWeightedOptimizer,
|
||||
RiskParityOptimizer,
|
||||
MeanVarianceOptimizer,
|
||||
)
|
||||
import pandas as pd
|
||||
|
||||
# 因子分数表(来自 FactorEngine)
|
||||
scores_df = pd.DataFrame({
|
||||
"code": ["000001", "600519", "601318", "000858", "600036"],
|
||||
"score": [0.8, 0.6, 0.5, 0.3, 0.1],
|
||||
})
|
||||
|
||||
# 1. 等权:选前 N 只,等权分配
|
||||
opt1 = EqualWeightOptimizer()
|
||||
weights1 = opt1.optimize(scores_df, n_stocks=3)
|
||||
# {'000001': 0.333, '600519': 0.333, '601318': 0.333}
|
||||
|
||||
# 2. 因子加权:分数越高权重越大
|
||||
opt2 = FactorWeightedOptimizer()
|
||||
weights2 = opt2.optimize(scores_df, n_stocks=3)
|
||||
# {'000001': 0.42, '600519': 0.32, '601318': 0.26}
|
||||
|
||||
# 3. 风险平价:按波动率倒数加权
|
||||
returns_df = pd.DataFrame(...) # 收益率矩阵
|
||||
opt3 = RiskParityOptimizer(returns_df)
|
||||
weights3 = opt3.optimize(scores_df, n_stocks=3)
|
||||
|
||||
# 4. 均值方差:scipy SLSQP 优化(无 scipy 退化为等权)
|
||||
opt4 = MeanVarianceOptimizer(returns_df)
|
||||
weights4 = opt4.optimize(scores_df, n_stocks=3)
|
||||
```
|
||||
|
||||
### 4.2 风险模型
|
||||
|
||||
```python
|
||||
from easy_tdx.portfolio import RiskModel
|
||||
import pandas as pd
|
||||
|
||||
risk = RiskModel()
|
||||
|
||||
# 估计协方差矩阵(Ledoit-Wolf 收缩)
|
||||
returns = pd.DataFrame(...) # N 只股票 × T 天收益率
|
||||
cov = risk.estimate_covariance(returns, method="shrinkage", window=60)
|
||||
|
||||
# 组合风险分解
|
||||
weights = {"000001": 0.3, "600519": 0.4, "601318": 0.3}
|
||||
metrics = risk.portfolio_risk(weights, cov)
|
||||
print(f"年化波动率: {metrics['total_volatility']:.2%}")
|
||||
print(f"最大风险贡献: {metrics['max_risk_contribution']:.2%}")
|
||||
print(f"持仓数: {metrics['n_positions']}")
|
||||
```
|
||||
|
||||
### 4.3 再平衡引擎
|
||||
|
||||
```python
|
||||
from easy_tdx.portfolio import RebalanceEngine, FactorWeightedOptimizer
|
||||
from easy_tdx import TdxClient
|
||||
|
||||
client = TdxClient()
|
||||
stock_pool = ["000001", "000858", "600519", "600036", "601318"]
|
||||
data = {c: client.get_security_bars(..., c, ...) for c in stock_pool}
|
||||
|
||||
# 创建引擎
|
||||
engine = RebalanceEngine(
|
||||
optimizer=FactorWeightedOptimizer(),
|
||||
factor_name="momentum_20d", # 用哪个因子选股
|
||||
n_stocks=3, # 持仓数量
|
||||
rebalance_freq="M", # 调仓频率: W/M/Q
|
||||
commission=0.0003, # 佣金率
|
||||
slippage=0.001, # 滑点率
|
||||
cash=1_000_000, # 初始资金
|
||||
)
|
||||
|
||||
# 运行回测
|
||||
result = engine.run(data, start_date=20230101, end_date=20240101)
|
||||
|
||||
# 结果
|
||||
print(f"总收益: {result.performance['total_return']:.2%}")
|
||||
print(f"年化: {result.performance['annual_return']:.2%}")
|
||||
print(f"最大回撤: {result.performance['max_drawdown']:.2%}")
|
||||
print(f"夏普: {result.performance['sharpe']:.4f}")
|
||||
print(f"调仓次数: {len(result.rebalance_dates)}")
|
||||
print(f"交易笔数: {len(result.trades)}")
|
||||
|
||||
# 权益曲线
|
||||
print(result.equity_curve.head())
|
||||
|
||||
# 持仓历史
|
||||
for state in result.states[-5:]:
|
||||
print(f" {state.date}: 持仓{state.positions_count}只 净值{state.total_value:.0f}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 高级回测
|
||||
|
||||
### 5.1 滑点模型
|
||||
|
||||
4 种可插拔滑点模型,替代原有固定滑点:
|
||||
|
||||
```python
|
||||
from easy_tdx.backtest import BacktestEngine
|
||||
from easy_tdx.backtest.slippage import (
|
||||
FixedSlippage,
|
||||
PercentSlippage,
|
||||
SquareRootSlippage,
|
||||
VolumeSlippage,
|
||||
)
|
||||
|
||||
# 1. 固定每股滑点(与旧行为一致)
|
||||
model1 = FixedSlippage(per_share=0.01)
|
||||
|
||||
# 2. 按金额百分比
|
||||
model2 = PercentSlippage(rate=0.001)
|
||||
|
||||
# 3. 方根市场冲击模型(Almgren-Chriss 简化版)
|
||||
# impact = sigma * sqrt(participation_rate) * price * size * coeff
|
||||
# A 股量化主流:参与率 >5% 时冲击显著
|
||||
model3 = SquareRootSlippage(impact_coeff=0.1)
|
||||
|
||||
# 4. 成交量比例滑点
|
||||
model4 = VolumeSlippage(base_bps=10.0)
|
||||
|
||||
# 在 BacktestEngine 中使用
|
||||
engine = BacktestEngine(
|
||||
MyStrategy,
|
||||
cash=1_000_000,
|
||||
slippage_model=SquareRootSlippage(impact_coeff=0.1),
|
||||
)
|
||||
result = engine.run(df)
|
||||
```
|
||||
|
||||
**模型选择建议**:
|
||||
|
||||
| 场景 | 推荐模型 | 参数 |
|
||||
|------|---------|------|
|
||||
| 快速原型 | `FixedSlippage` | `per_share=0.01` |
|
||||
| 中频策略 | `PercentSlippage` | `rate=0.001` |
|
||||
| 大额订单 | `SquareRootSlippage` | `impact_coeff=0.1` |
|
||||
| 低流动性股票 | `VolumeSlippage` | `base_bps=10.0` |
|
||||
|
||||
### 5.2 执行仿真
|
||||
|
||||
4 种执行模型,将单笔信号拆分为多笔子交易:
|
||||
|
||||
```python
|
||||
from easy_tdx.backtest.execution import (
|
||||
ImmediateExecution,
|
||||
TWAPExecution,
|
||||
VWAPExecution,
|
||||
LimitExecution,
|
||||
)
|
||||
|
||||
# 1. 即时成交(默认,与旧行为一致)
|
||||
exec1 = ImmediateExecution()
|
||||
|
||||
# 2. TWAP:时间加权平均价格,N 根 K 线均匀拆单
|
||||
exec2 = TWAPExecution(n_bars=5)
|
||||
|
||||
# 3. VWAP:成交量加权平均价格,按历史量分布拆单
|
||||
exec3 = VWAPExecution(n_bars=5, volume_lookback=20)
|
||||
|
||||
# 4. 限价单:目标价挂单,TTL 内未触发则放弃
|
||||
exec4 = LimitExecution(ttl_bars=5)
|
||||
|
||||
# 在 BacktestEngine 中使用
|
||||
engine = BacktestEngine(
|
||||
MyStrategy,
|
||||
cash=1_000_000,
|
||||
execution_model=TWAPExecution(n_bars=3),
|
||||
slippage_model=SquareRootSlippage(),
|
||||
)
|
||||
result = engine.run(df)
|
||||
```
|
||||
|
||||
**执行模型选择**:
|
||||
|
||||
| 场景 | 推荐模型 | 参数 |
|
||||
|------|---------|------|
|
||||
| 小额/快速验证 | `ImmediateExecution` | 默认 |
|
||||
| 大额建仓/平仓 | `TWAPExecution` | `n_bars=3~5` |
|
||||
| 追踪 VWAP 基准 | `VWAPExecution` | `n_bars=5` |
|
||||
| 精确入场价位 | `LimitExecution` | `ttl_bars=5` |
|
||||
|
||||
**TWAP vs VWAP 示例**:
|
||||
|
||||
```python
|
||||
# TWAP: 300 股拆成 3 笔 100 股,在 bar 1/2/3 以 close 执行
|
||||
engine = BacktestEngine(
|
||||
MyStrategy, cash=100_000,
|
||||
execution_model=TWAPExecution(n_bars=3),
|
||||
)
|
||||
|
||||
# VWAP: 按成交量分布拆 300 股 — 成交量大的 bar 分配更多
|
||||
engine = BacktestEngine(
|
||||
MyStrategy, cash=100_000,
|
||||
execution_model=VWAPExecution(n_bars=3, volume_lookback=20),
|
||||
)
|
||||
|
||||
# 限价单:在 50 元挂买入,5 根 K 线内 low <= 50 才成交
|
||||
class LimitBuyStrategy(Strategy):
|
||||
def init(self): pass
|
||||
def next(self):
|
||||
if self._bar_index == 0:
|
||||
self.buy(size=100, price=50.0) # 指定限价
|
||||
|
||||
engine = BacktestEngine(
|
||||
LimitBuyStrategy, cash=100_000,
|
||||
execution_model=LimitExecution(ttl_bars=5),
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 归因分析
|
||||
|
||||
从回测结果生成归因报告:
|
||||
|
||||
```python
|
||||
from easy_tdx.backtest import BacktestEngine
|
||||
from easy_tdx.backtest.attribution import AttributionAnalyzer
|
||||
|
||||
# 运行回测
|
||||
engine = BacktestEngine(MyStrategy, cash=1_000_000)
|
||||
result = engine.run(df)
|
||||
|
||||
# --- 成本归因 ---
|
||||
analyzer = AttributionAnalyzer(result.trades, result.equity_curve)
|
||||
cost_report = analyzer.cost_attribution()
|
||||
print(f"总收益: {cost_report.total_return:.2%}")
|
||||
print(f"总交易成本: {cost_report.total_trade_cost:.0f} 元")
|
||||
print(f" 佣金: {cost_report.commission_cost:.0f}")
|
||||
print(f" 滑点: {cost_report.slippage_cost:.0f}")
|
||||
print(f" 印花税: {cost_report.stamp_tax_cost:.0f}")
|
||||
|
||||
# --- Brinson 归因(需要基准)---
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
# 构造基准曲线(如沪深300)
|
||||
benchmark = pd.DataFrame({
|
||||
"datetime": result.equity_curve["datetime"],
|
||||
"total": np.linspace(100000, 108000, len(result.equity_curve)),
|
||||
})
|
||||
analyzer = AttributionAnalyzer(result.trades, result.equity_curve, benchmark=benchmark)
|
||||
brinson_report = analyzer.brinson_attribution()
|
||||
print(f"配置贡献: {brinson_report.allocation_return:.2%}")
|
||||
print(f"选股贡献: {brinson_report.selection_return:.2%}")
|
||||
print(f"交叉效应: {brinson_report.interaction_return:.2%}")
|
||||
|
||||
# --- 因子归因(需要因子数据)---
|
||||
exposures = pd.DataFrame({"momentum": [0.5, 0.3, 0.2], "quality": [0.1, -0.1, 0.0]})
|
||||
returns = pd.DataFrame({"momentum": [0.05, 0.03, 0.02], "quality": [0.01, -0.02, 0.0]})
|
||||
analyzer = AttributionAnalyzer(
|
||||
result.trades, result.equity_curve,
|
||||
factor_exposures=exposures, factor_returns=returns,
|
||||
)
|
||||
factor_report = analyzer.factor_attribution()
|
||||
for name, ret in factor_report.factor_returns.items():
|
||||
print(f" {name}: {ret:.4f}")
|
||||
print(f"特质收益: {factor_report.specific_return:.4f}")
|
||||
|
||||
# --- 完整报告(自动选择最佳归因模式)---
|
||||
full_report = analyzer.full_report()
|
||||
```
|
||||
|
||||
**归因模式优先级**:因子归因 > Brinson 归因 > 成本归因。`full_report()` 自动选择数据最完整的模式。
|
||||
|
||||
---
|
||||
|
||||
## 6. CLI 命令
|
||||
|
||||
```bash
|
||||
# 列出所有内置因子
|
||||
easy-tdx factor list --table
|
||||
|
||||
# 因子分析(需要数据,输出示例代码)
|
||||
easy-tdx factor analyze momentum_20d
|
||||
|
||||
# 组合因子回测(需要数据,输出示例代码)
|
||||
easy-tdx pfactor backtest momentum_20d --n-stocks 10 --optimizer factor_weighted
|
||||
```
|
||||
|
||||
CLI 命令输出 Python API 示例代码,方便复制使用。完整的因子计算和组合回测建议通过 Python API 完成。
|
||||
|
||||
---
|
||||
|
||||
## 7. 完整工作流示例
|
||||
|
||||
从数据获取到组合回测再到归因分析的完整管道:
|
||||
|
||||
```python
|
||||
"""
|
||||
easy-tdx 量化研究完整工作流示例。
|
||||
|
||||
依赖: pip install easy-tdx
|
||||
"""
|
||||
|
||||
from easy_tdx import TdxClient, Market, KlineCategory
|
||||
from easy_tdx.factor import FactorEngine, FactorAnalyzer, preprocess
|
||||
from easy_tdx.portfolio import RebalanceEngine, FactorWeightedOptimizer
|
||||
from easy_tdx.backtest import BacktestEngine
|
||||
from easy_tdx.backtest.slippage import SquareRootSlippage
|
||||
from easy_tdx.backtest.execution import TWAPExecution
|
||||
from easy_tdx.backtest.attribution import AttributionAnalyzer
|
||||
|
||||
# ── 1. 数据获取 ──────────────────────────────────────
|
||||
client = TdxClient()
|
||||
stock_pool = ["000001", "000858", "600519", "600036", "601318",
|
||||
"000333", "002415", "601012", "600276", "000568"]
|
||||
|
||||
data = {}
|
||||
for code in stock_pool:
|
||||
market = Market.SH if code.startswith("6") else Market.SZ
|
||||
data[code] = client.get_security_bars(
|
||||
market, code, KlineCategory.DAY, 0, 500
|
||||
)
|
||||
print(f"获取 {len(data)} 只股票数据")
|
||||
|
||||
# ── 2. 因子计算 ──────────────────────────────────────
|
||||
engine = FactorEngine()
|
||||
factor_data = engine.compute_cross_section(
|
||||
data, ["momentum_20d", "volatility_20d", "rsi_14"]
|
||||
)
|
||||
print(f"截面因子数据: {len(factor_data)} 行")
|
||||
|
||||
# ── 3. 因子预处理 ─────────────────────────────────────
|
||||
clean = preprocess(
|
||||
factor_data,
|
||||
factor_names=["momentum_20d", "volatility_20d", "rsi_14"],
|
||||
steps=["winsorize", "zscore", "fill_missing"],
|
||||
)
|
||||
|
||||
# ── 4. 因子分析 ──────────────────────────────────────
|
||||
forward_returns = engine.compute_forward_returns(data, period=5)
|
||||
|
||||
for factor_name in ["momentum_20d", "volatility_20d", "rsi_14"]:
|
||||
analyzer = FactorAnalyzer(clean, forward_returns)
|
||||
report = analyzer.full_report(factor_name)
|
||||
print(f"\n── {factor_name} ──")
|
||||
print(f" IC均值: {report.mean_ic:.4f} ICIR: {report.icir:.4f}")
|
||||
print(f" 多头年化: {report.long_only_annual:.2%}")
|
||||
print(f" 多空夏普: {report.long_short_sharpe:.4f}")
|
||||
|
||||
# ── 5. 组合回测 ──────────────────────────────────────
|
||||
rebalancer = RebalanceEngine(
|
||||
optimizer=FactorWeightedOptimizer(),
|
||||
factor_name="momentum_20d",
|
||||
n_stocks=5,
|
||||
rebalance_freq="M",
|
||||
cash=1_000_000,
|
||||
)
|
||||
result = rebalancer.run(data, start_date=20230101, end_date=20240101)
|
||||
print(f"\n── 组合回测 ──")
|
||||
print(f" 总收益: {result.performance['total_return']:.2%}")
|
||||
print(f" 年化: {result.performance['annual_return']:.2%}")
|
||||
print(f" 最大回撤: {result.performance['max_drawdown']:.2%}")
|
||||
print(f" 夏普: {result.performance['sharpe']:.4f}")
|
||||
|
||||
# ── 6. 高级单策略回测(滑点 + 执行仿真)──────
|
||||
from easy_tdx.backtest import Strategy
|
||||
|
||||
class MomentumStrategy(Strategy):
|
||||
def init(self):
|
||||
pass
|
||||
def next(self):
|
||||
if self._bar_index < 20:
|
||||
return
|
||||
ret = (self.data.close[0] - self.data.close[-20]) / self.data.close[-20]
|
||||
if ret > 0.05 and self.position["size"] == 0:
|
||||
self.buy(size=0)
|
||||
elif ret < -0.03 and self.position["size"] > 0:
|
||||
self.sell(size=0)
|
||||
|
||||
bt_engine = BacktestEngine(
|
||||
MomentumStrategy,
|
||||
cash=500_000,
|
||||
slippage_model=SquareRootSlippage(impact_coeff=0.1),
|
||||
execution_model=TWAPExecution(n_bars=3),
|
||||
)
|
||||
# 选一只股票做回测
|
||||
bt_result = bt_engine.run(data["600519"])
|
||||
print(f"\n── 高级回测(600519)──")
|
||||
print(f" 总收益: {bt_result.performance['total_return']:.2%}")
|
||||
print(f" 夏普: {bt_result.performance['sharpe']:.4f}")
|
||||
|
||||
# ── 7. 归因分析 ──────────────────────────────────────
|
||||
att_analyzer = AttributionAnalyzer(bt_result.trades, bt_result.equity_curve)
|
||||
cost_report = att_analyzer.cost_attribution()
|
||||
print(f"\n── 成本归因 ──")
|
||||
print(f" 总交易成本: {cost_report.total_trade_cost:.0f} 元")
|
||||
print(f" 佣金: {cost_report.commission_cost:.0f}")
|
||||
print(f" 滑点: {cost_report.slippage_cost:.0f}")
|
||||
print(f" 印花税: {cost_report.stamp_tax_cost:.0f}")
|
||||
|
||||
print("\n完成。")
|
||||
client.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 向后兼容
|
||||
|
||||
所有新功能通过可选参数启用,**现有代码零改动**:
|
||||
|
||||
| 现有调用 | 行为 |
|
||||
|---------|------|
|
||||
| `BacktestEngine(strategy, slippage=0.01)` | 与旧版完全一致 |
|
||||
| `BacktestEngine(strategy)` | 无滑点,与旧版一致 |
|
||||
| `OrderSimulator(df, slippage=0.01)` | 与旧版完全一致 |
|
||||
| `BacktestEngine(strategy, slippage_model=...)` | 使用新滑点模型 |
|
||||
| `BacktestEngine(strategy, execution_model=...)` | 使用新执行引擎 |
|
||||
|
||||
新增模块(`factor/`, `portfolio/`, `backtest/slippage.py`, `backtest/execution.py`, `backtest/attribution.py`)为独立新增,不修改任何现有接口。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
# v1.15.0 归因分析 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 新增归因分析模块,支持 Brinson 归因(配置 vs 选股)、因子归因、成本归因。
|
||||
|
||||
**Architecture:** 新增 `backtest/attribution.py`,纯 pandas/numpy 计算,与现有 `FactorEngine` 无缝衔接。
|
||||
|
||||
**Tech Stack:** 纯 numpy/pandas,无新外部依赖。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/easy_tdx/backtest/attribution.py` | 新增 | AttributionReport + AttributionAnalyzer |
|
||||
| `tests/unit/test_backtest_attribution.py` | 新增 | 归因分析测试(~20 个) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: AttributionReport + cost_attribution + brinson_attribution + factor_attribution
|
||||
|
||||
**Files:**
|
||||
- Create: `src/easy_tdx/backtest/attribution.py`
|
||||
- Create: `tests/unit/test_backtest_attribution.py`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
```python
|
||||
"""归因分析模块。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributionReport:
|
||||
"""归因分析报告。"""
|
||||
|
||||
total_return: float = 0.0
|
||||
# Brinson 归因
|
||||
allocation_return: float = 0.0
|
||||
selection_return: float = 0.0
|
||||
interaction_return: float = 0.0
|
||||
# 因子归因
|
||||
factor_returns: dict[str, float] = field(default_factory=dict)
|
||||
specific_return: float = 0.0
|
||||
# 成本归因
|
||||
total_trade_cost: float = 0.0
|
||||
slippage_cost: float = 0.0
|
||||
commission_cost: float = 0.0
|
||||
stamp_tax_cost: float = 0.0
|
||||
|
||||
|
||||
class AttributionAnalyzer:
|
||||
"""收益归因分析器。
|
||||
|
||||
支持三种归因视角:
|
||||
1. 成本归因:分解交易成本的来源(佣金/滑点/印花税)
|
||||
2. Brinson 归因:分解超额收益(配置 vs 选股)
|
||||
3. 因子归因:分解收益为因子贡献 + 特质收益
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trades: pd.DataFrame,
|
||||
equity_curve: pd.DataFrame,
|
||||
benchmark: pd.DataFrame | None = None,
|
||||
factor_exposures: pd.DataFrame | None = None,
|
||||
factor_returns: pd.DataFrame | None = None,
|
||||
groups: pd.DataFrame | None = None,
|
||||
) -> None:
|
||||
self._trades = trades
|
||||
self._equity_curve = equity_curve
|
||||
self._benchmark = benchmark
|
||||
self._factor_exposures = factor_exposures
|
||||
self._factor_returns = factor_returns
|
||||
self._groups = groups
|
||||
|
||||
def cost_attribution(self) -> AttributionReport:
|
||||
"""成本归因:分解交易成本。"""
|
||||
if self._trades.empty:
|
||||
return AttributionReport()
|
||||
|
||||
valid = self._trades[~self._trades["rejected"]] if "rejected" in self._trades.columns else self._trades
|
||||
|
||||
slippage_cost = float(valid["slippage"].sum()) if "slippage" in valid.columns else 0.0
|
||||
commission_cost = float(valid["commission"].sum()) if "commission" in valid.columns else 0.0
|
||||
|
||||
# 总成本 = 滑点 + 佣金(佣金内含印花税)
|
||||
total_trade_cost = slippage_cost + commission_cost
|
||||
|
||||
# 估算印花税(卖出交易 0.1%)
|
||||
sell_mask = valid["direction"] == "SELL" if "direction" in valid.columns else pd.Series(dtype=bool)
|
||||
stamp_tax_cost = 0.0
|
||||
if sell_mask.any():
|
||||
sell_trades = valid[sell_mask]
|
||||
if "price" in sell_trades.columns and "size" in sell_trades.columns:
|
||||
stamp_tax_cost = float((sell_trades["price"] * sell_trades["size"] * 0.001).sum())
|
||||
|
||||
total_return = 0.0
|
||||
if not self._equity_curve.empty and "total" in self._equity_curve.columns:
|
||||
total_arr = self._equity_curve["total"].to_numpy()
|
||||
if len(total_arr) >= 2 and total_arr[0] > 0:
|
||||
total_return = float((total_arr[-1] / total_arr[0]) - 1)
|
||||
|
||||
return AttributionReport(
|
||||
total_return=total_return,
|
||||
total_trade_cost=total_trade_cost,
|
||||
slippage_cost=slippage_cost,
|
||||
commission_cost=commission_cost,
|
||||
stamp_tax_cost=stamp_tax_cost,
|
||||
)
|
||||
|
||||
def brinson_attribution(self) -> AttributionReport:
|
||||
"""Brinson-Hood-Beebower 归因分解。
|
||||
|
||||
Total = Allocation + Selection + Interaction
|
||||
R_p = Σ(w_pi × R_pi) 组合收益
|
||||
R_b = Σ(w_bi × R_bi) 基准收益
|
||||
Allocation = Σ((w_pi - w_bi) × R_bi)
|
||||
Selection = Σ(w_bi × (R_pi - R_bi))
|
||||
Interaction = Σ((w_pi - w_bi) × (R_pi - R_bi))
|
||||
|
||||
需要提供 benchmark 参数。
|
||||
如果没有 benchmark,只返回 total_return。
|
||||
"""
|
||||
cost_report = self.cost_attribution()
|
||||
|
||||
if self._benchmark is None:
|
||||
return cost_report
|
||||
|
||||
# 简化 Brinson:使用 equity_curve 估算
|
||||
if self._equity_curve.empty:
|
||||
return cost_report
|
||||
|
||||
total_arr = self._equity_curve["total"].to_numpy()
|
||||
if len(total_arr) < 2 or total_arr[0] <= 0:
|
||||
return cost_report
|
||||
|
||||
portfolio_return = float((total_arr[-1] / total_arr[0]) - 1)
|
||||
|
||||
# 基准收益
|
||||
benchmark_return = 0.0
|
||||
if "total" in self._benchmark.columns:
|
||||
bench_arr = self._benchmark["total"].to_numpy()
|
||||
if len(bench_arr) >= 2 and bench_arr[0] > 0:
|
||||
benchmark_return = float((bench_arr[-1] / bench_arr[0]) - 1)
|
||||
|
||||
excess_return = portfolio_return - benchmark_return
|
||||
|
||||
# 如果有 groups 信息,按组计算
|
||||
allocation = 0.0
|
||||
selection = 0.0
|
||||
interaction = 0.0
|
||||
|
||||
if self._groups is not None and not self._groups.empty:
|
||||
# 按组分解(简化版)
|
||||
allocation, selection, interaction = self._compute_grouped_brinson(
|
||||
portfolio_return, benchmark_return,
|
||||
)
|
||||
else:
|
||||
# 无分组信息时,将全部超额收益归为 selection
|
||||
selection = excess_return
|
||||
|
||||
return AttributionReport(
|
||||
total_return=portfolio_return,
|
||||
allocation_return=allocation,
|
||||
selection_return=selection,
|
||||
interaction_return=interaction,
|
||||
total_trade_cost=cost_report.total_trade_cost,
|
||||
slippage_cost=cost_report.slippage_cost,
|
||||
commission_cost=cost_report.commission_cost,
|
||||
stamp_tax_cost=cost_report.stamp_tax_cost,
|
||||
)
|
||||
|
||||
def _compute_grouped_brinson(
|
||||
self, portfolio_return: float, benchmark_return: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""按组计算 Brinson 归因(简化版)。
|
||||
|
||||
当 groups 包含 weight 和 return 列时进行分解。
|
||||
"""
|
||||
if self._groups is None or self._groups.empty:
|
||||
return 0.0, portfolio_return - benchmark_return, 0.0
|
||||
|
||||
allocation = 0.0
|
||||
selection = 0.0
|
||||
interaction = 0.0
|
||||
|
||||
if "portfolio_weight" in self._groups.columns and "benchmark_weight" in self._groups.columns:
|
||||
pw = self._groups["portfolio_weight"].to_numpy()
|
||||
bw = self._groups["benchmark_weight"].to_numpy()
|
||||
|
||||
if "portfolio_return" in self._groups.columns and "benchmark_return" in self._groups.columns:
|
||||
pr = self._groups["portfolio_return"].to_numpy()
|
||||
br = self._groups["benchmark_return"].to_numpy()
|
||||
|
||||
allocation = float(np.sum((pw - bw) * br))
|
||||
selection = float(np.sum(bw * (pr - br)))
|
||||
interaction = float(np.sum((pw - bw) * (pr - br)))
|
||||
|
||||
return allocation, selection, interaction
|
||||
|
||||
def factor_attribution(self) -> AttributionReport:
|
||||
"""因子归因分解。
|
||||
|
||||
R = Σ(β_i × f_i) + α
|
||||
β_i: 因子暴露度
|
||||
f_i: 因子收益率
|
||||
α: 特质收益
|
||||
|
||||
需要提供 factor_exposures 和 factor_returns。
|
||||
"""
|
||||
cost_report = self.cost_attribution()
|
||||
|
||||
if self._factor_exposures is None or self._factor_returns is None:
|
||||
return cost_report
|
||||
|
||||
if self._factor_exposures.empty or self._factor_returns.empty:
|
||||
return cost_report
|
||||
|
||||
# 计算因子贡献
|
||||
factor_contributions: dict[str, float] = {}
|
||||
|
||||
# 简化:按列名匹配
|
||||
common_factors = set(self._factor_exposures.columns) & set(self._factor_returns.columns)
|
||||
for factor_name in common_factors:
|
||||
exposures = self._factor_exposures[factor_name].to_numpy()
|
||||
returns = self._factor_returns[factor_name].to_numpy()
|
||||
min_len = min(len(exposures), len(returns))
|
||||
if min_len > 0:
|
||||
contrib = float(np.sum(exposures[:min_len] * returns[:min_len]))
|
||||
factor_contributions[factor_name] = contrib
|
||||
|
||||
total_factor_return = sum(factor_contributions.values())
|
||||
|
||||
# 总收益
|
||||
total_arr = self._equity_curve["total"].to_numpy()
|
||||
total_return = 0.0
|
||||
if len(total_arr) >= 2 and total_arr[0] > 0:
|
||||
total_return = float((total_arr[-1] / total_arr[0]) - 1)
|
||||
|
||||
specific_return = total_return - total_factor_return
|
||||
|
||||
return AttributionReport(
|
||||
total_return=total_return,
|
||||
factor_returns=factor_contributions,
|
||||
specific_return=specific_return,
|
||||
total_trade_cost=cost_report.total_trade_cost,
|
||||
slippage_cost=cost_report.slippage_cost,
|
||||
commission_cost=cost_report.commission_cost,
|
||||
stamp_tax_cost=cost_report.stamp_tax_cost,
|
||||
)
|
||||
|
||||
def full_report(self) -> AttributionReport:
|
||||
"""完整归因报告。
|
||||
|
||||
按优先级使用:
|
||||
1. 因子归因(如果 factor_exposures/factor_returns 可用)
|
||||
2. Brinson 归因(如果 benchmark 可用)
|
||||
3. 成本归因(始终可用)
|
||||
"""
|
||||
if self._factor_exposures is not None and self._factor_returns is not None:
|
||||
return self.factor_attribution()
|
||||
if self._benchmark is not None:
|
||||
return self.brinson_attribution()
|
||||
return self.cost_attribution()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write tests**
|
||||
|
||||
```python
|
||||
"""归因分析单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.attribution import AttributionAnalyzer, AttributionReport
|
||||
|
||||
|
||||
def _make_trades(
|
||||
n_buys: int = 2, n_sells: int = 2,
|
||||
commission: float = 10.0, slippage: float = 5.0,
|
||||
) -> pd.DataFrame:
|
||||
"""构造测试交易记录。"""
|
||||
trades: list[dict[str, object]] = []
|
||||
for i in range(n_buys):
|
||||
trades.append({
|
||||
"datetime": 20240101 + i, "direction": "BUY",
|
||||
"size": 100, "price": 100.0 + i,
|
||||
"commission": commission, "slippage": slippage, "pnl": 0.0, "rejected": False,
|
||||
})
|
||||
for i in range(n_sells):
|
||||
trades.append({
|
||||
"datetime": 20240110 + i, "direction": "SELL",
|
||||
"size": 100, "price": 110.0 + i,
|
||||
"commission": commission, "slippage": slippage, "pnl": 500.0, "rejected": False,
|
||||
})
|
||||
return pd.DataFrame(trades)
|
||||
|
||||
|
||||
def _make_equity(initial: float = 100000.0, final: float = 110000.0, n: int = 20) -> pd.DataFrame:
|
||||
"""构造资金曲线。"""
|
||||
total = np.linspace(initial, final, n)
|
||||
return pd.DataFrame({
|
||||
"datetime": [20240101 + i for i in range(n)],
|
||||
"total": total,
|
||||
"cash": total * 0.5,
|
||||
"position_value": total * 0.5,
|
||||
})
|
||||
|
||||
|
||||
def _make_benchmark(initial: float = 100000.0, final: float = 105000.0, n: int = 20) -> pd.DataFrame:
|
||||
"""构造基准资金曲线。"""
|
||||
total = np.linspace(initial, final, n)
|
||||
return pd.DataFrame({
|
||||
"datetime": [20240101 + i for i in range(n)],
|
||||
"total": total,
|
||||
})
|
||||
|
||||
|
||||
class TestCostAttribution:
|
||||
"""成本归因。"""
|
||||
|
||||
def test_basic_cost_breakdown(self) -> None:
|
||||
"""基本成本分解。"""
|
||||
trades = _make_trades(n_buys=2, n_sells=2, commission=10.0, slippage=5.0)
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.cost_attribution()
|
||||
# 4 trades × 10.0 commission = 40.0
|
||||
assert report.commission_cost == pytest.approx(40.0)
|
||||
# 4 trades × 5.0 slippage = 20.0
|
||||
assert report.slippage_cost == pytest.approx(20.0)
|
||||
# total = 60.0
|
||||
assert report.total_trade_cost == pytest.approx(60.0)
|
||||
|
||||
def test_total_return(self) -> None:
|
||||
"""总收益计算。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity(100000.0, 110000.0)
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.cost_attribution()
|
||||
assert report.total_return == pytest.approx(0.1)
|
||||
|
||||
def test_empty_trades(self) -> None:
|
||||
"""空交易记录。"""
|
||||
trades = pd.DataFrame(columns=["datetime", "direction", "size", "price", "commission", "slippage"])
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.cost_attribution()
|
||||
assert report.total_trade_cost == 0.0
|
||||
assert report.slippage_cost == 0.0
|
||||
|
||||
def test_stamp_tax_estimation(self) -> None:
|
||||
"""印花税估算(卖出 0.1%)。"""
|
||||
trades = _make_trades(n_buys=0, n_sells=1, commission=0.0, slippage=0.0)
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.cost_attribution()
|
||||
# 卖出 100 股 × 110 元 × 0.001 = 11.0
|
||||
assert report.stamp_tax_cost == pytest.approx(11.0)
|
||||
|
||||
|
||||
class TestBrinsonAttribution:
|
||||
"""Brinson 归因。"""
|
||||
|
||||
def test_no_benchmark_returns_only_total(self) -> None:
|
||||
"""无基准时只返回总收益。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq, benchmark=None)
|
||||
report = analyzer.brinson_attribution()
|
||||
assert report.total_return == pytest.approx(0.1)
|
||||
assert report.allocation_return == 0.0
|
||||
assert report.selection_return == 0.0
|
||||
|
||||
def test_with_benchmark_selection(self) -> None:
|
||||
"""有基准时超额收益归为 selection。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity(100000.0, 110000.0) # +10%
|
||||
bench = _make_benchmark(100000.0, 105000.0) # +5%
|
||||
analyzer = AttributionAnalyzer(trades, eq, benchmark=bench)
|
||||
report = analyzer.brinson_attribution()
|
||||
assert report.total_return == pytest.approx(0.1)
|
||||
# excess = 10% - 5% = 5%, all attributed to selection
|
||||
assert report.selection_return == pytest.approx(0.05)
|
||||
|
||||
def test_with_groups_decomposition(self) -> None:
|
||||
"""有分组时进行 Brinson 三因子分解。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity(100000.0, 110000.0)
|
||||
bench = _make_benchmark(100000.0, 105000.0)
|
||||
groups = pd.DataFrame({
|
||||
"portfolio_weight": [0.6, 0.4],
|
||||
"benchmark_weight": [0.5, 0.5],
|
||||
"portfolio_return": [0.15, 0.05],
|
||||
"benchmark_return": [0.10, 0.0],
|
||||
})
|
||||
analyzer = AttributionAnalyzer(trades, eq, benchmark=bench, groups=groups)
|
||||
report = analyzer.brinson_attribution()
|
||||
# Allocation = (0.6-0.5)*0.10 + (0.4-0.5)*0.0 = 0.01
|
||||
assert report.allocation_return == pytest.approx(0.01)
|
||||
# Selection = 0.5*(0.15-0.10) + 0.5*(0.05-0.0) = 0.05
|
||||
assert report.selection_return == pytest.approx(0.05)
|
||||
# Interaction = (0.1)*0.05 + (-0.1)*0.05 = 0.0
|
||||
assert report.interaction_return == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestFactorAttribution:
|
||||
"""因子归因。"""
|
||||
|
||||
def test_no_factors_returns_only_cost(self) -> None:
|
||||
"""无因子数据时只返回成本归因。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.factor_attribution()
|
||||
assert report.factor_returns == {}
|
||||
assert report.specific_return == 0.0
|
||||
|
||||
def test_basic_factor_decomposition(self) -> None:
|
||||
"""基本因子分解。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity(100000.0, 110000.0)
|
||||
exposures = pd.DataFrame({
|
||||
"momentum": [0.5, 0.3, 0.2],
|
||||
"volatility": [0.1, -0.1, 0.0],
|
||||
})
|
||||
returns = pd.DataFrame({
|
||||
"momentum": [0.05, 0.03, 0.02],
|
||||
"volatility": [0.01, -0.02, 0.0],
|
||||
})
|
||||
analyzer = AttributionAnalyzer(
|
||||
trades, eq,
|
||||
factor_exposures=exposures, factor_returns=returns,
|
||||
)
|
||||
report = analyzer.factor_attribution()
|
||||
# momentum: sum(0.5*0.05, 0.3*0.03, 0.2*0.02) = 0.025+0.009+0.004 = 0.038
|
||||
assert report.factor_returns["momentum"] == pytest.approx(0.038)
|
||||
# volatility: sum(0.1*0.01, -0.1*-0.02, 0*0) = 0.001+0.002+0 = 0.003
|
||||
assert report.factor_returns["volatility"] == pytest.approx(0.003)
|
||||
# total_return = 0.1
|
||||
# specific = 0.1 - 0.038 - 0.003 = 0.059
|
||||
assert report.specific_return == pytest.approx(0.059)
|
||||
|
||||
def test_empty_factor_data(self) -> None:
|
||||
"""空因子数据。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity()
|
||||
exposures = pd.DataFrame()
|
||||
returns = pd.DataFrame()
|
||||
analyzer = AttributionAnalyzer(
|
||||
trades, eq,
|
||||
factor_exposures=exposures, factor_returns=returns,
|
||||
)
|
||||
report = analyzer.factor_attribution()
|
||||
assert report.factor_returns == {}
|
||||
|
||||
|
||||
class TestFullReport:
|
||||
"""完整报告。"""
|
||||
|
||||
def test_prefers_factor_over_brinson(self) -> None:
|
||||
"""有因子数据时优先使用因子归因。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity(100000.0, 110000.0)
|
||||
bench = _make_benchmark(100000.0, 105000.0)
|
||||
exposures = pd.DataFrame({"momentum": [0.5]})
|
||||
returns = pd.DataFrame({"momentum": [0.05]})
|
||||
analyzer = AttributionAnalyzer(
|
||||
trades, eq, benchmark=bench,
|
||||
factor_exposures=exposures, factor_returns=returns,
|
||||
)
|
||||
report = analyzer.full_report()
|
||||
assert "momentum" in report.factor_returns
|
||||
assert report.specific_return != 0.0 # 因子归因有 specific
|
||||
|
||||
def test_falls_back_to_cost_only(self) -> None:
|
||||
"""无基准无因子时只返回成本归因。"""
|
||||
trades = _make_trades()
|
||||
eq = _make_equity()
|
||||
analyzer = AttributionAnalyzer(trades, eq)
|
||||
report = analyzer.full_report()
|
||||
assert report.total_trade_cost > 0
|
||||
assert report.factor_returns == {}
|
||||
assert report.allocation_return == 0.0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/unit/test_backtest_attribution.py -v --no-header
|
||||
```
|
||||
|
||||
- [ ] **Step 4: ruff check**
|
||||
|
||||
```bash
|
||||
ruff check src/easy_tdx/backtest/attribution.py tests/unit/test_backtest_attribution.py
|
||||
ruff format --check src/easy_tdx/backtest/attribution.py tests/unit/test_backtest_attribution.py
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Full test suite**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/unit/ -q --no-header
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/easy_tdx/backtest/attribution.py tests/unit/test_backtest_attribution.py
|
||||
git commit -m "feat(backtest): add AttributionAnalyzer with Brinson, factor, cost attribution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 版本号 bump + 最终验证
|
||||
|
||||
- [ ] **Step 1**: Update pyproject.toml version from `1.14.0` to `1.15.0`
|
||||
|
||||
- [ ] **Step 2**: Run full test suite
|
||||
|
||||
```bash
|
||||
python -m pytest tests/unit/ -q --no-header
|
||||
```
|
||||
|
||||
- [ ] **Step 3**: Commit
|
||||
|
||||
```bash
|
||||
git add pyproject.toml
|
||||
git commit -m "chore: bump version to v1.15.0"
|
||||
```
|
||||
@@ -0,0 +1,355 @@
|
||||
# 高级回测增强 — 设计文档
|
||||
|
||||
> **日期**: 2026-06-12
|
||||
> **版本**: v1.0
|
||||
> **前置**: 方案 A(v1.11.0–v1.13.0)已完成
|
||||
> **范围**: 方案 B — 滑点建模、执行仿真、归因分析
|
||||
> **目标市场**: 纯 A 股
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
easy-tdx 的回测引擎(`BacktestEngine` + `OrderSimulator`)已支持基础信号→撮合→绩效管道,但成本建模过于简单(固定每股滑点),执行假设过于理想(瞬间成交),且无收益归因能力。
|
||||
|
||||
本设计在**不破坏现有 API** 的前提下,新增三个核心能力:
|
||||
|
||||
1. **可插拔滑点模型** — 从固定滑点升级为市场冲击模型(方根模型、成交量比例等)
|
||||
2. **执行仿真引擎** — 支持大额订单拆分(TWAP/VWAP)、限价单等真实执行方式
|
||||
3. **归因分析** — Brinson 归因(配置 vs 选股)、因子归因(收益分解为因子贡献)
|
||||
|
||||
## 2. 模块总览
|
||||
|
||||
```
|
||||
src/easy_tdx/backtest/
|
||||
├── slippage.py # 新增:可插拔滑点模型(4 种)
|
||||
├── execution.py # 新增:执行仿真引擎(4 种)
|
||||
├── attribution.py # 新增:归因分析(Brinson + 因子 + 成本)
|
||||
├── engine.py # 修改:接入 slippage_model / execution_model
|
||||
├── orders.py # 修改:用 SlippageModel 替代固定滑点
|
||||
├── performance.py # 不变
|
||||
├── strategy.py # 不变
|
||||
├── types.py # 修改:新增 AttributionReport
|
||||
├── portfolio.py # 不变
|
||||
├── portfolio_engine.py # 不变
|
||||
└── combo.py # 不变
|
||||
```
|
||||
|
||||
### 依赖关系
|
||||
|
||||
```
|
||||
Signal → ExecutionModel → SlippageModel → Trade
|
||||
↓
|
||||
AttributionAnalyzer → AttributionReport
|
||||
```
|
||||
|
||||
## 3. 滑点建模(`slippage.py`)
|
||||
|
||||
### 3.1 基类
|
||||
|
||||
```python
|
||||
class SlippageModel(ABC):
|
||||
"""滑点模型基类。"""
|
||||
|
||||
@abstractmethod
|
||||
def compute(
|
||||
self,
|
||||
price: float,
|
||||
size: float,
|
||||
volume: float,
|
||||
volatility: float,
|
||||
direction: str,
|
||||
) -> float:
|
||||
"""返回总滑点成本(金额)。
|
||||
|
||||
Args:
|
||||
price: 成交价
|
||||
size: 订单数量(股)
|
||||
volume: 当日成交量(股),0 表示无数据
|
||||
volatility: 近期年化波动率,0 表示无数据
|
||||
direction: BUY / SELL
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### 3.2 四种内置模型
|
||||
|
||||
| 模型 | 公式 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| `FixedSlippage(per_share=0.01)` | `size × per_share` | 向后兼容,快速原型 |
|
||||
| `PercentSlippage(rate=0.001)` | `price × size × rate` | 按成交金额百分比 |
|
||||
| `SquareRootSlippage(impact_coeff=0.1)` | `σ × √(Q/V) × price × Q × coeff` | A 股量化主流,参与率高时冲击大 |
|
||||
| `VolumeSlippage(base_bps=10.0)` | `base_bps/10000 × (size/volume) × price × size` | 基于成交量比例,流动性差时成本高 |
|
||||
|
||||
### 3.3 SquareRootSlippage 详解
|
||||
|
||||
```
|
||||
participation_rate = size / volume # 参与率
|
||||
impact = volatility × √(participation_rate) × price × size × impact_coeff
|
||||
```
|
||||
|
||||
- 当 `volume=0` 或 `volatility=0` 时,退化为 `PercentSlippage(rate=0.001)`
|
||||
- `impact_coeff` 默认 0.1,对应 A 股中小盘股的经验值
|
||||
- 参与率 > 5% 时冲击成本显著增大(√ 函数的自然效果)
|
||||
|
||||
### 3.4 集成点
|
||||
|
||||
`OrderSimulator` 新增参数:
|
||||
|
||||
```python
|
||||
slippage_model: SlippageModel | None = None
|
||||
```
|
||||
|
||||
当 `slippage_model` 非空时,忽略原有 `self.slippage` 参数,调用 `slippage_model.compute()` 计算滑点。
|
||||
|
||||
`BacktestEngine` 透传:
|
||||
|
||||
```python
|
||||
BacktestEngine(strategy, slippage_model=SquareRootSlippage())
|
||||
```
|
||||
|
||||
当同时提供 `slippage_model` 和 `slippage` 时,`slippage_model` 优先。
|
||||
|
||||
## 4. 执行仿真(`execution.py`)
|
||||
|
||||
### 4.1 基类
|
||||
|
||||
```python
|
||||
class ExecutionModel(ABC):
|
||||
"""执行仿真基类。"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
cash: float,
|
||||
position: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
"""将信号转换为一笔或多笔成交记录。"""
|
||||
...
|
||||
```
|
||||
|
||||
### 4.2 四种内置模型
|
||||
|
||||
| 模型 | 行为 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| `ImmediateExecution` | 现有行为,下一 bar 即时成交 | 向后兼容 |
|
||||
| `TWAPExecution(n_bars=5)` | 将订单均匀拆分为 N 份,在连续 N bar 执行 | 大额订单分批建仓 |
|
||||
| `VWAPExecution(n_bars=5, volume_lookback=20)` | 按历史成交量分布比例拆分 | 追踪 VWAP 基准 |
|
||||
| `LimitExecution(ttl_bars=5)` | 限价挂单,仅当价格触及才成交 | 精确入场价位控制 |
|
||||
|
||||
### 4.3 TWAPExecution 详解
|
||||
|
||||
```python
|
||||
class TWAPExecution(ExecutionModel):
|
||||
def __init__(self, n_bars: int = 5) -> None:
|
||||
self.n_bars = n_bars
|
||||
|
||||
def execute(self, signal, df, bar_idx, ...):
|
||||
sub_size = total_size / n_bars
|
||||
trades = []
|
||||
for i in range(n_bars):
|
||||
exec_bar = bar_idx + 1 + i
|
||||
if exec_bar >= len(df):
|
||||
break # 超出数据范围,剩余未执行
|
||||
price = df["close"].iloc[exec_bar] # 按 close 执行
|
||||
trade = self._make_trade(signal, sub_size, price, exec_bar, ...)
|
||||
trades.append(trade)
|
||||
return trades
|
||||
```
|
||||
|
||||
- 买入时使用 `position_mode` 确定总数量,然后均匀拆分
|
||||
- 卖出时直接拆分持仓
|
||||
- 每笔子交易独立计算佣金和滑点
|
||||
- 100 股整手约束:每笔子交易向下取整到 100 的倍数
|
||||
|
||||
### 4.4 VWAPExecution 详解
|
||||
|
||||
```python
|
||||
class VWAPExecution(ExecutionModel):
|
||||
def __init__(self, n_bars: int = 5, volume_lookback: int = 20) -> None: ...
|
||||
|
||||
def execute(self, signal, df, bar_idx, ...):
|
||||
# 取最近 volume_lookback 根 K 线的成交量分布
|
||||
lookback = df.iloc[max(0, bar_idx - volume_lookback):bar_idx + 1]
|
||||
avg_volumes = []
|
||||
for i in range(n_bars):
|
||||
offset = i % len(lookback)
|
||||
avg_volumes.append(float(lookback["volume"].iloc[-(offset + 1)]))
|
||||
total_vol = sum(avg_volumes)
|
||||
weights = [v / total_vol for v in avg_volumes]
|
||||
# 按 weights 拆分订单
|
||||
...
|
||||
```
|
||||
|
||||
### 4.5 LimitExecution 详解
|
||||
|
||||
```python
|
||||
class LimitExecution(ExecutionModel):
|
||||
def __init__(self, ttl_bars: int = 5) -> None:
|
||||
self.ttl_bars = ttl_bars # 限价单有效期(bar 数)
|
||||
|
||||
def execute(self, signal, df, bar_idx, ...):
|
||||
if signal.price is None:
|
||||
# 无限价,退化为即时执行
|
||||
return ImmediateExecution().execute(...)
|
||||
target_price = signal.price
|
||||
trades = []
|
||||
for i in range(self.ttl_bars):
|
||||
exec_bar = bar_idx + 1 + i
|
||||
if exec_bar >= len(df):
|
||||
break
|
||||
row = df.iloc[exec_bar]
|
||||
if signal.direction == "BUY" and row["low"] <= target_price:
|
||||
trades.append(self._make_trade(signal, size, target_price, exec_bar, ...))
|
||||
break
|
||||
elif signal.direction == "SELL" and row["high"] >= target_price:
|
||||
trades.append(self._make_trade(signal, size, target_price, exec_bar, ...))
|
||||
break
|
||||
return trades # 可能返回空列表(限价未触发)
|
||||
```
|
||||
|
||||
### 4.6 集成点
|
||||
|
||||
`BacktestEngine` 新增参数:
|
||||
|
||||
```python
|
||||
execution_model: ExecutionModel | None = None
|
||||
```
|
||||
|
||||
当 `execution_model` 非空时,信号处理从执行模型走,不走原有 `_resolve_exec_index` / `_get_price`。
|
||||
|
||||
**关键:执行模型产生多笔 Trade,需要修正 `BacktestEngine._generate_signals` 的信号循环逻辑**。
|
||||
|
||||
现有逻辑:
|
||||
|
||||
```python
|
||||
for signal in signals:
|
||||
trades = simulator.simulate([signal], cash, position)
|
||||
```
|
||||
|
||||
新逻辑(当 execution_model 存在时):
|
||||
|
||||
```python
|
||||
for signal in signals:
|
||||
sub_trades = execution_model.execute(signal, df, bar_idx, cash, position, ...)
|
||||
all_trades.extend(sub_trades)
|
||||
```
|
||||
|
||||
## 5. 归因分析(`attribution.py`)
|
||||
|
||||
### 5.1 数据结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AttributionReport:
|
||||
"""归因分析报告。"""
|
||||
# 总收益
|
||||
total_return: float
|
||||
# Brinson 归因
|
||||
allocation_return: float
|
||||
selection_return: float
|
||||
interaction_return: float
|
||||
# 因子归因
|
||||
factor_returns: dict[str, float]
|
||||
specific_return: float
|
||||
# 成本归因
|
||||
total_trade_cost: float
|
||||
slippage_cost: float
|
||||
commission_cost: float
|
||||
stamp_tax_cost: float
|
||||
```
|
||||
|
||||
### 5.2 AttributionAnalyzer
|
||||
|
||||
```python
|
||||
class AttributionAnalyzer:
|
||||
"""收益归因分析器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trades: pd.DataFrame,
|
||||
equity_curve: pd.DataFrame,
|
||||
benchmark: pd.DataFrame | None = None,
|
||||
factor_exposures: pd.DataFrame | None = None,
|
||||
factor_returns: pd.DataFrame | None = None,
|
||||
groups: pd.DataFrame | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def brinson_attribution(self) -> AttributionReport:
|
||||
"""Brinson-Hood-Beebower 归因分解。
|
||||
|
||||
Total = Allocation + Selection + Interaction
|
||||
R_p = Σ(w_pi × R_pi) # 组合收益
|
||||
R_b = Σ(w_bi × R_bi) # 基准收益
|
||||
Allocation = Σ((w_pi - w_bi) × R_bi)
|
||||
Selection = Σ(w_bi × (R_pi - R_bi))
|
||||
Interaction = Σ((w_pi - w_bi) × (R_pi - R_bi))
|
||||
"""
|
||||
|
||||
def factor_attribution(self) -> AttributionReport:
|
||||
"""因子归因分解。
|
||||
|
||||
R = Σ(β_i × f_i) + α
|
||||
β_i: 因子暴露度
|
||||
f_i: 因子收益率
|
||||
α: 特质收益
|
||||
"""
|
||||
|
||||
def cost_attribution(self) -> AttributionReport:
|
||||
"""成本归因:分解佣金/滑点/印花税。"""
|
||||
|
||||
def full_report(self) -> AttributionReport:
|
||||
"""完整归因报告。"""
|
||||
```
|
||||
|
||||
### 5.3 与现有模块衔接
|
||||
|
||||
- `trades` 参数直接来自 `BacktestResult.trades`
|
||||
- `equity_curve` 来自 `BacktestResult.equity_curve`
|
||||
- `factor_exposures` / `factor_returns` 来自 `FactorEngine`(v1.11.0 已实现)
|
||||
- `groups` 可用于 Brinson 分组(如行业分类),可选
|
||||
|
||||
## 6. 向后兼容策略
|
||||
|
||||
| 现有调用 | 行为 |
|
||||
|---------|------|
|
||||
| `BacktestEngine(strategy, slippage=0.01)` | 与现有行为完全一致 |
|
||||
| `BacktestEngine(strategy)` | 无滑点,与现有行为一致 |
|
||||
| `BacktestEngine(strategy, slippage_model=SquareRootSlippage())` | 使用新滑点模型 |
|
||||
| `BacktestEngine(strategy, execution_model=TWAPExecution())` | 使用新执行引擎 |
|
||||
| `OrderSimulator(df, slippage=0.01)` | 与现有行为完全一致 |
|
||||
| `OrderSimulator(df, slippage_model=FixedSlippage(0.01))` | 等价 |
|
||||
|
||||
**不变更的文件**: `strategy.py`, `performance.py`, `portfolio.py`, `combo.py`
|
||||
|
||||
## 7. 版本计划
|
||||
|
||||
### v1.14.0 — 滑点 + 执行
|
||||
|
||||
- `slippage.py`: SlippageModel ABC + 4 种模型
|
||||
- `execution.py`: ExecutionModel ABC + 4 种模型
|
||||
- `orders.py`: 集成 SlippageModel
|
||||
- `engine.py`: 集成 SlippageModel + ExecutionModel
|
||||
- `types.py`: 无变更(Trade/Signal 已够用)
|
||||
- 测试: ~35 个
|
||||
|
||||
### v1.15.0 — 归因分析
|
||||
|
||||
- `attribution.py`: AttributionAnalyzer + AttributionReport
|
||||
- `types.py`: 新增 AttributionReport
|
||||
- `performance.py`: 可选集成 AttributionAnalyzer
|
||||
- CLI: `easy-tdx backtest attribution` 命令
|
||||
- 测试: ~20 个
|
||||
|
||||
## 8. 不做的事
|
||||
|
||||
- **订单簿仿真**:A 股 Level-2 数据获取困难,回测中用成交量比例代理
|
||||
- **融资融券**:需要额外保证金模型,超出当前范围
|
||||
- **期指/期权对冲**:超出纯 A 股范围
|
||||
- **高频仿真**:当前是日线级别回测,微秒级仿真不适用
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.15.0"
|
||||
version = "1.11.1"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -20,13 +20,13 @@ from .cmd_capital import capital_flow
|
||||
from .cmd_chanlun import chanlun
|
||||
from .cmd_ex import ex
|
||||
from .cmd_factor import factor
|
||||
from .cmd_pfactor import pfactor
|
||||
from .cmd_finance import f10, fund_flow
|
||||
from .cmd_indicator import indicator, indicator_list
|
||||
from .cmd_info import server_info, symbol_info
|
||||
from .cmd_kline import kline
|
||||
from .cmd_monitor import market_stat, unusual
|
||||
from .cmd_offline import offline
|
||||
from .cmd_pfactor import pfactor
|
||||
from .cmd_quote import quote, quote_list
|
||||
from .cmd_run_all import run_all
|
||||
from .cmd_tick import tick
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.factor.analysis import FactorAnalyzer, FactorReport
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.factor.transform import (
|
||||
fill_missing,
|
||||
|
||||
@@ -57,7 +57,11 @@ class TestRiskParity:
|
||||
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]})
|
||||
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"]
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -27,7 +26,11 @@ def _make_market(n_stocks: int = 10, n_days: int = 120, seed: int = 42) -> dict[
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -35,7 +38,10 @@ class TestRebalanceEngine:
|
||||
assert "total_return" in result.performance
|
||||
|
||||
def test_with_factor_weighted(self):
|
||||
engine = RebalanceEngine(optimizer=FactorWeightedOptimizer(), factor_name="momentum_20d", n_stocks=5)
|
||||
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
|
||||
|
||||
@@ -44,12 +50,16 @@ class TestRebalanceEngine:
|
||||
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)
|
||||
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")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user