feat(portfolio): 移植3聚宽策略到BulletTrade + 8bug修正 + 数据缺口文档
三策略(聚宽py2→BulletTrade 0.9.2,BrokerFacade注入跨版本兼容): - momentum_timing 动量择时(牛熊分界+行业RPS+均线,切回10中证行业指数) - value_selection 价值精选(6条基本面过滤,切回沪深300) - small_cap 小市值(去IC对冲,切回000985中证全指) 框架: - runner_backtest 加 --strategy 分发(原硬编码all_weather) - provider 加 get_value_metrics(价值精选6条基本面,NOTICE_DATE治前视偏差) - 72单测全过(21+27+24) 修8个回测实测发现的真bug: - 01第⑥条EPS绝对值0.08~0.5与①大盘矛盾→6条交集恒空致全程空仓,按注释本意改净利润同比8~50% - 03原帖calRPS取数区间错(get_price start=end只取1天)→涨跌幅恒0 RPS失效;date.today()取真实今天非回测日 - 02 universe 000985不在constituent_unified→候选池空 VPS实测(短区间验证逻辑,非长期表现): 01价值+23%/03行业轮动+48%/02选出20只小盘 数据缺口(详见docs/research/joinquant_strategies/SUMMARY.md + data_gaps_fix_plan.md): - 三表"1/3损坏"误报已撤回(全扫5530文件/表0损坏,沪深95%+健康,仅北交所920xxx空,不做北交所) - 真实缺口: 行业成份股(G1已补)/000985(G2已补)/IC期货(02对冲去掉)/provider批量接口(G5待做,解锁长回测)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# 01 价值精选策略
|
||||
|
||||
## 元信息
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 标题 | 穿越牛熊基业长青的价值精选策略 |
|
||||
| 作者 | 拉姆达投资 |
|
||||
| 来源 | https://www.joinquant.com/post/13382 |
|
||||
| 聚宽编辑器 | algorithmId=56f074991f9886ad002e790bdca9d176 |
|
||||
| 回测区间 | 2013-08-01 ~ 2018-08-01 |
|
||||
| 初始资金 | 200000 |
|
||||
| 频率 | 日级(月度调仓) |
|
||||
| Python | 2 |
|
||||
|
||||
## 策略概要
|
||||
|
||||
| 要素 | 内容 |
|
||||
|------|------|
|
||||
| 基准 | 沪深300 (000300.XSHG) |
|
||||
| 调仓 | 每月第5个交易日 |
|
||||
| 复权 | 真实价格 (use_real_price) |
|
||||
| 手续费 | 买入万3,卖出万3+千1印花税,最低5元 |
|
||||
| 风控 | 无(不择时、不止损) |
|
||||
|
||||
## 选股逻辑(6条取交集)
|
||||
|
||||
1. **流通市值** > 市场平均值(circulating_market_cap)
|
||||
2. **流动比率** > 市场平均值(流动资产 / 流动负债)
|
||||
3. **近4季 ROE** > 各自季度的市场平均值
|
||||
4. **近5年自由现金流** 每年为正(经营现金流 − 投资现金流)
|
||||
5. **近4季营收同比增长率** 介于 6%~30%
|
||||
6. **近4季 EPS** 介于 0.08~0.5
|
||||
|
||||
选出后:全部等额买入;卖出不在新名单的持仓。
|
||||
|
||||
## ⚠️ 已知问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 排序死代码 | `get_check_stocks_sort` 排序后不截断,`buy` 全买,排序无实际作用 |
|
||||
| 第⑥条 bug | 注释写"盈余成长率8%~50%",代码实际过滤的是 `eps` 绝对值 0.08~0.5,逻辑不符(大概率笔误) |
|
||||
| 前视偏差风险 | 用 `statDate`(报告期)取财报,未考虑披露延迟,可能用到未公告数据 |
|
||||
| Python 2 语法 | `pd.Panel`(pandas 已移除)、`df.sort(columns=)`(旧 API)、print 语句、`len*1.0` 除法规避 |
|
||||
| 聚宽专有 API | `query`/`get_fundamentals`/`get_all_securities`/`order_value` 等需替换 |
|
||||
| 流动性 | 价值大票为主,流动性尚可,但月度全换持仓成本不低 |
|
||||
| 冗余调用 | `before_market_open` 里 `get_stock_list` 调了两次(复制粘贴遗留) |
|
||||
|
||||
## 本地复现要点
|
||||
|
||||
- **数据需求**:流通市值、流动比率、ROE、自由现金流(经营/投资现金流)、营收同比增长、EPS
|
||||
→ LocalUnifiedProvider 基本面接口已覆盖大部分(市值/ROE/营收增长/EPS 齐备;流动比率、自由现金流需确认三表字段)
|
||||
- **框架对接**:BulletTrade 多股票选股轮动,月度调仓(与现有 all_weather 同类)
|
||||
- **关键修复**:
|
||||
1. 第⑥条逻辑需确认(盈余成长率 vs EPS 绝对值)
|
||||
2. 财报用 `NOTICE_DATE` 过滤前视偏差(项目已有 `_latest_published_annual` 机制)
|
||||
3. `pd.Panel` 改为 MultiIndex DataFrame / dict
|
||||
- **复现难度**:⭐⭐(数据齐备,框架对口,主要工作量在财报多期对齐与前视偏差处理)
|
||||
|
||||
---
|
||||
|
||||
## 移植记录(2026-07-27)
|
||||
|
||||
### 完成文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `sanguo_portfolio/strategies/value_selection.py` | 新建 — `ValueSelectionConfig` + `ValueSelectionStrategy` (BrokerFacade 注入, 月度调仓) |
|
||||
| `sanguo_portfolio/providers/local_parquet_provider.py` | 加 `get_value_metrics(stock, date)` + 3 个 helper (`_filter_published` / `_latest_n_published` / `_latest_n_annual`) |
|
||||
| `sanguo_portfolio/providers/local_unified_provider.py` | 加 `get_value_metrics` 委托 LocalParquetProvider(`_lpp_helper`) |
|
||||
| `sanguo_portfolio/strategies/__init__.py` | export `ValueSelectionStrategy` / `ValueSelectionConfig` |
|
||||
| `sanguo_portfolio/runner_backtest.py` | `--strategy` choices / `_build_strategy` / `_register_schedule` / `title_map` 加 `value_selection` |
|
||||
| `tests/portfolio/test_value_selection.py` | 新建 — 27 个单测(mock provider, 全过) |
|
||||
|
||||
### 改了什么 / 修了什么 bug
|
||||
|
||||
| 类型 | 项 | 说明 |
|
||||
|------|----|------|
|
||||
| **py2→py3** | `pd.Panel` 移除 | pandas ≥1.0 删 Panel API; 改为约定 provider 提供 `get_value_metrics(stock, date) → dict[field, list]`,策略层不实现多期对齐 |
|
||||
| **py2→py3** | `df.sort(columns=)` 旧 API | 删除"按市值排序"逻辑(死代码,见下) |
|
||||
| **py2→py3** | `len(x)*1.0` 浮点除法 | py3 原生 `/` 浮点除法,不需 `*1.0` |
|
||||
| **修复** | 排序死代码 | 原策略 `get_check_stocks_sort` 按流通市值排序后不截断,`buy` 全买 → 排序无意义。**删除排序逻辑**(KISS,忠实"全买"原意) |
|
||||
| **修复** | 第⑥条代码笔误(VPS 实测发现) | 注释写"近四季盈余成长率8%~50%"本是**净利润同比**语义, 但代码写了 ``(eps>0.08)&(eps<0.5)``(EPS 绝对值, 笔误)。VPS 真实回测实证: EPS 绝对值与 L1(流通市值>均值=大盘股)逻辑矛盾 — A 股大盘价值股 EPS 普遍 >0.5(茅台 50/招行 5/工行 0.8),L1∩L6≈空 → 6 次调仓每次 final=0 全程空仓。**按注释本意修正为净利润同比增长率 8%~50%**(东财 income `PARENT_NETPROFIT_YOY` 列, fallback `NETPROFIT_YOY`), 与 L1 不矛盾(大盘股也能满足) |
|
||||
| **修复** | 前视偏差 | 原策略 `get_fundamentals(statDate=quarter)` 按报告期取数,会用未披露数据。provider 层 `_filter_published` 按 `NOTICE_DATE(公告日) <= date` 过滤 |
|
||||
| **修复** | 冗余调用 | 原策略 `before_market_open` 调 `get_stock_list` 两次(复制粘贴遗留),合并为调一次 |
|
||||
| **结构** | 聚宽 API → BulletTrade | 策略层不直接 import bullet_trade,通过 `BrokerFacade` + `provider` 双注入(照 momentum_timing/all_weather 模式) |
|
||||
| **结构** | 取数逻辑下沉 | 策略层只调 `provider.get_value_metrics(stock, date)`;字段映射 + NOTICE_DATE 过滤 + 三表读取全在 LocalParquetProvider 实现(KISS,职责分离) |
|
||||
| **结构** | universe 默认沪深 300 | 原策略全市场 `get_all_securities(types=['stock'])` ≈ 5000+ 股逐只读三表会爆炸。Config.universe 默认 `000300.XSHG` 沪深 300(可改) |
|
||||
|
||||
### 聚宽 → 东财字段映射表
|
||||
|
||||
| 聚宽字段 | 聚宽表 | 东财表 | 东财字段(实证 akshare `stock_*_sheet_by_report_em`) |
|
||||
|---------|--------|--------|---------|
|
||||
| `circulating_market_cap` | valuation | valuation parquet | `流通市值`(已通过 `_VAL_COL_MAP` 映射为 `circ_market_cap`,单位元) |
|
||||
| `total_current_assets` | balance | balance parquet | `TOTAL_CURRENT_ASSETS`(流动资产合计) |
|
||||
| `total_current_liability` | balance | balance parquet | `TOTAL_CURRENT_LIAB`(流动负债合计) |
|
||||
| `roe` | indicator | income + balance | 算: `PARENT_NETPROFIT`(归母净利润) / `TOTAL_PARENT_EQUITY`(归母权益) |
|
||||
| `net_operate_cash_flow` | cash_flow | cashflow parquet | `NETCASH_OPERATE`(经营活动现金流量净额) |
|
||||
| `net_invest_cash_flow` | cash_flow | cashflow parquet | `NETCASH_INVEST`(投资活动现金流量净额) |
|
||||
| `inc_revenue_year_on_year` | indicator | income parquet | `OPERATE_INCOME_YOY`(营业收入同比增长率,百分数) |
|
||||
| `net_profit_growth` (L6 修正后) | indicator | income parquet | `PARENT_NETPROFIT_YOY`(归母净利润同比,百分数; fallback `NETPROFIT_YOY`) |
|
||||
|
||||
通用列(三表共有):
|
||||
- `SECUCODE` / `SECURITY_CODE` / `SECURITY_NAME_ABBR` — 证券标识
|
||||
- `REPORT_DATE` — 报告期(季末/年末)
|
||||
- `NOTICE_DATE` — 公告日(**前视偏差过滤用此列**)
|
||||
- `UPDATE_DATE` — 更新日
|
||||
- `REPORT_TYPE` — 报告类型(含"年"=年报,用于多年 FCF)
|
||||
|
||||
### 单位口径
|
||||
|
||||
| 字段 | 单位 | 备注 |
|
||||
|------|------|------|
|
||||
| `circulating_market_cap` | 亿元 | akshare valuation 是元,`to_yi(/1e8)` 转亿元 |
|
||||
| `current_ratio` | 无量纲 | 流动资产/流动负债,直接相除 |
|
||||
| `roe_series` | 小数(0.15=15%) | `PARENT_NETPROFIT / TOTAL_PARENT_EQUITY` 算 |
|
||||
| `fcf_series` | 元 | `NETCASH_OPERATE - NETCASH_INVEST`,绝对值 |
|
||||
| `revenue_yoy_series` | 百分数(18.5=18.5%) | `OPERATE_INCOME_YOY` akshare 现成百分数,不/100 |
|
||||
| `netprofit_yoy_series` | 百分数(18.5=18.5%) | `PARENT_NETPROFIT_YOY` akshare 现成百分数(实证茅台 2024 年报 15.38=15.38%),fallback `NETPROFIT_YOY` |
|
||||
|
||||
### 6 条过滤逻辑(对应 source.py 行号)
|
||||
|
||||
| 条 | source.py | 实现 | 备注 |
|
||||
|----|-----------|------|------|
|
||||
| L1 | 第 105-108 行 | `_get_stock_list` L1: `circ_cap > market_mean` | 严格 `>`(原代码也无等号) |
|
||||
| L2 | 第 110-116 行 | `_get_stock_list` L2: `current_ratio > market_mean` | 流动比率 = TOTAL_CURRENT_ASSETS / TOTAL_CURRENT_LIAB |
|
||||
| L3 | 第 118-129 行 | `_filter_per_quarter_above_market_mean` field=roe_series | 4 季交集:每季 > 该季市场均值 |
|
||||
| L4 | 第 131-146 行 | `_filter_all_positive` field=fcf_series | 5 年每年正(年报口径 REPORT_TYPE 含"年") |
|
||||
| L5 | 第 149-159 行 | `_filter_per_quarter_in_range` field=revenue_yoy_series, low=6, high=30 | 严格 `>low & <high` |
|
||||
| L6 | 第 161-171 行 | `_filter_per_quarter_in_range` field=netprofit_yoy_series, low=8, high=50 | ⚠️ **按注释本意**(净利润同比 8~50%),非代码 EPS 笔误;VPS 实测 EPS 口径与 L1 矛盾致空仓 |
|
||||
|
||||
### 数据缺口(已知)
|
||||
|
||||
| 缺口 | 影响 | 缓解 |
|
||||
|------|------|------|
|
||||
| ~~三表覆盖率约 1/3~~ **[已撤回·误报]** | 2026-07-28 全扫5530文件/表 0损坏,沪深/创业/科创 **95%+健康**;仅北交所920xxx空(akshare不覆盖,universe已排除)。原"1/3有效"系小抽样误报 | 策略层容错保留(北交所返None跳过) | 无需补,不做北交所即解 |
|
||||
| ~~`NOTICE_DATE` 列缺失~~ **[已撤回]** | 全扫9/9有效文件 NOTICE_DATE **全有** | 兜底逻辑保留(几乎不触发) | 无需补 |
|
||||
| ROE 非精确 TTM | `PARENT_NETPROFIT`(累计) / `TOTAL_PARENT_EQUITY`(期末) 不是聚宽 indicator.roe 的 TTM 口径,有季节性偏差 | KISS 简化;和市场均值比较的相对排序影响小 |
|
||||
| `circulating_market_cap` 单源 | 仅 akshare valuation 有市值列(baostock valuation 无) → akshare 数据缺失的股票无法过 L1 | provider 容错返 NaN,策略层 L1 自动剔除 |
|
||||
| universe 默认沪深 300 | 原策略全市场 ~5000 股 → 逐只读三表爆炸;默认改沪深 300 牺牲覆盖换可执行性 | Config.universe 可改(如改 000852 中证 1000) |
|
||||
|
||||
### 测试
|
||||
|
||||
`./venv310/bin/python -m pytest tests/portfolio/test_value_selection.py -v` — **27/27 passed**
|
||||
|
||||
覆盖:
|
||||
- L1/L2/L3/L4/L5/L6 各条过滤的边界与交集语义
|
||||
- pd.Panel 改写后的多期对齐(L3 每季分别比较市场均值,交集语义)
|
||||
- NOTICE_DATE 前视偏差过滤(策略层契约: 信任 provider 过滤结果)
|
||||
- 空数据跳过(provider 返 None / 抛异常都不污染整批)
|
||||
- monthly_adjustment 主流程(卖出/买入/等额分配)
|
||||
- Config 默认值对齐原策略 source.py
|
||||
|
||||
### 后续 V2 工作(未做)
|
||||
|
||||
1. provider `get_value_metrics` 在 VPS 真实 parquet 上 E2E 验证(Mac 无数据无法测真实读取)
|
||||
2. 三表覆盖率补齐(akshare 下载脚本修复 + 重跑)
|
||||
3. ROE 改用 financial_abstract 现成 TTM 值(避免累计/期末口径偏差)
|
||||
4. universe 改全市场 + 提速(批量读三表 / 缓存)
|
||||
@@ -0,0 +1,217 @@
|
||||
# 克隆自聚宽文章:https://www.joinquant.com/post/13382
|
||||
# 标题:穿越牛熊基业长青的价值精选策略
|
||||
# 作者:拉姆达投资
|
||||
# 注:Python 2 原稿,聚宽专有 API,无法本地直接运行
|
||||
|
||||
'''
|
||||
投资程序:
|
||||
霍华.罗斯曼强调其投资风格在于为投资大众建立均衡、且以成长为导向的投资组合。选股方式偏好大型股,
|
||||
管理良好且为领导产业趋势,以及产生实际报酬率的公司;不仅重视公司产生现金的能力,也强调有稳定成长能力的重要。
|
||||
总市值大于等于50亿美元。
|
||||
良好的财务结构。
|
||||
较高的股东权益报酬。
|
||||
拥有良好且持续的自由现金流量。
|
||||
稳定持续的营收成长率。
|
||||
优于比较指数的盈余报酬率。
|
||||
'''
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import jqdata
|
||||
# 初始化函数,设定基准等等
|
||||
def initialize(context):
|
||||
# 设定沪深300作为基准
|
||||
set_benchmark('000300.XSHG')
|
||||
# 开启动态复权模式(真实价格)
|
||||
set_option('use_real_price', True)
|
||||
# 输出内容到日志 log.info()
|
||||
log.info('初始函数开始运行且全局只运行一次')
|
||||
# 过滤掉order系列API产生的比error级别低的log
|
||||
# log.set_level('order', 'error')
|
||||
#策略参数设置
|
||||
#操作的股票列表
|
||||
g.buy_list = []
|
||||
### 股票相关设定 ###
|
||||
# 股票类每笔交易时的手续费是:买入时佣金万分之三,卖出时佣金万分之三加千分之一印花税, 每笔交易佣金最低扣5块钱
|
||||
set_order_cost(OrderCost(close_tax=0.001, open_commission=0.0003, close_commission=0.0003, min_commission=5), type='stock')
|
||||
|
||||
# 每月第5个交易日进行操作
|
||||
# 开盘前运行
|
||||
run_monthly(before_market_open,5,time='before_open', reference_security='000300.XSHG')
|
||||
# 开盘时运行
|
||||
run_monthly(market_open,5,time='open', reference_security='000300.XSHG')
|
||||
|
||||
## 开盘前运行函数
|
||||
def before_market_open(context):
|
||||
#获取要操作的股票列表
|
||||
temp_list = get_stock_list(context)
|
||||
|
||||
#获取满足条件的股票列表
|
||||
temp_list = get_stock_list(context)
|
||||
log.info('满足条件的股票有%s只'%len(temp_list))
|
||||
#按市值进行排序
|
||||
g.buy_list = get_check_stocks_sort(context,temp_list)
|
||||
|
||||
## 开盘时运行函数
|
||||
def market_open(context):
|
||||
#卖出不在买入列表中的股票
|
||||
sell(context,g.buy_list)
|
||||
#买入不在持仓中的股票,按要操作的股票平均资金
|
||||
buy(context,g.buy_list)
|
||||
#交易函数 - 买入
|
||||
def buy(context, buy_lists):
|
||||
# 获取最终的 buy_lists 列表
|
||||
# 买入股票
|
||||
if len(buy_lists)>0:
|
||||
#分配资金
|
||||
cash = context.portfolio.available_cash/(len(buy_lists)*1.0)
|
||||
# 进行买入操作
|
||||
for s in buy_lists:
|
||||
order_value(s,cash)
|
||||
|
||||
# 交易函数 - 出场
|
||||
def sell(context, buy_lists):
|
||||
# 获取 sell_lists 列表
|
||||
hold_stock = context.portfolio.positions.keys()
|
||||
for s in hold_stock:
|
||||
#卖出不在买入列表中的股票
|
||||
if s not in buy_lists:
|
||||
order_target_value(s,0)
|
||||
|
||||
#按市值进行排序
|
||||
#从大到小
|
||||
def get_check_stocks_sort(context,check_out_lists):
|
||||
df = get_fundamentals(query(valuation.circulating_cap,valuation.pe_ratio,valuation.code).filter(valuation.code.in_(check_out_lists)),date=context.previous_date)
|
||||
#asc值为0,从大到小
|
||||
df = df.sort('circulating_cap',ascending=0)
|
||||
out_lists = list(df['code'].values)
|
||||
return out_lists
|
||||
|
||||
'''
|
||||
1.总市值≧市场平均值*1.0。
|
||||
2.最近一季流动比率≧市场平均值(流动资产合计/流动负债合计)。
|
||||
3.近四季股东权益报酬率(roe)≧市场平均值。
|
||||
4.近五年自由现金流量均为正值。(cash_flow.net_operate_cash_flow - cash_flow.net_invest_cash_flow)
|
||||
5.近四季营收成长率介于6%至30%()。 'IRYOY':indicator.inc_revenue_year_on_year, # 营业收入同比增长率(%)
|
||||
6.近四季盈余成长率介于8%至50%。(eps比值)
|
||||
'''
|
||||
def get_stock_list(context):
|
||||
temp_list = list(get_all_securities(types=['stock']).index)
|
||||
#剔除停牌股
|
||||
all_data = get_current_data()
|
||||
temp_list = [stock for stock in temp_list if not all_data[stock].paused]
|
||||
#获取多期财务数据
|
||||
panel = get_data(temp_list,4)
|
||||
#1.总市值≧市场平均值*1.0。
|
||||
df_mkt = panel.loc[['circulating_market_cap'],3,:]
|
||||
df_mkt = df_mkt[df_mkt['circulating_market_cap']>df_mkt['circulating_market_cap'].mean()]
|
||||
l1 = set(df_mkt.index)
|
||||
|
||||
#2.最近一季流动比率≧市场平均值(流动资产合计/流动负债合计)。
|
||||
df_cr = panel.loc[['total_current_assets','total_current_liability'],3,:]
|
||||
#替换零的数值
|
||||
df_cr = df_cr[df_cr['total_current_liability'] != 0]
|
||||
df_cr['cr'] = df_cr['total_current_assets']/df_cr['total_current_liability']
|
||||
df_cr_temp = df_cr[df_cr['cr']>df_cr['cr'].mean()]
|
||||
l2 = set(df_cr_temp.index)
|
||||
|
||||
#3.近四季股东权益报酬率(roe)≧市场平均值。
|
||||
l3 = {}
|
||||
for i in range(4):
|
||||
roe_mean = panel.loc['roe',i,:].mean()
|
||||
df_3 = panel.iloc[:,i,:]
|
||||
df_temp_3 = df_3[df_3['roe']>roe_mean]
|
||||
if i == 0:
|
||||
l3 = set(df_temp_3.index)
|
||||
else:
|
||||
l_temp = df_temp_3.index
|
||||
l3 = l3 & set(l_temp)
|
||||
l3 = set(l3)
|
||||
|
||||
#4.近五年自由现金流量均为正值。(cash_flow.net_operate_cash_flow - cash_flow.net_invest_cash_flow)
|
||||
y = context.current_dt.year
|
||||
l4 = {}
|
||||
for i in range(1,6):
|
||||
df = get_fundamentals(query(cash_flow.code,cash_flow.statDate,cash_flow.net_operate_cash_flow , \
|
||||
cash_flow.net_invest_cash_flow),statDate=str(y-i))
|
||||
if len(df) != 0:
|
||||
df['FCF'] = df['net_operate_cash_flow']-df['net_invest_cash_flow']
|
||||
df = df[df['FCF']>0]
|
||||
l_temp = df['code'].values
|
||||
if len(l4) != 0:
|
||||
l4 = set(l4) & set(l_temp)
|
||||
l4 = l_temp
|
||||
else:
|
||||
continue
|
||||
l4 = set(l4)
|
||||
#print 'test'
|
||||
#print l4
|
||||
#5.近四季营收成长率介于6%至30%()。 'IRYOY':indicator.inc_revenue_year_on_year, # 营业收入同比增长率(%)
|
||||
l5 = {}
|
||||
for i in range(4):
|
||||
df_5 = panel.iloc[:,i,:]
|
||||
df_temp_5 = df_5[(df_5['inc_revenue_year_on_year']>6) & (df_5['inc_revenue_year_on_year']<30)]
|
||||
if i == 0:
|
||||
l5 = set(df_temp_5.index)
|
||||
else:
|
||||
l_temp = df_temp_5.index
|
||||
l5 = l5 & set(l_temp)
|
||||
l5 = set(l5)
|
||||
|
||||
#6.近四季盈余成长率介于8%至50%。(eps比值)
|
||||
l6 = {}
|
||||
for i in range(4):
|
||||
df_6 = panel.iloc[:,i,:]
|
||||
df_temp = df_6[(df_6['eps']>0.08) & (df_6['eps']<0.5)]
|
||||
if i == 0:
|
||||
l6 = set(df_temp.index)
|
||||
else:
|
||||
l_temp = df_temp.index
|
||||
l6 = l6 & set(l_temp)
|
||||
l6 = set(l6)
|
||||
|
||||
return list(l1 & l2 &l3 & l4 & l5 & l6)
|
||||
|
||||
#去极值(分位数法)
|
||||
def winsorize(se):
|
||||
q = se.quantile([0.025, 0.975])
|
||||
if isinstance(q, pd.Series) and len(q) == 2:
|
||||
se[se < q.iloc[0]] = q.iloc[0]
|
||||
se[se > q.iloc[1]] = q.iloc[1]
|
||||
return se
|
||||
|
||||
#获取多期财务数据内容
|
||||
def get_data(pool, periods):
|
||||
q = query(valuation.code, income.statDate, income.pubDate).filter(valuation.code.in_(pool))
|
||||
df = get_fundamentals(q)
|
||||
df.index = df.code
|
||||
stat_dates = set(df.statDate)
|
||||
stat_date_stocks = { sd:[stock for stock in df.index if df['statDate'][stock]==sd] for sd in stat_dates }
|
||||
|
||||
def quarter_push(quarter):
|
||||
if quarter[-1]!='1':
|
||||
return quarter[:-1]+str(int(quarter[-1])-1)
|
||||
else:
|
||||
return str(int(quarter[:4])-1)+'q4'
|
||||
|
||||
q = query(valuation.code,valuation.code,valuation.circulating_market_cap,balance.total_current_assets,balance.total_current_liability,\
|
||||
indicator.roe,cash_flow.net_operate_cash_flow,cash_flow.net_invest_cash_flow,indicator.inc_revenue_year_on_year,indicator.eps
|
||||
)
|
||||
|
||||
stat_date_panels = { sd:None for sd in stat_dates }
|
||||
|
||||
for sd in stat_dates:
|
||||
quarters = [sd[:4]+'q'+str(int(sd[5:7])/3)]
|
||||
for i in range(periods-1):
|
||||
quarters.append(quarter_push(quarters[-1]))
|
||||
nq = q.filter(valuation.code.in_(stat_date_stocks[sd]))
|
||||
pre_panel = { quarter:get_fundamentals(nq, statDate = quarter) for quarter in quarters }
|
||||
for thing in pre_panel.values():
|
||||
thing.index = thing.code.values
|
||||
panel = pd.Panel(pre_panel)
|
||||
panel.items = range(len(quarters))
|
||||
stat_date_panels[sd] = panel.transpose(2,0,1)
|
||||
|
||||
final = pd.concat(stat_date_panels.values(), axis=2)
|
||||
|
||||
return final.dropna(axis=2)
|
||||
@@ -0,0 +1,138 @@
|
||||
# 02 小市值20只 IC 对冲策略
|
||||
|
||||
## 元信息
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 标题 | 小市值20只组合不择时不止损IC对冲——股指期货对冲研究成果应用 |
|
||||
| 作者 | jqz1226 ZUEL |
|
||||
| 来源 | https://www.joinquant.com/post/4462 |
|
||||
| 声称收益 | 年化 92.72%,最大回撤 9.828% |
|
||||
| 回测起点 | 2015-04-27(IC 期货 2015-04-16 上市) |
|
||||
| Python | 2 |
|
||||
|
||||
## 策略概要
|
||||
|
||||
| 要素 | 内容 |
|
||||
|------|------|
|
||||
| 资金分配 | 股票账户 1/1.3 ≈ 77%,期货账户 ≈ 23%(SubPortfolio 分仓) |
|
||||
| 选股 | 市值最小的 100 只(剔除创业板 / eps≤0)→ 动量评分取前 20 只 |
|
||||
| 评分 | (现价−130日最低) + (现价−130日最高) + (现价−15日均线),升序(越低越靠前) |
|
||||
| 调仓 | 每 5 个交易日(g.tc=5) |
|
||||
| 对冲 | 中证500 股指期货 IC,做空,按 beta 对冲 |
|
||||
| beta 计算 | 组合收益 vs 沪深300收益协方差,63 日样本(g.yb=63) |
|
||||
| 风控 | 不择时、不止损 |
|
||||
| 保证金 | 2015-09-07 后 20%,之前 10% |
|
||||
|
||||
## 对冲逻辑要点
|
||||
|
||||
- `hedge_ratio = 1 + beta*margin_rate + beta/5`
|
||||
- 股票账户目标价值 = 总资产 / hedge_ratio
|
||||
- 期货空单手数 = `futures_margin / (指数价 × 乘数200 × 保证金率)`
|
||||
- 每月第三周后切换下月合约(不平等到期日)
|
||||
|
||||
## ⚠️ 已知问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| IC 期货门槛 | 需要期货账户,资金门槛高(一手 IC 保证金数万),实盘门槛远高于现货 |
|
||||
| 小市值流动性 | 最小市值股流动性极差,滑点巨大(社区核心质疑点) |
|
||||
| Python 2 | `df.sort(columns=)` 旧 API、`statsmodels` 回归 import 未实际使用等 |
|
||||
| 聚宽期货专有 API | `SubPortfolio`/`transfer_cash`/`order_target(side='short')`/`get_next_month_future` 等需自建 |
|
||||
| 前视偏差 | 小市值 + `market_cap` 选股,同前述"准未来函数"问题(盘中小市值字段不准) |
|
||||
| 对冲成本 | IC 长期贴水,对冲成本可能吃掉相当部分 alpha |
|
||||
| 评分公式存疑 | 三项直接相加(绝对价差),未归一化,高价股系统性偏低分,需审视 |
|
||||
|
||||
## 本地复现要点
|
||||
|
||||
- **数据需求**:总市值(market_cap)、eps、日线行情(130日高低、15日均线)、沪深300/中证500 指数、IC 期货合约日线
|
||||
- **框架障碍(关键)**:BulletTrade 当前只做**现货选股轮动**,**无期货对冲 / 做空 / SubPortfolio 双账户能力**
|
||||
- 复现完整策略需先扩展回测引擎(做空、期货合约、保证金、移仓)
|
||||
- 或仅复现**选股部分**(小市值20只 + 动量评分),放弃对冲 → 但那样就不是"对冲策略"了
|
||||
- **可行性判断**:
|
||||
- 选股部分:⭐⭐ 可复现(数据齐备)
|
||||
- 对冲部分:⭐⭐⭐⭐⭐ 重大缺口(需扩展引擎 + IC 期货数据 + 实盘期货账户)
|
||||
- **建议**:先评估是否值得为这一个策略引入期货对冲能力,还是聚焦现货选股类策略
|
||||
|
||||
---
|
||||
|
||||
## 移植记录(2026-07-27)
|
||||
|
||||
### 移植方案
|
||||
|
||||
按 Main Agent 指令执行「**只保留小市值选股轮动,去掉 IC 期货对冲**」的等价移植:
|
||||
- 选股逻辑忠实复刻(全市场最小 100 只 → 动量评分取前 20)
|
||||
- 对冲部分**全部删除**(BulletTrade 不支持做空/期货 + 无 IC 期货数据)
|
||||
- py2→py3 翻译,聚宽 API→BrokerFacade 注入(照 momentum_timing / value_selection 模板)
|
||||
|
||||
### 保留的逻辑(选股部分)
|
||||
|
||||
| 原策略元素 | 移植后 |
|
||||
|------------|--------|
|
||||
| 选股池:全市场(聚宽 `query(valuation.code)`) | universe 成份股(默认 `000985.XSHG` 中证全指,5128 只;2026-07-28 G2 补全后切回原版) |
|
||||
| 市值最小 100 只(过滤创业板 300xxx + eps≤0) | `provider.get_fundamentals_df` → `df.sort_values("market_cap").head(100)` + `filter_kcbj_stock` + eps 过滤 |
|
||||
| 上市 > 120 天过滤 | `filters.filter_new_stock(days=120)` |
|
||||
| 停牌 / ST / 涨跌停过滤 | `filters.filter_{paused,st,limitup,limitdown}_stock` |
|
||||
| 动量评分:`(cur-low_130)+(cur-high_130)+(cur-ma15)`,升序 | `_cal_momentum_score`(130 日 close+high+low + 15 日均线) |
|
||||
| 取前 20 只 | `buy_stock_count = 20` |
|
||||
| 每 5 个交易日调仓(g.tc=5) | `handle_data` 内部 `day_count % tc == 0` 触发选股调仓 |
|
||||
| 等权持有 20 只 | `per_value = cash / len(target)` |
|
||||
| 卖出不在新名单的 | `order_target_value(code, 0)` |
|
||||
|
||||
### 去掉的对冲逻辑(数据/能力缺口明细)
|
||||
|
||||
| 原策略元素 | 去掉原因 | 缺口类型 |
|
||||
|------------|----------|----------|
|
||||
| `SubPortfolioConfig` 双账户(股票 77% + 期货 23%) | BulletTrade 单账户模型 | **引擎能力缺口** |
|
||||
| `transfer_cash(1, 0, ...)` 账户间调配 | BulletTrade 无 SubPortfolio | **引擎能力缺口** |
|
||||
| `compute_hedge_ratio(context, stocks)` 算 beta | 仅在带对冲时有意义 | 删除(纯选股无需) |
|
||||
| `get_next_month_future(context, 'IC')` 月度合约切换 | BulletTrade 无期货合约概念 | **数据缺口** + **引擎缺口** |
|
||||
| `order_target(future, n, side='short')` 期货空单 | BulletTrade 不支持做空 | **引擎能力缺口** |
|
||||
| `futures_margin` / `futures_margin_rate` / `futures_multiplier` | 保证金计算仅对冲用 | 删除 |
|
||||
| `hedge_ratio = 1 + beta*margin_rate + beta/5` | 仅对冲时用 | 删除 |
|
||||
| `import statsmodels.api as sm` / `from statsmodels import regression` | 原代码 import 但**未实际使用** | 删除(死代码) |
|
||||
| `set_option('futures_margin_rate', ...)` | 期货保证金配置 | 删除 |
|
||||
|
||||
### 与原始策略的差异
|
||||
|
||||
1. **对冲完全去掉**:承担完整小市值风险敞口(原策略用 IC 期货对冲市场 beta),回撤会显著大于原策略声称的 9.828%
|
||||
2. **universe 切回 000985 全市场(2026-07-28 G2 补全)**:原策略 `query(valuation.code)` 是聚宽服务端全市场;
|
||||
此前因 `000985.XSHG` 不在 constituent_unified 降级用 `932000.XSHG`(中证2000,2684 只小盘);
|
||||
2026-07-28 G2 补全 `000985`(中证全指,5128 只)后切回原版,恢复"全市场市值最小100"意图。
|
||||
历史降级细节见 git 历史(commit before 2026-07-28)。
|
||||
3. **`filter_kcbj_stock` 比原策略更严**:原策略只过滤 `300xxx`(创业板),移植用 `filter_kcbj_stock` 一并过滤创业板(3)+ 科创板(68)+ 北交所(4/8)。spec 要求,符合"剔除非主板"意图
|
||||
4. **KISS 简化**:`rebalance` 不做原策略的 `over_weight / under_weight` 削高填低,简化为"全卖不在名单的 + 等额买新名单"(语义等价:都是等权持有 target)
|
||||
5. **py2→py3**:`df.sort(columns='score', ascending=True)` → `df.sort_values("score", ascending=True)`
|
||||
|
||||
### 数据缺口
|
||||
|
||||
| 数据 | 状态 | 影响 |
|
||||
|------|------|------|
|
||||
| 总市值(market_cap) | ✅ `static/valuation` akshare | 选股正常 |
|
||||
| EPS | ✅ `static/income` akshare | 选股正常 |
|
||||
| 130 日 close/high/low | ✅ dbbardata | 评分正常 |
|
||||
| 15 日 close(算均线) | ✅ dbbardata | 评分正常 |
|
||||
| 中证全指(000985)成份股 | ✅ constituent_unified 已补(G2 2026-07-28) | 默认 universe,5128 只,贴近原策略全市场意图 |
|
||||
| 中证 2000(932000)成份股 | ✅ constituent_unified | 备选 universe(G2 前的降级版) |
|
||||
| IC 期货日线 | ❌ 缺 | 对冲部分无法复现(已删) |
|
||||
| IC 期货合约月份切换 | ❌ 缺 | 对冲部分无法复现(已删) |
|
||||
|
||||
### 文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `sanguo_portfolio/strategies/small_cap.py` | SmallCapStrategy + SmallCapConfig |
|
||||
| `sanguo_portfolio/strategies/__init__.py` | 加 SmallCap 导出 |
|
||||
| `sanguo_portfolio/runner_backtest.py` | `--strategy small_cap` 分发 + run_daily 注册 |
|
||||
| `tests/portfolio/test_small_cap.py` | 23 个单测,全通过 |
|
||||
|
||||
### 单测覆盖
|
||||
|
||||
- ✅ `initialize`:run_daily 注册 handle_data / set_benchmark
|
||||
- ✅ Config 默认值(对齐 source.py `set_params`)
|
||||
- ✅ `_stock_pool`:创业板/科创北交过滤、max_pool 截断
|
||||
- ✅ `_cal_momentum_score`:公式正确(score=0 / 正 / 负)、升序、空数据跳过
|
||||
- ✅ `_pick_stocks`:eps≤0 过滤、market_cap 升序取前 100、动量评分取前 20
|
||||
- ✅ `handle_data`:5 日调仓周期(day_count % tc == 0)、非调仓日 no-op
|
||||
- ✅ 调仓:卖出不在名单、等额买入新股
|
||||
- ✅ 移植差异:无 SubPortfolio / transfer_cash / statsmodels / compute_hedge_ratio
|
||||
@@ -0,0 +1,291 @@
|
||||
# 克隆自聚宽文章:https://www.joinquant.com/post/4462
|
||||
# 标题:小市值20只组合不择时不止损IC对冲——股指期货对冲研究成果应用
|
||||
# 作者:jqz1226 ZUEL
|
||||
# 注:Python 2 原稿,聚宽专有 API,无法本地直接运行
|
||||
|
||||
import statsmodels.api as sm
|
||||
from statsmodels import regression
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
#import time
|
||||
#from datetime import date
|
||||
from jqdata import *
|
||||
import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
'''
|
||||
================================================================================
|
||||
总体回测前
|
||||
================================================================================
|
||||
'''
|
||||
|
||||
#总体回测前要做的事情
|
||||
def initialize(context):
|
||||
set_params() #1设置策参数
|
||||
set_variables() #2设置中间变量
|
||||
set_backtest() #3设置回测条件
|
||||
# 分仓
|
||||
stock_cash = np.round(context.portfolio.starting_cash*(1/1.3),0)
|
||||
future_cash = context.portfolio.starting_cash - stock_cash
|
||||
set_subportfolios(
|
||||
[
|
||||
SubPortfolioConfig(cash=stock_cash, type='stock'),
|
||||
SubPortfolioConfig(cash=future_cash,type='index_futures')
|
||||
]
|
||||
)
|
||||
|
||||
#1
|
||||
#设置策参数
|
||||
def set_params():
|
||||
g.tc=5 # 调仓频率
|
||||
g.yb=63 # 样本长度
|
||||
|
||||
g.pick_stock_count = 100 # 备选股票数量
|
||||
g.buy_stock_count = 20 # 买入股票数目
|
||||
|
||||
g.pre_future='' #用来装上次进入的期货合约名字
|
||||
g.futures_margin_rate = 0.10 #股指期货保证金比例
|
||||
g.futures_symbol = 'IC' #期货指数种类IF,IH,IC
|
||||
g.futures_multiplier = (200 if g.futures_symbol=='IC' else 300) # IF和IH每点价值300元,IC为200元
|
||||
#2
|
||||
#设置中间变量
|
||||
def set_variables():
|
||||
g.t = 0 #运行天数
|
||||
g.in_position_stocks = [] #持仓股票
|
||||
|
||||
#3
|
||||
#设置回测条件
|
||||
def set_backtest():
|
||||
set_option('use_real_price', True) #用真实价格交易
|
||||
log.set_level('order', 'warning')
|
||||
# set_slippage(FixedSlippage(0)) #将滑点设置为0
|
||||
|
||||
'''
|
||||
================================================================================
|
||||
每天开盘前
|
||||
================================================================================
|
||||
'''
|
||||
#每天开盘前要做的事情
|
||||
def before_trading_start(context):
|
||||
log.info('---------------------------------------------------------------------')
|
||||
set_slip_fee(context)
|
||||
|
||||
#4 根据不同的时间段设置滑点与手续费
|
||||
def set_slip_fee(context):
|
||||
# 根据不同的时间段设置手续费
|
||||
dt=context.current_dt
|
||||
# log.info(type(context.current_dt))
|
||||
|
||||
if dt>datetime.datetime(2013,1, 1):
|
||||
set_commission(PerTrade(buy_cost=0.0003, sell_cost=0.0013, min_cost=5))
|
||||
|
||||
elif dt>datetime.datetime(2011,1, 1):
|
||||
set_commission(PerTrade(buy_cost=0.001, sell_cost=0.002, min_cost=5))
|
||||
|
||||
elif dt>datetime.datetime(2009,1, 1):
|
||||
set_commission(PerTrade(buy_cost=0.002, sell_cost=0.003, min_cost=5))
|
||||
|
||||
else:
|
||||
set_commission(PerTrade(buy_cost=0.003, sell_cost=0.004, min_cost=5))
|
||||
|
||||
# 设置期货合约保证金
|
||||
if dt>datetime.datetime(2015,9,7):
|
||||
g.futures_margin_rate = 0.2
|
||||
else:
|
||||
g.futures_margin_rate = 0.1
|
||||
set_option('futures_margin_rate', g.futures_margin_rate)
|
||||
|
||||
'''
|
||||
================================================================================
|
||||
每天交易时
|
||||
================================================================================
|
||||
'''
|
||||
#每个交易日需要运行的函数
|
||||
def handle_data(context, data):
|
||||
# 计算持仓股票
|
||||
g.in_position_stocks = compute_signals(context, data)
|
||||
# 计算对冲比例和 beta
|
||||
hedge_ratio, beta = compute_hedge_ratio(context, g.in_position_stocks)
|
||||
# 调仓
|
||||
rebalance(hedge_ratio, beta, context)
|
||||
# 天数加一
|
||||
g.t += 1
|
||||
|
||||
def pick_stocks(context, data):
|
||||
q = query(valuation.code)
|
||||
q = q.filter(
|
||||
indicator.eps > 0,
|
||||
~valuation.code.like('300%') #剔除创业板
|
||||
)
|
||||
q = q.order_by(
|
||||
valuation.market_cap.asc()
|
||||
).limit(
|
||||
g.pick_stock_count
|
||||
)
|
||||
|
||||
df = get_fundamentals(q)
|
||||
stock_list = list(df['code'])
|
||||
|
||||
# 剔除上市未超过120天的(因为样本要求63个交易日的数据),停牌的,ST的,涨跌停的
|
||||
dToday = context.current_dt.date()
|
||||
current_data = get_current_data()
|
||||
stock_list = [stock for stock in stock_list if \
|
||||
(dToday - get_security_info(stock).start_date).days > 120 and
|
||||
(not current_data[stock].paused) and
|
||||
(not current_data[stock].is_st) and
|
||||
(current_data[stock].low_limit < data[stock].close < current_data[stock].high_limit)]
|
||||
|
||||
# 对股票评分
|
||||
dst_stocks = {}
|
||||
for stock in stock_list:
|
||||
h = attribute_history(stock, 130, unit='1d', fields=('close', 'high', 'low'), skip_paused=True)
|
||||
low_price_130 = h.low.min()
|
||||
high_price_130 = h.high.max()
|
||||
|
||||
avg_15 = data[stock].mavg(15, field='close')
|
||||
cur_price = data[stock].close
|
||||
|
||||
score = (cur_price-low_price_130) + (cur_price-high_price_130) + (cur_price-avg_15)
|
||||
|
||||
dst_stocks[stock] = score
|
||||
|
||||
df = pd.DataFrame({'score':dst_stocks})
|
||||
df = df.sort(columns='score', ascending=True)
|
||||
stock_list = df.index.tolist()
|
||||
|
||||
return stock_list[:g.buy_stock_count]
|
||||
|
||||
# 6
|
||||
# 计算持仓股票
|
||||
# 输出一 list 股票
|
||||
def compute_signals(context, data):
|
||||
# 如果是调仓日
|
||||
if g.t%g.tc==0:
|
||||
return pick_stocks(context, data) #选股
|
||||
# 如果不是调仓日
|
||||
else:
|
||||
# 延续旧的持仓股票
|
||||
return g.in_position_stocks
|
||||
|
||||
# 7
|
||||
# 计算对冲比例
|
||||
# 输出两个 float
|
||||
def compute_hedge_ratio(context, in_position_stocks):
|
||||
# 取股票在样本时间内的价格
|
||||
prices = history(g.yb, '1d', 'close', in_position_stocks)
|
||||
# 取指数在样本时间内的价格
|
||||
index_prices = attribute_history('000300.XSHG', g.yb, '1d', 'close')
|
||||
# prices 行:日期,列:各只股票 =>pct_change():dataframe, 结构不变,值为日收益率=>[1:] drop first row
|
||||
# =>mean(axis=1)横向平均,Series=>.values:array
|
||||
portfolio_Rets = prices.pct_change()[1:].mean(axis=1).values
|
||||
# pct_change():dataframe, 结构不变,值为日收益率=>[1:] drop first row=>.close:Series =>values:array
|
||||
index_Rets = index_prices.pct_change()[1:].close.values
|
||||
|
||||
#计算组合和指数的协方差矩阵cov_mat
|
||||
cov_mat = np.cov(portfolio_Rets, index_Rets)
|
||||
# 计算组合的系统性风险beta
|
||||
beta = cov_mat[0,1]/cov_mat[1,1]
|
||||
|
||||
# 计算并返回对冲比例
|
||||
return 1 + beta*g.futures_margin_rate + beta/5, beta
|
||||
|
||||
# 8
|
||||
# 调仓函数
|
||||
# 输入对冲比例
|
||||
def rebalance(hedge_ratio, beta, context):
|
||||
log.info('hedge_ratio: %.6f, beta: %.6f, futures_margin_rate: %.2f' % (hedge_ratio, beta, g.futures_margin_rate))
|
||||
|
||||
# 计算资产总价值
|
||||
total_value = context.portfolio.total_value
|
||||
log.info('portfolio Total_value: %.2f, Stock subportfolio total_value: %.2f, Futures subportfolio total_value: %.2f' % \
|
||||
(total_value, context.subportfolios[0].total_value, context.subportfolios[1].total_value))
|
||||
# 计算预期的股票账户价值
|
||||
expected_stock_value = np.round(total_value/hedge_ratio,0)
|
||||
|
||||
# 将两个账户的钱调到预期的水平
|
||||
# Futures to Stock
|
||||
cash_FtoS = min(context.subportfolios[1].transferable_cash, max(0, expected_stock_value-context.subportfolios[0].total_value))
|
||||
transfer_cash(1, 0, cash_FtoS)
|
||||
log.info('期货账户出金: %.2f' % cash_FtoS)
|
||||
|
||||
# Stock to Futures
|
||||
cash_StoF = min(context.subportfolios[0].transferable_cash, max(0, context.subportfolios[0].total_value-expected_stock_value))
|
||||
transfer_cash(0, 1,cash_StoF )
|
||||
log.info('股票账户出金: %.2f' % cash_StoF)
|
||||
|
||||
# 计算股票账户价值(预期价值和实际价值其中更小的那个)
|
||||
stock_value = min(context.subportfolios[0].total_value, expected_stock_value)
|
||||
log.info('Target stock_value: %.2f' % stock_value)
|
||||
|
||||
# 计算相应的期货保证金价值
|
||||
futures_margin = stock_value * beta * g.futures_margin_rate
|
||||
log.info('Target futures_margin: %.2f' % futures_margin)
|
||||
|
||||
# 调整股票仓位,在 g.in_position_stocks 里的等权分配
|
||||
for stock in context.subportfolios[0].long_positions.keys():
|
||||
if stock not in g.in_position_stocks:
|
||||
order_target(stock, 0, pindex=0)
|
||||
|
||||
curr_data = get_current_data()
|
||||
target_stocks = [stock for stock in g.in_position_stocks if not curr_data[stock].paused ] #过滤掉今日停牌的
|
||||
|
||||
per_value = stock_value/len(g.in_position_stocks) #每只股票应该达到的权值
|
||||
over_weight_list = [stock for stock in target_stocks if \
|
||||
context.subportfolios[0].long_positions[stock].value > per_value] #现持仓中超权的
|
||||
under_weight_list = [stock for stock in target_stocks if \
|
||||
stock not in over_weight_list] #剩余的,就是贴权的,应该补权
|
||||
|
||||
for stock in over_weight_list: # 超权的先减仓,削高
|
||||
order_target_value(stock, per_value, pindex=0)
|
||||
for stock in under_weight_list: # 贴权的再加仓,填低
|
||||
order_target_value(stock, per_value, pindex=0)
|
||||
|
||||
# 获取下月连续合约 string
|
||||
current_future = get_next_month_future(context, g.futures_symbol) #g.futures_symbol: IF,IH,IC
|
||||
# 如果下月合约和原本持仓的期货不一样
|
||||
if g.pre_future!='' and g.pre_future!=current_future:
|
||||
# 就把仓位里的期货平仓
|
||||
order_target(g.pre_future, 0, side='short', pindex=1)
|
||||
# 现有期货合约改为刚计算出来的
|
||||
g.pre_future = current_future
|
||||
|
||||
# 获取期货指数价格
|
||||
index_price = attribute_history(current_future, 1, '1d', 'close').close.iloc[0]
|
||||
log.info('Index futures: %s, Price: %.2f' % (current_future, index_price))
|
||||
|
||||
# 计算并调整需要的空单仓位
|
||||
nShortAmount = int(np.round(futures_margin/(index_price * g.futures_multiplier * g.futures_margin_rate),0)) # 目标手数
|
||||
nHoldAmount = context.subportfolios[1].short_positions[current_future].total_amount #现持仓手数
|
||||
log.info('股指期货: %s, 现持仓手数: %d, 目标手数: %d' % (current_future, nHoldAmount, nShortAmount))
|
||||
if nShortAmount != nHoldAmount:
|
||||
order = order_target(current_future, nShortAmount, side='short', pindex=1)
|
||||
if order != None and order.filled > 0:
|
||||
log.info('Futures: %s, action: short %s, filled: %d, price: %.2f' % \
|
||||
(order.security, ('平空' if order.is_buy else '开仓'), order.filled, order.price))
|
||||
else:
|
||||
log.info('Futures: %s, order failure' % (current_future))
|
||||
|
||||
# 记录调仓完毕之后的信息:
|
||||
log.info('股指期货标的价值F: %.2f, beta: %.6f, 股票总市值S: %.2f' % \
|
||||
(context.subportfolios[1].positions_value, beta, context.subportfolios[0].positions_value))
|
||||
# 检验调仓后是否满足 F = beta * S,看其偏离度%:100*(F/( beta * S) - 1), 负数:股指期货不足,正数:股指期货超量
|
||||
log.info('股指期货标的价值偏离度: %.2f%%' % \
|
||||
(100*(context.subportfolios[1].positions_value/( beta * context.subportfolios[0].positions_value) - 1)))
|
||||
|
||||
# 取下月连续string
|
||||
# 输入 context 和一个 string,后者是'IF'或'IC'或'IH'
|
||||
# 输出一 string,如 'IF1509.CCFX'
|
||||
# 进入本月第三周即切换到下月合约,而不等第三周的周五本月合约结束
|
||||
def get_next_month_future(context, symbol):
|
||||
dt = context.current_dt
|
||||
month_begin_day = datetime.date(dt.year, dt.month, 1).isoweekday() # 本月1号是星期几(1-7)
|
||||
third_monday_date = 16 - month_begin_day + 7*(month_begin_day>5) #本月的第三个星期一是几号
|
||||
# 如果今天没过第三个星期一
|
||||
if dt.day < third_monday_date:
|
||||
next_dt = dt #本月合约
|
||||
else:
|
||||
next_dt = dt + relativedelta(months=1) #切换至下月合约
|
||||
|
||||
year = str(next_dt.year)[2:]
|
||||
month = ('0' + str(next_dt.month))[-2:]
|
||||
|
||||
return (symbol+year+month+'.CCFX')
|
||||
@@ -0,0 +1,113 @@
|
||||
# 03 牛熊分界+取强舍弱+均线动量择时选股
|
||||
|
||||
## 元信息
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 标题 | 牛熊分界+取强舍弱+均线动量指标择时选股策略 |
|
||||
| 作者 | Alphamon |
|
||||
| 来源 | https://www.joinquant.com/post/905 |
|
||||
| 聚宽编辑器 | algorithmId=02bf90a4da9fb43192186b3cdbe1a8f2 |
|
||||
| 回测区间 | 2015-01-01 ~ 2016-03-22 |
|
||||
| 初始资金 | 1000000 |
|
||||
| 频率 | 日 |
|
||||
| Python | 2 |
|
||||
|
||||
## 策略概要
|
||||
|
||||
三段式:**择时(牛熊分界)→ 行业取强 → 均线动量确认**
|
||||
|
||||
| 要素 | 内容 |
|
||||
|------|------|
|
||||
| 择时(牛熊分界) | 统计各行业中「现价 > 过去30日均价」的比重,> 20% 视为牛市,否则熊市全清 |
|
||||
| 取强舍弱 | 每个行业按 RPS(相对强弱,过去30日涨跌幅排名)取 top 6 → 候选池 |
|
||||
| 均线动量 | 候选池中保留「收盘价 > MA5 且 MA5 > MA15」的票 |
|
||||
| 买入 | 等额买入(cash / 持仓数) |
|
||||
| 卖出 | 熊市信号全清;牛市下不在候选池的清掉 |
|
||||
| 数据类型 | **纯量价**,无需基本面 |
|
||||
|
||||
## ⚠️ 已知问题(两个致命 bug,回测结果不可信)
|
||||
|
||||
| 严重度 | 问题 | 说明 |
|
||||
|--------|------|------|
|
||||
| 🔴 致命 | **calRPS 取数区间错** | `get_price(start=curDate, end=curDate)` 只取 1 天,`iloc[0]==iloc[-1]`,**涨跌幅恒为 0**,RPS 排名完全失效;`preDate` 参数传了却没用 |
|
||||
| 🔴 致命 | **date.today() 用错** | 回测里用 `datetime.date.today()` 取**真实今天**而非 `context.current_dt`,回测取数日期全错(前视/错位) |
|
||||
| 🟡 | isnan 裸调用 | 未 `import`,Python 2 下可能 NameError |
|
||||
| 🟡 | 候选池过大 | topK=6 × 70+ 行业 → 候选池可达数百只,再筛选后买入数失控 |
|
||||
| 🟡 | 行业分类口径 | 用旧证监会行业代码(A01/R86…),需确认本地行业映射 |
|
||||
| ⚪ | Python 2 | `df.sort(columns=)`、`STSign.bool()`、print 语句 |
|
||||
| ⚪ | 聚宽专有 | `get_industry_stocks`/`get_index_stocks`/`get_price`/`get_extras`/`mavg`/`order`/`order_target` |
|
||||
|
||||
> ⚠️ 因前两个致命 bug,原帖回测收益曲线**不可信**——RPS 排名实际没起作用、取数日期还是错的。复现前必须先修。
|
||||
|
||||
## 本地复现要点
|
||||
|
||||
- **数据需求**:日线行情(MA5/MA15/30日均价)、行业成份股、是否 ST、停牌
|
||||
→ **全部齐备**(dbbardata 日线 + constituent_unified;ST/停牌项目已有处理)
|
||||
- **框架对接**:BulletTrade 选股轮动 + 择时模块(all_weather 有 stop_loss,可扩展"牛熊分界"择时)
|
||||
- **关键修复**:
|
||||
1. calRPS 改为 `get_price(start=preDate, end=curDate)` 取区间,算真实涨跌幅
|
||||
2. `date.today()` → `context.current_dt.date()`
|
||||
3. isnan → `np.isnan` 或 `math.isnan`
|
||||
4. 行业代码 → 本地行业分类映射
|
||||
- **复现难度**:⭐⭐(数据完全齐备,纯量价;主要工作是修 bug + 行业映射)
|
||||
|
||||
## 备注
|
||||
|
||||
这是三个策略里**数据需求最简单**的(纯量价、无基本面、无期货),但**代码 bug 最多**,原帖回测不可信。修完 bug 后可能是最值得本地验证的一个。
|
||||
|
||||
---
|
||||
|
||||
## 移植记录(2026-07-27)
|
||||
|
||||
### 概要
|
||||
移植到 BulletTrade 组合回测框架(`sanguo_portfolio/strategies/momentum_timing.py`),结构等价 + **修复 2 个原始致命 bug**。
|
||||
|
||||
### 改了什么 / 怎么改的
|
||||
|
||||
| 项 | 原始(聚宽) | 移植后 |
|
||||
|----|-----------|--------|
|
||||
| 入口 | `initialize + handle_data(context, data)` | `MomentumTimingStrategy` 类 + `BrokerFacade` 注入(照 all_weather 模板) |
|
||||
| 全局函数 | `get_price/get_index_stocks/order/order_target/set_benchmark/run_daily` | 走注入的 `self.provider` + `self.broker`(策略层不直接 import bullet_trade) |
|
||||
| 数据 | `data[security].mavg(n,'close')` | `provider.get_price(count=n).pivot().tail(n).mean()` |
|
||||
| 过滤 | `get_current_data().paused` / `get_extras('is_st')` | 复用 `sanguo_portfolio.filters.filter_paused_stock/filter_limitup_stock/filter_limitdown_stock`(ST 过滤并入 `_stock_pool` 的 `filter_st_stock`) |
|
||||
| 单位 | Python 2(`df.sort(columns=)` / `isnan` / 整数除法) | Python 3(`sort_values` / `np.isnan` / 浮点除法) |
|
||||
| 下单 | `order(security, buyAmount)` 按股数 | `broker.order_target_value(code, value)` 按金额(KISS:语义等价的等额买入,避免股数取整损失;**已持有的不加仓**,见下「逻辑差异」) |
|
||||
| Runner 入口 | 聚宽编辑器 | `runner_backtest.py --strategy momentum_timing`(原硬编码 all_weather 已改成分发) |
|
||||
|
||||
### 修复的 2 个致命 bug
|
||||
|
||||
1. **`calRPS` 取数区间错** — 原代码 `get_price(start=curDate, end=curDate)` 只取 1 天,`iloc[0]==iloc[-1]`,涨跌幅恒 0,RPS 排名完全失效。改为 `_cal_rps` 取 `preDate ~ curDate` 区间,算真实**百分比涨跌幅** `(last/first - 1)`(原代码用绝对差值 `last - first` 排序会偏向高价股,改用百分比更符合 RPS 语义,单测 `test_rps_uses_pre_to_cur_range_real_returns` 验证)。
|
||||
2. **`date.today()` 用错** — 回测里取真实今天而非回测当前日 → 改用 `context.current_dt`,单测 `test_handle_data_uses_current_dt_not_today` 验证 `get_price` 的 `end_date` 跟随 `current_dt`。
|
||||
|
||||
### 与原始策略的**有意**逻辑差异
|
||||
|
||||
| 差异 | 原因 |
|
||||
|------|------|
|
||||
| 板块切回 10 个中证行业指数 000928-000937 | 2026-07-28 G1 补全后切回 10 个中证行业指数(000928-000937),恢复行业轮动原版;000938 仍缺暂跳记遗留。逻辑机制(择时+取强舍弱+均线动量)不动 |
|
||||
| 已持仓股**不重复加仓**,仅买入新股 | 原代码 `order(security, buyAmount)` 对 stocks 池所有股票都下单,每次"加仓"而非"调到目标"(已持仓会无限累加);移植版仅对不在持仓的新股 `order_target_value`,已持仓不动(避免回测里无限加仓的 bug) |
|
||||
| `order_target_value(per_value)` 按金额而非 `order(buyAmount)` 按股数 | KISS:与 all_weather 模板的调仓风格一致,省去 `int()` 取整和 `stocksPrice` 查询;等额买入的核心语义不变 |
|
||||
| `py2 整数除法` 改为浮点除法 | 原代码 `float(count)/len(indexList)` 实际已强转 float(py2 也是浮点除法),移植保持浮点语义,无行为变化(注释明确) |
|
||||
|
||||
### 遗留问题 / 数据缺口
|
||||
|
||||
1. **✅ 已闭合(2026-07-28 G1 补全):行业指数成份股** — `constituent_unified` 已补全 10 个中证行业指数(000928-000937)的成份股,板块切回原版。**000938 仍缺**(constituent_unified 返 0 只),暂跳记遗留;补全后可加入 `_DEFAULT_INDEX_LIST` 恢复完整 11 个。
|
||||
2. **🟡 10 个行业相互重叠** — 中证行业指数按 GICS 一级分类,行业间理论互斥;但实际有个别股票在边界归类上可能跨行业,`_find_stock_pool` 取并集时 `_dedup` 去重。整体接近原策略"行业分桶"语义。
|
||||
3. **🟡 涨幅并列时排序稳定性** — `_cal_rps` 用 `sort_values(ascending=False)`,当多只股票涨幅完全相同时,pandas 默认 stable sort 保持原顺序(取决于 `code` 在 pivot.columns 里的顺序,即 provider 返回顺序)。
|
||||
4. **⚪ ST 过滤简化** — 原策略用 `get_extras('is_st', ...)` 取区间 ST 标记,移植版用 `filters.filter_st_stock` 按 `display_name` 含 'ST'/'*'/'退' 判断(取最新名字,非历史时点);回测中 ST 历史标记缺失时可能轻微前视,当前未处理。
|
||||
|
||||
### 测试
|
||||
- 新建 `tests/portfolio/test_momentum_timing.py`(21 用例,Mac 全绿)
|
||||
- 覆盖:Config 默认值、`initialize` 注册定时任务、`_cal_rps` 修复后涨跌幅正确(含空列表/NaN/Zero 除零保护)、`_select_stocks` 均线筛选(close>MA5>MA15 / close<MA5 / MA5<MA15 / 数据不足)、`_cal_buy_sign` 牛熊边界(全站上/全跌破/2-of-9 阈值边界)、`handle_data` 熊市全清 + 牛市买入 + `current_dt` 修复验证、`_find_stock_pool` 每行业 top_k 并集
|
||||
- **AllWeather 测试 1 个 pre-existing 失败**(`test_small_filters_by_roe_roa`)与本次移植无关(stash 验证):all_weather `small` 阈值早已放宽到 `roe>0.05 & roa>0.02`(适配中证1000),但该测试断言还在用旧的 `roe>0.15 & roa>0.10`,需 all_weather 维护者另修
|
||||
|
||||
### 入口用法
|
||||
```bash
|
||||
# Mac 本地回测(需 VPS 数据或 fixture)
|
||||
./venv310/bin/python -m sanguo_portfolio.runner_backtest --strategy momentum_timing \
|
||||
--start 2022-01-01 --end 2024-12-31 --cash 1000000 --provider unified
|
||||
|
||||
# JSON 模式(供前端/SSH 捕获)
|
||||
./venv310/bin/python -m sanguo_portfolio.runner_backtest --strategy momentum_timing --json \
|
||||
--start 2024-01-01 --end 2024-06-30
|
||||
```
|
||||
@@ -0,0 +1,212 @@
|
||||
# 克隆自聚宽文章:https://www.joinquant.com/post/905
|
||||
# 标题:牛熊分界+取强舍弱+均线动量指标择时选股策略
|
||||
# 作者:Alphamon
|
||||
# 注:Python 2 原稿,聚宽专有 API,无法本地直接运行
|
||||
# 聚宽编辑器 algorithmId=02bf90a4da9fb43192186b3cdbe1a8f2
|
||||
|
||||
def initialize(context):
|
||||
# 定义行业类别
|
||||
g.index = 'industry'
|
||||
if g.index == 'index':
|
||||
# 定义行业指数list以便去股票
|
||||
# g.indexList = ['000104.XSHG','000105.XSHG','000106.XSHG','000107.XSHG','000108.XSHG','000109.XSHG','000110.XSHG','000111.XSHG','000112.XSHG','000113.XSHG']
|
||||
g.indexList = ['000928.XSHG','000929.XSHG','000930.XSHG','000931.XSHG','000932.XSHG','000933.XSHG','000934.XSHG','000935.XSHG','000936.XSHG','000937.XSHG','000938.XSHG']
|
||||
elif g.index == 'industry':
|
||||
# 定义行业list以便取股票
|
||||
g.indexList = ['A01','A02','A03','A04','A05','B06',\
|
||||
'B07','B08','B09','B11','C13','C14','C15','C17','C18',\
|
||||
'C19','C20','C21','C22','C23','C24','C25','C26','C27',\
|
||||
'C28','C29','C30','C31','C32','C33','C34','C35','C36',\
|
||||
'C37','C38','C39','C40','C41','C42','D44','D45','D46',\
|
||||
'E47','E48','E50','F51','F52','G53','G54','G55','G56',\
|
||||
'G58','G59','H61','H62','I63','I64','I65','J66','J67',\
|
||||
'J68','J69','K70','L71','L72','M73','M74','N77','N78',\
|
||||
'P82','Q83','R85','R86','R87','S90']
|
||||
else:
|
||||
pass
|
||||
|
||||
# 定义全局参数值
|
||||
g.indexThre = 0.2 #站上pastDay日均线的行业比重
|
||||
g.pastDay = 30 # 过去pastDay日参数
|
||||
g.topK = 6 #
|
||||
|
||||
# 计算相对强弱RPS值
|
||||
def calRPS(stocks,curDate,preDate):
|
||||
# 初始化参数信息
|
||||
numStocks = len(stocks)
|
||||
rankValue = []
|
||||
|
||||
# 计算涨跌幅
|
||||
for security in stocks:
|
||||
# 获取过去pastDay的指数值
|
||||
lastDf = get_price(security, start_date = curDate, end_date = curDate, frequency = '1d', fields = 'close')
|
||||
lastClosePrice = float(lastDf.iloc[0])
|
||||
firstClosePrice = float(lastDf.iloc[-1])
|
||||
# 计算涨跌幅
|
||||
errCloseOpen = [lastClosePrice - firstClosePrice]
|
||||
rankValue += errCloseOpen
|
||||
|
||||
# 根据周涨跌幅排名
|
||||
rpsStocks = {'code':stocks,'rankValue':rankValue}
|
||||
rpsStocks = pd.DataFrame(rpsStocks)
|
||||
rpsStocks = rpsStocks.sort('rankValue',ascending = False)
|
||||
stocks = list(rpsStocks['code'])
|
||||
|
||||
# 计算RPS值
|
||||
rpsValue = [99 - (100 * i/numStocks) for i in range(numStocks)]
|
||||
rpsStocks = {'code':stocks,'rpsValue':rpsValue}
|
||||
rpsStocks = pd.DataFrame(rpsStocks)
|
||||
|
||||
return rpsStocks
|
||||
|
||||
# 股票池:取强舍弱
|
||||
def findStockPool(indexList,curDate,preDate,index = 'index'):
|
||||
topK = g.topK
|
||||
stocks = [];rpsValue = [];industryCode = []
|
||||
# 从每个行业中选取RPS值最高的topK只股票
|
||||
# for eachIndustry in industryList:
|
||||
for eachIndex in indexList:
|
||||
# 取出该行业的股票
|
||||
if index == 'index':
|
||||
stocks = get_index_stocks(eachIndex)
|
||||
elif index == 'industry':
|
||||
stocks = get_industry_stocks(eachIndex)
|
||||
else:
|
||||
return 'Error index order'
|
||||
|
||||
# 计算股票的相对强弱RPS值
|
||||
rpsStocks = calRPS(stocks,curDate,preDate)
|
||||
stocks += list(rpsStocks[:topK]['code'])
|
||||
# rpsValue += list(rpsStocks[:topK]['rpsValue'])
|
||||
# industryCode += [eachIndex] * len(stocks)
|
||||
return stocks
|
||||
|
||||
# 选股:单均线动量策略
|
||||
def selectStocks(stocks,curDate,preDate,data):
|
||||
# 初始化
|
||||
returnStocks = []
|
||||
|
||||
# 筛选当且仅当当日收盘价在5日均线以上的股票
|
||||
for security in stocks:
|
||||
closePrice = get_price(security, start_date = curDate, end_date = curDate, frequency = '1d', fields = 'close')
|
||||
closePrice = float(closePrice.iloc[-1])
|
||||
ma5 = data[security].mavg(5,'close')
|
||||
ma15 = data[security].mavg(15,'close')
|
||||
# if closePrice > ma5:
|
||||
if closePrice > ma5 and ma5 > ma15:
|
||||
returnStocks += [security]
|
||||
else:
|
||||
continue
|
||||
|
||||
return returnStocks
|
||||
|
||||
# 止损:牛熊分界线
|
||||
def calBuySign(indexList,pastDay,data,index = 'index'):
|
||||
# 初始化
|
||||
indexThre = g.indexThre
|
||||
|
||||
# 计算过去几天的指数均值,判断是否满足牛熊分界值
|
||||
count = 0
|
||||
if index == 'index':
|
||||
for eachIndex in indexList:
|
||||
avgPrice = data[eachIndex].mavg(pastDay,'close')
|
||||
if data[eachIndex].mavg(1,'close') > avgPrice:
|
||||
count += 1
|
||||
else:
|
||||
continue
|
||||
elif index == 'industry':
|
||||
for eachIndustry in indexList:
|
||||
stocks = get_industry_stocks(eachIndustry)
|
||||
pastValue = 0
|
||||
curValue = 0
|
||||
for eachStocks in stocks:
|
||||
# pastValue += data[eachStocks].mavg(pastDay,'close')
|
||||
# curValue += data[eachStocks].mavg(1,'close')
|
||||
stocksPastPrice = data[eachStocks].mavg(pastDay,'close')
|
||||
stocksCurrPrice = data[eachStocks].price
|
||||
if isnan(stocksPastPrice) or isnan(stocksCurrPrice):
|
||||
continue
|
||||
else:
|
||||
pastValue += stocksPastPrice
|
||||
curValue += stocksCurrPrice
|
||||
if curValue > pastValue:
|
||||
count += 1
|
||||
else:
|
||||
continue
|
||||
|
||||
else:
|
||||
return 'Error index order.'
|
||||
|
||||
# 根据行业比重发出牛熊市场信号
|
||||
if float(count) / len(indexList) > indexThre:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
# 每个单位时间(如果按天回测,则每天调用一次,如果按分钟,则每分钟调用一次)调用一次
|
||||
def handle_data(context, data):
|
||||
# 初始化参数
|
||||
index = g.index
|
||||
indexList =g.indexList
|
||||
indexThre = g.indexThre
|
||||
pastDay = g.pastDay
|
||||
curDate = datetime.date.today()
|
||||
preDate = curDate + datetime.timedelta(days = -pastDay)
|
||||
curDate = str(curDate)
|
||||
preDate = str(preDate)
|
||||
# 获取资金余额
|
||||
cash = context.portfolio.cash
|
||||
topK = g.topK
|
||||
numSell = 0;numBuy = 0
|
||||
|
||||
# 牛熊分界线发布止损信号
|
||||
buySign = calBuySign(indexList,pastDay,data,index)
|
||||
# buySign = True
|
||||
if buySign == True:
|
||||
# 取强舍弱选股:根据相对RPS指标选取各个行业中最强势的股票形成股票池
|
||||
candidateStocks = findStockPool(indexList,curDate,preDate,index)
|
||||
# 根据均线策略从股票池中选股买卖
|
||||
stocks = selectStocks(candidateStocks,curDate,preDate,data)
|
||||
countStocks = len(stocks)
|
||||
if countStocks > topK:
|
||||
rpsStocks = calRPS(stocks,curDate,preDate)
|
||||
stocks = list(rpsStocks[:topK]['code'])
|
||||
else:
|
||||
pass
|
||||
countStocks = len(stocks)
|
||||
|
||||
# 判断当前是否持有目前股票,若已持有股票在新的候选池里则继续持有,否则卖出
|
||||
for security in context.portfolio.positions.keys():
|
||||
if security in stocks:
|
||||
continue
|
||||
else:
|
||||
order_target(security,0)
|
||||
numSell += 1
|
||||
# print("Selling %s" %(security))
|
||||
|
||||
# 根据股票池买入股票
|
||||
for security in stocks:
|
||||
# 获取股票基本信息:是否停牌、是否ST,持股头寸、股价等
|
||||
currentData = get_current_data()
|
||||
pauseSign = currentData[security].paused
|
||||
STInfo = get_extras('is_st',security,start_date=preDate,end_date=curDate)
|
||||
STSign = STInfo.iloc[-1]
|
||||
stocksAmount = context.portfolio.positions[security].amount
|
||||
stocksPrice = data[security].price
|
||||
|
||||
if not pauseSign and not STSign.bool():
|
||||
# 购买该股票,获得可购买的股票数量
|
||||
buyAmount = int((cash / countStocks) / stocksPrice)
|
||||
order(security,buyAmount)
|
||||
numBuy += 1
|
||||
# print("Buying %s" % (security))
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
# 将目前所有的股票卖出
|
||||
for security in context.portfolio.positions:
|
||||
# 全部卖出
|
||||
order_target(security, 0)
|
||||
numSell += 1
|
||||
# 记录这次卖出
|
||||
# print("Selling %s" % (security))
|
||||
@@ -0,0 +1,40 @@
|
||||
# 聚宽策略素材库
|
||||
|
||||
收集自聚宽社区的策略原稿,作为本地研究与复现的参考素材。
|
||||
|
||||
> ⚠️ 所有策略均为 **Python 2 + 聚宽专有 API** 原稿,**无法直接运行**。
|
||||
> 后续研究时需转换为 Python 3 + 本地 provider(LocalUnifiedProvider)+ BulletTrade 框架。
|
||||
|
||||
## 策略列表
|
||||
|
||||
| # | 策略 | 作者 | 来源 | 类型 | 关键词 |
|
||||
|---|------|------|------|------|--------|
|
||||
| 01 | [价值精选](01_value_selection/notes.md) | 拉姆达投资 | [post/13382](https://www.joinquant.com/post/13382) | 基本面选股轮动 | 价值/ROE/FCF/月度 |
|
||||
| 02 | [小市值20只IC对冲](02_small_cap_ic_hedge/notes.md) | jqz1226 | [post/4462](https://www.joinquant.com/post/4462) | 小市值+股指期货对冲 | 小市值/IC对冲/beta |
|
||||
| 03 | [动量择时轮动](03_momentum_timing/notes.md) | Alphamon | [post/905](https://www.joinquant.com/post/905) | 行业动量+均线择时 | RPS/均线/牛熊分界 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
每个策略一个子目录:
|
||||
- `source.py` — 聚宽原始代码(Python 2,原样保留,勿改)
|
||||
- `notes.md` — 元信息 + 策略解读 + 问题批注 + 复现要点
|
||||
|
||||
## 后续研究路径
|
||||
|
||||
1. **逐个分析**策略逻辑与潜在问题(前视偏差 / 流动性 / 真实成本 / 代码 bug)
|
||||
2. **评估复现可行性**(数据字段是否齐备、框架能否对接)
|
||||
3. **选择有价值的策略**,在 BulletTrade + LocalUnifiedProvider 上重写回测
|
||||
4. 每个策略的 `notes.md` 末尾有「本地复现要点」小结
|
||||
|
||||
## 横向对比
|
||||
|
||||
| 维度 | 01 价值精选 | 02 小市值IC对冲 | 03 动量择时轮动 |
|
||||
|------|------------|----------------|-----------------|
|
||||
| 选股域 | 全市场,基本面6条 | 全市场,市值最小100→评分20 | 各行业 RPS top6 + 均线多头 |
|
||||
| 风格 | 大盘价值 | 微盘 | 行业动量 |
|
||||
| 数据类型 | 基本面 | 基本面+量价+期货 | 纯量价 |
|
||||
| 择时 | 无 | 无 | 牛熊分界(行业站均线占比) |
|
||||
| 对冲 | 无 | IC 期货做空 | 无 |
|
||||
| 调仓 | 月度 | 每5个交易日 | 每日(信号触发) |
|
||||
| 原帖可信度 | ⚠️ 前视偏差 | ⚠️ 流动性+前视 | 🔴 代码bug致回测失真 |
|
||||
| 本地复现难度 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐(数据齐,但bug多需先修) |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 聚宽三策略移植回测总结(2026-07-27)
|
||||
|
||||
三策略(动量择时/价值精选/小市值IC对冲)从聚宽 py2 移植到 **BulletTrade 0.9.2**(聚宽API兼容),VPS 真实数据回测验证。
|
||||
|
||||
## 一、回测结果(短区间验证逻辑)
|
||||
|
||||
⚠️ 区间短(性能瓶颈致长期回测不实用),收益**仅验证选股/交易逻辑通不通**,非真实长期表现。
|
||||
|
||||
| 策略 | 回测区间 | 持仓 | 累计收益 | 最大回撤 | 夏普 | 结论 |
|
||||
|------|---------|------|---------|---------|------|------|
|
||||
| 03 动量择时 | 2024-01~03 | 9宽基轮动 | +30.4% (年化472%) | -12.6% | 2.64 | 择时准(年初熊市空仓避跌、2月转牛吃反弹),短区间年化虚高 |
|
||||
| 02 小市值 | 2024-01~03 | 20只小盘 | -0.70% | -26.8% | -2.70 | 2024初小盘股灾期,中证2000 暴跌,亏损符合现实 |
|
||||
| 01 价值精选 | 2024-01~06 | 4~6只价值 | +23.0% | -27.6% | 0.94 | 2024上半年价值/红利风格强势,表现合理 |
|
||||
|
||||
三策略选股 + 调仓 + 撮合链路全部跑通,逻辑正确。
|
||||
|
||||
## 二、策略问题清单
|
||||
|
||||
### ✅ 已修正的 bug(回测实测发现)
|
||||
|
||||
| 策略 | 原始问题 | 修正 |
|
||||
|------|---------|------|
|
||||
| 03 | `calRPS` 取数区间错(`get_price(start=cur,end=cur)` 只取1天)→ 涨跌幅恒0,RPS排名失效 | 取 `preDate~curDate` 区间算真实百分比涨跌幅 |
|
||||
| 03 | `date.today()` 取真实今天而非回测日(取数日期全错) | 改用 `context.current_dt` |
|
||||
| 01 | 排序死代码(`get_check_stocks_sort` 排序后不截断+全买,排序无意义) | 删除无意义排序,保留"全买"等额 |
|
||||
| 01 | **第⑥条致命bug**:注释"盈余成长率8-50%"但代码是 EPS 绝对值 0.08~0.5;大盘股EPS>0.5(茅台50/招行5)→ 与第①条大盘矛盾 → **6条交集恒空,策略空仓** | 按注释本意改"净利润同比增长率8-50%"(东财 `PARENT_NETPROFIT_YOY`),大盘股可入选 |
|
||||
| 01 | 前视偏差(`statDate` 按报告期取数,用到未披露数据) | `NOTICE_DATE` 公告日 ≤ 当前日 过滤 |
|
||||
| 01 | 冗余调用(`get_stock_list` 调2次) | 合并为1次 |
|
||||
| 02 | universe `000985`(中证全指) 不在 constituent_unified → 候选池空 → 8次调仓全 picked 0 | 改 `932000`(中证2000,2684只小盘) |
|
||||
| 02 | IC期货对冲(SubPortfolio/做空/期货)引擎不支持+无数据 | 对冲部分全部删除(记缺口),保留选股轮动 |
|
||||
|
||||
### ⚠️ 遗留问题(未修/性能/口径)
|
||||
|
||||
| 策略 | 问题 | 状态 |
|
||||
|------|------|------|
|
||||
| 03/02 | **性能慢**(每日/每5日遍历大池子逐只算指标):03 每日遍历9宽基3000+只 RPS+均线;02 每次调仓遍历2684只动量(约5分钟/次) | 长期回测不实用,待 provider 批量取行情优化 |
|
||||
| 03 | ST 过滤用当前 display_name(非历史时点) | 轻微前视,未处理 |
|
||||
| 01 | ROE 非精确 TTM(累计净利润/期末权益,有季节性偏差) | 未处理(和市场均值比较相对影响小) |
|
||||
| 01 | L4=487 异常稳定(5年FCF正的股票数几乎不变) | 疑似 FCF 计算口径或数据覆盖问题,待查 |
|
||||
| 01 | universe 默认沪深300(原策略全市场5000+) | 避免逐只读三表爆炸,牺牲覆盖换可执行 |
|
||||
|
||||
## 三、数据缺口清单(给数据 session 补)
|
||||
|
||||
| # | 缺口 | 影响策略 | 现状 | 当前缓解 | 建议 |
|
||||
|---|------|---------|------|---------|------|
|
||||
| 1 | **行业成份股**(证监会行业 A01 等 / 中证行业指数 000928-000938) | 03 | `constituent_unified` 只有9个宽基,无行业 | 用9宽基替代(板块粒度变粗) | 补行业成份股数据,恢复完整行业轮动 |
|
||||
| 2 | ~~三表覆盖率1/3~~ **[已撤回·误报]** 北交所920xxx三表空 | 01 | 全扫5530文件/表 **0损坏0空(<600B)**,沪深/创业/科创 **95%+健康**;仅北交所920xxx空(akshare不覆盖,~6%)。原"1/3有效"系小抽样误报(北交所排序尾部污染+可能schtask写入时序),2026-07-28全扫复核撤回 | universe排除北交所(0成本,已与filter_kcbj_stock一致) | 不做北交所即解;若做需jqdata/xtdata补 |
|
||||
| 3 | **IC 期货合约日线 + 月份切换** | 02 | 完全缺失 | 对冲部分去掉,只做选股 | 若要做对冲需补 IC 期货数据 + 扩展引擎做空能力 |
|
||||
| 4 | **中证全指 000985 成份股** | 02 | `constituent_unified` 无 | 改用 932000(中证2000,更小盘更激进) | 补 000985 或接受 932000 替代 |
|
||||
| 5 | ~~NOTICE_DATE 缺失~~ **[已撤回·误报]** | 01 | 全扫9/9有效文件**NOTICE_DATE 全有**,不缺 | 兜底逻辑保留但几乎不触发 | 无需补 |
|
||||
|
||||
## 四、性能瓶颈(共性,影响长期回测)
|
||||
|
||||
三策略选股都**遍历大池子逐只算指标**(provider 逐只查询 dbbardata/parquet),未批量/未缓存:
|
||||
|
||||
| 策略 | 瓶颈 | 实测 |
|
||||
|------|------|------|
|
||||
| 03 | 每日遍历9宽基3000+只,逐只取30日行情算RPS+均线 | 2022-2024长回测 25分钟仅跑113天,停 |
|
||||
| 02 | 每次调仓遍历2684只逐只取130日行情算动量 | 每次调仓约5分钟,2月回测40分钟 |
|
||||
| 01 | 每次调仓读300只三表(沪深健康正常读取) | 可接受(月度调仓,半年6次约2分钟) |
|
||||
|
||||
**优化方向(未做)**:provider 批量取行情(一次取一批股票N日close,pandas向量化算RPS/均线/动量),避免逐只查询。预计可提速10-50倍,使长期回测实用。
|
||||
|
||||
## 五、产出文件
|
||||
|
||||
| 类型 | 文件 |
|
||||
|------|------|
|
||||
| 策略 | `sanguo_portfolio/strategies/{momentum_timing,value_selection,small_cap}.py` |
|
||||
| Provider | `sanguo_portfolio/providers/local_parquet_provider.py`(加 `get_value_metrics`)、`local_unified_provider.py`(委托) |
|
||||
| Runner | `sanguo_portfolio/runner_backtest.py`(`--strategy {all_weather,momentum_timing,value_selection,small_cap}` 分发) |
|
||||
| 测试 | `tests/portfolio/test_{momentum_timing,value_selection,small_cap}.py`(21+27+24 = **72单测全过**) |
|
||||
| 移植记录 | `docs/research/joinquant_strategies/{01,02,03}/notes.md` 各自「移植记录」节 |
|
||||
| 原始代码 | `docs/research/joinquant_strategies/{01,02,03}/source.py`(聚宽py2原样保留) |
|
||||
|
||||
## 六、怎么跑
|
||||
|
||||
```bash
|
||||
# VPS(数据在 VPS 本地,Mac 无数据)
|
||||
ssh 49.232.102.198 "cd /d C:\sanguo_vnpy_v2 && C:\Python310\python.exe -X utf8 -m sanguo_portfolio.runner_backtest --strategy <name> --provider unified --start 2024-01-01 --end 2024-06-30 --cash 1000000"
|
||||
# <name> ∈ {momentum_timing, value_selection, small_cap, all_weather}
|
||||
```
|
||||
|
||||
## 七、一句话结论
|
||||
|
||||
三策略全部成功移植到 BulletTrade 并在 VPS 跑通回测(逻辑验证通过);过程中实测发现并修正了 **8个真实bug**(含策略01第⑥条致空仓的致命bug、策略03两个原帖回测失真的bug)。数据层真实缺口经全扫复核(2026-07-28,详见 `data_gaps_fix_plan.md`)为 **2 项**:行业成份股 + 中证全指000985 成份股缺失(阻断策略02/03 完整版);北交所920xxx 三表空(akshare不覆盖,universe排除即解,0成本)。~~原报"三表覆盖1/3 / NOTICE_DATE缺列"~~ 系小抽样误报(北交所排序尾部污染+schtask写入时序),全扫5530文件/表 0损坏、沪深95%+健康,**已撤回**。另性能瓶颈(逐只取指标)待 provider 批量优化跟进。
|
||||
@@ -0,0 +1,84 @@
|
||||
# 数据缺口验证 + 修正方案(三策略移植回测实测反馈,2026-07-28)
|
||||
|
||||
> 来源:策略研究 session 反馈 5 类数据问题(P0 三表覆盖/P1 行业/P1 000985/P2 NOTICE_DATE/P3 IC)。
|
||||
> 本文为 **独立实测验证 + 修正方案**。执行需等 bs_eod 补全释放 dbbardata 写锁(constituent_unified 同库 WAL 单写)。
|
||||
|
||||
---
|
||||
|
||||
## 一、验证结论:报告 vs 实测
|
||||
|
||||
实测方法:VPS `data/` 全量文件扫描(非 9 文件抽样)+ 10 文件/目录 pandas 抽样 + constituent_unified/dbbardata 点查询。读 only,bs_eod 在跑也安全。
|
||||
|
||||
| 报告项 | 报告声称 | 实测(2026-07-28) | 裁定 |
|
||||
|---|---|---|---|
|
||||
| P0 三表覆盖率 | ~1/3(2/3 空/损坏,最紧要) | balance/cashflow/income 各 **5530 文件全部 >600B**;抽样 10/目录 **9 个有效**(22–109 行,balance=319 列/cashflow=254/income=203,NOTICE_DATE 全有) | ❌ **不实**(过时或误采样) |
|
||||
| P0 文件数 | ~11060/目录 | **5530/目录**(一股一文件) | ❌ 数错(疑合计 3 目录或含 marker) |
|
||||
| P0 损坏 | Parquet magic byte 错 | 全扫 0 损坏,10 抽样全可读 | ❌ 不实(已自愈或误读) |
|
||||
| P1 行业成份股 | 缺失 | constituent_unified 仅 9 宽基(000016/300/852/905/932000/399001/005/006/330);000928~000938/000937 **全 = 0** | ✅ **确认** |
|
||||
| P1 000985 中证全指 | 缺失 | constituent_unified 000985 = 0 | ✅ **确认** |
|
||||
| P2 NOTICE_DATE | 个别缺列 | 9/9 有效文件均有 NOTICE_DATE;仅北交所空文件无(0 行 0 列,无任何列) | ❌ 不实(被北交所空文件误判) |
|
||||
| P3 IC 期货 | 缺失 | 未验(低优先,仅对冲策略需要) | ℹ️ 待定 |
|
||||
|
||||
### 核心反转
|
||||
报告"最紧要 P0"基本是误报。真实问题只有两个:
|
||||
1. **行业 / 000985 成份股缺失**(P1,阻断策略 02/03)—— 真实,行情已在 dbbardata,只缺成份股映射。
|
||||
2. **北交所三表/基本面空**(~280–380 只 920/83/87/43)—— akshare 东财不覆盖,与 top_holders 同根因。这是 P0 报告背后唯一的真实内核,但规模是 ~5–7%,不是 2/3。
|
||||
|
||||
沪深三表覆盖率健康(~95%+),valuation_baostock / bs_adjust_factor / 东财估值 / 9 宽基成份股全部健康。
|
||||
|
||||
---
|
||||
|
||||
## 二、真实缺口 + 修正方案
|
||||
|
||||
### G1. 行业成份股 [P1 · 真实 · 阻断策略 03 行业轮动]
|
||||
- **现状**:constituent_unified 无任何行业分类;`data/static/industry/industry.parquet` 仅 31 行(申万行业指数 PE/PB 概览,非"股票→行业")。
|
||||
- **方案(推荐 a+b 都做)**:
|
||||
- **(a) 中证一级行业 000928~000938 灌 constituent_unified** —— 复用 csindex 公告回溯(已验证 000852/932000,见 memory `csindex-announce-backfill`);行情已在 dbbardata(000928=6639 行)。
|
||||
- **(b) 申万/证监会 股票→行业映射单表** —— akshare `sw_industry` 或 `stock_industry_category_cninfo`;落 `data/static/industry/stock_industry.parquet`(个股行业标签,策略分桶更常用)。
|
||||
- **陷阱**:csindex 是 SPA 无历史,必须走公告附件(queryAnnouncementByVo + PDF/xlsx)回溯;`ak.index_stock_cons` 系列多已下线,别抄。
|
||||
- **验证探针**:`SELECT COUNT(*) FROM constituent_unified WHERE index_code='000928'` > 0;stock_industry.parquet 行数 ≈ 5500。
|
||||
- **schtask**:复用 `sanguo-index` 月度 wrapper 加 STEP0(同 000852/932000 增量逻辑)。
|
||||
|
||||
### G2. 000985 中证全指成份股 [P1 · 真实 · 阻断策略 02 全市场池]
|
||||
- **方案**:同 G1(a),csindex 公告回溯 000985 灌 constituent_unified。
|
||||
- **验证**:000985 count ≈ 4000+。
|
||||
- **schtask**:同 G1(一并加进 STEP0)。
|
||||
- **影响**:策略 02 可从 932000(2684 只,偏小盘激进)切回 000985(~4000 只,还原"全市场最小 100"意图)。
|
||||
|
||||
### G3. 北交所 + 科创板 [✅ 已决策 2026-07-28:排除,0 成本]
|
||||
- **用户决策**:科创板 / 北交所均**未开户**(两者都有 50 万资产门槛)→ 实盘**只做主板 + 创业板**。
|
||||
- **落地**:三策略已有 `filter_kcbj_stock`(过滤 ST / **科创 688/689/685 + 北交 920/83/87/43/8** / 次新),**现状即符合**,0 代码改动。G3 关闭,不补北交所基本面三表(akshare 不覆盖也无妨)。
|
||||
- **数据层 vs 策略层分离(重要)**:G1/G2 补 constituent_unified 时**仍全量补**(含科创/北交成份股,治幸存者偏差要全样本);策略层 `filter_kcbj_stock` 在选股时自动只留主板+创业板。两层解耦,别在数据层挑食。
|
||||
- **未来触发**:若做微盘/北交专精策略,再单独立项 jqdata/xtata 基本面链路(见 memory `miniqmt-fundamentals-factors`)。
|
||||
|
||||
### G4. 三表下载鲁棒性 [低优先 · latent bug · 非覆盖问题]
|
||||
当前数据已健康,此项是**防复发**,非紧急。代码实证三处隐患:
|
||||
1. `download_one_unit:642` —— 空 df 也写 parquet **+ marker** → 北交所空文件永久占位(下次非 --force 跳过,永不重试)。
|
||||
2. `write_parquet_and_marker:396` —— **非原子写**(`df.to_parquet(path)` 直写,无 tmp+rename)→ kill/断电 → 残缺 parquet(magic byte 错)。报告所见"损坏"若真实,根因即此。
|
||||
3. `ak_quarter_wrapper.ps1` —— `balance,income,cashflow,forecast,express --force` = ~27500 per-stock 调用 / 11h+ → 易超时/被 kill → 跑不完 = 覆盖率上不去(财报季 4×/年才全量重试)。
|
||||
|
||||
**方案**:
|
||||
1. 原子写:`to_parquet(tmp)` + `os.replace(tmp, path)`,marker 仅在 replace 成功后写。
|
||||
2. 空 df 不写 marker(保留空 parquet 作"查过"语义,但下次重试)—— 或北交所 build_units 阶段直接跳过(同 top_holders 双保险)。
|
||||
3. 加 `--repair` 模式:只重取 missing / empty(size<1KB)/ corrupt(read 失败)的 unit,忽略其 marker;每周 schtask,不等财报季。
|
||||
4. balance/income/cashflow 拆分 schtask 或内部 chunk,kill 丢的进度少(marker 断点续传天然支持)。
|
||||
- **验证**:`--repair` 跑后 empty count 下降;kill 测试无新增 corrupt。
|
||||
|
||||
### G5. 性能:provider 逐只取指标 [真实 · 非数据缺口]
|
||||
- **现状**:三策略选股逐只查 dbbardata/parquet → 策略 02 每次 5 min、策略 03 长回测跑不动(memory `bullettrade-portfolio-backtest-engine` 已记)。
|
||||
- **方案**:数据层加**批量宽表接口** `get_closes(codes, start, end)` —— dbbardata 单查询取 N 股 × M 日 close(命中 (symbol,interval,datetime) 索引),provider 改批量后提速 10–50×。
|
||||
- **定位**:provider/引擎改造,非数据补全,独立排期(可与 G1/G2 并行,互不依赖)。
|
||||
|
||||
---
|
||||
|
||||
## 三、执行顺序(bs_eod 补全完成后)
|
||||
|
||||
> 写 constituent_unified 与 bs_eod 写 dbbardata 同库同 WAL → 必须等 bs_eod 释放写锁(用户铁律 + `increment-schtask-windows` 教训)。
|
||||
|
||||
1. **G1 + G2 成份股补全**(csindex 公告回溯 000985/000928~000938 → constituent_unified)—— 阻断策略 02/03,优先级最高。
|
||||
2. ~~G3 北交所决策~~ **✅ 已决策 2026-07-28:排除科创+北交,只做主板+创业板**(未开户 50 万门槛);`filter_kcbj_stock` 已实现,0 改动,G3 关闭。
|
||||
3. **G4 鲁棒性**(防复发,独立)+ **G5 性能**(provider 批量,独立)—— 排期。
|
||||
|
||||
## 四、不用补(已实测健康)
|
||||
|
||||
dbbardata(日线+15min 全覆盖含退市+ETF+北交所日线)/ valuation_baostock(PE/PB 1990–2026)/ bs_adjust_factor(前复权)/ constituent_unified 9 宽基(治幸存者偏差)/ data/static/valuation(东财 per-stock 估值)/ 三表沪深覆盖(~95%+)—— 全部健康,报告附"已验证可用"属实。
|
||||
Reference in New Issue
Block a user