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:
2026-07-28 22:20:49 +08:00
parent e4f6416765
commit de04a8904b
19 changed files with 4511 additions and 27 deletions
@@ -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-27IC 期货 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_unifiedST/停牌项目已有处理)
- **框架对接**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 + 本地 providerLocalUnifiedProvider+ 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`(中证20002684只小盘) |
| 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 点查询。读 onlybs_eod 在跑也安全。
| 报告项 | 报告声称 | 实测(2026-07-28 | 裁定 |
|---|---|---|---|
| P0 三表覆盖率 | ~1/32/3 空/损坏,最紧要) | balance/cashflow/income 各 **5530 文件全部 >600B**;抽样 10/目录 **9 个有效**22109 行,balance=319 列/cashflow=254/income=203NOTICE_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. **北交所三表/基本面空**~280380 只 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`);行情已在 dbbardata000928=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'` > 0stock_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/断电 → 残缺 parquetmagic 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 / emptysize<1KB/ corruptread 失败)的 unit,忽略其 marker;每周 schtask,不等财报季。
4. balance/income/cashflow 拆分 schtask 或内部 chunkkill 丢的进度少(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 改批量后提速 1050×。
- **定位**: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_baostockPE/PB 19902026/ bs_adjust_factor(前复权)/ constituent_unified 9 宽基(治幸存者偏差)/ data/static/valuation(东财 per-stock 估值)/ 三表沪深覆盖(~95%+)—— 全部健康,报告附"已验证可用"属实。