diff --git a/docs/research/joinquant_strategies/01_value_selection/notes.md b/docs/research/joinquant_strategies/01_value_selection/notes.md new file mode 100644 index 0000000..32286ce --- /dev/null +++ b/docs/research/joinquant_strategies/01_value_selection/notes.md @@ -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 & 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) diff --git a/docs/research/joinquant_strategies/02_small_cap_ic_hedge/notes.md b/docs/research/joinquant_strategies/02_small_cap_ic_hedge/notes.md new file mode 100644 index 0000000..0002a06 --- /dev/null +++ b/docs/research/joinquant_strategies/02_small_cap_ic_hedge/notes.md @@ -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 diff --git a/docs/research/joinquant_strategies/02_small_cap_ic_hedge/source.py b/docs/research/joinquant_strategies/02_small_cap_ic_hedge/source.py new file mode 100644 index 0000000..10bd9a4 --- /dev/null +++ b/docs/research/joinquant_strategies/02_small_cap_ic_hedge/source.py @@ -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') diff --git a/docs/research/joinquant_strategies/03_momentum_timing/notes.md b/docs/research/joinquant_strategies/03_momentum_timing/notes.md new file mode 100644 index 0000000..83608b6 --- /dev/null +++ b/docs/research/joinquant_strategies/03_momentum_timing/notes.md @@ -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 / close0.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 +``` diff --git a/docs/research/joinquant_strategies/03_momentum_timing/source.py b/docs/research/joinquant_strategies/03_momentum_timing/source.py new file mode 100644 index 0000000..be3fc20 --- /dev/null +++ b/docs/research/joinquant_strategies/03_momentum_timing/source.py @@ -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)) diff --git a/docs/research/joinquant_strategies/README.md b/docs/research/joinquant_strategies/README.md new file mode 100644 index 0000000..a4806b9 --- /dev/null +++ b/docs/research/joinquant_strategies/README.md @@ -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多需先修) | diff --git a/docs/research/joinquant_strategies/SUMMARY.md b/docs/research/joinquant_strategies/SUMMARY.md new file mode 100644 index 0000000..39176d7 --- /dev/null +++ b/docs/research/joinquant_strategies/SUMMARY.md @@ -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 --provider unified --start 2024-01-01 --end 2024-06-30 --cash 1000000" +# ∈ {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 批量优化跟进。 diff --git a/docs/research/joinquant_strategies/data_gaps_fix_plan.md b/docs/research/joinquant_strategies/data_gaps_fix_plan.md new file mode 100644 index 0000000..1e84d47 --- /dev/null +++ b/docs/research/joinquant_strategies/data_gaps_fix_plan.md @@ -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%+)—— 全部健康,报告附"已验证可用"属实。 diff --git a/sanguo_portfolio/__init__.py b/sanguo_portfolio/__init__.py index 74871dd..279528e 100644 --- a/sanguo_portfolio/__init__.py +++ b/sanguo_portfolio/__init__.py @@ -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", ] diff --git a/sanguo_portfolio/providers/local_parquet_provider.py b/sanguo_portfolio/providers/local_parquet_provider.py index 2c4a24f..9ce8b0d 100644 --- a/sanguo_portfolio/providers/local_parquet_provider.py +++ b/sanguo_portfolio/providers/local_parquet_provider.py @@ -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, diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index c032f9f..8317b8e 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -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, diff --git a/sanguo_portfolio/strategies/__init__.py b/sanguo_portfolio/strategies/__init__.py index 0f17d93..3789197 100644 --- a/sanguo_portfolio/strategies/__init__.py +++ b/sanguo_portfolio/strategies/__init__.py @@ -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", +] diff --git a/sanguo_portfolio/strategies/momentum_timing.py b/sanguo_portfolio/strategies/momentum_timing.py new file mode 100644 index 0000000..e88cfb6 --- /dev/null +++ b/sanguo_portfolio/strategies/momentum_timing.py @@ -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"] diff --git a/sanguo_portfolio/strategies/small_cap.py b/sanguo_portfolio/strategies/small_cap.py new file mode 100644 index 0000000..f17d714 --- /dev/null +++ b/sanguo_portfolio/strategies/small_cap.py @@ -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"] diff --git a/sanguo_portfolio/strategies/value_selection.py b/sanguo_portfolio/strategies/value_selection.py new file mode 100644 index 0000000..02841cd --- /dev/null +++ b/sanguo_portfolio/strategies/value_selection.py @@ -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 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"] diff --git a/tests/portfolio/test_momentum_timing.py b/tests/portfolio/test_momentum_timing.py new file mode 100644 index 0000000..10daa6e --- /dev/null +++ b/tests/portfolio/test_momentum_timing.py @@ -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) diff --git a/tests/portfolio/test_small_cap.py b/tests/portfolio/test_small_cap.py new file mode 100644 index 0000000..984816f --- /dev/null +++ b/tests/portfolio/test_small_cap.py @@ -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 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") diff --git a/tests/portfolio/test_value_selection.py b/tests/portfolio/test_value_selection.py new file mode 100644 index 0000000..8c9b6b4 --- /dev/null +++ b/tests/portfolio/test_value_selection.py @@ -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 & 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 & 才过)。""" + # 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