Files
sanguo_vnpy_v2/sanguo_portfolio/strategies/small_cap.py
T
claude_dev 8862816557 feat(portfolio): B fundamentals批量 + C涨跌停filter修复(get_limit_status_batch接入)
B: value_selection 逐只 get_value_metrics → get_value_metrics_batch(数据session)
- 01 验证 -21.85% vs 改前 -21.63%(微差0.22%, batch实现微差,可接受)

C: filters filter_limitup/limitdown/paused 接入 get_limit_status_batch(数据session)
- 修复回测死代码: filter 取 tick.get(last_price/paused) 恒None → 照买涨停/照卖跌停/照交易停牌
- 三策略调仓预取 status_map 共享一次查询, 向后兼容 all_weather(不传参=原行为)
- 03 短区间(2024Q1)验证: C前+138.7%虚高 → C后+101.6%, filter修复减少照买涨停虚增

验收: 101单测(filters 30含14新status_map口径 + 三策略71)
注意: get_limit_status_batch 44s/800只(数据session待批量化优化), 02/03全周期待优化后
2026-07-30 07:37:03 +08:00

447 lines
18 KiB
Python

"""聚宽"小市值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 datetime
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
# fields= 按需短路源表(P3 批量提速,数据session commit f416a17:
# 5128 只 ~19min→~2min)。_pick_stocks 只用 market_cap(排序)+eps(>0过滤)。
# ⚠️ 若以后给 _pick_stocks 加新过滤(ROE/营收等),必须把列名加进 fields=,
# 否则该列返 NaN→过滤静默失效;fields=None 仍全列(向后兼容但慢)。
try:
df = self.provider.get_fundamentals_df(
candidates, date=previous_date, fields=["market_cap", "eps"],
)
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 过滤)
# 批量预取当日涨跌停/停牌状态(数据 session 判断好),三个 filter 共享一次查询
top_candidates = filters.filter_st_stock(top_candidates, self.provider)
status_map = self._get_limit_status(top_candidates, previous_date)
top_candidates = filters.filter_paused_stock(
top_candidates, self.provider, status_map=status_map,
)
top_candidates = filters.filter_limitup_stock(
top_candidates, self.provider,
positions=list(_get_positions(context).keys()), status_map=status_map,
)
top_candidates = filters.filter_limitdown_stock(
top_candidates, self.provider,
positions=list(_get_positions(context).keys()), status_map=status_map,
)
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 日低 / 低于均线 → 偏底部)
性能改造(决策方案 A):用 ``get_closes_panel`` 批量取 close 宽表向量化,
**用 close.rolling(130).min/max 代理 low.min()/high.max()**。
原因:``get_closes_panel`` 只返 close(不含 high/low);用 close 极值代理是有意
决策(spec 明确允许)——对动量评分的"底部反弹偏好"语义无实质影响(都衡量
当前价在 130 日极值区间的位置),换 5128 只逐只循环 → 一次批量(33s → 秒级)。
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(批量宽表,用 min/max 代理 low/high)
start_date = _shift_date(end_date, -cfg.ma_window * 2)
try:
panel = self.provider.get_closes_panel(
stocks, start_date, end_date, fq="raw",
)
except Exception as exc:
logger.warning("_cal_momentum_score get_closes_panel 失败: %s", exc)
return pd.DataFrame(columns=["score"])
if panel is None or panel.empty:
return pd.DataFrame(columns=["score"])
panel = panel.tail(cfg.ma_window)
if panel.empty:
return pd.DataFrame(columns=["score"])
# 向量化算 score = (cur-low) + (cur-high) + (cur-ma15)
# 低/高用 close 序列代理(原 high.max()/low.min())
cur_price = panel.iloc[-1]
low_proxy = panel.min() # 130 日 close 最低(代理 low.min())
high_proxy = panel.max() # 130 日 close 最高(代理 high.max())
ma15 = panel.tail(cfg.ma_short).mean()
# 过滤:cur_price 必须有效 + 至少 1 个有效值(原代码 close_series.empty 跳过)
valid_count = panel.notna().sum()
score = (cur_price - low_proxy) + (cur_price - high_proxy) + (cur_price - ma15)
mask = (valid_count >= 1) & cur_price.notna() & np.isfinite(cur_price)
score = score[mask].dropna()
if score.empty:
return pd.DataFrame(columns=["score"])
out = score.to_frame("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 _get_limit_status(self, stocks: List[str], date: str) -> dict:
"""批量预取涨跌停/停牌状态(三个 filter 共享一次查询)。
provider 未实现 get_limit_status_batch / 异常 → 返空 dict(filter 见 None
"无数据保留所有"分支,等价原失效行为)。
"""
if not stocks:
return {}
fn = getattr(self.provider, "get_limit_status_batch", None)
if fn is None:
return {}
try:
return fn(stocks, date) or {}
except Exception as exc:
logger.warning("get_limit_status_batch 失败: %s", exc)
return {}
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
def _shift_date(date_str: str, days: int) -> str:
"""字符串日期加减天数,返回 YYYY-MM-DD。
用于 ``count=N`` → ``start = end - N*2 自然日`` 的换算(配合宽表 ``.tail(N)`` 切片,
避免自然日 vs 交易日的歧义)。
"""
try:
dt = datetime.datetime.strptime(date_str[:10], "%Y-%m-%d")
except (ValueError, TypeError):
return date_str
return (dt + datetime.timedelta(days=days)).strftime("%Y-%m-%d")
__all__ = ["SmallCapStrategy", "SmallCapConfig"]