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