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%+)—— 全部健康,报告附"已验证可用"属实。
+9 -1
View File
@@ -33,7 +33,13 @@ if "jqdatasdk" not in _sys.modules:
from . import factors, filters
from .providers import BaostockProvider, SanguoMiniQmtProvider
from .strategies import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
from .strategies import (
AllWeatherConfig,
AllWeatherStrategy,
BrokerFacade,
MomentumTimingConfig,
MomentumTimingStrategy,
)
__all__ = [
"factors",
@@ -43,4 +49,6 @@ __all__ = [
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
"MomentumTimingStrategy",
"MomentumTimingConfig",
]
@@ -352,6 +352,192 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc]
return sub.iloc[-1]
return LocalParquetProvider._latest_row_before(df, "REPORT_DATE", date_str)
# ==================== 多期已披露财报(供 ValueSelectionStrategy) ====================
@staticmethod
def _filter_published(df: pd.DataFrame, date_str: str) -> pd.DataFrame:
"""NOTICE_DATE <= date_str 已披露行, 按 NOTICE_DATE 升序。
⚠️ NOTICE_DATE 全有(2026-07-28全扫确认);REPORT_DATE兜底保留但几乎不触发。
"""
if df is None or df.empty:
return pd.DataFrame()
if "NOTICE_DATE" not in df.columns:
if "REPORT_DATE" not in df.columns:
return pd.DataFrame()
d = df.assign(_notice=pd.to_datetime(df["REPORT_DATE"], errors="coerce"))
else:
d = df.assign(_notice=pd.to_datetime(df["NOTICE_DATE"], errors="coerce"))
ts = pd.Timestamp(date_str)
sub = d[d["_notice"] <= ts].sort_values("_notice")
return sub
@staticmethod
def _latest_n_published(
df: pd.DataFrame, date_str: str, n: int,
) -> List[pd.Series]:
"""近 n 个已披露报告期(任意季报),按 NOTICE_DATE 降序(最新在前)。"""
sub = LocalParquetProvider._filter_published(df, date_str)
if sub.empty:
return []
take = min(n, len(sub))
return [sub.iloc[-(i + 1)] for i in range(take)]
@staticmethod
def _latest_n_annual(
df: pd.DataFrame, date_str: str, n: int,
) -> List[pd.Series]:
"""近 n 个已披露年报(REPORT_TYPE 含""),按 NOTICE_DATE 降序。"""
sub = LocalParquetProvider._filter_published(df, date_str)
if sub.empty:
return []
if "REPORT_TYPE" in sub.columns:
sub = sub[sub["REPORT_TYPE"].astype(str).str.contains("", na=False)]
if sub.empty:
return []
take = min(n, len(sub))
return [sub.iloc[-(i + 1)] for i in range(take)]
def get_value_metrics(
self,
stock: str,
date: Union[str, datetime],
) -> Optional[Dict[str, Any]]:
"""单股多期价值精选指标(供 ``ValueSelectionStrategy`` 调用)。
数据源(全本地 parquet, 零 online):
- valuation: 流通市值(akshare 服务端现成值, 单位元→亿元)
- balance: TOTAL_CURRENT_ASSETS / TOTAL_CURRENT_LIAB(算流动比率) /
TOTAL_PARENT_EQUITY(算 ROE)
- income: BASIC_EPS / OPERATE_INCOME_YOY / PARENT_NETPROFIT(算 ROE)
- cashflow: NETCASH_OPERATE - NETCASH_INVEST(算 FCF, 年报口径)
聚宽→东财字段映射(完整表见 ``docs/research/joinquant_strategies/01_value_selection/notes.md``):
| 聚宽字段 | 聚宽表 | 东财表 | 东财字段 |
|---------|--------|--------|---------|
| circulating_market_cap | valuation | valuation | circ_market_cap |
| total_current_assets | balance | balance | TOTAL_CURRENT_ASSETS |
| total_current_liability | balance | balance | TOTAL_CURRENT_LIAB |
| roe | indicator | income/balance | PARENT_NETPROFIT / TOTAL_PARENT_EQUITY |
| net_operate_cash_flow | cash_flow | cashflow | NETCASH_OPERATE |
| net_invest_cash_flow | cash_flow | cashflow | NETCASH_INVEST |
| inc_revenue_year_on_year | indicator | income | OPERATE_INCOME_YOY |
| net_profit_growth | indicator | income | PARENT_NETPROFIT_YOY (fallback NETPROFIT_YOY) |
前视偏差修复: 所有财报按 ``NOTICE_DATE(公告日) <= date`` 过滤(原聚宽用 REPORT_DATE
会有前视, 见 notes.md「移植记录」)。
Args:
stock: jq 风格代码 "600519.XSHG"
date: 取数日期 YYYY-MM-DD
Returns:
None(三表全空 / 完全没数据) 或 dict 含:
- circulating_market_cap: float (亿元)
- current_ratio: float (近一季流动比率, NaN if 缺)
- roe_series: List[float] (近 4 季 ROE, 小数 0.15=15%, 最新在前)
- fcf_series: List[float] (近 5 年 FCF, 元, 最新在前)
- revenue_yoy_series: List[float] (近 4 季营收同比, 百分数 18.5=18.5%)
- netprofit_yoy_series: List[float] (近 4 季归母净利润同比, 百分数 18.5=18.5%)
数据缺口(已知):
- 北交所920xxx三表空(akshare不覆盖)→返回None;沪深95%+健康(2026-07-28全扫复核,原"1/3"系误报已撤回)
- NOTICE_DATE 全有(全扫确认);兜底按REPORT_DATE逻辑保留以防万一
"""
import math
fc = jq_to_file_code(stock)
date_str = self._to_date_str(date) or datetime.now().strftime("%Y-%m-%d")
# 流通市值(近一日已披露)
val = self._latest_row_before(self._read_valuation(fc), "date", date_str)
circ_cap = float("nan")
if val is not None:
cv = _to_float(val.get("circ_market_cap"))
if cv:
circ_cap = to_yi(cv)
# 三表
balance_df = self._read_quarter("balance", fc)
income_df = self._read_quarter("income", fc)
cashflow_df = self._read_quarter("cashflow", fc)
# 三表全空 → 跳过(北交所920xxx空, akshare不覆盖)
if balance_df.empty and income_df.empty and cashflow_df.empty:
return None
# 近一季流动比率
cur_ratio = float("nan")
if not balance_df.empty:
bal_rows = self._latest_n_published(balance_df, date_str, 1)
if bal_rows:
b = bal_rows[0]
ca = _to_float(b.get("TOTAL_CURRENT_ASSETS"))
cl = _to_float(b.get("TOTAL_CURRENT_LIAB"))
if ca is not None and cl and cl != 0:
cur_ratio = ca / cl
# 近 4 季 ROE(PARENT_NETPROFIT / TOTAL_PARENT_EQUITY, 按报告期对齐)
roe_series: List[float] = []
if not income_df.empty and not balance_df.empty:
inc_rows = self._latest_n_published(income_df, date_str, 4)
bal_rows = self._latest_n_published(balance_df, date_str, 4)
for inc_row in inc_rows:
rdate = inc_row.get("REPORT_DATE")
if rdate is None:
continue
# 按报告期对齐: 找同 REPORT_DATE 的 balance 行
bal_match = next(
(b for b in bal_rows if b.get("REPORT_DATE") == rdate), None,
)
if bal_match is None:
continue
np_ = _to_float(inc_row.get("PARENT_NETPROFIT"))
eq = _to_float(bal_match.get("TOTAL_PARENT_EQUITY"))
if np_ is not None and eq and eq != 0:
roe_series.append(np_ / eq)
# 近 4 季营收同比(OPERATE_INCOME_YOY, 百分数) + 净利润同比(PARENT_NETPROFIT_YOY, 百分数)
yoy_series: List[float] = []
netprofit_yoy_series: List[float] = []
if not income_df.empty:
inc_rows = self._latest_n_published(income_df, date_str, 4)
for inc_row in inc_rows:
yoy = _to_float(inc_row.get("OPERATE_INCOME_YOY"))
if yoy is not None:
yoy_series.append(yoy)
# 归母净利润同比优先, 缺则用净利润同比 fallback
np_yoy = _to_float(inc_row.get("PARENT_NETPROFIT_YOY"))
if np_yoy is None:
np_yoy = _to_float(inc_row.get("NETPROFIT_YOY"))
if np_yoy is not None:
netprofit_yoy_series.append(np_yoy)
# 近 5 年 FCF(NETCASH_OPERATE - NETCASH_INVEST, 年报口径)
fcf_series: List[float] = []
if not cashflow_df.empty:
cf_rows = self._latest_n_annual(cashflow_df, date_str, 5)
for cf_row in cf_rows:
op = _to_float(cf_row.get("NETCASH_OPERATE"))
inv = _to_float(cf_row.get("NETCASH_INVEST"))
if op is not None and inv is not None:
fcf_series.append(op - inv)
# 完全没数据 → 跳过(北交所三表空等边缘情况)
if (math.isnan(circ_cap) and math.isnan(cur_ratio)
and not roe_series and not fcf_series
and not yoy_series and not netprofit_yoy_series):
return None
return {
"circulating_market_cap": circ_cap,
"current_ratio": cur_ratio,
"roe_series": roe_series,
"fcf_series": fcf_series,
"revenue_yoy_series": yoy_series,
"netprofit_yoy_series": netprofit_yoy_series,
}
# ==================== get_fundamentals_df ====================
def get_fundamentals_df(
self,
+98 -25
View File
@@ -42,13 +42,18 @@ logger = logging.getLogger(__name__)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="sanguo_portfolio 全天候回测")
p = argparse.ArgumentParser(description="sanguo_portfolio 组合回测")
p.add_argument("--start", default="2020-01-01", help="回测开始日期 YYYY-MM-DD")
p.add_argument("--end", default="2024-12-31", help="回测结束日期 YYYY-MM-DD")
p.add_argument("--cash", type=float, default=1_000_000.0, help="初始资金(元)")
p.add_argument("--benchmark", default="000300.XSHG", help="基准代码")
p.add_argument("--max-pool", type=int, default=0, help="限制选股池前N只(0=不限,MVP验证用)")
p.add_argument("--frequency", default="day", help="回测频率 day/minute")
p.add_argument(
"--strategy", default="all_weather",
choices=["all_weather", "momentum_timing", "value_selection", "small_cap"],
help="策略: all_weather(全天候轮动) / momentum_timing(牛熊分界+取强舍弱+均线动量) / value_selection(价值精选6条月度调仓) / small_cap(小市值20只轮动,无对冲)",
)
p.add_argument(
"--provider", default="local", choices=["local", "baostock", "miniqmt", "unified"],
help="数据 provider:local(parquet,旧) / baostock(Mac 跨平台) / miniqmt(VPS 实盘) / unified(方案A 权威层)",
@@ -135,17 +140,86 @@ def build_broker_facade(engine: Any) -> Any:
)
def _build_strategy(args: argparse.Namespace, provider: Any) -> Any:
"""根据 --strategy 构造策略实例(分发)。"""
name = args.strategy
if name == "all_weather":
from .strategies import AllWeatherConfig, AllWeatherStrategy
return AllWeatherStrategy(
provider=provider,
config=AllWeatherConfig(max_pool=args.max_pool),
)
if name == "momentum_timing":
from .strategies import MomentumTimingConfig, MomentumTimingStrategy
return MomentumTimingStrategy(
provider=provider,
config=MomentumTimingConfig(max_pool=args.max_pool),
)
if name == "value_selection":
from .strategies import ValueSelectionConfig, ValueSelectionStrategy
return ValueSelectionStrategy(
provider=provider,
config=ValueSelectionConfig(max_pool=args.max_pool),
)
if name == "small_cap":
from .strategies import SmallCapConfig, SmallCapStrategy
return SmallCapStrategy(
provider=provider,
config=SmallCapConfig(max_pool=args.max_pool),
)
raise ValueError(
f"未知 strategy: {name}(支持: all_weather / momentum_timing / value_selection / small_cap)"
)
def _register_schedule(strategy: Any) -> None:
"""按策略类型注册 bullet_trade 顶层 run_daily/run_monthly 定时任务。"""
try:
from bullet_trade.core import run_daily, run_monthly # type: ignore
except Exception as exc:
logger.warning("注册定时任务失败(回测可能不触达): %s", exc)
return
try:
from .strategies import (
AllWeatherStrategy,
MomentumTimingStrategy,
SmallCapStrategy,
ValueSelectionStrategy,
)
if isinstance(strategy, AllWeatherStrategy):
run_daily(strategy.prepare_stock_list, "9:05")
run_monthly(strategy.monthly_adjustment, 1, "9:30")
run_daily(strategy.stop_loss, "14:00")
return
if isinstance(strategy, MomentumTimingStrategy):
# 原策略 handle_data 单位时间触发 → 每日 9:30
run_daily(strategy.handle_data, "9:30")
return
if isinstance(strategy, ValueSelectionStrategy):
# 原策略 run_monthly 第 5 个交易日(月度调仓)
run_monthly(strategy.monthly_adjustment, 5, "9:30")
return
if isinstance(strategy, SmallCapStrategy):
# 原策略 handle_data 单位时间触发 → 每日 9:30
# 5 日调仓周期由 handle_data 内部 day_count % tc == 0 控制(对齐 g.t % g.tc)
run_daily(strategy.handle_data, "9:30")
return
except Exception as exc:
logger.warning("注册定时任务失败(%s): %s", type(strategy).__name__, exc)
return
logger.warning("未知策略类型 %s,未注册定时任务", type(strategy).__name__)
def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
"""跑回测,返回结果 dict。
BulletTrade 的 BacktestEngine 接受 strategy_file 或 initialize 等函数。
我们把 AllWeatherStrategy 包成 initialize 函数:initialize 闭包挂 run_daily 等。
我们把策略类包成 initialize 函数:initialize 闭包挂 run_daily 等。
"""
from bullet_trade import BacktestEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from .strategies import AllWeatherStrategy, AllWeatherConfig
provider = build_provider(args.provider, args.provider_config)
set_data_provider(provider)
@@ -153,24 +227,11 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
holder: Dict[str, Any] = {}
def initialize(context):
strategy = AllWeatherStrategy(
provider=provider,
config=AllWeatherConfig(max_pool=args.max_pool),
)
strategy = _build_strategy(args, provider)
holder["strategy"] = strategy
# bullet-trade 的 run_daily/run_monthly 接受全局函数;把 method 暴露为模块级
# 这里偷个懒:用 functools.partial 注册到 engine 的 scheduler
import functools
# bullet-trade 顶层 run_daily 等可调用,context._scheduler 暴露
try:
from bullet_trade.core import run_daily, run_monthly # type: ignore
run_daily(strategy.prepare_stock_list, "9:05")
run_monthly(strategy.monthly_adjustment, 1, "9:30")
run_daily(strategy.stop_loss, "14:00")
except Exception as exc:
logger.warning("注册定时任务失败(回测可能不触达): %s", exc)
# 注册定时任务(按策略类型分发)
_register_schedule(strategy)
# 先注入 broker(含 set_option 委托) 再 initialize: initialize 里 set_option("use_real_price",True)
# 才能真正设到 bullet_trade settings → fq_mode=pre 与 get_current_data 一致, 买入才成交
@@ -178,7 +239,7 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
strategy.broker = holder["broker"]
strategy.initialize(context)
def build_broker_facade_inner(strategy: AllWeatherStrategy, context: Any):
def build_broker_facade_inner(strategy: Any, context: Any):
from .strategies.all_weather import BrokerFacade
# 在回测内,聚宽风格 order_target_value 来自 bullet_trade 顶层
from bullet_trade.core.api import ( # type: ignore
@@ -194,7 +255,7 @@ def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
set_option=lambda k, v: bt_set_option(k, v),
)
print("[runner] ENGINE_BUILD_PRE", flush=True)
print(f"[runner] ENGINE_BUILD_PRE strategy={args.strategy}", flush=True)
engine = BacktestEngine(
initialize=initialize,
start_date=args.start,
@@ -217,9 +278,18 @@ def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace
"""把回测关键指标写成 markdown(给 docs/portfolio_backtest_result.md)。"""
try:
summary = result.get("summary", {}) if isinstance(result, dict) else {}
strategy_name = getattr(args, "strategy", "all_weather")
title_map = {
"all_weather": "全天候轮动",
"momentum_timing": "牛熊分界+均线动量",
"value_selection": "价值精选6条月度调仓",
"small_cap": "小市值20只轮动(无对冲)",
}
title = title_map.get(strategy_name, strategy_name)
lines = [
"# sanguo_portfolio 全天候回测结果",
f"# sanguo_portfolio {title}回测结果",
"",
f"- 策略: {strategy_name}",
f"- 区间: {args.start} ~ {args.end}",
f"- 初始资金: {args.cash:,.0f}",
f"- 基准: {args.benchmark}",
@@ -256,7 +326,7 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
Returns:
{
"strategy": "all_weather",
"strategy": "all_weather" | "momentum_timing",
"period": {"start": ..., "end": ..., "trading_days": N},
"stocks_selected": [{"code":..., "name":...}, ...], # 末日持仓
"trades": [{date, code, side, amount, price, ...}, ...],
@@ -265,12 +335,14 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
}
"""
# 构造一个 Namespace 复用 run_backtest
strategy_name = params.get("strategy", "all_weather")
args = argparse.Namespace(
start=params.get("start_date", "2024-01-01"),
end=params.get("end_date", "2024-02-29"),
cash=float(params.get("initial_cash", 1_000_000.0)),
benchmark=params.get("benchmark", "000300.XSHG"),
frequency="day",
strategy=strategy_name,
provider=params.get("provider", "local"),
provider_config="{}",
result_file="", # JSON 模式不写 md
@@ -292,7 +364,7 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
meta = raw.get("meta", {}) if isinstance(raw, dict) else {}
return {
"strategy": "all_weather",
"strategy": strategy_name,
"period": {
"start": meta.get("start_date", args.start),
"end": meta.get("end_date", args.end),
@@ -425,6 +497,7 @@ def main() -> None:
if args.json:
# JSON 模式:stderr 仍打日志,stdout 只输出 JSON(供 SSH 捕获)
result = run_backtest_json({
"strategy": args.strategy,
"start_date": args.start,
"end_date": args.end,
"initial_cash": args.cash,
+14 -1
View File
@@ -1,4 +1,17 @@
"""sanguo_portfolio 策略层。"""
from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
from .momentum_timing import MomentumTimingConfig, MomentumTimingStrategy
from .small_cap import SmallCapConfig, SmallCapStrategy
from .value_selection import ValueSelectionConfig, ValueSelectionStrategy
__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"]
__all__ = [
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
"MomentumTimingStrategy",
"MomentumTimingConfig",
"SmallCapStrategy",
"SmallCapConfig",
"ValueSelectionStrategy",
"ValueSelectionConfig",
]
@@ -0,0 +1,421 @@
"""聚宽"牛熊分界+取强舍弱+均线动量"策略(post905)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/03_momentum_timing/source.py``,
这里做**结构等价 + bug 修复**翻译:
- ``initialize`` → ``MomentumTimingStrategy.initialize``
- ``calRPS`` → ``MomentumTimingStrategy._cal_rps`` (**修复取数区间**)
- ``findStockPool`` → ``MomentumTimingStrategy._find_stock_pool``
- ``selectStocks`` → ``MomentumTimingStrategy._select_stocks``
- ``calBuySign`` → ``MomentumTimingStrategy._cal_buy_sign``
- ``handle_data`` → ``MomentumTimingStrategy.handle_data`` (**修复 date.today()**)
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 all_weather 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
⚠️ 已修复原始策略的两个致命 bug(详见 notes.md「移植记录」):
1. **calRPS 取数区间错** — 原代码 ``get_price(start=curDate, end=curDate)`` 只取 1 天,
``iloc[0]==iloc[-1]``,涨跌幅恒 0,RPS 排名完全失效 → 改为 ``start=preDate, end=curDate``
取真实区间算百分比涨跌幅。
2. **date.today() 用错** — 回测里取真实今天而非回测当前日 → 改用 ``context.current_dt``。
"""
from __future__ import annotations
import datetime
import logging
from dataclasses import dataclass, field
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
# ✅ 板块选择说明(2026-07-28 G1 数据补全后切回原版):
# 原策略用 11 个中证行业指数(000928-000938)'index' 模式。此前因 constituent_unified 表
# 无行业指数成份股,降级用 9 个宽基指数替代;现 G1 已补全 000928-000937 共 10 个
# (000938 仍缺,记为遗留),恢复行业轮动原版。
# 逻辑机制(择时+取强舍弱+均线动量)不动,仅切回行业指数列表。
_DEFAULT_INDEX_LIST: List[str] = [
"000928.XSHG", # 中证能源
"000929.XSHG", # 中证材料
"000930.XSHG", # 中证工业
"000931.XSHG", # 中证可选消费
"000932.XSHG", # 中证主要消费
"000933.XSHG", # 中证医药卫生
"000934.XSHG", # 中证金融地产
"000935.XSHG", # 中证信息技术
"000936.XSHG", # 中证电信业务
"000937.XSHG", # 中证公用事业
]
@dataclass
class MomentumTimingConfig:
"""牛熊分界+取强舍弱+均线动量 策略参数(聚宽 g.* 全局变量抽出便于调参)。"""
# 板块列表(默认 10 个中证行业指数 000928-000937,G1 补全后切回原版,见模块顶部说明)
index_list: List[str] = field(default_factory=lambda: list(_DEFAULT_INDEX_LIST))
index_thre: float = 0.2 # g.indexThre:站上 past_day 日均线的行业比重阈值
past_day: int = 30 # g.pastDay:RPS + 牛熊分界回看窗口
top_k: int = 6 # g.topK:每行业 RPS top K + 最终持仓上限
benchmark: str = "000300.XSHG"
new_stock_days: int = 375 # 次新股过滤阈值
max_pool: int = 0 # 0=不限;MVP 验证用,限制 _stock_pool 返回前 N 只
ma_short: int = 5 # selectStocks 短均线窗口(原 mavg(5,'close'))
ma_long: int = 15 # selectStocks 长均线窗口(原 mavg(15,'close'))
# ------------------------ 策略 ------------------------
class MomentumTimingStrategy:
"""牛熊分界+取强舍弱+均线动量策略(纯量价,无基本面)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[MomentumTimingConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or MomentumTimingConfig()
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 定时任务:每日 9:30 触发 handle_data(原策略 handle_data 单位时间触发)
b.run_daily(self.handle_data, "9:30")
# =================== handle_data (主流程) ===================
def handle_data(self, context: Any) -> None:
"""每日调仓:牛熊分界 → 取强舍弱 → 均线动量 → 调仓下单。
⚠️ **修复原始 bug** — 用 ``context.current_dt`` 而非 ``datetime.date.today()``。
"""
cfg = self.config
cur_dt = _current_dt(context)
if cur_dt is None:
logger.warning("handle_data: context.current_dt 为 None,跳过")
return
cur_date = _to_date_str(cur_dt)
pre_date = _to_date_str(cur_dt - datetime.timedelta(days=cfg.past_day))
# 1) 牛熊分界
buy_sign = self._cal_buy_sign(cfg.index_list, cfg.past_day, cur_date)
logger.info("[%s] buy_sign=%s", cur_date, buy_sign)
positions = _get_positions(context)
if not buy_sign:
# 熊市:全部清仓(原策略语义)
logger.info("[%s] 熊市信号,清仓 %d", cur_date, len(positions))
for stock in list(positions.keys()):
self._close_position(stock)
return
# 2) 牛市:取强舍弱(每行业 RPS top_k 并集) → 候选池
candidates = self._find_stock_pool(cfg.index_list, cur_date, pre_date)
# 3) 均线动量过滤(close > MA_short > MA_long)
stocks = self._select_stocks(candidates, cur_date)
# 4) 候选过多时再按 RPS 取前 top_k (原策略 handle_data 第 171-175 行)
if len(stocks) > cfg.top_k:
rps_df = self._cal_rps(stocks, cur_date, pre_date)
stocks = list(rps_df["code"])[: cfg.top_k]
# 5) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters)
stocks = filters.filter_limitup_stock(
stocks, self.provider, positions=list(positions.keys())
)
stocks = filters.filter_limitdown_stock(
stocks, self.provider, positions=list(positions.keys())
)
stocks = filters.filter_paused_stock(stocks, self.provider)
stocks = _dedup(stocks)
# 6) 调仓:先清掉不在 stocks 的
for stock in list(positions.keys()):
if stock in stocks:
continue
self._close_position(stock)
# 7) 等额买入 stocks 里的新股(原策略 cash/countStocks 语义)
positions = _get_positions(context) # 卖出后刷新
target_num = len(stocks)
if target_num == 0:
return
cash = _available_cash(context)
if cash <= 0:
return
per_value = cash / target_num
for stock in stocks:
if stock in positions:
continue
if self._open_position(stock, per_value):
positions = _get_positions(context) # 刷新
if len(positions) >= target_num:
break
logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks)
# =================== calRPS (修复:取 preDate~curDate 区间) ===================
def _cal_rps(
self,
stocks: List[str],
cur_date: str,
pre_date: str,
) -> pd.DataFrame:
"""计算 RPS(相对强弱)排名。
⚠️ **修复原始 bug** — 原策略 ``get_price(start=curDate, end_date=curDate)``
只取 1 天,``iloc[0]==iloc[-1]``,涨跌幅恒 0,RPS 排名完全失效 →
改为取 ``preDate ~ curDate`` 区间算**百分比涨跌幅**(更符合 RPS 语义,
原代码用绝对差值排序会偏向高价股,见 notes.md「移植记录」)。
Returns:
DataFrame[code, rps_value],按 rps_value 降序;``rps_value = 99 - 100*i/n``。
"""
n = len(stocks)
if n == 0:
return pd.DataFrame({"code": [], "rps_value": []})
try:
df = self.provider.get_price(
stocks,
start_date=pre_date,
end_date=cur_date,
frequency="daily",
fields=["close"],
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_rps get_price 失败: %s", exc)
return pd.DataFrame({"code": [], "rps_value": []})
if df is None or df.empty:
return pd.DataFrame({"code": [], "rps_value": []})
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception as exc:
logger.warning("_cal_rps pivot 失败: %s", exc)
return pd.DataFrame({"code": [], "rps_value": []})
if pivot.empty or len(pivot) < 2:
return pd.DataFrame({"code": [], "rps_value": []})
# 每只股票涨跌幅(末值/首值 - 1)
first = pivot.iloc[0]
last = pivot.iloc[-1]
with np.errstate(divide="ignore", invalid="ignore"):
returns = (last / first) - 1.0
# 过滤 NaN/Inf(数据不全或首值为 0)
valid = returns.replace([np.inf, -np.inf], np.nan).dropna()
if valid.empty:
return pd.DataFrame({"code": [], "rps_value": []})
# 降序:涨幅大的排前
sorted_codes = valid.sort_values(ascending=False).index.tolist()
m = len(sorted_codes)
rps_value = [99 - (100 * i / m) for i in range(m)]
return pd.DataFrame({"code": sorted_codes, "rps_value": rps_value})
# =================== findStockPool (取强舍弱) ===================
def _find_stock_pool(
self,
index_list: List[str],
cur_date: str,
pre_date: str,
) -> List[str]:
"""每个行业取 RPS top_k → 候选池并集。
原策略 ``findStockPool`` 第 67-82 行:逐行业 get_index_stocks → calRPS → 前 topK。
"""
cfg = self.config
out: List[str] = []
for each_index in index_list:
stocks = self._stock_pool(each_index, cur_date)
if not stocks:
continue
rps_df = self._cal_rps(stocks, cur_date, pre_date)
top = list(rps_df["code"])[: cfg.top_k]
out.extend(top)
return _dedup(out)
# =================== selectStocks (均线动量) ===================
def _select_stocks(self, stocks: List[str], cur_date: str) -> List[str]:
"""均线动量过滤:``close > MA_short`` 且 ``MA_short > MA_long``。
原策略 ``data[security].mavg(5,'close')`` (聚宽 Security.mavg),
翻译为 provider.get_price(count=ma_long) 后段求均值。
"""
cfg = self.config
if not stocks:
return []
try:
df = self.provider.get_price(
stocks,
end_date=cur_date,
frequency="daily",
fields=["close"],
count=cfg.ma_long,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_select_stocks get_price 失败: %s", exc)
return []
if df is None or df.empty:
return []
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception:
return []
if pivot.empty:
return []
out: List[str] = []
for col in pivot.columns:
series = pivot[col].dropna()
if len(series) < cfg.ma_long:
continue
close = float(series.iloc[-1])
ma_short = float(series.tail(cfg.ma_short).mean())
ma_long = float(series.tail(cfg.ma_long).mean())
if np.isnan(close) or np.isnan(ma_short) or np.isnan(ma_long):
continue
if close > ma_short and ma_short > ma_long:
out.append(col)
return out
# =================== calBuySign (牛熊分界) ===================
def _cal_buy_sign(
self,
index_list: List[str],
past_day: int,
cur_date: str,
) -> bool:
"""统计 past_day 均线上方的指数占比 > index_thre → 牛市(True)。
原策略 'index' 模式(第 110-115 行):对每个指数算 ``mavg(past_day,'close')``
与 ``mavg(1,'close')`` 比较。翻译为取 past_day 日 close(含当日),
算均值与最后一根 close 比较。
⚠️ 原代码 ``float(count)/len(indexList)`` 在 py2 是浮点除法(因 float()强转),
与 py3 一致。这里保留浮点除法语义。
"""
cfg = self.config
if not index_list:
return False
try:
df = self.provider.get_price(
index_list,
end_date=cur_date,
frequency="daily",
fields=["close"],
count=past_day,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_buy_sign get_price 失败: %s", exc)
return False
if df is None or df.empty:
return False
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception:
return False
if pivot.empty:
return False
count = 0
for col in pivot.columns:
series = pivot[col].dropna()
if len(series) < 2:
continue
ma_past = float(series.tail(past_day).mean())
cur_close = float(series.iloc[-1])
if np.isnan(ma_past) or np.isnan(cur_close):
continue
if cur_close > ma_past:
count += 1
return (count / len(index_list)) > cfg.index_thre
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, cur_date: str) -> List[str]:
"""成分股 + 过滤 ST/科创北交/次新。"""
try:
stocks = self.provider.get_index_stocks(index_symbol, cur_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks)
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
stocks = filters.filter_st_stock(stocks, self.provider)
stocks = filters.filter_new_stock(
stocks, self.provider, cur_date, self.config.new_stock_days
)
return stocks
# ======================== 日期辅助 ========================
def _to_date_str(value: Any) -> str:
"""datetime/date/str → YYYY-MM-DD str。
聚宽风格 get_price 的 start/end_date 接受 'YYYY-MM-DD' 字符串。
"""
if isinstance(value, str):
return value[:10]
try:
return value.strftime("%Y-%m-%d")
except AttributeError:
return str(value)[:10]
__all__ = ["MomentumTimingStrategy", "MomentumTimingConfig"]
+410
View File
@@ -0,0 +1,410 @@
"""聚宽"小市值20只 IC 对冲"策略(post4462)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/02_small_cap_ic_hedge/source.py``,
这里做**结构等价 + 去除对冲 + py2→py3** 翻译。
⚠️ 移植决策(详见 notes.md「移植记录」):
- **保留**选股部分:全市场市值最小 100 只(剔除创业板 300xxx / eps≤0)→
动量评分取前 20 只 → 每 5 个交易日调仓,等权持有。
- **去掉**全部对冲逻辑(BulletTrade 不支持做空/期货,数据缺 IC 行情):
- SubPortfolio 双账户分仓 / transfer_cash 资金调配
- IC 股指期货做空对冲 / beta 计算 / hedge_ratio / compute_hedge_ratio
- get_next_month_future 期货合约月度切换
- futures_margin / 保证金计算 / order_target(side='short')
- statsmodels 回归 import(原代码 import 但未实际用)
翻译对照:
- ``initialize`` → ``SmallCapStrategy.initialize``
- ``pick_stocks`` → ``SmallCapStrategy._pick_stocks`` (**py2→py3**: df.sort→sort_values)
- ``compute_signals``→ ``SmallCapStrategy.handle_data`` (**5 日计数器**替代 g.t)
- ``rebalance`` → ``SmallCapStrategy._rebalance`` (**仅保留股票部分**,
去掉期货/账户调配/保证金,等权调仓)
- ``compute_hedge_ratio`` / ``get_next_month_future`` / SubPortfolio → **删除**
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 momentum_timing / value_selection 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
_previous_date_str,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
@dataclass
class SmallCapConfig:
"""小市值 20 只轮动策略参数(聚宽 g.* 全局变量抽出便于调参)。
默认值严格对齐原策略 ``set_params`` (source.py 第 38-48 行):
- g.tc=5(调仓频率)
- g.pick_stock_count=100(备选股数)
- g.buy_stock_count=20(买入股数)
"""
# 调仓频率(交易日)
tc: int = 5
# 备选股票数量(市值最小的 N 只)
pick_stock_count: int = 100
# 最终买入股票数目
buy_stock_count: int = 20
# 动量评分窗口(原 source.py:130 日高低 + 15 日均线)
ma_window: int = 130 # 130 日最高/最低
ma_short: int = 15 # 15 日均线
# 上市天数过滤(原 source.py: > 120 天,因 63 交易日样本要求)
new_stock_days: int = 120
# 选股池:默认中证全指 000985.XSHG(5128 只,贴近原策略"全市场"意图)
# 2026-07-28 G2 补全后切回原版(此前 000985 不在 constituent_unified 降级用 932000 中证2000)。
universe: str = "000985.XSHG"
benchmark: str = "000300.XSHG"
# 0=不限;MVP 验证用,限制候选池前 N 只(避免全市场逐只查 fundamentals 过慢)
max_pool: int = 0
# ------------------------ 策略 ------------------------
class SmallCapStrategy:
"""小市值 20 只轮动策略(纯选股,无对冲)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
⚠️ **去掉的对冲部分**(详见 notes.md):
- 无 SubPortfolio 双账户(单账户股票现货)
- 无 IC 期货做空对冲(beta / hedge_ratio 全删)
- 等价于原策略"股票账户独立运行",承担完整小市值风险敞口
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[SmallCapConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or SmallCapConfig()
# 聚宽 g.* 全局变量映射到实例属性
self.day_count: int = 0 # g.t:运行天数
self.in_position_stocks: List[str] = [] # g.in_position_stocks:当前持仓名单
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 原策略 handle_data 单位时间触发 → 每日 9:30
# 5 日调仓周期由 handle_data 内部 day_count % tc == 0 控制
b.run_daily(self.handle_data, "9:30")
# =================== handle_data (主流程) ===================
def handle_data(self, context: Any) -> None:
"""每日运行:每 ``tc`` 个交易日调仓一次,其他日持仓不变。
对齐原策略 ``handle_data`` + ``compute_signals`` 语义:
- 调仓日(g.t % g.tc == 0):pick_stocks 选股 → rebalance 调仓
- 非调仓日:延续旧持仓(no-op)
"""
cfg = self.config
# 1) 判断是否调仓日(对齐原策略 g.t % g.tc == 0)
is_rebalance_day = (self.day_count % cfg.tc) == 0
logger.info(
"[day=%d] is_rebalance=%s tc=%d", self.day_count, is_rebalance_day, cfg.tc,
)
if is_rebalance_day:
# 2) 选股
new_picks = self._pick_stocks(context)
self.in_position_stocks = new_picks
logger.info(
"[day=%d] picked %d stocks: %s",
self.day_count, len(new_picks), new_picks,
)
# 3) 调仓(仅股票部分,去掉对冲)
self._rebalance(context)
# 4) 天数加一(对齐原策略 g.t += 1)
self.day_count += 1
# =================== pick_stocks (选股) ===================
def _pick_stocks(self, context: Any) -> List[str]:
"""选股:全市场市值最小 100 只 → 过滤 → 动量评分取前 20。
对齐原策略 ``pick_stocks`` (source.py 第 113-155 行):
1. query valuation + indicator 过滤 eps>0、~code.like('300%'),按 market_cap asc 取前 100
2. 过滤上市<120 天 / 停牌 / ST / 涨跌停
3. 动量评分 = (现价-130日低) + (现价-130日高) + (现价-15日均线),升序
4. 取前 buy_stock_count 只
"""
cfg = self.config
previous_date = _previous_date_str(context)
if previous_date is None:
logger.warning("pick_stocks: previous_date 为 None,返回空列表")
return []
# 1) 全市场候选池(universe 成份股)
candidates = self._stock_pool(cfg.universe, previous_date)
if not candidates:
logger.info("[%s] 候选池为空", previous_date)
return []
# 2) get_fundamentals_df 一次性取 market_cap + eps
try:
df = self.provider.get_fundamentals_df(candidates, date=previous_date)
except Exception as exc:
logger.warning("get_fundamentals_df 失败: %s", exc)
return []
if df is None or df.empty:
logger.warning("[%s] fundamentals 为空", previous_date)
return []
# 3) 过滤 eps > 0(原策略 indicator.eps > 0)
eps_col = "eps" if "eps" in df.columns else None
if eps_col is None:
logger.warning("fundamentals 缺 eps 列,跳过 eps 过滤")
eps_mask = pd.Series([True] * len(df), index=df.index)
else:
eps_mask = df[eps_col].apply(_is_valid_positive_number)
df = df[eps_mask]
# 4) 按 market_cap 升序(原策略 valuation.market_cap.asc()),取前 pick_stock_count
if "market_cap" not in df.columns:
logger.warning("fundamentals 缺 market_cap 列")
return []
df = df.sort_values("market_cap", ascending=True, na_position="last")
top_candidates = list(df.index)[: cfg.pick_stock_count]
if not top_candidates:
return []
# 5) 过滤次新股(原策略上市 > 120 天)
top_candidates = filters.filter_new_stock(
top_candidates, self.provider, previous_date, cfg.new_stock_days,
)
# 6) 过滤 ST/停牌/涨跌停(原策略 current_data 过滤)
top_candidates = filters.filter_st_stock(top_candidates, self.provider)
top_candidates = filters.filter_paused_stock(top_candidates, self.provider)
top_candidates = filters.filter_limitup_stock(
top_candidates, self.provider, positions=list(_get_positions(context).keys()),
)
top_candidates = filters.filter_limitdown_stock(
top_candidates, self.provider, positions=list(_get_positions(context).keys()),
)
top_candidates = _dedup(top_candidates)
if not top_candidates:
return []
# 7) 动量评分(130 日高低 + 15 日均线),升序
scored = self._cal_momentum_score(top_candidates, previous_date)
if scored.empty:
return []
# 8) 取前 buy_stock_count
out = list(scored.index)[: cfg.buy_stock_count]
return out
# =================== 动量评分 ===================
def _cal_momentum_score(
self, stocks: List[str], end_date: str,
) -> pd.DataFrame:
"""动量评分:score = (cur-low_130) + (cur-high_130) + (cur-ma15),升序。
对齐原策略 ``pick_stocks`` 评分逻辑(source.py 第 140-153 行):
- ``attribute_history(stock, 130, '1d', ('close','high','low'))``
- ``low_price_130 = h.low.min()``(130 日最低)
- ``high_price_130 = h.high.max()``(130 日最高)
- ``avg_15 = data[stock].mavg(15, 'close')``(15 日均线)
- ``score = (cur-low_130) + (cur-high_130) + (cur-avg_15)``
- 升序(分数越低越靠前:price 接近 130 日低 / 低于均线 → 偏底部)
py2→py3:``df.sort(columns=)`` → ``df.sort_values(by=)``。
Returns:
DataFrame(index=code, column=['score']),按 score 升序。
"""
cfg = self.config
if not stocks:
return pd.DataFrame(columns=["score"])
# 一次性取 ma_window=130 日 close/high/low(对所有候选)
try:
df = self.provider.get_price(
stocks,
end_date=end_date,
frequency="daily",
fields=["close", "high", "low"],
count=cfg.ma_window,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_momentum_score get_price 失败: %s", exc)
return pd.DataFrame(columns=["score"])
if df is None or df.empty:
return pd.DataFrame(columns=["score"])
scores: dict[str, float] = {}
for code in stocks:
sub = df[df["code"] == code] if "code" in df.columns else df
if sub is None or sub.empty:
continue
close_series = sub["close"].dropna() if "close" in sub.columns else None
high_series = sub["high"].dropna() if "high" in sub.columns else None
low_series = sub["low"].dropna() if "low" in sub.columns else None
if close_series is None or close_series.empty:
continue
cur_price = float(close_series.iloc[-1])
if not np.isfinite(cur_price):
continue
# 130 日最低 / 最高(skip_paused=True 后 dropna)
low_130 = float(low_series.min()) if low_series is not None and not low_series.empty else cur_price
high_130 = float(high_series.max()) if high_series is not None and not high_series.empty else cur_price
# 15 日均线:close 序列最后 15 根均值
ma15 = float(close_series.tail(cfg.ma_short).mean()) if len(close_series) >= 1 else cur_price
if not (np.isfinite(low_130) and np.isfinite(high_130) and np.isfinite(ma15)):
continue
score = (cur_price - low_130) + (cur_price - high_130) + (cur_price - ma15)
scores[code] = score
if not scores:
return pd.DataFrame(columns=["score"])
out = pd.DataFrame.from_dict(scores, orient="index", columns=["score"])
# 升序:分数越低越靠前(原策略 df.sort(columns='score', ascending=True))
out = out.sort_values("score", ascending=True)
return out
# =================== rebalance (调仓,仅股票部分) ===================
def _rebalance(self, context: Any) -> None:
"""调仓:卖出不在名单的 → 等额买入名单中的新股。
对齐原策略 ``rebalance`` (source.py 第 194-240 行)的**股票部分**:
- 卖出:持仓中不在 ``in_position_stocks`` 的(原策略 order_target(stock, 0, pindex=0))
- 买入:等权分配(原策略 per_value = stock_value / len(in_position_stocks))
⚠️ **去掉的对冲部分**(详见 notes.md):
- 无 transfer_cash 账户调配(单账户)
- 无 over_weight/under_weight 削高填低(简化为"全卖 + 等额买",KISS)
- 无期货空单开仓 / 月度切换合约 / 保证金计算
"""
target_stocks = list(self.in_position_stocks)
if not target_stocks:
# 名单空 → 全清(防御性,正常不会到这里)
for code in list(_get_positions(context).keys()):
self._close_position(code)
return
positions = _get_positions(context)
# 1) 卖出不在 target 的(原策略 order_target(stock, 0, pindex=0))
for code in list(positions.keys()):
if code in target_stocks:
continue
self._close_position(code)
# 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len)
positions = _get_positions(context) # 刷新
target_num = len(target_stocks)
cash = _available_cash(context)
if cash <= 0 or target_num == 0:
return
per_value = cash / target_num
for code in target_stocks:
if code in positions:
continue
if self._open_position(code, per_value):
positions = _get_positions(context)
if len(positions) >= target_num:
break
logger.info(
"[day=%d] rebalance 结束: target=%d stocks", self.day_count, target_num,
)
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, previous_date: str) -> List[str]:
"""全市场候选池 = universe 成份股 + 过滤创业板/科创北交。
对齐原策略 ``~valuation.code.like('300%')`` 剔除创业板。
``filters.filter_kcbj_stock`` 会一并剔除创业板(3)、科创(68)、北交(4/8),
比原策略更严但符合"剔除非主板"意图(spec 要求)。
"""
try:
stocks = self.provider.get_index_stocks(index_symbol, previous_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks) # 剔除创业板/科创北交
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
return stocks
# ======================== 数值辅助 ========================
def _is_valid_positive_number(v: Any) -> bool:
"""判 v 是否有效正数(原策略 ``indicator.eps > 0``)。
None / NaN / Inf / 非数 / ≤0 → False。
"""
if v is None:
return False
try:
fv = float(v)
except (TypeError, ValueError):
return False
if not np.isfinite(fv):
return False
return fv > 0
__all__ = ["SmallCapStrategy", "SmallCapConfig"]
@@ -0,0 +1,430 @@
"""聚宽"穿越牛熊基业长青的价值精选"策略(post13382)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/01_value_selection/source.py``,
这里做**结构等价 + bug 修复 + py2→py3** 翻译:
- ``initialize`` → ``ValueSelectionStrategy.initialize``
- ``get_stock_list`` → ``ValueSelectionStrategy._get_stock_list``
- ``get_check_stocks_sort`` → **删除**(排序后不截断+全买的死代码,KISS)
- ``buy`` / ``sell`` → 调仓逻辑合入 ``monthly_adjustment``
- ``get_data`` (pd.Panel) → ``provider.get_value_metrics`` 接口替代
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 momentum_timing/all_weather 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
⚠️ 已修复原始策略的问题(详见 notes.md「移植记录」):
1. **pd.Panel 移除** — pandas ≥1.0 已删除 Panel API;改为约定 provider 提供
``get_value_metrics(stock, date)`` 接口返回多期指标 dict。
2. **前视偏差** — 原策略 ``get_fundamentals(statDate=quarter)`` 按报告期取数,
会用到尚未披露的数据;改用 NOTICE_DATE(公告日) <= 当前回测日 过滤。
3. **排序死代码** — ``get_check_stocks_sort`` 排序后不截断 + ``buy`` 全买 →
排序无意义;保留"全买"等额逻辑(KISS,忠实原意),删除无意义排序。
4. **第⑥条代码笔误(实测发现)** — 注释写"盈余成长率8%~50%"本是**净利润同比**语义,
但代码写了 ``(eps>0.08)&(eps<0.5)``(EPS 绝对值,笔误)。VPS 真实回测实证:
EPS 绝对值与 L1(流通市值>均值=大盘股)逻辑矛盾(大盘价值股 EPS 普遍 >0.5),
L1∩L6≈空 → 6 次调仓 final 全 0。**按注释本意修正为净利润同比增长率 8%~50**,
对应东财 income ``PARENT_NETPROFIT_YOY`` 列。
5. **冗余调用** — 原策略 ``before_market_open`` 调 ``get_stock_list`` 两次(复制粘贴),
简化为调一次。
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
_previous_date_str,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
@dataclass
class ValueSelectionConfig:
"""价值精选 6 条策略参数(聚宽 g.* 全局变量抽出便于调参)。
6 条过滤阈值严格对齐原策略 source.py 第 91-97 行注释 + 第 105-171 行代码。
"""
# 第 1 条:流通市值 > 市场均值(单位:亿元,全市场比较,绝对单位不影响过滤结果)
# (无阈值,运行时算 market mean)
# 第 2 条:流动比率(流动资产/流动负债) > 市场均值
# (无阈值,运行时算 market mean)
# 第 3 条:近 4 季 ROE > 各季市场均值(取交集)
roe_quarters: int = 4
# 第 4 条:近 5 年自由现金流(经营-投资)每年为正
fcf_years: int = 5
# 第 5 条:近 4 季营收同比增长率 6%~30%
revenue_yoy_low: float = 6.0 # 百分数(原代码 >6)
revenue_yoy_high: float = 30.0 # 百分数(原代码 <30)
revenue_yoy_quarters: int = 4
# 第 6 条:近 4 季净利润同比增长率(盈余成长率)8%~50%
# ⚠️ 注释修正:原 source.py 第 96/165 行注释"盈余成长率8%~50%"本是**净利润同比**语义,
# 但代码写了 ``(eps>0.08)&(eps<0.5)``(EPS 绝对值,笔误)。
# 按 VPS 真实回测实证:EPS 绝对值 0.08~0.5 与 L1(流通市值>均值=大盘股)逻辑矛盾
# (A股大盘价值股 EPS 普遍 >0.5: 茅台50/招行5/工行0.8),L1∩L6≈空 → 6次调仓 final 全 0。
# 修正为按注释本意"净利润同比增长率8%~50%",与 L1 不矛盾(大盘股也能满足)。
earnings_growth_low: float = 8.0 # 百分数(归母净利润同比 >8%)
earnings_growth_high: float = 50.0 # 百分数(<50%)
earnings_growth_quarters: int = 4
# 其他配置
benchmark: str = "000300.XSHG"
universe: str = "000300.XSHG" # 选股池(默认沪深300,避免全市场 5000+ 股逐只读三表爆炸)
new_stock_days: int = 375 # 次新股过滤阈值
max_pool: int = 0 # 0=不限;MVP 验证用,限制候选池前 N 只
# ------------------------ 策略 ------------------------
class ValueSelectionStrategy:
"""价值精选 6 条策略(全市场横向比较 + 月度调仓)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
数据契约:
- 策略层调 ``provider.get_value_metrics(stock, current_date)`` 拿多期指标
(dict 含 circulating_market_cap / current_ratio / roe_series /
fcf_series / revenue_yoy_series / eps_series)。
- provider 层负责 NOTICE_DATE 过滤和聚宽字段→东财列名映射(详见 notes.md)。
- provider 未实现该接口 / 返回 None → 该股跳过(不入选)。
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[ValueSelectionConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or ValueSelectionConfig()
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 每月第 5 个交易日 9:30 调仓(原策略 run_monthly before_market_open+market_open 第5日)
b.run_monthly(self.monthly_adjustment, 5, "9:30")
# =================== monthly_adjustment (主流程) ===================
def monthly_adjustment(self, context: Any) -> None:
"""每月调仓:6 条过滤 → 卖出不在名单 → 等额买入。
对齐原策略 ``before_market_open``(取名单) + ``market_open``(买卖)。
"""
cfg = self.config
# 原策略用 ``context.previous_date`` 取上一交易日数据(get_fundamentals 的 date 参数)
previous_date = _previous_date_str(context)
if previous_date is None:
logger.warning("monthly_adjustment: previous_date 为 None,跳过")
return
# 1) 候选池:universe 成份股 + 过滤 ST/科创北交/次新
candidates = self._stock_pool(cfg.universe, previous_date)
if not candidates:
logger.info("[%s] 候选池为空,跳过调仓", previous_date)
return
# 2) 6 条过滤取交集
buy_list = self._get_stock_list(candidates, previous_date)
logger.info("[%s] 6条过滤后候选:%d/%d", previous_date, len(buy_list), len(candidates))
# 3) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters)
positions = _get_positions(context)
buy_list = filters.filter_limitup_stock(
buy_list, self.provider, positions=list(positions.keys())
)
buy_list = filters.filter_limitdown_stock(
buy_list, self.provider, positions=list(positions.keys())
)
buy_list = filters.filter_paused_stock(buy_list, self.provider)
buy_list = _dedup(buy_list)
# 4) 调仓:卖出不在 buy_list 的(原策略 sell 函数)
for stock in list(positions.keys()):
if stock in buy_list:
continue
self._close_position(stock)
# 5) 等额买入 buy_list 里的新股(原策略 buy 函数, cash/countStocks 语义)
positions = _get_positions(context) # 卖出后刷新
target_num = len(buy_list)
if target_num == 0:
return
cash = _available_cash(context)
if cash <= 0:
return
per_value = cash / target_num
for stock in buy_list:
if stock in positions:
continue
if self._open_position(stock, per_value):
positions = _get_positions(context) # 刷新
if len(positions) >= target_num:
break
logger.info("[%s] 月度调仓结束: target=%s", previous_date, buy_list)
# =================== get_stock_list (6 条过滤) ===================
def _get_stock_list(self, stocks: List[str], date_str: str) -> List[str]:
"""6 条过滤取交集(原策略 ``get_stock_list`` 翻译)。
Args:
stocks: 候选池
date_str: 取数日期(YYYY-MM-DD,通常是 context.previous_date)
Returns:
通过全部 6 条过滤的股票列表
"""
cfg = self.config
if not stocks:
return []
# 1) 取所有候选股的多期指标(provider 实现 NOTICE_DATE 过滤)
metrics: dict[str, dict[str, Any]] = {}
for stock in stocks:
m = self._load_value_metrics(stock, date_str)
if m is None:
continue
metrics[stock] = m
if not metrics:
logger.warning("[%s] 所有股票多期指标都为空,返回空列表", date_str)
return []
# 2) 第 1 条:流通市值 > 市场均值
cap_field = "circulating_market_cap"
cap_valid = {s: m for s, m in metrics.items()
if _is_valid_number(m.get(cap_field))}
if not cap_valid:
return []
cap_mean = np.mean([m[cap_field] for m in cap_valid.values()])
l1 = {s for s, m in cap_valid.items() if m[cap_field] > cap_mean}
logger.debug("[%s] L1 流通市值>均值: %d/%d (mean=%.2f亿)",
date_str, len(l1), len(metrics), cap_mean)
# 3) 第 2 条:流动比率 > 市场均值
cr_field = "current_ratio"
cr_valid = {s: m for s, m in metrics.items()
if _is_valid_number(m.get(cr_field))}
if not cr_valid:
return []
cr_mean = np.mean([m[cr_field] for m in cr_valid.values()])
l2 = {s for s, m in cr_valid.items() if m[cr_field] > cr_mean}
logger.debug("[%s] L2 流动比率>均值: %d/%d (mean=%.2f)",
date_str, len(l2), len(cr_valid), cr_mean)
# 4) 第 3 条:近 roe_quarters 季 ROE > 各季市场均值(取交集)
l3 = self._filter_per_quarter_above_market_mean(
metrics, "roe_series", cfg.roe_quarters,
)
# 5) 第 4 条:近 fcf_years 年 FCF 每年为正
l4 = self._filter_all_positive(
metrics, "fcf_series", cfg.fcf_years,
)
# 6) 第 5 条:近 revenue_yoy_quarters 季营收同比 6%~30%
l5 = self._filter_per_quarter_in_range(
metrics, "revenue_yoy_series", cfg.revenue_yoy_quarters,
cfg.revenue_yoy_low, cfg.revenue_yoy_high,
)
# 7) 第 6 条:近 earnings_growth_quarters 季净利润同比增长率 8%~50%
l6 = self._filter_per_quarter_in_range(
metrics, "netprofit_yoy_series", cfg.earnings_growth_quarters,
cfg.earnings_growth_low, cfg.earnings_growth_high,
)
out = list(l1 & l2 & l3 & l4 & l5 & l6)
logger.info(
"[%s] L1=%d L2=%d L3=%d L4=%d L5=%d L6=%d → final=%d",
date_str, len(l1), len(l2), len(l3), len(l4), len(l5), len(l6),
len(out),
)
return out
# =================== 6 条过滤 helper ===================
@staticmethod
def _filter_per_quarter_above_market_mean(
metrics: dict[str, dict[str, Any]],
field: str,
n_quarters: int,
) -> set[str]:
"""原策略第 3 条:近 n 季 field 每季都 > 市场均值的交集。
对齐 source.py 第 118-129 行 ROE 取交集逻辑。
"""
# 只保留至少 n_quarters 期数据的股票
valid = {s: list(m[field]) for s, m in metrics.items()
if isinstance(m.get(field), (list, tuple))
and len(m[field]) >= n_quarters}
if not valid:
return set()
# result 初始 = 所有 valid 股票, 然后逐季取交集
# (原策略 panel.iloc[:,i,:].filter(roe>mean).index 与之前季取交集)
result: set[str] = set(valid.keys())
for i in range(n_quarters):
# 该季所有股票的值
i_vals = {}
for s, series in valid.items():
v = series[i] if i < len(series) else None
if _is_valid_number(v):
i_vals[s] = float(v)
if not i_vals:
continue
market_mean = float(np.mean(list(i_vals.values())))
above = {s for s, v in i_vals.items() if v > market_mean}
result &= above
if not result:
break
return result
@staticmethod
def _filter_all_positive(
metrics: dict[str, dict[str, Any]],
field: str,
n_periods: int,
) -> set[str]:
"""原策略第 4 条:近 n 期 field 每期都 > 0。"""
out: set[str] = set()
for s, m in metrics.items():
series = m.get(field)
if not isinstance(series, (list, tuple)):
continue
if len(series) < n_periods:
continue
recent = series[:n_periods]
if all(_is_valid_number(v) and float(v) > 0 for v in recent):
out.add(s)
return out
@staticmethod
def _filter_per_quarter_in_range(
metrics: dict[str, dict[str, Any]],
field: str,
n_quarters: int,
low: float,
high: float,
) -> set[str]:
"""原策略第 5/6 条:近 n 季 field 每季都 ∈ [low, high](原代码严格 < high)。"""
out: set[str] = set()
for s, m in metrics.items():
series = m.get(field)
if not isinstance(series, (list, tuple)):
continue
if len(series) < n_quarters:
continue
recent = series[:n_quarters]
ok = True
for v in recent:
if not _is_valid_number(v):
ok = False
break
fv = float(v)
# 原代码 ``(x>low) & (x<high)`` 严格不等式,保留语义
if not (fv > low and fv < high):
ok = False
break
if ok:
out.add(s)
return out
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, previous_date: str) -> List[str]:
"""成份股 + 过滤 ST/科创北交/次新。"""
try:
stocks = self.provider.get_index_stocks(index_symbol, previous_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks)
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
stocks = filters.filter_st_stock(stocks, self.provider)
stocks = filters.filter_new_stock(
stocks, self.provider, previous_date, self.config.new_stock_days
)
return stocks
def _load_value_metrics(
self, stock: str, date_str: str,
) -> Optional[dict[str, Any]]:
"""从 provider 取该股的多期价值精选指标。
调用 provider 的 ``get_value_metrics(stock, date_str)`` 接口(由 provider 层
实现 NOTICE_DATE 过滤和聚宽→东财字段映射)。provider 未实现该接口 / 返回
None / 异常 → 该股被跳过(不入选)。
"""
fn = getattr(self.provider, "get_value_metrics", None)
if fn is None:
return None
try:
return fn(stock, date_str)
except Exception as exc:
logger.debug("get_value_metrics(%s) 失败: %s", stock, exc)
return None
# ======================== 数值辅助 ========================
def _is_valid_number(v: Any) -> bool:
"""判 v 是否有效数(非 None / 非 NaN / 非 Inf)。"""
if v is None:
return False
try:
fv = float(v)
except (TypeError, ValueError):
return False
return not math.isnan(fv) and not math.isinf(fv)
__all__ = ["ValueSelectionStrategy", "ValueSelectionConfig"]
+495
View File
@@ -0,0 +1,495 @@
"""MomentumTimingStrategy 单元测试(mock provider + mock broker)。
策略层只测**逻辑分支正确**(RPS / 均线 / 牛熊信号 / 调仓),不测真实数据。
真实数据回测在 VPS 跑,这里只保证策略翻译等价 + 两个原始 bug 已修复。
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import numpy as np
import pandas as pd
import pytest
from sanguo_portfolio import BrokerFacade
from sanguo_portfolio.strategies.momentum_timing import (
MomentumTimingConfig,
MomentumTimingStrategy,
)
from tests.portfolio.conftest import FakeContext, FakePosition
# ------------------------ 测试 helper ------------------------
def make_strategy(
*,
index_stocks_map: Optional[Dict[str, List[str]]] = None,
price_df_map: Optional[Dict[Any, pd.DataFrame]] = None,
config: Optional[MomentumTimingConfig] = None,
) -> MomentumTimingStrategy:
"""构造一个 mock provider + mock broker 驱动的策略。
- index_stocks_map: get_index_stocks 返回,dict[index] -> List[code]
- price_df_map: get_price 按 (security, fields, count) 或 (security, start, end) 缓存的返回
"""
provider = MagicMock(name="provider")
# get_index_stocks
index_stocks_map = index_stocks_map or {}
def _get_index_stocks(index_symbol, date=None):
return list(index_stocks_map.get(index_symbol, []))
provider.get_index_stocks.side_effect = _get_index_stocks
# get_security_info(filter_st/filter_new 默认放过)
provider.get_security_info.return_value = {
"display_name": "NORMAL",
"name": "600519",
"start_date": datetime(2000, 1, 1),
}
# get_live_current:不停牌不涨跌停
provider.get_live_current.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
provider.get_current_tick.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
# get_price 按 key 缓存(支持 count 模式 + start/end 模式)
# 规范化:把 key 第一项(list)转 tuple 以保证可 hash
def _normalize_key(k: Any) -> Any:
if isinstance(k, tuple) and k and isinstance(k[0], (list, tuple)):
return (tuple(k[0]),) + tuple(k[1:])
return k
price_df_map = {_normalize_key(k): v for k, v in (price_df_map or {}).items()}
def _get_price(security, **kwargs):
# 构造 cache key:两种取数模式
# 1) count 模式:(sec_key, fields, count)
# 2) start/end 模式:(sec_key, fields, start_date, end_date)
# 注意:list 不可 hash → 转 tuple
sec_key = tuple(security) if isinstance(security, list) else security
fields = tuple(kwargs.get("fields") or [])
if kwargs.get("count") is not None:
key = (sec_key, fields, kwargs.get("count"))
else:
key = (sec_key, fields, kwargs.get("start_date"), kwargs.get("end_date"))
return price_df_map.get(key, pd.DataFrame())
provider.get_price.side_effect = _get_price
broker = BrokerFacade()
broker.order_target_value = MagicMock(return_value=MagicMock(filled=100))
broker.order_value = MagicMock(return_value=MagicMock(filled=100))
broker.set_benchmark = MagicMock()
broker.set_option = MagicMock()
broker.run_daily = MagicMock()
broker.run_monthly = MagicMock()
return MomentumTimingStrategy(provider=provider, broker=broker, config=config)
def _make_close_panel(
codes: List[str],
closes: List[List[float]],
end_date: str = "2024-09-30",
days: int = 30,
) -> pd.DataFrame:
"""构造 panel=False 风格的 close DataFrame。
Args:
codes: 股票代码列表
closes: 每只股票的 close 序列(长度 <= days, 不足重复首值)
end_date: 最后一根 K 线日期
days: 总 K 线根数(默认 30)
"""
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
dates = [(end_dt - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d") for i in range(days)]
rows = []
for code, close_list in zip(codes, closes):
# 不足 days 的补首值
full = list(close_list) + [close_list[-1]] * (days - len(close_list))
for d, c in zip(dates, full):
rows.append({"time": pd.Timestamp(d), "code": code, "close": float(c)})
return pd.DataFrame(rows)
# =================== initialize ===================
class TestInitialize:
def test_initialize_registers_daily_handle_data(self, fake_context):
s = make_strategy()
s.initialize(fake_context)
# run_daily 至少被调一次(注册 handle_data)
assert s.broker.run_daily.called
# run_daily 的第一个参数应是 handle_data 方法
first_call = s.broker.run_daily.call_args_list[0]
assert first_call.args[0].__name__ == "handle_data"
def test_initialize_sets_benchmark(self, fake_context):
cfg = MomentumTimingConfig(benchmark="000300.XSHG")
s = make_strategy(config=cfg)
s.initialize(fake_context)
s.broker.set_benchmark.assert_called_with("000300.XSHG")
# =================== _cal_rps (修复后涨跌幅正确) ===================
class TestCalRps:
def test_empty_stocks_returns_empty_df(self):
"""空股票列表 → 空 DataFrame。"""
s = make_strategy()
out = s._cal_rps([], cur_date="2024-09-30", pre_date="2024-09-01")
assert out.empty
assert "rps_value" in out.columns
def test_rps_uses_pre_to_cur_range_real_returns(self):
"""⚠️ 核心修复验证:RPS 必须用 preDate~curDate 区间算真实涨跌幅,
而非原始 bug 的 ``get_price(start=curDate, end=curDate)`` 单日恒 0。
"""
# 3 只股票,涨幅依次为 +100% / +50% / 0%
# preDate 首值 = 10, curDate 末值 = 20 / 15 / 10
codes = ["A.XSHG", "B.XSHG", "C.XSHG"]
df = pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "A.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "A.XSHG", "close": 20.0}, # +100%
{"time": pd.Timestamp("2024-09-01"), "code": "B.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "B.XSHG", "close": 15.0}, # +50%
{"time": pd.Timestamp("2024-09-01"), "code": "C.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "C.XSHG", "close": 10.0}, # 0%
])
s = make_strategy(price_df_map={
# 按 start_date/end_date 取数,确认 _cal_rps 走的是区间查询
((str(codes),) if False else (tuple(codes), ("close",), "2024-09-01", "2024-09-30")): df,
})
out = s._cal_rps(codes, cur_date="2024-09-30", pre_date="2024-09-01")
# 排序:A(+100%) > B(+50%) > C(0%)
assert list(out["code"]) == ["A.XSHG", "B.XSHG", "C.XSHG"]
# RPS: 99 - 100*i/n → [99, 99-100/3, 99-200/3] = [99, 65.67, 32.33]
assert out["rps_value"].iloc[0] == pytest.approx(99.0, abs=0.01)
assert out["rps_value"].iloc[1] == pytest.approx(99 - 100 / 3, abs=0.01)
assert out["rps_value"].iloc[2] == pytest.approx(99 - 200 / 3, abs=0.01)
def test_rps_descending_by_return(self):
"""涨幅大的排前(降序)。"""
codes = ["X.XSHG", "Y.XSHG"]
df = pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "X.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "X.XSHG", "close": 12.0}, # +20%
{"time": pd.Timestamp("2024-09-01"), "code": "Y.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "Y.XSHG", "close": 15.0}, # +50%
])
s = make_strategy(price_df_map={
(tuple(codes), ("close",), "2024-09-01", "2024-09-30"): df,
})
out = s._cal_rps(codes, cur_date="2024-09-30", pre_date="2024-09-01")
# Y 涨幅大,排前
assert out["code"].iloc[0] == "Y.XSHG"
assert out["code"].iloc[1] == "X.XSHG"
def test_rps_filters_nan_and_zero_first(self):
"""首值为 0(除零)或 NaN → 过滤掉。"""
codes = ["GOOD.XSHG", "ZERO.XSHG", "NAN.XSHG"]
df = pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "GOOD.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "GOOD.XSHG", "close": 20.0},
{"time": pd.Timestamp("2024-09-01"), "code": "ZERO.XSHG", "close": 0.0},
{"time": pd.Timestamp("2024-09-30"), "code": "ZERO.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-01"), "code": "NAN.XSHG", "close": np.nan},
{"time": pd.Timestamp("2024-09-30"), "code": "NAN.XSHG", "close": 10.0},
])
s = make_strategy(price_df_map={
(tuple(codes), ("close",), "2024-09-01", "2024-09-30"): df,
})
out = s._cal_rps(codes, cur_date="2024-09-30", pre_date="2024-09-01")
assert list(out["code"]) == ["GOOD.XSHG"]
# =================== _select_stocks (均线动量) ===================
class TestSelectStocks:
def test_empty_input(self):
s = make_strategy()
assert s._select_stocks([], cur_date="2024-09-30") == []
def test_keep_close_above_ma_short_above_ma_long(self):
"""close > MA5 且 MA5 > MA15 → 保留。"""
# 构造 15 日 close 序列:上升 → close(末) > MA5 > MA15
rising = [10.0 + i * 0.5 for i in range(15)] # 10→17
df = _make_close_panel(["UP.XSHG"], [rising], end_date="2024-09-30", days=15)
s = make_strategy(price_df_map={
# 注意:_select_stocks 传 list,get_price 内部转 tuple → key 第一项必须是 tuple
(("UP.XSHG",), ("close",), 15): df,
})
out = s._select_stocks(["UP.XSHG"], cur_date="2024-09-30")
assert out == ["UP.XSHG"]
def test_filter_close_below_ma_short(self):
"""close < MA5 → 剔除(下行趋势)。"""
falling = [20.0 - i * 0.5 for i in range(15)] # 20→13
df = _make_close_panel(["DOWN.XSHG"], [falling], end_date="2024-09-30", days=15)
s = make_strategy(price_df_map={
(("DOWN.XSHG"), ("close",), 15): df,
})
out = s._select_stocks(["DOWN.XSHG"], cur_date="2024-09-30")
assert out == []
def test_filter_ma_short_below_ma_long(self):
"""close > MA5 但 MA5 < MA15(下跌但末值小反弹)→ 剔除。"""
# 前 10 日大涨(20→30),后 5 日跌(30→26):MA5 < MA15
series = [20 + i for i in range(10)] + [30 - i for i in range(1, 6)] # 20..29, 29..25
df = _make_close_panel(["FLAT.XSHG"], [series], end_date="2024-09-30", days=15)
s = make_strategy(price_df_map={
(("FLAT.XSHG"), ("close",), 15): df,
})
out = s._select_stocks(["FLAT.XSHG"], cur_date="2024-09-30")
# close=25, MA5 = mean(29,28,27,26,25)=27, MA15 = mean(all)=24.67
# close(25) < MA5(27) → 不满足 close>MA5
assert out == []
def test_insufficient_data_skipped(self):
"""不足 ma_long=15 根 → 跳过。"""
short_df = _make_close_panel(["NEW.XSHG"], [[10, 11, 12]], end_date="2024-09-30", days=15)
s = make_strategy(price_df_map={
(("NEW.XSHG"), ("close",), 15): short_df,
})
# 序列被 _make_close_panel 补齐到 15,这里改为真短数据
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = pd.DataFrame([
{"time": pd.Timestamp("2024-09-28"), "code": "NEW.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-29"), "code": "NEW.XSHG", "close": 11.0},
{"time": pd.Timestamp("2024-09-30"), "code": "NEW.XSHG", "close": 12.0},
])
out = s._select_stocks(["NEW.XSHG"], cur_date="2024-09-30")
assert out == []
# =================== _cal_buy_sign (牛熊分界) ===================
class TestCalBuySign:
def test_empty_index_list_returns_false(self):
s = make_strategy()
assert s._cal_buy_sign([], past_day=30, cur_date="2024-09-30") is False
def test_bull_when_above_ma_ratio_exceeds_threshold(self):
"""所有指数都站在 30 日均线上方 → 占比 100% > 20% → 牛市(True)。"""
# 上升序列:末值远高于均值
idx_list = ["000300.XSHG", "000905.XSHG"]
rising = [10.0 + i for i in range(30)] # 10→39
df = _make_close_panel(idx_list, [rising, rising], end_date="2024-09-30", days=30)
s = make_strategy(price_df_map={
(tuple(idx_list), ("close",), 30): df,
})
assert s._cal_buy_sign(idx_list, past_day=30, cur_date="2024-09-30") is True
def test_bear_when_below_ma_ratio_below_threshold(self):
"""所有指数都跌破 30 日均线 → 占比 0% < 20% → 熊市(False)。"""
idx_list = ["000300.XSHG", "000905.XSHG"]
falling = [40.0 - i for i in range(30)] # 40→11
df = _make_close_panel(idx_list, [falling, falling], end_date="2024-09-30", days=30)
s = make_strategy(price_df_map={
(tuple(idx_list), ("close",), 30): df,
})
assert s._cal_buy_sign(idx_list, past_day=30, cur_date="2024-09-30") is False
def test_threshold_boundary_3_of_9_above_is_bull(self):
"""9 个指数中 2 个站上(2/9=0.222 > 0.2)→ 牛市。1 个站上(0.111 < 0.2)→ 熊市。"""
idx_list = [f"IDX{i}.XSHG" for i in range(9)]
rising = [10.0 + i for i in range(30)]
falling = [40.0 - i for i in range(30)]
# 2 个 rising + 7 个 falling
series_list = [rising, rising] + [falling] * 7
df = _make_close_panel(idx_list, series_list, end_date="2024-09-30", days=30)
s = make_strategy(price_df_map={
(tuple(idx_list), ("close",), 30): df,
})
# 2/9 ≈ 0.222 > 0.2 → 牛市
assert s._cal_buy_sign(idx_list, past_day=30, cur_date="2024-09-30") is True
# 改为 1 个 rising:1/9 ≈ 0.111 < 0.2 → 熊市
series_list_1 = [rising] + [falling] * 8
df_1 = _make_close_panel(idx_list, series_list_1, end_date="2024-09-30", days=30)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df_1
assert s._cal_buy_sign(idx_list, past_day=30, cur_date="2024-09-30") is False
# =================== handle_data (主流程) ===================
class TestHandleData:
def test_bear_signal_clears_all_positions(self):
"""熊市信号 → 全部持仓清掉。"""
cfg = MomentumTimingConfig(index_list=["IDX.XSHG"])
s = make_strategy(config=cfg)
# 触发熊市:get_price 返回下行 close
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = _make_close_panel(
["IDX.XSHG"], [[40.0 - i for i in range(30)]],
end_date="2024-10-08", days=30,
)
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
previous_date="2024-09-30",
positions={
"600519.XSHG": FakePosition("600519.XSHG", avg_cost=1600, price=1500),
"000001.XSHE": FakePosition("000001.XSHE", avg_cost=10, price=9),
},
)
s.handle_data(ctx)
# 两只持仓都被 order_target_value(code, 0)
sell_calls = [
c for c in s.broker.order_target_value.call_args_list if c.args[1] == 0
]
assert len(sell_calls) == 2
sell_codes = {c.args[0] for c in sell_calls}
assert sell_codes == {"600519.XSHG", "000001.XSHE"}
def test_bull_signal_buys_new_stocks(self):
"""牛市信号 + 候选池选股 → 买入(等额)。"""
# 构造场景:1 个指数,成份股 1 只,close 上升(RPS 正,均线多)
cfg = MomentumTimingConfig(index_list=["IDX.XSHG"], top_k=6, ma_short=5, ma_long=15)
s = make_strategy(
index_stocks_map={"IDX.XSHG": ["CAND.XSHG"]},
config=cfg,
)
rising_30 = [10.0 + i for i in range(30)] # 牛市信号用
rising_15 = [10.0 + i for i in range(15)] # 均线筛选用
# 提供所有可能查询路径的 price 数据
idx_codes = ["IDX.XSHG"]
stock_codes = ["CAND.XSHG"]
def _gp(security, **kwargs):
fields = tuple(kwargs.get("fields") or [])
# 1) _cal_buy_sign: idx_list, count=30
if security == idx_codes and kwargs.get("count") == 30:
return _make_close_panel(idx_codes, [rising_30], days=30)
# 2) _cal_rps for index 股池:股票, start/end 模式
if (
isinstance(security, list)
and security == stock_codes
and kwargs.get("start_date")
):
return pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "CAND.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-10-08"), "code": "CAND.XSHG", "close": 39.0},
])
# 3) _select_stocks: count=15
if (
isinstance(security, list)
and security == stock_codes
and kwargs.get("count") == cfg.ma_long
):
return _make_close_panel(stock_codes, [rising_15], days=15)
return pd.DataFrame()
s.provider.get_price.side_effect = _gp
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
previous_date="2024-09-30",
positions={},
cash=1_000_000,
)
s.handle_data(ctx)
# 应有 1 笔买入 CAND.XSHG,金额 ≈ 1_000_000 / 1 = 1_000_000
buy_calls = [
c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0
]
assert len(buy_calls) >= 1
assert any(c.args[0] == "CAND.XSHG" for c in buy_calls)
def test_handle_data_uses_current_dt_not_today(self):
"""⚠️ 修复原始 bug 验证:handle_data 必须用 context.current_dt 计算 cur_date,
不能用 datetime.date.today()(后者取真实今天)。
"""
# 用一个明显不同的 current_dt,确认 get_price 的 end_date 跟随它
cfg = MomentumTimingConfig(index_list=["IDX.XSHG"])
s = make_strategy(config=cfg)
captured_end_dates: List[Any] = []
def _gp(security, **kwargs):
# 记录 end_date 用于断言
if kwargs.get("end_date"):
captured_end_dates.append(str(kwargs["end_date"]))
# 下行 → 熊市(快速 return,不查其他)
return _make_close_panel(
["IDX.XSHG"], [[40.0 - i for i in range(30)]],
end_date=str(kwargs.get("end_date", "2024-10-08"))[:10],
days=30,
)
s.provider.get_price.side_effect = _gp
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
s.handle_data(ctx)
# 至少一次 get_price 的 end_date 是 "2024-10-08"(来自 current_dt),非今天
assert any("2024-10-08" in d for d in captured_end_dates)
# =================== _find_stock_pool (取强舍弱) ===================
class TestFindStockPool:
def test_picks_top_k_per_index(self):
"""每个行业取 RPS top_k → 候选池并集。"""
cfg = MomentumTimingConfig(index_list=["IDX1.XSHG", "IDX2.XSHG"], top_k=2)
s = make_strategy(
index_stocks_map={
"IDX1.XSHG": ["A.XSHG", "B.XSHG", "C.XSHG"],
"IDX2.XSHG": ["D.XSHG", "E.XSHG"],
},
config=cfg,
)
# 涨幅:A=+100%, B=+50%, C=0%, D=+30%, E=-10%
rps_df_1 = pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "A.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "A.XSHG", "close": 20.0},
{"time": pd.Timestamp("2024-09-01"), "code": "B.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "B.XSHG", "close": 15.0},
{"time": pd.Timestamp("2024-09-01"), "code": "C.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "C.XSHG", "close": 10.0},
])
rps_df_2 = pd.DataFrame([
{"time": pd.Timestamp("2024-09-01"), "code": "D.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "D.XSHG", "close": 13.0},
{"time": pd.Timestamp("2024-09-01"), "code": "E.XSHG", "close": 10.0},
{"time": pd.Timestamp("2024-09-30"), "code": "E.XSHG", "close": 9.0},
])
def _gp(security, **kwargs):
if isinstance(security, list):
if "A.XSHG" in security:
return rps_df_1
if "D.XSHG" in security:
return rps_df_2
return pd.DataFrame()
s.provider.get_price.side_effect = _gp
out = s._find_stock_pool(
["IDX1.XSHG", "IDX2.XSHG"], cur_date="2024-09-30", pre_date="2024-09-01",
)
# IDX1 top2 = [A, B], IDX2 top2 = [D, E]
assert set(out) == {"A.XSHG", "B.XSHG", "D.XSHG", "E.XSHG"}
# =================== Config 默认值 ===================
class TestConfigDefaults:
def test_default_index_list_is_10_csi_industry_indices(self):
"""✅ 默认板块是 10 个中证行业指数(G1 补全后切回原版,000938 缺跳过)。"""
cfg = MomentumTimingConfig()
assert len(cfg.index_list) == 10
# 10 个中证行业指数 000928-000937 全部存在
for code in ["000928", "000929", "000930", "000931", "000932",
"000933", "000934", "000935", "000936", "000937"]:
assert f"{code}.XSHG" in cfg.index_list
# 000938 缺(constituent_unified 仍无,暂跳记遗留)
assert "000938.XSHG" not in cfg.index_list
def test_default_params_match_original(self):
"""关键参数与原策略 g.* 一致。"""
cfg = MomentumTimingConfig()
assert cfg.index_thre == 0.2 # g.indexThre
assert cfg.past_day == 30 # g.pastDay
assert cfg.top_k == 6 # g.topK
assert cfg.ma_short == 5 # mavg(5)
assert cfg.ma_long == 15 # mavg(15)
+564
View File
@@ -0,0 +1,564 @@
"""SmallCapStrategy 单元测试(mock provider + mock broker)。
策略层只测**逻辑分支正确**(选股排序 / eps 过滤 / 创业板过滤 / 动量评分 / 5 日周期 / 调仓),
不测真实数据真实数据回测在 VPS
移植验证范围:
- 选股排序:市值最小 100 (过滤 eps0 / 创业板 300xxx / 上市<120 )
- 动量评分公式:(cur-low_130) + (cur-high_130) + (cur-ma15),升序
- 5 日调仓周期:day_count % tc == 0 时选股+调仓,其他日 no-op
- 等权 20
- 对冲部分(已删,不测)
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import numpy as np
import pandas as pd
import pytest
from sanguo_portfolio import BrokerFacade
from sanguo_portfolio.strategies.small_cap import (
SmallCapConfig,
SmallCapStrategy,
)
from tests.portfolio.conftest import FakeContext, FakePosition
# ------------------------ 测试 helper ------------------------
def make_strategy(
*,
universe_stocks: Optional[List[str]] = None,
fundamentals_df: Optional[pd.DataFrame] = None,
price_df_map: Optional[Dict[Any, pd.DataFrame]] = None,
config: Optional[SmallCapConfig] = None,
) -> SmallCapStrategy:
"""构造一个 mock provider + mock broker 驱动的策略。
- universe_stocks: get_index_stocks(universe, date) 返回的全市场候选列表
- fundamentals_df: get_fundamentals_df 返回(index=code, cols=[market_cap, eps, ...])
- price_df_map: get_price (security_tuple, fields_tuple, count) 缓存的返回
"""
provider = MagicMock(name="provider")
universe_stocks = universe_stocks or []
def _get_index_stocks(index_symbol, date=None):
return list(universe_stocks)
provider.get_index_stocks.side_effect = _get_index_stocks
# get_security_info(filter_st/filter_new 默认放过)
provider.get_security_info.return_value = {
"display_name": "NORMAL",
"name": "600519",
"start_date": datetime(2000, 1, 1),
}
# get_live_current:不停牌不涨跌停
provider.get_live_current.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
provider.get_current_tick.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
# get_fundamentals_df
if fundamentals_df is not None:
provider.get_fundamentals_df.return_value = fundamentals_df
else:
provider.get_fundamentals_df.return_value = pd.DataFrame()
# get_price 按 key 缓存
def _normalize_key(k: Any) -> Any:
if isinstance(k, tuple) and k and isinstance(k[0], (list, tuple)):
return (tuple(k[0]),) + tuple(k[1:])
return k
price_df_map = {_normalize_key(k): v for k, v in (price_df_map or {}).items()}
def _get_price(security, **kwargs):
sec_key = tuple(security) if isinstance(security, list) else security
fields = tuple(kwargs.get("fields") or [])
if kwargs.get("count") is not None:
key = (sec_key, fields, kwargs.get("count"))
else:
key = (sec_key, fields, kwargs.get("start_date"), kwargs.get("end_date"))
return price_df_map.get(key, pd.DataFrame())
provider.get_price.side_effect = _get_price
broker = BrokerFacade()
broker.order_target_value = MagicMock(return_value=MagicMock(filled=100))
broker.order_value = MagicMock(return_value=MagicMock(filled=100))
broker.set_benchmark = MagicMock()
broker.set_option = MagicMock()
broker.run_daily = MagicMock()
broker.run_monthly = MagicMock()
return SmallCapStrategy(provider=provider, broker=broker, config=config)
def _make_fundamentals_df(
stocks_with_cap_eps: List[tuple[str, float, float]],
) -> pd.DataFrame:
"""构造 fundamentals DataFrame(index=code, cols=[code, market_cap, eps])。
Args:
stocks_with_cap_eps: [(code, market_cap_亿, eps), ...]
"""
rows = [
{"code": c, "market_cap": cap, "eps": eps}
for c, cap, eps in stocks_with_cap_eps
]
df = pd.DataFrame(rows, columns=["code", "market_cap", "eps"])
return df.set_index("code", drop=False)
def _make_hlc_panel(
stocks: List[str],
closes: List[List[float]],
*,
highs: Optional[List[List[float]]] = None,
lows: Optional[List[List[float]]] = None,
end_date: str = "2024-09-30",
days: int = 130,
) -> pd.DataFrame:
"""构造 panel=False 风格的 close+high+low DataFrame。
Args:
stocks: 股票代码列表
closes: 每只股票的 close 序列(长度 <= days, 不足重复首值)
highs: close,None close
lows: close,None close
days: K 线根数(默认 130)
"""
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
dates = [
(end_dt - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
for i in range(days)
]
rows = []
for idx, code in enumerate(stocks):
close_list = closes[idx]
high_list = highs[idx] if highs else close_list
low_list = lows[idx] if lows else close_list
c_full = list(close_list) + [close_list[-1]] * (days - len(close_list))
h_full = list(high_list) + [high_list[-1]] * (days - len(high_list))
l_full = list(low_list) + [low_list[-1]] * (days - len(low_list))
for d, c, h, l in zip(dates, c_full, h_full, l_full):
rows.append({
"time": pd.Timestamp(d),
"code": code,
"close": float(c),
"high": float(h),
"low": float(l),
})
return pd.DataFrame(rows)
# =================== initialize ===================
class TestInitialize:
def test_initialize_registers_daily_handle_data(self, fake_context):
s = make_strategy()
s.initialize(fake_context)
assert s.broker.run_daily.called
first_call = s.broker.run_daily.call_args_list[0]
assert first_call.args[0].__name__ == "handle_data"
assert first_call.args[1] == "9:30"
def test_initialize_sets_benchmark(self, fake_context):
cfg = SmallCapConfig(benchmark="000300.XSHG")
s = make_strategy(config=cfg)
s.initialize(fake_context)
s.broker.set_benchmark.assert_called_with("000300.XSHG")
# =================== Config 默认值(对齐原策略) ===================
class TestConfigDefaults:
def test_default_params_match_original(self):
"""关键参数与原策略 source.py set_params 一致。"""
cfg = SmallCapConfig()
assert cfg.tc == 5 # g.tc
assert cfg.pick_stock_count == 100 # g.pick_stock_count
assert cfg.buy_stock_count == 20 # g.buy_stock_count
assert cfg.ma_window == 130 # attribute_history(stock, 130, ...)
assert cfg.ma_short == 15 # data[stock].mavg(15, 'close')
assert cfg.new_stock_days == 120 # 原策略 120 天过滤
def test_default_universe_is_csi_allshare(self):
"""✅ universe 默认是 000985.XSHG(中证全指 5128 只),G2 补全后切回原版。
此前 000985 不在 constituent_unified 降级用 932000(中证2000);2026-07-28 G2
补全 000985 后切回,恢复原策略"全市场市值最小100"意图
"""
cfg = SmallCapConfig()
assert cfg.universe == "000985.XSHG"
# 防回退到 932000(降级版)
assert cfg.universe != "932000.XSHG"
# =================== _stock_pool (创业板/科创北交过滤) ===================
class TestStockPool:
def test_filter_kcbj_excluded(self):
"""创业板 300xxx / 科创 688xxx / 北交 8/4 开头都被剔除。"""
s = make_strategy(universe_stocks=[
"600519.XSHG", # 沪市主板 - 保留
"000001.XSHE", # 深市主板 - 保留
"300001.XSHE", # 创业板 - 剔除
"688001.XSHG", # 科创板 - 剔除
"830001.XSHG", # 北交 - 剔除
"430001.XSHG", # 北交 - 剔除
])
out = s._stock_pool("ANY.XSHG", "2024-09-30")
assert set(out) == {"600519.XSHG", "000001.XSHE"}
assert "300001.XSHE" not in out
assert "688001.XSHG" not in out
def test_max_pool_limits_count(self):
"""max_pool > 0 时截断候选池前 N 只。"""
s = make_strategy(
universe_stocks=[f"60000{i}.XSHG" for i in range(10)],
config=SmallCapConfig(max_pool=3),
)
out = s._stock_pool("ANY.XSHG", "2024-09-30")
assert len(out) == 3
# =================== _cal_momentum_score (动量评分) ===================
class TestCalMomentumScore:
def test_empty_input_returns_empty(self):
s = make_strategy()
out = s._cal_momentum_score([], end_date="2024-09-30")
assert out.empty
def test_score_formula_is_cur_minus_low_high_ma15(self):
"""score = (cur-low_130) + (cur-high_130) + (cur-ma15)。
构造已知序列验证公式:
- close 10():low=high=ma15=10,cur=10,score=0
- close 上升:cur>low/high/ma15 score
- close 下降:cur<low/high/ma15 score
"""
flat = [10.0] * 130
rising = [10.0 + i * 0.1 for i in range(130)] # 10→22.9,cur=22.9
falling = [23.0 - i * 0.1 for i in range(130)] # 23→10.1,cur=10.1
df = _make_hlc_panel(
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
[flat, rising, falling],
end_date="2024-09-30", days=130,
)
s = make_strategy(price_df_map={
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"), ("close", "high", "low"), 130): df,
})
out = s._cal_momentum_score(
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"], end_date="2024-09-30",
)
# FLAT: score = 0(全部相同)
assert out.loc["FLAT.XSHG", "score"] == pytest.approx(0.0, abs=0.01)
# UP: cur=22.9, low=10, high=22.9, ma15=mean([21.5..22.9])≈22.2
# score = (22.9-10) + (22.9-22.9) + (22.9-22.2) = 12.9 + 0 + 0.7 ≈ 13.6
assert out.loc["UP.XSHG", "score"] > 0
# DOWN: cur=10.1, low=10.1, high=23, ma15≈10.8
# score = (10.1-10.1) + (10.1-23) + (10.1-10.8) ≈ 0 + (-12.9) + (-0.7) ≈ -13.6
assert out.loc["DOWN.XSHG", "score"] < 0
def test_score_sorted_ascending(self):
"""升序:分数低的排前(原策略 df.sort ascending=True)。"""
flat = [10.0] * 130
rising = [10.0 + i * 0.1 for i in range(130)]
falling = [23.0 - i * 0.1 for i in range(130)]
df = _make_hlc_panel(
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"],
[flat, rising, falling],
end_date="2024-09-30", days=130,
)
s = make_strategy(price_df_map={
(("FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"), ("close", "high", "low"), 130): df,
})
out = s._cal_momentum_score(
["FLAT.XSHG", "UP.XSHG", "DOWN.XSHG"], end_date="2024-09-30",
)
# 升序:DOWN(负) < FLAT(0) < UP(正)
assert list(out.index) == ["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"]
def test_insufficient_data_skipped(self):
"""K 线序列不足/空 → 该股跳过(不在结果里)。"""
s = make_strategy()
# 让 provider.get_price 返回空 DataFrame
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = pd.DataFrame()
out = s._cal_momentum_score(["EMPTY.XSHG"], end_date="2024-09-30")
assert out.empty
# =================== _pick_stocks (主选股流程) ===================
class TestPickStocks:
def test_empty_universe_returns_empty(self):
s = make_strategy(universe_stocks=[])
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
assert s._pick_stocks(ctx) == []
def test_filters_stocks_with_eps_le_zero(self):
"""eps ≤ 0 的股票被剔除(原策略 indicator.eps > 0)。"""
# 4 只股,eps 分别为 0.5(过) / -0.1(剔) / 0(剔,严格>) / 0.3(过)
# market_cap 都一样保证不卡排序
fund = _make_fundamentals_df([
("A.XSHG", 10.0, 0.5),
("B.XSHG", 11.0, -0.1),
("C.XSHG", 12.0, 0.0),
("D.XSHG", 13.0, 0.3),
])
s = make_strategy(universe_stocks=["A.XSHG", "B.XSHG", "C.XSHG", "D.XSHG"],
fundamentals_df=fund)
# 不传 price → _cal_momentum_score 会拿到空 df → 结果可能为空
# 我们只验证 eps 过滤生效:在 fundamentals 过滤后 top_candidates 不含 B/C
# 直接调 _pick_stocks 会因 price 空导致评分为空 → 返回空
# 这里通过 mock price 给所有候选相同 close,看最终名单
df = _make_hlc_panel(
["A.XSHG", "D.XSHG"], [[10.0] * 130, [10.0] * 130],
end_date="2024-09-30", days=130,
)
# _pick_stocks 的 get_price 入参可能是 list 形式
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
out = s._pick_stocks(ctx)
# eps>0 的 A/D 都进入候选,B/C 被剔
assert "B.XSHG" not in out
assert "C.XSHG" not in out
# A/D 都在最终名单(因 score 相同,顺序由 sort_values 保留)
assert set(out) == {"A.XSHG", "D.XSHG"} or set(out).issubset({"A.XSHG", "D.XSHG"})
def test_sorts_by_market_cap_asc_takes_top100(self):
"""按 market_cap 升序取前 pick_stock_count。"""
# 3 只股,市值依次升序,eps 都 > 0
fund = _make_fundamentals_df([
("SMALL.XSHG", 5.0, 0.3), # 最小,必入
("MID.XSHG", 50.0, 0.3),
("BIG.XSHG", 500.0, 0.3), # 最大,在 pick_stock_count=2 时被剔
])
cfg = SmallCapConfig(pick_stock_count=2, buy_stock_count=2)
s = make_strategy(
universe_stocks=["SMALL.XSHG", "MID.XSHG", "BIG.XSHG"],
fundamentals_df=fund,
config=cfg,
)
df = _make_hlc_panel(
["SMALL.XSHG", "MID.XSHG"],
[[10.0] * 130, [10.0] * 130],
end_date="2024-09-30", days=130,
)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
out = s._pick_stocks(ctx)
# market_cap 升序后前 2 只 = SMALL/MID,BIG 被剔
assert "BIG.XSHG" not in out
assert "SMALL.XSHG" in out
assert "MID.XSHG" in out
def test_takes_buy_stock_count_from_scored(self):
"""动量评分后取前 buy_stock_count 只(默认 20)。"""
# 构造 25 只股,确保 buy_stock_count=20 截断
stocks = [f"S{i:03d}.XSHG" for i in range(25)]
fund = _make_fundamentals_df([
(c, float(i + 1), 0.3) for i, c in enumerate(stocks)
])
cfg = SmallCapConfig(pick_stock_count=25, buy_stock_count=20)
s = make_strategy(
universe_stocks=stocks, fundamentals_df=fund, config=cfg,
)
# 所有股票 close 相同 → score 相同 → 顺序由 sort_values stable 决定
closes = [[10.0] * 130 for _ in stocks]
df = _make_hlc_panel(stocks, closes, end_date="2024-09-30", days=130)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
out = s._pick_stocks(ctx)
assert len(out) == 20
def test_momentum_score_ranks_low_first(self):
"""动量评分升序:分数低(底部反弹)的优先入选。"""
# 3 只候选,close 走势不同:
# DOWN: 持续下跌 → score 最负(最优先)
# FLAT: 平盘 → score = 0
# UP: 持续上涨 → score 最正(最后)
# buy_stock_count=2 时,DOWN/FLAT 入选,UP 被剔
fund = _make_fundamentals_df([
("DOWN.XSHG", 10.0, 0.3),
("FLAT.XSHG", 11.0, 0.3),
("UP.XSHG", 12.0, 0.3),
])
cfg = SmallCapConfig(pick_stock_count=3, buy_stock_count=2)
s = make_strategy(
universe_stocks=["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"],
fundamentals_df=fund, config=cfg,
)
flat = [10.0] * 130
rising = [10.0 + i * 0.1 for i in range(130)]
falling = [23.0 - i * 0.1 for i in range(130)]
df = _make_hlc_panel(
["DOWN.XSHG", "FLAT.XSHG", "UP.XSHG"],
[falling, flat, rising], end_date="2024-09-30", days=130,
)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
out = s._pick_stocks(ctx)
# 顺序:DOWN(score 最负) → FLAT(0),UP 被剔
assert out[0] == "DOWN.XSHG"
assert "UP.XSHG" not in out
# =================== handle_data (5 日调仓周期) ===================
class TestHandleDataPeriod:
def test_first_day_is_rebalance_day(self):
"""day_count=0 → 0%5=0 → 调仓日(对齐原策略 g.t=0 时调仓)。"""
cfg = SmallCapConfig(tc=5)
s = make_strategy(config=cfg)
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30), cash=1_000_000)
s.handle_data(ctx)
assert s.day_count == 1 # 调仓后 +1
# in_position_stocks 被赋值(pick_stocks 调用过,即使返回空也是赋值)
assert isinstance(s.in_position_stocks, list)
def test_non_rebalance_day_no_trade(self):
"""day_count=1..4 → 1%5..4%5 != 0 → 不调仓,持仓不变。"""
cfg = SmallCapConfig(tc=5)
s = make_strategy(config=cfg)
# 预置持仓名单(模拟上一次调仓的状态)
s.in_position_stocks = ["PREV1.XSHG", "PREV2.XSHG"]
s.day_count = 1
ctx = FakeContext(
current_dt=datetime(2024, 10, 9, 9, 30),
positions={"PREV1.XSHG": FakePosition("PREV1.XSHG", 10, 11)},
cash=1_000_000,
)
s.handle_data(ctx)
# 非调仓日:pick_stocks 未被调用,in_position_stocks 不变
assert s.in_position_stocks == ["PREV1.XSHG", "PREV2.XSHG"]
# 没有下单
assert not s.broker.order_target_value.called
def test_period_5_triggers_rebalance_every_5_days(self):
"""tc=5 → 每 5 个交易日触发一次选股调仓。"""
cfg = SmallCapConfig(tc=5)
s = make_strategy(config=cfg)
# 模拟 11 个交易日,应在 day_count=0,5,10 触发
rebalance_days = []
for _ in range(11):
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30), cash=1_000_000)
before = s.day_count
is_rebal = (before % cfg.tc) == 0
if is_rebal:
rebalance_days.append(before)
s.handle_data(ctx)
# day 0, 5, 10 是调仓日
assert rebalance_days == [0, 5, 10]
# =================== handle_data (调仓行为) ===================
class TestHandleDataRebalance:
def test_sells_positions_not_in_target(self):
"""调仓时卖出不在新名单的持仓。"""
fund = _make_fundamentals_df([
("NEW.XSHG", 5.0, 0.3),
])
cfg = SmallCapConfig(pick_stock_count=1, buy_stock_count=1)
s = make_strategy(
universe_stocks=["NEW.XSHG"],
fundamentals_df=fund,
config=cfg,
)
df = _make_hlc_panel(["NEW.XSHG"], [[10.0] * 130], end_date="2024-09-30", days=130)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
positions={
"OLD.XSHG": FakePosition("OLD.XSHG", avg_cost=10, price=11),
},
cash=1_000_000,
)
s.handle_data(ctx)
# OLD 被卖出(order_target_value(code, 0))
sell_calls = [
c for c in s.broker.order_target_value.call_args_list
if c.args[1] == 0
]
assert any(c.args[0] == "OLD.XSHG" for c in sell_calls)
def test_buys_new_stocks_equal_value(self):
"""等额买入名单中的新股(等权 = cash / buy_stock_count)。"""
# 构造 2 只候选,都入选
fund = _make_fundamentals_df([
("A.XSHG", 5.0, 0.3),
("B.XSHG", 6.0, 0.3),
])
cfg = SmallCapConfig(pick_stock_count=2, buy_stock_count=2)
s = make_strategy(
universe_stocks=["A.XSHG", "B.XSHG"],
fundamentals_df=fund,
config=cfg,
)
df = _make_hlc_panel(
["A.XSHG", "B.XSHG"], [[10.0] * 130, [10.0] * 130],
end_date="2024-09-30", days=130,
)
s.provider.get_price.side_effect = None
s.provider.get_price.return_value = df
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
positions={}, cash=1_000_000,
)
s.handle_data(ctx)
# A / B 都被买入(value != 0)
buy_calls = [
c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0
]
buy_codes = {c.args[0] for c in buy_calls}
assert "A.XSHG" in buy_codes
assert "B.XSHG" in buy_codes
# 等额:per_value = 1_000_000 / 2 = 500_000
for c in buy_calls:
assert c.args[1] == pytest.approx(500_000, rel=0.01)
# =================== 移植差异验证(原策略对照) ===================
class TestPortingDifferences:
"""验证移植后的"无对冲"差异点(确保对冲逻辑被正确去掉)。"""
def test_no_subportfolio_attribute(self):
"""策略实例不应有 SubPortfolio / 期货相关属性。"""
s = make_strategy()
assert not hasattr(s, "subportfolios")
assert not hasattr(s, "pre_future")
assert not hasattr(s, "futures_margin_rate")
assert not hasattr(s, "futures_symbol")
def test_no_statsmodels_import(self):
"""模块不应 import statsmodels(原代码 import 但未实际用)。"""
import sanguo_portfolio.strategies.small_cap as mod
assert "statsmodels" not in dir(mod)
# sys.modules 不应有 statsmodels.regression(由 small_cap 间接 import 的)
# 注意:其他模块可能 import statsmodels,只验证 small_cap 不引用
def test_rebalance_does_not_call_transfer_cash(self):
"""_rebalance 不应调用 transfer_cash(原策略双账户调配已删)。"""
s = make_strategy()
# broker 没暴露 transfer_cash(BrokerFacade 无此字段)
assert not hasattr(s.broker, "transfer_cash")
def test_handle_data_no_hedge_logic(self):
"""handle_data 主流程只做选股+调仓,不调 compute_hedge_ratio。"""
s = make_strategy()
# 策略实例没有 _compute_hedge_ratio 方法
assert not hasattr(s, "_compute_hedge_ratio")
assert not hasattr(s, "_get_next_month_future")
+545
View File
@@ -0,0 +1,545 @@
"""ValueSelectionStrategy 单元测试(mock provider + mock broker)。
策略层只测**逻辑分支正确**(6 条过滤 / 调仓 / 多期对齐),不测真实数据
真实数据回测在 VPS ,这里只保证策略翻译等价 + bug 已修
provider.get_value_metrics 接口的契约由 LocalParquetProvider 实现(单测见
test_local_unified_provider / test_local_parquet_provider),本文件只 mock 它的返回
L1/L2/L3 "和市场均值比较"(严格 ``>``),单只股票 / 两只股票值相同时
都会被卡死(均值=自身,严格>不过)所以测试都用 **HIGH vs LOW 双股对照**:
HIGH 所有指标都高,LOW 所有指标都低 HIGH 入选 LOW 不入选
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import numpy as np
import pandas as pd
import pytest
from sanguo_portfolio import BrokerFacade
from sanguo_portfolio.strategies.value_selection import (
ValueSelectionConfig,
ValueSelectionStrategy,
)
from tests.portfolio.conftest import FakeContext, FakePosition
# ------------------------ 测试 helper ------------------------
def _make_metrics(
*,
circ_cap: float = 100.0,
current_ratio: float = 1.5,
roe_series: Optional[List[float]] = None,
fcf_series: Optional[List[float]] = None,
revenue_yoy_series: Optional[List[float]] = None,
netprofit_yoy_series: Optional[List[float]] = None,
) -> Dict[str, Any]:
"""构造一个 metrics dict。"""
return {
"circulating_market_cap": circ_cap,
"current_ratio": current_ratio,
"roe_series": roe_series if roe_series is not None else [0.15, 0.15, 0.15, 0.15],
"fcf_series": fcf_series if fcf_series is not None else [1e8, 1e8, 1e8, 1e8, 1e8],
"revenue_yoy_series": revenue_yoy_series if revenue_yoy_series is not None else [15.0, 15.0, 15.0, 15.0],
"netprofit_yoy_series": netprofit_yoy_series if netprofit_yoy_series is not None else [20.0, 20.0, 20.0, 20.0],
}
def _make_high_metrics(**overrides) -> Dict[str, Any]:
"""所有指标都""的对照股 → 全 6 条过滤都过(前提是 LOW 在场拉低均值)。"""
base = {
"circ_cap": 500.0, # L1 > mean(500,10)=255 过
"current_ratio": 3.0, # L2 > mean(3.0,0.5)=1.75 过
"roe_series": [0.3, 0.3, 0.3, 0.3], # L3 > mean(0.3,0.001)=0.15 各季过
"fcf_series": [1e8, 1e8, 1e8, 1e8, 1e8], # L4 5 年正
"revenue_yoy_series": [15.0, 15.0, 15.0, 15.0], # L5 ∈ (6,30)
"netprofit_yoy_series": [20.0, 20.0, 20.0, 20.0], # L6 ∈ (8,50) 净利润同比
}
base.update(overrides)
return _make_metrics(**base)
def _make_low_metrics(**overrides) -> Dict[str, Any]:
"""所有指标都""的对照股 → 6 条过滤都不过。"""
base = {
"circ_cap": 10.0, # L1 < 均值(255) 不过
"current_ratio": 0.5, # L2 < 均值(1.75) 不过
"roe_series": [0.001, 0.001, 0.001, 0.001], # L3 < 均值(0.15) 各季不过
"fcf_series": [-1e8, -1e8, -1e8, -1e8, -1e8], # L4 5 年负
"revenue_yoy_series": [3.0, 3.0, 3.0, 3.0], # L5 <6 不过
"netprofit_yoy_series": [1.0, 1.0, 1.0, 1.0], # L6 <8 不过(净利润同比)
}
base.update(overrides)
return _make_metrics(**base)
def make_strategy(
*,
metrics_map: Optional[Dict[str, Dict[str, Any]]] = None,
config: Optional[ValueSelectionConfig] = None,
) -> ValueSelectionStrategy:
"""构造一个 mock provider + mock broker 驱动的策略。
- metrics_map: dict[code -> metrics_dict] provider.get_value_metrics 返回
"""
provider = MagicMock(name="provider")
metrics_map = metrics_map or {}
def _get_value_metrics(stock, date=None):
return metrics_map.get(stock)
provider.get_value_metrics.side_effect = _get_value_metrics
provider.get_index_stocks.return_value = []
provider.get_security_info.return_value = {
"display_name": "NORMAL",
"name": "600519",
"start_date": datetime(2000, 1, 1),
}
provider.get_live_current.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
provider.get_current_tick.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
broker = BrokerFacade()
broker.order_target_value = MagicMock(return_value=MagicMock(filled=100))
broker.order_value = MagicMock(return_value=MagicMock(filled=100))
broker.set_benchmark = MagicMock()
broker.set_option = MagicMock()
broker.run_daily = MagicMock()
broker.run_monthly = MagicMock()
cfg = config or ValueSelectionConfig()
return ValueSelectionStrategy(provider=provider, broker=broker, config=cfg)
# =================== initialize ===================
class TestInitialize:
def test_initialize_registers_monthly(self, fake_context):
"""initialize 注册 run_monthly(monthly_adjustment, day=5, time='9:30')。"""
s = make_strategy()
s.initialize(fake_context)
assert s.broker.run_monthly.called
first_call = s.broker.run_monthly.call_args_list[0]
assert first_call.args[0].__name__ == "monthly_adjustment"
assert first_call.args[1] == 5
assert first_call.args[2] == "9:30"
def test_initialize_sets_benchmark(self, fake_context):
cfg = ValueSelectionConfig(benchmark="000300.XSHG")
s = make_strategy(config=cfg)
s.initialize(fake_context)
s.broker.set_benchmark.assert_called_with("000300.XSHG")
# =================== _get_stock_list (6 条过滤) ===================
class TestGetStockList:
def test_empty_candidates_returns_empty(self):
s = make_strategy()
assert s._get_stock_list([], "2024-09-30") == []
def test_all_metrics_missing_returns_empty(self):
"""所有股票 provider 都返 None → 返回空。"""
s = make_strategy(metrics_map={})
out = s._get_stock_list(["A.XSHG", "B.XSHG"], "2024-09-30")
assert out == []
def test_single_stock_fails_mean_filters(self):
"""单只股票: L1/L2/L3 严格 ``> 均值`` 不过(均值=自身,严格>恒 False)。"""
s = make_strategy(metrics_map={
"A.XSHG": _make_high_metrics(),
})
out = s._get_stock_list(["A.XSHG"], "2024-09-30")
# L1 把单股卡死(均值=自身)
assert out == []
# ----- L1: 流通市值 > 市场均值 -----
def test_L1_filters_below_mean_market_cap(self):
"""流通市值低于市场均值的被剔除。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(circ_cap=500),
"LOW.XSHG": _make_low_metrics(circ_cap=10),
})
out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "LOW.XSHG" not in out
def test_L1_nan_market_cap_excluded_from_mean(self):
"""circ_cap NaN 的股票不入选, 也不参与均值计算(避免拉低均值)。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(circ_cap=500),
"NAN.XSHG": _make_high_metrics(circ_cap=float("nan")),
})
out = s._get_stock_list(["HIGH.XSHG", "NAN.XSHG"], "2024-09-30")
# 均值 = 500(HIGH 一只, NaN 排除), HIGH 严格 > 500 不过
# 这验证 NaN 不被算入 mean
assert "NAN.XSHG" not in out
# ----- L2: 流动比率 > 市场均值 -----
def test_L2_filters_below_mean_current_ratio(self):
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(current_ratio=3.0),
"LOW.XSHG": _make_low_metrics(current_ratio=0.5),
})
out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "LOW.XSHG" not in out
# ----- L3: 近 4 季 ROE > 各季市场均值 -----
def test_L3_takes_intersection_of_4_quarters(self):
"""4 季 ROE 都 > 各季市场均值才过(交集语义)。
LOW 作分母拉低均值( HIGH/BADQ3 能在 L1/L2 )
HIGH 各季 ROE 都比 BADQ3 HIGH 各季过; BADQ3 第3季 ROE 不过
"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]),
"BADQ3.XSHG": _make_high_metrics(roe_series=[0.1, 0.1, 0.001, 0.1]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(["HIGH.XSHG", "BADQ3.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "BADQ3.XSHG" not in out
def test_L3_insufficient_roe_quarters_filtered(self):
"""ROE series < 4 季 → 该股剔除。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]),
"SHORT.XSHG": _make_high_metrics(roe_series=[0.3, 0.3]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["HIGH.XSHG", "SHORT.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "SHORT.XSHG" not in out
# ----- L4: 近 5 年 FCF 每年为正 -----
def test_L4_requires_all_5_years_positive(self):
"""FCF 5 年必须都 > 0。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, 1]),
"LAST_NEG.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, -1]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["HIGH.XSHG", "LAST_NEG.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "HIGH.XSHG" in out
assert "LAST_NEG.XSHG" not in out
def test_L4_insufficient_fcf_years_filtered(self):
"""FCF 年数 < 5 → 剔除。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(fcf_series=[1, 1, 1, 1, 1]),
"SHORT.XSHG": _make_high_metrics(fcf_series=[1, 1, 1]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["HIGH.XSHG", "SHORT.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "SHORT.XSHG" not in out
# ----- L5: 近 4 季营收同比 6%~30% -----
def test_L5_revenue_yoy_must_be_6_to_30_all_quarters(self):
"""营收同比 4 季都 ∈ (6, 30)。"""
s = make_strategy(metrics_map={
"IN.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 15]),
"HIGH50.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 50]),
"LOW3.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 3]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["IN.XSHG", "HIGH50.XSHG", "LOW3.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "IN.XSHG" in out
assert "HIGH50.XSHG" not in out
assert "LOW3.XSHG" not in out
def test_L5_strict_inequality_at_boundary(self):
"""原代码 ``>low & <high`` 严格不等式: 6.0/30.0 边界不过。"""
s = make_strategy(metrics_map={
"EDGE6.XSHG": _make_high_metrics(revenue_yoy_series=[6.0, 15, 15, 15]),
"EDGE30.XSHG": _make_high_metrics(revenue_yoy_series=[30.0, 15, 15, 15]),
"IN.XSHG": _make_high_metrics(revenue_yoy_series=[15, 15, 15, 15]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["EDGE6.XSHG", "EDGE30.XSHG", "IN.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "EDGE6.XSHG" not in out
assert "EDGE30.XSHG" not in out
assert "IN.XSHG" in out
# ----- L6: 近 4 季净利润同比增长率 8%~50% -----
def test_L6_netprofit_yoy_must_be_8_to_50_all_quarters(self):
"""⚠️ 修正 VPS 实测 bug:原代码用 EPS 绝对值 0.08~0.5 与 L1 矛盾(大盘股 EPS 普遍 >0.5)
导致全程空仓按注释本意改为净利润同比增长率 8%~50%
"""
s = make_strategy(metrics_map={
"IN.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 20]),
"HIGH60.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 60]),
"LOW5.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 5]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["IN.XSHG", "HIGH60.XSHG", "LOW5.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "IN.XSHG" in out
assert "HIGH60.XSHG" not in out
assert "LOW5.XSHG" not in out
def test_L6_strict_inequality_at_boundary(self):
"""原代码 ``>low & <high`` 严格不等式: 8.0/50.0 边界不过。"""
s = make_strategy(metrics_map={
"EDGE8.XSHG": _make_high_metrics(netprofit_yoy_series=[8.0, 20, 20, 20]),
"EDGE50.XSHG": _make_high_metrics(netprofit_yoy_series=[50.0, 20, 20, 20]),
"IN.XSHG": _make_high_metrics(netprofit_yoy_series=[20, 20, 20, 20]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(
["EDGE8.XSHG", "EDGE50.XSHG", "IN.XSHG", "LOW.XSHG"], "2024-09-30",
)
assert "EDGE8.XSHG" not in out
assert "EDGE50.XSHG" not in out
assert "IN.XSHG" in out
# ----- 交集语义 -----
def test_intersection_of_all_6_filters(self):
"""全部 6 条都过才入选(HIGH 入选, LOW 全部不过)。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "LOW.XSHG" not in out
# =================== pd.Panel 改写后的多期对齐 ===================
class TestMultiPeriodAlignment:
"""原策略用 ``pd.Panel`` 做多期对齐, 移植后改为 ``dict[field, list]``。
验证多期对齐语义正确"""
def test_roe_per_quarter_market_mean_comparison(self):
"""L3: 每季分别比较市场均值,不是整体均值。
反例: BADQ3 整体 ROE 大部分高,但第3季 ROE 低于该季市场均值 第3季被剔 整体被剔
LOW 在场拉低均值, HIGH/BADQ3 L1/L2/其他季能过
"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]),
"BADQ3.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.001, 0.3]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(["HIGH.XSHG", "BADQ3.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "BADQ3.XSHG" not in out
def test_per_quarter_filter_is_intersection(self):
"""L3 是 4 季的交集(每季都 > 才过)。"""
# BAD2: 后 2 季 < 均值 → 交集为空 → 不过
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.3, 0.3]),
"BAD2.XSHG": _make_high_metrics(roe_series=[0.3, 0.3, 0.001, 0.001]),
"LOW.XSHG": _make_low_metrics(),
})
out = s._get_stock_list(["HIGH.XSHG", "BAD2.XSHG", "LOW.XSHG"], "2024-09-30")
assert "HIGH.XSHG" in out
assert "BAD2.XSHG" not in out
# =================== NOTICE_DATE 前视偏差过滤 ===================
class TestNoticeDateFiltering:
"""前视偏差修复: provider 返回的 metrics 应只含 NOTICE_DATE <= date 的数据。
策略层契约: 信任 provider NOTICE_DATE 过滤结果, 不再二次过滤(职责分离)
本测试用 mock 模拟: 验证策略**依赖** provider 过滤(只把 date 传过去)
"""
def test_strategy_passes_date_to_provider(self):
"""策略层把 previous_date 传给 provider.get_value_metrics(stock, date)。"""
captured_dates: List[Any] = []
def _capture(stock, date):
captured_dates.append(date)
return _make_high_metrics()
provider = MagicMock()
provider.get_value_metrics.side_effect = _capture
provider.get_index_stocks.return_value = ["HIGH.XSHG", "LOW.XSHG"]
provider.get_security_info.return_value = {
"display_name": "A", "name": "A", "start_date": datetime(2000, 1, 1),
}
provider.get_live_current.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
# 让第二只 metrics 全空, 这样均值 = HIGH 自身, HIGH 不过(均值=自身)
# 改为返回 LOW metrics 拉低均值, HIGH 才能过
provider.get_value_metrics.side_effect = lambda stock, date: (
_make_high_metrics() if "HIGH" in stock else _make_low_metrics()
)
s = ValueSelectionStrategy(provider=provider, broker=BrokerFacade())
s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
# provider 收到的 date 应是 "2024-09-30"(由策略层传过去)
# 验证 side_effect 被调用时收到 date 参数
assert provider.get_value_metrics.called
for call in provider.get_value_metrics.call_args_list:
# call.args = (stock, date) 或 call.args = (stock,) + kwargs
if len(call.args) >= 2:
assert call.args[1] == "2024-09-30"
else:
assert call.kwargs.get("date") == "2024-09-30"
# =================== 空数据跳过 ===================
class TestEmptyDataSkip:
"""三表损坏/空的股票 → provider.get_value_metrics 返 None → 该股不入选。"""
def test_provider_returns_none_stock_excluded(self):
"""provider 返 None 表示该股三表全空/损坏 → 跳过。"""
s = make_strategy(metrics_map={
"HIGH.XSHG": _make_high_metrics(),
"BAD.XSHG": None,
})
out = s._get_stock_list(["HIGH.XSHG", "BAD.XSHG"], "2024-09-30")
assert "BAD.XSHG" not in out
# HIGH 单只剩下的情况 → 均值=自身,不过(预期行为,不阻塞主流程)
def test_provider_raises_stock_excluded(self):
"""provider 异常 → 跳过,不污染整批。"""
provider = MagicMock()
# HIGH 正常, LOW 抛异常
def _gnm(stock, date=None):
if "LOW" in stock:
raise RuntimeError("三表损坏")
return _make_low_metrics()
provider.get_value_metrics.side_effect = _gnm
s = ValueSelectionStrategy(provider=provider, broker=BrokerFacade())
# 不抛异常(异常被吞)
out = s._get_stock_list(["HIGH.XSHG", "LOW.XSHG"], "2024-09-30")
assert "LOW.XSHG" not in out
# HIGH 因均值=自身不过(预期), 但**没有崩**
assert isinstance(out, list)
# =================== monthly_adjustment (主流程) ===================
class TestMonthlyAdjustment:
def test_empty_universe_no_trade(self):
"""候选池空 → 不调仓。"""
s = make_strategy(metrics_map={})
s.provider.get_index_stocks.return_value = []
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
s.monthly_adjustment(ctx)
assert not s.broker.order_target_value.called
def test_sells_positions_not_in_buy_list(self):
"""卖出不在新名单的持仓(原策略 sell 函数)。"""
cfg = ValueSelectionConfig(universe="IDX.XSHG")
# 构造 HIGH 入选 LOW 不入选的场景
s = make_strategy(
metrics_map={
"HIGH.XSHG": _make_high_metrics(),
"LOW.XSHG": _make_low_metrics(),
},
config=cfg,
)
s.provider.get_index_stocks.return_value = ["HIGH.XSHG", "LOW.XSHG"]
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
positions={
"OLD.XSHG": FakePosition("OLD.XSHG", avg_cost=10, price=11),
},
)
s.monthly_adjustment(ctx)
# OLD 被卖出(order_target_value(code, 0))
sell_calls = [
c for c in s.broker.order_target_value.call_args_list
if c.args[1] == 0
]
assert any(c.args[0] == "OLD.XSHG" for c in sell_calls)
def test_buys_new_stocks_equal_value(self):
"""买入 buy_list 里的新股(等额)。
构造 4 : HIGH_A / HIGH_B 入选, LOW_X / LOW_Y 拉低均值不入
"""
cfg = ValueSelectionConfig(universe="IDX.XSHG")
s = make_strategy(
metrics_map={
"HA.XSHG": _make_high_metrics(),
"HB.XSHG": _make_high_metrics(),
"LX.XSHG": _make_low_metrics(),
"LY.XSHG": _make_low_metrics(),
},
config=cfg,
)
s.provider.get_index_stocks.return_value = [
"HA.XSHG", "HB.XSHG", "LX.XSHG", "LY.XSHG",
]
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
positions={},
cash=1_000_000,
)
s.monthly_adjustment(ctx)
# HA / HB 被买入(value != 0)
buy_calls = [
c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0
]
buy_codes = {c.args[0] for c in buy_calls}
assert "HA.XSHG" in buy_codes
assert "HB.XSHG" in buy_codes
def test_per_value_is_cash_divided_by_target_num(self):
"""等额: per_value = available_cash / len(buy_list)。"""
cfg = ValueSelectionConfig(universe="IDX.XSHG")
s = make_strategy(
metrics_map={
"HA.XSHG": _make_high_metrics(),
"HB.XSHG": _make_high_metrics(),
"LX.XSHG": _make_low_metrics(),
"LY.XSHG": _make_low_metrics(),
},
config=cfg,
)
s.provider.get_index_stocks.return_value = [
"HA.XSHG", "HB.XSHG", "LX.XSHG", "LY.XSHG",
]
ctx = FakeContext(
current_dt=datetime(2024, 10, 8, 9, 30),
positions={},
cash=1_000_000,
)
s.monthly_adjustment(ctx)
buy_calls = [
c for c in s.broker.order_target_value.call_args_list if c.args[1] != 0
]
# 入选 2 只 (HA, HB), per_value = 1_000_000 / 2 = 500_000
for c in buy_calls:
assert c.args[1] == pytest.approx(500_000, rel=0.01)
# =================== Config 默认值(对齐原策略) ===================
class TestConfigDefaults:
def test_default_params_match_original(self):
"""关键阈值与原策略 source.py 第 91-97 行注释 + 第 105-171 行代码一致。"""
cfg = ValueSelectionConfig()
assert cfg.roe_quarters == 4
assert cfg.fcf_years == 5
assert cfg.revenue_yoy_low == 6.0
assert cfg.revenue_yoy_high == 30.0
assert cfg.revenue_yoy_quarters == 4
# ⚠️ 第 6 条: VPS 实测后改为净利润同比增长率 8~50(原代码 EPS 笔误与 L1 矛盾)
assert cfg.earnings_growth_low == 8.0
assert cfg.earnings_growth_high == 50.0
assert cfg.earnings_growth_quarters == 4