Files
sanguo_vnpy_v2/sanguo_portfolio/strategies/small_cap.py
T
claude_dev ea1d41b04c
CI/CD / test (push) Successful in 2s
CI/CD / nas-deploy (push) Successful in 10s
CI/CD / nas-verify (push) Successful in 10s
fix(live): day_count隔日恢复+1双计修正(落盘值已含自增,恢复直取saved_count) [vps]
2026-08-30 11:54:03 +08:00

495 lines
21 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
import time
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,
_load_day_state,
_save_day_state,
_total_value,
_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:当前持仓名单
# day_count 跨重启持久化恢复(2026-08-28:08-24~08-28 连续 5 交易日
# day=0 全量调仓根治——重启不再归零,tc=5 周期跨重启保持)。回测无
# 实例 env → _load_day_state 返回 (0,"") 等价旧内存语义。
# 落盘值已是上个运行日 handle_data 自增后的终值,恢复直接取
# saved_count:今日 day_count 由今日 handle_data 再 +1,隔日恢复
# 不额外 +1(否则当日双计,tc=5 周期被重启压缩)。
saved_count, saved_date = _load_day_state("small_cap")
if saved_date:
self.day_count = saved_count
logger.info(
"[day-restore] day_count=%d (saved=%d @%s)",
self.day_count, saved_count, saved_date)
# =================== 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) 选股(None=数据失败跳过,[]/list=正常语义,见 _pick_stocks docstring)
new_picks = self._pick_stocks(context)
if new_picks is None:
# 数据失败 ≠ 空名单(2026-08-19 假熊市清仓事故同型):
# 跳过本次调仓保住持仓;合法空名单仍走 _rebalance 清仓 = 原语义
logger.warning(
"[day=%d] 选股数据失败,跳过本次调仓(持仓不动,不清仓)",
self.day_count,
)
else:
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)+ 落盘(live/影子跨重启保持周期)
self.day_count += 1
_save_day_state("small_cap", self.day_count)
# =================== pick_stocks (选股) ===================
def _pick_stocks(self, context: Any) -> Optional[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 只
⚠️ 返回值三态(2026-08-19 假熊市清仓事故同型修复):
- ``None`` = 数据取数失败 → handle_data 跳过本次调仓(持仓不动);
- ``[]`` = 合法空名单(过滤后真空)→ 清仓 = 原策略语义;
- 非空 list = 正常目标。
"""
cfg = self.config
previous_date = _previous_date_str(context)
if previous_date is None:
logger.warning("pick_stocks: previous_date 为 None,跳过本次选股")
return None
# 阶段耗时日志(2026-08-21 实盘选股挂死定位用)
_t0 = time.monotonic()
# 1) 全市场候选池(universe 成份股)
candidates = self._stock_pool(cfg.universe, previous_date)
logger.info("[%s] 选股1/3 候选池完成: %d%.1fs",
previous_date, len(candidates or []), time.monotonic() - _t0)
if candidates is None:
return None
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 None
if df is None or df.empty:
logger.warning("[%s] fundamentals 为空", previous_date)
return None
# 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 None
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 []
logger.info("[%s] 选股2/3 fundamentals 完成: top %d%.1fs",
previous_date, len(top_candidates), time.monotonic() - _t0)
# 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, self.broker).keys()), status_map=status_map,
)
top_candidates = filters.filter_limitdown_stock(
top_candidates, self.provider,
positions=list(_get_positions(context, self.broker).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 is None:
return None
if scored.empty:
return []
# 8) 取前 buy_stock_count
out = list(scored.index)[: cfg.buy_stock_count]
logger.info("[%s] 选股3/3 动量评分完成: %d%.1fs",
previous_date, len(out), time.monotonic() - _t0)
return out
# =================== 动量评分 ===================
def _cal_momentum_score(
self, stocks: List[str], end_date: str,
) -> Optional[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 升序;
``None`` = 取数失败/查无数据(调用方跳过本次调仓,不退化成空名单清仓)。
"""
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 None
if panel is None or panel.empty:
return None
panel = panel.tail(cfg.ma_window)
if panel.empty:
return None
# 向量化算 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 None
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, self.broker).keys()):
self._close_position(code)
return
positions = _get_positions(context, self.broker)
# 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, self.broker) # 刷新
target_num = len(target_stocks)
total = _total_value(context)
if total <= 0 or target_num == 0:
return
per_value = total / target_num
for code in target_stocks:
if code in positions:
continue
if self._open_position(code, per_value):
positions = _get_positions(context, self.broker)
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 要求)。
Returns: ``None`` = 取数失败(调用方跳过本次调仓);``[]`` = 合法空池。
"""
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 None
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"]