8862816557
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全周期待优化后
447 lines
18 KiB
Python
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"]
|