feat(portfolio): 移植3聚宽策略到BulletTrade + 8bug修正 + 数据缺口文档

三策略(聚宽py2→BulletTrade 0.9.2,BrokerFacade注入跨版本兼容):
- momentum_timing 动量择时(牛熊分界+行业RPS+均线,切回10中证行业指数)
- value_selection 价值精选(6条基本面过滤,切回沪深300)
- small_cap 小市值(去IC对冲,切回000985中证全指)

框架:
- runner_backtest 加 --strategy 分发(原硬编码all_weather)
- provider 加 get_value_metrics(价值精选6条基本面,NOTICE_DATE治前视偏差)
- 72单测全过(21+27+24)

修8个回测实测发现的真bug:
- 01第⑥条EPS绝对值0.08~0.5与①大盘矛盾→6条交集恒空致全程空仓,按注释本意改净利润同比8~50%
- 03原帖calRPS取数区间错(get_price start=end只取1天)→涨跌幅恒0 RPS失效;date.today()取真实今天非回测日
- 02 universe 000985不在constituent_unified→候选池空

VPS实测(短区间验证逻辑,非长期表现): 01价值+23%/03行业轮动+48%/02选出20只小盘

数据缺口(详见docs/research/joinquant_strategies/SUMMARY.md + data_gaps_fix_plan.md):
- 三表"1/3损坏"误报已撤回(全扫5530文件/表0损坏,沪深95%+健康,仅北交所920xxx空,不做北交所)
- 真实缺口: 行业成份股(G1已补)/000985(G2已补)/IC期货(02对冲去掉)/provider批量接口(G5待做,解锁长回测)
This commit is contained in:
2026-07-28 22:20:49 +08:00
parent e4f6416765
commit de04a8904b
19 changed files with 4511 additions and 27 deletions
+9 -1
View File
@@ -33,7 +33,13 @@ if "jqdatasdk" not in _sys.modules:
from . import factors, filters
from .providers import BaostockProvider, SanguoMiniQmtProvider
from .strategies import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
from .strategies import (
AllWeatherConfig,
AllWeatherStrategy,
BrokerFacade,
MomentumTimingConfig,
MomentumTimingStrategy,
)
__all__ = [
"factors",
@@ -43,4 +49,6 @@ __all__ = [
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
"MomentumTimingStrategy",
"MomentumTimingConfig",
]
@@ -352,6 +352,192 @@ class LocalParquetProvider(DataProvider): # type: ignore[misc]
return sub.iloc[-1]
return LocalParquetProvider._latest_row_before(df, "REPORT_DATE", date_str)
# ==================== 多期已披露财报(供 ValueSelectionStrategy) ====================
@staticmethod
def _filter_published(df: pd.DataFrame, date_str: str) -> pd.DataFrame:
"""NOTICE_DATE <= date_str 已披露行, 按 NOTICE_DATE 升序。
⚠️ NOTICE_DATE 全有(2026-07-28全扫确认);REPORT_DATE兜底保留但几乎不触发。
"""
if df is None or df.empty:
return pd.DataFrame()
if "NOTICE_DATE" not in df.columns:
if "REPORT_DATE" not in df.columns:
return pd.DataFrame()
d = df.assign(_notice=pd.to_datetime(df["REPORT_DATE"], errors="coerce"))
else:
d = df.assign(_notice=pd.to_datetime(df["NOTICE_DATE"], errors="coerce"))
ts = pd.Timestamp(date_str)
sub = d[d["_notice"] <= ts].sort_values("_notice")
return sub
@staticmethod
def _latest_n_published(
df: pd.DataFrame, date_str: str, n: int,
) -> List[pd.Series]:
"""近 n 个已披露报告期(任意季报),按 NOTICE_DATE 降序(最新在前)。"""
sub = LocalParquetProvider._filter_published(df, date_str)
if sub.empty:
return []
take = min(n, len(sub))
return [sub.iloc[-(i + 1)] for i in range(take)]
@staticmethod
def _latest_n_annual(
df: pd.DataFrame, date_str: str, n: int,
) -> List[pd.Series]:
"""近 n 个已披露年报(REPORT_TYPE 含""),按 NOTICE_DATE 降序。"""
sub = LocalParquetProvider._filter_published(df, date_str)
if sub.empty:
return []
if "REPORT_TYPE" in sub.columns:
sub = sub[sub["REPORT_TYPE"].astype(str).str.contains("", na=False)]
if sub.empty:
return []
take = min(n, len(sub))
return [sub.iloc[-(i + 1)] for i in range(take)]
def get_value_metrics(
self,
stock: str,
date: Union[str, datetime],
) -> Optional[Dict[str, Any]]:
"""单股多期价值精选指标(供 ``ValueSelectionStrategy`` 调用)。
数据源(全本地 parquet, 零 online):
- valuation: 流通市值(akshare 服务端现成值, 单位元→亿元)
- balance: TOTAL_CURRENT_ASSETS / TOTAL_CURRENT_LIAB(算流动比率) /
TOTAL_PARENT_EQUITY(算 ROE)
- income: BASIC_EPS / OPERATE_INCOME_YOY / PARENT_NETPROFIT(算 ROE)
- cashflow: NETCASH_OPERATE - NETCASH_INVEST(算 FCF, 年报口径)
聚宽→东财字段映射(完整表见 ``docs/research/joinquant_strategies/01_value_selection/notes.md``):
| 聚宽字段 | 聚宽表 | 东财表 | 东财字段 |
|---------|--------|--------|---------|
| circulating_market_cap | valuation | valuation | circ_market_cap |
| total_current_assets | balance | balance | TOTAL_CURRENT_ASSETS |
| total_current_liability | balance | balance | TOTAL_CURRENT_LIAB |
| roe | indicator | income/balance | PARENT_NETPROFIT / TOTAL_PARENT_EQUITY |
| net_operate_cash_flow | cash_flow | cashflow | NETCASH_OPERATE |
| net_invest_cash_flow | cash_flow | cashflow | NETCASH_INVEST |
| inc_revenue_year_on_year | indicator | income | OPERATE_INCOME_YOY |
| net_profit_growth | indicator | income | PARENT_NETPROFIT_YOY (fallback NETPROFIT_YOY) |
前视偏差修复: 所有财报按 ``NOTICE_DATE(公告日) <= date`` 过滤(原聚宽用 REPORT_DATE
会有前视, 见 notes.md「移植记录」)。
Args:
stock: jq 风格代码 "600519.XSHG"
date: 取数日期 YYYY-MM-DD
Returns:
None(三表全空 / 完全没数据) 或 dict 含:
- circulating_market_cap: float (亿元)
- current_ratio: float (近一季流动比率, NaN if 缺)
- roe_series: List[float] (近 4 季 ROE, 小数 0.15=15%, 最新在前)
- fcf_series: List[float] (近 5 年 FCF, 元, 最新在前)
- revenue_yoy_series: List[float] (近 4 季营收同比, 百分数 18.5=18.5%)
- netprofit_yoy_series: List[float] (近 4 季归母净利润同比, 百分数 18.5=18.5%)
数据缺口(已知):
- 北交所920xxx三表空(akshare不覆盖)→返回None;沪深95%+健康(2026-07-28全扫复核,原"1/3"系误报已撤回)
- NOTICE_DATE 全有(全扫确认);兜底按REPORT_DATE逻辑保留以防万一
"""
import math
fc = jq_to_file_code(stock)
date_str = self._to_date_str(date) or datetime.now().strftime("%Y-%m-%d")
# 流通市值(近一日已披露)
val = self._latest_row_before(self._read_valuation(fc), "date", date_str)
circ_cap = float("nan")
if val is not None:
cv = _to_float(val.get("circ_market_cap"))
if cv:
circ_cap = to_yi(cv)
# 三表
balance_df = self._read_quarter("balance", fc)
income_df = self._read_quarter("income", fc)
cashflow_df = self._read_quarter("cashflow", fc)
# 三表全空 → 跳过(北交所920xxx空, akshare不覆盖)
if balance_df.empty and income_df.empty and cashflow_df.empty:
return None
# 近一季流动比率
cur_ratio = float("nan")
if not balance_df.empty:
bal_rows = self._latest_n_published(balance_df, date_str, 1)
if bal_rows:
b = bal_rows[0]
ca = _to_float(b.get("TOTAL_CURRENT_ASSETS"))
cl = _to_float(b.get("TOTAL_CURRENT_LIAB"))
if ca is not None and cl and cl != 0:
cur_ratio = ca / cl
# 近 4 季 ROE(PARENT_NETPROFIT / TOTAL_PARENT_EQUITY, 按报告期对齐)
roe_series: List[float] = []
if not income_df.empty and not balance_df.empty:
inc_rows = self._latest_n_published(income_df, date_str, 4)
bal_rows = self._latest_n_published(balance_df, date_str, 4)
for inc_row in inc_rows:
rdate = inc_row.get("REPORT_DATE")
if rdate is None:
continue
# 按报告期对齐: 找同 REPORT_DATE 的 balance 行
bal_match = next(
(b for b in bal_rows if b.get("REPORT_DATE") == rdate), None,
)
if bal_match is None:
continue
np_ = _to_float(inc_row.get("PARENT_NETPROFIT"))
eq = _to_float(bal_match.get("TOTAL_PARENT_EQUITY"))
if np_ is not None and eq and eq != 0:
roe_series.append(np_ / eq)
# 近 4 季营收同比(OPERATE_INCOME_YOY, 百分数) + 净利润同比(PARENT_NETPROFIT_YOY, 百分数)
yoy_series: List[float] = []
netprofit_yoy_series: List[float] = []
if not income_df.empty:
inc_rows = self._latest_n_published(income_df, date_str, 4)
for inc_row in inc_rows:
yoy = _to_float(inc_row.get("OPERATE_INCOME_YOY"))
if yoy is not None:
yoy_series.append(yoy)
# 归母净利润同比优先, 缺则用净利润同比 fallback
np_yoy = _to_float(inc_row.get("PARENT_NETPROFIT_YOY"))
if np_yoy is None:
np_yoy = _to_float(inc_row.get("NETPROFIT_YOY"))
if np_yoy is not None:
netprofit_yoy_series.append(np_yoy)
# 近 5 年 FCF(NETCASH_OPERATE - NETCASH_INVEST, 年报口径)
fcf_series: List[float] = []
if not cashflow_df.empty:
cf_rows = self._latest_n_annual(cashflow_df, date_str, 5)
for cf_row in cf_rows:
op = _to_float(cf_row.get("NETCASH_OPERATE"))
inv = _to_float(cf_row.get("NETCASH_INVEST"))
if op is not None and inv is not None:
fcf_series.append(op - inv)
# 完全没数据 → 跳过(北交所三表空等边缘情况)
if (math.isnan(circ_cap) and math.isnan(cur_ratio)
and not roe_series and not fcf_series
and not yoy_series and not netprofit_yoy_series):
return None
return {
"circulating_market_cap": circ_cap,
"current_ratio": cur_ratio,
"roe_series": roe_series,
"fcf_series": fcf_series,
"revenue_yoy_series": yoy_series,
"netprofit_yoy_series": netprofit_yoy_series,
}
# ==================== get_fundamentals_df ====================
def get_fundamentals_df(
self,
+98 -25
View File
@@ -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,
+14 -1
View File
@@ -1,4 +1,17 @@
"""sanguo_portfolio 策略层。"""
from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
from .momentum_timing import MomentumTimingConfig, MomentumTimingStrategy
from .small_cap import SmallCapConfig, SmallCapStrategy
from .value_selection import ValueSelectionConfig, ValueSelectionStrategy
__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"]
__all__ = [
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
"MomentumTimingStrategy",
"MomentumTimingConfig",
"SmallCapStrategy",
"SmallCapConfig",
"ValueSelectionStrategy",
"ValueSelectionConfig",
]
@@ -0,0 +1,421 @@
"""聚宽"牛熊分界+取强舍弱+均线动量"策略(post905)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/03_momentum_timing/source.py``,
这里做**结构等价 + bug 修复**翻译:
- ``initialize`` → ``MomentumTimingStrategy.initialize``
- ``calRPS`` → ``MomentumTimingStrategy._cal_rps`` (**修复取数区间**)
- ``findStockPool`` → ``MomentumTimingStrategy._find_stock_pool``
- ``selectStocks`` → ``MomentumTimingStrategy._select_stocks``
- ``calBuySign`` → ``MomentumTimingStrategy._cal_buy_sign``
- ``handle_data`` → ``MomentumTimingStrategy.handle_data`` (**修复 date.today()**)
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 all_weather 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
⚠️ 已修复原始策略的两个致命 bug(详见 notes.md「移植记录」):
1. **calRPS 取数区间错** — 原代码 ``get_price(start=curDate, end=curDate)`` 只取 1 天,
``iloc[0]==iloc[-1]``,涨跌幅恒 0,RPS 排名完全失效 → 改为 ``start=preDate, end=curDate``
取真实区间算百分比涨跌幅。
2. **date.today() 用错** — 回测里取真实今天而非回测当前日 → 改用 ``context.current_dt``。
"""
from __future__ import annotations
import datetime
import logging
from dataclasses import dataclass, field
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
# ✅ 板块选择说明(2026-07-28 G1 数据补全后切回原版):
# 原策略用 11 个中证行业指数(000928-000938)'index' 模式。此前因 constituent_unified 表
# 无行业指数成份股,降级用 9 个宽基指数替代;现 G1 已补全 000928-000937 共 10 个
# (000938 仍缺,记为遗留),恢复行业轮动原版。
# 逻辑机制(择时+取强舍弱+均线动量)不动,仅切回行业指数列表。
_DEFAULT_INDEX_LIST: List[str] = [
"000928.XSHG", # 中证能源
"000929.XSHG", # 中证材料
"000930.XSHG", # 中证工业
"000931.XSHG", # 中证可选消费
"000932.XSHG", # 中证主要消费
"000933.XSHG", # 中证医药卫生
"000934.XSHG", # 中证金融地产
"000935.XSHG", # 中证信息技术
"000936.XSHG", # 中证电信业务
"000937.XSHG", # 中证公用事业
]
@dataclass
class MomentumTimingConfig:
"""牛熊分界+取强舍弱+均线动量 策略参数(聚宽 g.* 全局变量抽出便于调参)。"""
# 板块列表(默认 10 个中证行业指数 000928-000937,G1 补全后切回原版,见模块顶部说明)
index_list: List[str] = field(default_factory=lambda: list(_DEFAULT_INDEX_LIST))
index_thre: float = 0.2 # g.indexThre:站上 past_day 日均线的行业比重阈值
past_day: int = 30 # g.pastDay:RPS + 牛熊分界回看窗口
top_k: int = 6 # g.topK:每行业 RPS top K + 最终持仓上限
benchmark: str = "000300.XSHG"
new_stock_days: int = 375 # 次新股过滤阈值
max_pool: int = 0 # 0=不限;MVP 验证用,限制 _stock_pool 返回前 N 只
ma_short: int = 5 # selectStocks 短均线窗口(原 mavg(5,'close'))
ma_long: int = 15 # selectStocks 长均线窗口(原 mavg(15,'close'))
# ------------------------ 策略 ------------------------
class MomentumTimingStrategy:
"""牛熊分界+取强舍弱+均线动量策略(纯量价,无基本面)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[MomentumTimingConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or MomentumTimingConfig()
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 定时任务:每日 9:30 触发 handle_data(原策略 handle_data 单位时间触发)
b.run_daily(self.handle_data, "9:30")
# =================== handle_data (主流程) ===================
def handle_data(self, context: Any) -> None:
"""每日调仓:牛熊分界 → 取强舍弱 → 均线动量 → 调仓下单。
⚠️ **修复原始 bug** — 用 ``context.current_dt`` 而非 ``datetime.date.today()``。
"""
cfg = self.config
cur_dt = _current_dt(context)
if cur_dt is None:
logger.warning("handle_data: context.current_dt 为 None,跳过")
return
cur_date = _to_date_str(cur_dt)
pre_date = _to_date_str(cur_dt - datetime.timedelta(days=cfg.past_day))
# 1) 牛熊分界
buy_sign = self._cal_buy_sign(cfg.index_list, cfg.past_day, cur_date)
logger.info("[%s] buy_sign=%s", cur_date, buy_sign)
positions = _get_positions(context)
if not buy_sign:
# 熊市:全部清仓(原策略语义)
logger.info("[%s] 熊市信号,清仓 %d", cur_date, len(positions))
for stock in list(positions.keys()):
self._close_position(stock)
return
# 2) 牛市:取强舍弱(每行业 RPS top_k 并集) → 候选池
candidates = self._find_stock_pool(cfg.index_list, cur_date, pre_date)
# 3) 均线动量过滤(close > MA_short > MA_long)
stocks = self._select_stocks(candidates, cur_date)
# 4) 候选过多时再按 RPS 取前 top_k (原策略 handle_data 第 171-175 行)
if len(stocks) > cfg.top_k:
rps_df = self._cal_rps(stocks, cur_date, pre_date)
stocks = list(rps_df["code"])[: cfg.top_k]
# 5) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters)
stocks = filters.filter_limitup_stock(
stocks, self.provider, positions=list(positions.keys())
)
stocks = filters.filter_limitdown_stock(
stocks, self.provider, positions=list(positions.keys())
)
stocks = filters.filter_paused_stock(stocks, self.provider)
stocks = _dedup(stocks)
# 6) 调仓:先清掉不在 stocks 的
for stock in list(positions.keys()):
if stock in stocks:
continue
self._close_position(stock)
# 7) 等额买入 stocks 里的新股(原策略 cash/countStocks 语义)
positions = _get_positions(context) # 卖出后刷新
target_num = len(stocks)
if target_num == 0:
return
cash = _available_cash(context)
if cash <= 0:
return
per_value = cash / target_num
for stock in stocks:
if stock in positions:
continue
if self._open_position(stock, per_value):
positions = _get_positions(context) # 刷新
if len(positions) >= target_num:
break
logger.info("[%s] 牛市调仓结束: target=%s", cur_date, stocks)
# =================== calRPS (修复:取 preDate~curDate 区间) ===================
def _cal_rps(
self,
stocks: List[str],
cur_date: str,
pre_date: str,
) -> pd.DataFrame:
"""计算 RPS(相对强弱)排名。
⚠️ **修复原始 bug** — 原策略 ``get_price(start=curDate, end_date=curDate)``
只取 1 天,``iloc[0]==iloc[-1]``,涨跌幅恒 0,RPS 排名完全失效 →
改为取 ``preDate ~ curDate`` 区间算**百分比涨跌幅**(更符合 RPS 语义,
原代码用绝对差值排序会偏向高价股,见 notes.md「移植记录」)。
Returns:
DataFrame[code, rps_value],按 rps_value 降序;``rps_value = 99 - 100*i/n``。
"""
n = len(stocks)
if n == 0:
return pd.DataFrame({"code": [], "rps_value": []})
try:
df = self.provider.get_price(
stocks,
start_date=pre_date,
end_date=cur_date,
frequency="daily",
fields=["close"],
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_rps get_price 失败: %s", exc)
return pd.DataFrame({"code": [], "rps_value": []})
if df is None or df.empty:
return pd.DataFrame({"code": [], "rps_value": []})
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception as exc:
logger.warning("_cal_rps pivot 失败: %s", exc)
return pd.DataFrame({"code": [], "rps_value": []})
if pivot.empty or len(pivot) < 2:
return pd.DataFrame({"code": [], "rps_value": []})
# 每只股票涨跌幅(末值/首值 - 1)
first = pivot.iloc[0]
last = pivot.iloc[-1]
with np.errstate(divide="ignore", invalid="ignore"):
returns = (last / first) - 1.0
# 过滤 NaN/Inf(数据不全或首值为 0)
valid = returns.replace([np.inf, -np.inf], np.nan).dropna()
if valid.empty:
return pd.DataFrame({"code": [], "rps_value": []})
# 降序:涨幅大的排前
sorted_codes = valid.sort_values(ascending=False).index.tolist()
m = len(sorted_codes)
rps_value = [99 - (100 * i / m) for i in range(m)]
return pd.DataFrame({"code": sorted_codes, "rps_value": rps_value})
# =================== findStockPool (取强舍弱) ===================
def _find_stock_pool(
self,
index_list: List[str],
cur_date: str,
pre_date: str,
) -> List[str]:
"""每个行业取 RPS top_k → 候选池并集。
原策略 ``findStockPool`` 第 67-82 行:逐行业 get_index_stocks → calRPS → 前 topK。
"""
cfg = self.config
out: List[str] = []
for each_index in index_list:
stocks = self._stock_pool(each_index, cur_date)
if not stocks:
continue
rps_df = self._cal_rps(stocks, cur_date, pre_date)
top = list(rps_df["code"])[: cfg.top_k]
out.extend(top)
return _dedup(out)
# =================== selectStocks (均线动量) ===================
def _select_stocks(self, stocks: List[str], cur_date: str) -> List[str]:
"""均线动量过滤:``close > MA_short`` 且 ``MA_short > MA_long``。
原策略 ``data[security].mavg(5,'close')`` (聚宽 Security.mavg),
翻译为 provider.get_price(count=ma_long) 后段求均值。
"""
cfg = self.config
if not stocks:
return []
try:
df = self.provider.get_price(
stocks,
end_date=cur_date,
frequency="daily",
fields=["close"],
count=cfg.ma_long,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_select_stocks get_price 失败: %s", exc)
return []
if df is None or df.empty:
return []
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception:
return []
if pivot.empty:
return []
out: List[str] = []
for col in pivot.columns:
series = pivot[col].dropna()
if len(series) < cfg.ma_long:
continue
close = float(series.iloc[-1])
ma_short = float(series.tail(cfg.ma_short).mean())
ma_long = float(series.tail(cfg.ma_long).mean())
if np.isnan(close) or np.isnan(ma_short) or np.isnan(ma_long):
continue
if close > ma_short and ma_short > ma_long:
out.append(col)
return out
# =================== calBuySign (牛熊分界) ===================
def _cal_buy_sign(
self,
index_list: List[str],
past_day: int,
cur_date: str,
) -> bool:
"""统计 past_day 均线上方的指数占比 > index_thre → 牛市(True)。
原策略 'index' 模式(第 110-115 行):对每个指数算 ``mavg(past_day,'close')``
与 ``mavg(1,'close')`` 比较。翻译为取 past_day 日 close(含当日),
算均值与最后一根 close 比较。
⚠️ 原代码 ``float(count)/len(indexList)`` 在 py2 是浮点除法(因 float()强转),
与 py3 一致。这里保留浮点除法语义。
"""
cfg = self.config
if not index_list:
return False
try:
df = self.provider.get_price(
index_list,
end_date=cur_date,
frequency="daily",
fields=["close"],
count=past_day,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_buy_sign get_price 失败: %s", exc)
return False
if df is None or df.empty:
return False
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception:
return False
if pivot.empty:
return False
count = 0
for col in pivot.columns:
series = pivot[col].dropna()
if len(series) < 2:
continue
ma_past = float(series.tail(past_day).mean())
cur_close = float(series.iloc[-1])
if np.isnan(ma_past) or np.isnan(cur_close):
continue
if cur_close > ma_past:
count += 1
return (count / len(index_list)) > cfg.index_thre
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, cur_date: str) -> List[str]:
"""成分股 + 过滤 ST/科创北交/次新。"""
try:
stocks = self.provider.get_index_stocks(index_symbol, cur_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks)
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
stocks = filters.filter_st_stock(stocks, self.provider)
stocks = filters.filter_new_stock(
stocks, self.provider, cur_date, self.config.new_stock_days
)
return stocks
# ======================== 日期辅助 ========================
def _to_date_str(value: Any) -> str:
"""datetime/date/str → YYYY-MM-DD str。
聚宽风格 get_price 的 start/end_date 接受 'YYYY-MM-DD' 字符串。
"""
if isinstance(value, str):
return value[:10]
try:
return value.strftime("%Y-%m-%d")
except AttributeError:
return str(value)[:10]
__all__ = ["MomentumTimingStrategy", "MomentumTimingConfig"]
+410
View File
@@ -0,0 +1,410 @@
"""聚宽"小市值20只 IC 对冲"策略(post4462)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/02_small_cap_ic_hedge/source.py``,
这里做**结构等价 + 去除对冲 + py2→py3** 翻译。
⚠️ 移植决策(详见 notes.md「移植记录」):
- **保留**选股部分:全市场市值最小 100 只(剔除创业板 300xxx / eps≤0)→
动量评分取前 20 只 → 每 5 个交易日调仓,等权持有。
- **去掉**全部对冲逻辑(BulletTrade 不支持做空/期货,数据缺 IC 行情):
- SubPortfolio 双账户分仓 / transfer_cash 资金调配
- IC 股指期货做空对冲 / beta 计算 / hedge_ratio / compute_hedge_ratio
- get_next_month_future 期货合约月度切换
- futures_margin / 保证金计算 / order_target(side='short')
- statsmodels 回归 import(原代码 import 但未实际用)
翻译对照:
- ``initialize`` → ``SmallCapStrategy.initialize``
- ``pick_stocks`` → ``SmallCapStrategy._pick_stocks`` (**py2→py3**: df.sort→sort_values)
- ``compute_signals``→ ``SmallCapStrategy.handle_data`` (**5 日计数器**替代 g.t)
- ``rebalance`` → ``SmallCapStrategy._rebalance`` (**仅保留股票部分**,
去掉期货/账户调配/保证金,等权调仓)
- ``compute_hedge_ratio`` / ``get_next_month_future`` / SubPortfolio → **删除**
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 momentum_timing / value_selection 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
_previous_date_str,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
@dataclass
class SmallCapConfig:
"""小市值 20 只轮动策略参数(聚宽 g.* 全局变量抽出便于调参)。
默认值严格对齐原策略 ``set_params`` (source.py 第 38-48 行):
- g.tc=5(调仓频率)
- g.pick_stock_count=100(备选股数)
- g.buy_stock_count=20(买入股数)
"""
# 调仓频率(交易日)
tc: int = 5
# 备选股票数量(市值最小的 N 只)
pick_stock_count: int = 100
# 最终买入股票数目
buy_stock_count: int = 20
# 动量评分窗口(原 source.py:130 日高低 + 15 日均线)
ma_window: int = 130 # 130 日最高/最低
ma_short: int = 15 # 15 日均线
# 上市天数过滤(原 source.py: > 120 天,因 63 交易日样本要求)
new_stock_days: int = 120
# 选股池:默认中证全指 000985.XSHG(5128 只,贴近原策略"全市场"意图)
# 2026-07-28 G2 补全后切回原版(此前 000985 不在 constituent_unified 降级用 932000 中证2000)。
universe: str = "000985.XSHG"
benchmark: str = "000300.XSHG"
# 0=不限;MVP 验证用,限制候选池前 N 只(避免全市场逐只查 fundamentals 过慢)
max_pool: int = 0
# ------------------------ 策略 ------------------------
class SmallCapStrategy:
"""小市值 20 只轮动策略(纯选股,无对冲)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
⚠️ **去掉的对冲部分**(详见 notes.md):
- 无 SubPortfolio 双账户(单账户股票现货)
- 无 IC 期货做空对冲(beta / hedge_ratio 全删)
- 等价于原策略"股票账户独立运行",承担完整小市值风险敞口
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[SmallCapConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or SmallCapConfig()
# 聚宽 g.* 全局变量映射到实例属性
self.day_count: int = 0 # g.t:运行天数
self.in_position_stocks: List[str] = [] # g.in_position_stocks:当前持仓名单
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 原策略 handle_data 单位时间触发 → 每日 9:30
# 5 日调仓周期由 handle_data 内部 day_count % tc == 0 控制
b.run_daily(self.handle_data, "9:30")
# =================== handle_data (主流程) ===================
def handle_data(self, context: Any) -> None:
"""每日运行:每 ``tc`` 个交易日调仓一次,其他日持仓不变。
对齐原策略 ``handle_data`` + ``compute_signals`` 语义:
- 调仓日(g.t % g.tc == 0):pick_stocks 选股 → rebalance 调仓
- 非调仓日:延续旧持仓(no-op)
"""
cfg = self.config
# 1) 判断是否调仓日(对齐原策略 g.t % g.tc == 0)
is_rebalance_day = (self.day_count % cfg.tc) == 0
logger.info(
"[day=%d] is_rebalance=%s tc=%d", self.day_count, is_rebalance_day, cfg.tc,
)
if is_rebalance_day:
# 2) 选股
new_picks = self._pick_stocks(context)
self.in_position_stocks = new_picks
logger.info(
"[day=%d] picked %d stocks: %s",
self.day_count, len(new_picks), new_picks,
)
# 3) 调仓(仅股票部分,去掉对冲)
self._rebalance(context)
# 4) 天数加一(对齐原策略 g.t += 1)
self.day_count += 1
# =================== pick_stocks (选股) ===================
def _pick_stocks(self, context: Any) -> List[str]:
"""选股:全市场市值最小 100 只 → 过滤 → 动量评分取前 20。
对齐原策略 ``pick_stocks`` (source.py 第 113-155 行):
1. query valuation + indicator 过滤 eps>0、~code.like('300%'),按 market_cap asc 取前 100
2. 过滤上市<120 天 / 停牌 / ST / 涨跌停
3. 动量评分 = (现价-130日低) + (现价-130日高) + (现价-15日均线),升序
4. 取前 buy_stock_count 只
"""
cfg = self.config
previous_date = _previous_date_str(context)
if previous_date is None:
logger.warning("pick_stocks: previous_date 为 None,返回空列表")
return []
# 1) 全市场候选池(universe 成份股)
candidates = self._stock_pool(cfg.universe, previous_date)
if not candidates:
logger.info("[%s] 候选池为空", previous_date)
return []
# 2) get_fundamentals_df 一次性取 market_cap + eps
try:
df = self.provider.get_fundamentals_df(candidates, date=previous_date)
except Exception as exc:
logger.warning("get_fundamentals_df 失败: %s", exc)
return []
if df is None or df.empty:
logger.warning("[%s] fundamentals 为空", previous_date)
return []
# 3) 过滤 eps > 0(原策略 indicator.eps > 0)
eps_col = "eps" if "eps" in df.columns else None
if eps_col is None:
logger.warning("fundamentals 缺 eps 列,跳过 eps 过滤")
eps_mask = pd.Series([True] * len(df), index=df.index)
else:
eps_mask = df[eps_col].apply(_is_valid_positive_number)
df = df[eps_mask]
# 4) 按 market_cap 升序(原策略 valuation.market_cap.asc()),取前 pick_stock_count
if "market_cap" not in df.columns:
logger.warning("fundamentals 缺 market_cap 列")
return []
df = df.sort_values("market_cap", ascending=True, na_position="last")
top_candidates = list(df.index)[: cfg.pick_stock_count]
if not top_candidates:
return []
# 5) 过滤次新股(原策略上市 > 120 天)
top_candidates = filters.filter_new_stock(
top_candidates, self.provider, previous_date, cfg.new_stock_days,
)
# 6) 过滤 ST/停牌/涨跌停(原策略 current_data 过滤)
top_candidates = filters.filter_st_stock(top_candidates, self.provider)
top_candidates = filters.filter_paused_stock(top_candidates, self.provider)
top_candidates = filters.filter_limitup_stock(
top_candidates, self.provider, positions=list(_get_positions(context).keys()),
)
top_candidates = filters.filter_limitdown_stock(
top_candidates, self.provider, positions=list(_get_positions(context).keys()),
)
top_candidates = _dedup(top_candidates)
if not top_candidates:
return []
# 7) 动量评分(130 日高低 + 15 日均线),升序
scored = self._cal_momentum_score(top_candidates, previous_date)
if scored.empty:
return []
# 8) 取前 buy_stock_count
out = list(scored.index)[: cfg.buy_stock_count]
return out
# =================== 动量评分 ===================
def _cal_momentum_score(
self, stocks: List[str], end_date: str,
) -> pd.DataFrame:
"""动量评分:score = (cur-low_130) + (cur-high_130) + (cur-ma15),升序。
对齐原策略 ``pick_stocks`` 评分逻辑(source.py 第 140-153 行):
- ``attribute_history(stock, 130, '1d', ('close','high','low'))``
- ``low_price_130 = h.low.min()``(130 日最低)
- ``high_price_130 = h.high.max()``(130 日最高)
- ``avg_15 = data[stock].mavg(15, 'close')``(15 日均线)
- ``score = (cur-low_130) + (cur-high_130) + (cur-avg_15)``
- 升序(分数越低越靠前:price 接近 130 日低 / 低于均线 → 偏底部)
py2→py3:``df.sort(columns=)`` → ``df.sort_values(by=)``。
Returns:
DataFrame(index=code, column=['score']),按 score 升序。
"""
cfg = self.config
if not stocks:
return pd.DataFrame(columns=["score"])
# 一次性取 ma_window=130 日 close/high/low(对所有候选)
try:
df = self.provider.get_price(
stocks,
end_date=end_date,
frequency="daily",
fields=["close", "high", "low"],
count=cfg.ma_window,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("_cal_momentum_score get_price 失败: %s", exc)
return pd.DataFrame(columns=["score"])
if df is None or df.empty:
return pd.DataFrame(columns=["score"])
scores: dict[str, float] = {}
for code in stocks:
sub = df[df["code"] == code] if "code" in df.columns else df
if sub is None or sub.empty:
continue
close_series = sub["close"].dropna() if "close" in sub.columns else None
high_series = sub["high"].dropna() if "high" in sub.columns else None
low_series = sub["low"].dropna() if "low" in sub.columns else None
if close_series is None or close_series.empty:
continue
cur_price = float(close_series.iloc[-1])
if not np.isfinite(cur_price):
continue
# 130 日最低 / 最高(skip_paused=True 后 dropna)
low_130 = float(low_series.min()) if low_series is not None and not low_series.empty else cur_price
high_130 = float(high_series.max()) if high_series is not None and not high_series.empty else cur_price
# 15 日均线:close 序列最后 15 根均值
ma15 = float(close_series.tail(cfg.ma_short).mean()) if len(close_series) >= 1 else cur_price
if not (np.isfinite(low_130) and np.isfinite(high_130) and np.isfinite(ma15)):
continue
score = (cur_price - low_130) + (cur_price - high_130) + (cur_price - ma15)
scores[code] = score
if not scores:
return pd.DataFrame(columns=["score"])
out = pd.DataFrame.from_dict(scores, orient="index", columns=["score"])
# 升序:分数越低越靠前(原策略 df.sort(columns='score', ascending=True))
out = out.sort_values("score", ascending=True)
return out
# =================== rebalance (调仓,仅股票部分) ===================
def _rebalance(self, context: Any) -> None:
"""调仓:卖出不在名单的 → 等额买入名单中的新股。
对齐原策略 ``rebalance`` (source.py 第 194-240 行)的**股票部分**:
- 卖出:持仓中不在 ``in_position_stocks`` 的(原策略 order_target(stock, 0, pindex=0))
- 买入:等权分配(原策略 per_value = stock_value / len(in_position_stocks))
⚠️ **去掉的对冲部分**(详见 notes.md):
- 无 transfer_cash 账户调配(单账户)
- 无 over_weight/under_weight 削高填低(简化为"全卖 + 等额买",KISS)
- 无期货空单开仓 / 月度切换合约 / 保证金计算
"""
target_stocks = list(self.in_position_stocks)
if not target_stocks:
# 名单空 → 全清(防御性,正常不会到这里)
for code in list(_get_positions(context).keys()):
self._close_position(code)
return
positions = _get_positions(context)
# 1) 卖出不在 target 的(原策略 order_target(stock, 0, pindex=0))
for code in list(positions.keys()):
if code in target_stocks:
continue
self._close_position(code)
# 2) 等额买入 target 中的新股(原策略 per_value = stock_value/len)
positions = _get_positions(context) # 刷新
target_num = len(target_stocks)
cash = _available_cash(context)
if cash <= 0 or target_num == 0:
return
per_value = cash / target_num
for code in target_stocks:
if code in positions:
continue
if self._open_position(code, per_value):
positions = _get_positions(context)
if len(positions) >= target_num:
break
logger.info(
"[day=%d] rebalance 结束: target=%d stocks", self.day_count, target_num,
)
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, previous_date: str) -> List[str]:
"""全市场候选池 = universe 成份股 + 过滤创业板/科创北交。
对齐原策略 ``~valuation.code.like('300%')`` 剔除创业板。
``filters.filter_kcbj_stock`` 会一并剔除创业板(3)、科创(68)、北交(4/8),
比原策略更严但符合"剔除非主板"意图(spec 要求)。
"""
try:
stocks = self.provider.get_index_stocks(index_symbol, previous_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks) # 剔除创业板/科创北交
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
return stocks
# ======================== 数值辅助 ========================
def _is_valid_positive_number(v: Any) -> bool:
"""判 v 是否有效正数(原策略 ``indicator.eps > 0``)。
None / NaN / Inf / 非数 / ≤0 → False。
"""
if v is None:
return False
try:
fv = float(v)
except (TypeError, ValueError):
return False
if not np.isfinite(fv):
return False
return fv > 0
__all__ = ["SmallCapStrategy", "SmallCapConfig"]
@@ -0,0 +1,430 @@
"""聚宽"穿越牛熊基业长青的价值精选"策略(post13382)翻译到 BulletTrade 框架。
聚宽源码完整保留在 ``docs/research/joinquant_strategies/01_value_selection/source.py``,
这里做**结构等价 + bug 修复 + py2→py3** 翻译:
- ``initialize`` → ``ValueSelectionStrategy.initialize``
- ``get_stock_list`` → ``ValueSelectionStrategy._get_stock_list``
- ``get_check_stocks_sort`` → **删除**(排序后不截断+全买的死代码,KISS)
- ``buy`` / ``sell`` → 调仓逻辑合入 ``monthly_adjustment``
- ``get_data`` (pd.Panel) → ``provider.get_value_metrics`` 接口替代
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),
通过两个注入点接入(照 momentum_timing/all_weather 模式):
1. ``self.provider`` → LocalUnifiedProvider / 任意满足接口的 provider
2. ``self.broker`` → ``BrokerFacade``(注入聚宽风格全局函数)
⚠️ 已修复原始策略的问题(详见 notes.md「移植记录」):
1. **pd.Panel 移除** — pandas ≥1.0 已删除 Panel API;改为约定 provider 提供
``get_value_metrics(stock, date)`` 接口返回多期指标 dict。
2. **前视偏差** — 原策略 ``get_fundamentals(statDate=quarter)`` 按报告期取数,
会用到尚未披露的数据;改用 NOTICE_DATE(公告日) <= 当前回测日 过滤。
3. **排序死代码** — ``get_check_stocks_sort`` 排序后不截断 + ``buy`` 全买 →
排序无意义;保留"全买"等额逻辑(KISS,忠实原意),删除无意义排序。
4. **第⑥条代码笔误(实测发现)** — 注释写"盈余成长率8%~50%"本是**净利润同比**语义,
但代码写了 ``(eps>0.08)&(eps<0.5)``(EPS 绝对值,笔误)。VPS 真实回测实证:
EPS 绝对值与 L1(流通市值>均值=大盘股)逻辑矛盾(大盘价值股 EPS 普遍 >0.5),
L1∩L6≈空 → 6 次调仓 final 全 0。**按注释本意修正为净利润同比增长率 8%~50**,
对应东财 income ``PARENT_NETPROFIT_YOY`` 列。
5. **冗余调用** — 原策略 ``before_market_open`` 调 ``get_stock_list`` 两次(复制粘贴),
简化为调一次。
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Any, List, Optional
import numpy as np
import pandas as pd
from .. import filters
from .all_weather import (
BrokerFacade,
_available_cash,
_current_dt,
_dedup,
_get_positions,
_previous_date_str,
)
logger = logging.getLogger(__name__)
# ------------------------ Config ------------------------
@dataclass
class ValueSelectionConfig:
"""价值精选 6 条策略参数(聚宽 g.* 全局变量抽出便于调参)。
6 条过滤阈值严格对齐原策略 source.py 第 91-97 行注释 + 第 105-171 行代码。
"""
# 第 1 条:流通市值 > 市场均值(单位:亿元,全市场比较,绝对单位不影响过滤结果)
# (无阈值,运行时算 market mean)
# 第 2 条:流动比率(流动资产/流动负债) > 市场均值
# (无阈值,运行时算 market mean)
# 第 3 条:近 4 季 ROE > 各季市场均值(取交集)
roe_quarters: int = 4
# 第 4 条:近 5 年自由现金流(经营-投资)每年为正
fcf_years: int = 5
# 第 5 条:近 4 季营收同比增长率 6%~30%
revenue_yoy_low: float = 6.0 # 百分数(原代码 >6)
revenue_yoy_high: float = 30.0 # 百分数(原代码 <30)
revenue_yoy_quarters: int = 4
# 第 6 条:近 4 季净利润同比增长率(盈余成长率)8%~50%
# ⚠️ 注释修正:原 source.py 第 96/165 行注释"盈余成长率8%~50%"本是**净利润同比**语义,
# 但代码写了 ``(eps>0.08)&(eps<0.5)``(EPS 绝对值,笔误)。
# 按 VPS 真实回测实证:EPS 绝对值 0.08~0.5 与 L1(流通市值>均值=大盘股)逻辑矛盾
# (A股大盘价值股 EPS 普遍 >0.5: 茅台50/招行5/工行0.8),L1∩L6≈空 → 6次调仓 final 全 0。
# 修正为按注释本意"净利润同比增长率8%~50%",与 L1 不矛盾(大盘股也能满足)。
earnings_growth_low: float = 8.0 # 百分数(归母净利润同比 >8%)
earnings_growth_high: float = 50.0 # 百分数(<50%)
earnings_growth_quarters: int = 4
# 其他配置
benchmark: str = "000300.XSHG"
universe: str = "000300.XSHG" # 选股池(默认沪深300,避免全市场 5000+ 股逐只读三表爆炸)
new_stock_days: int = 375 # 次新股过滤阈值
max_pool: int = 0 # 0=不限;MVP 验证用,限制候选池前 N 只
# ------------------------ 策略 ------------------------
class ValueSelectionStrategy:
"""价值精选 6 条策略(全市场横向比较 + 月度调仓)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
数据契约:
- 策略层调 ``provider.get_value_metrics(stock, current_date)`` 拿多期指标
(dict 含 circulating_market_cap / current_ratio / roe_series /
fcf_series / revenue_yoy_series / eps_series)。
- provider 层负责 NOTICE_DATE 过滤和聚宽字段→东财列名映射(详见 notes.md)。
- provider 未实现该接口 / 返回 None → 该股跳过(不入选)。
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[ValueSelectionConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or ValueSelectionConfig()
# =================== initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / 成本滑点 / 定时任务。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 每月第 5 个交易日 9:30 调仓(原策略 run_monthly before_market_open+market_open 第5日)
b.run_monthly(self.monthly_adjustment, 5, "9:30")
# =================== monthly_adjustment (主流程) ===================
def monthly_adjustment(self, context: Any) -> None:
"""每月调仓:6 条过滤 → 卖出不在名单 → 等额买入。
对齐原策略 ``before_market_open``(取名单) + ``market_open``(买卖)。
"""
cfg = self.config
# 原策略用 ``context.previous_date`` 取上一交易日数据(get_fundamentals 的 date 参数)
previous_date = _previous_date_str(context)
if previous_date is None:
logger.warning("monthly_adjustment: previous_date 为 None,跳过")
return
# 1) 候选池:universe 成份股 + 过滤 ST/科创北交/次新
candidates = self._stock_pool(cfg.universe, previous_date)
if not candidates:
logger.info("[%s] 候选池为空,跳过调仓", previous_date)
return
# 2) 6 条过滤取交集
buy_list = self._get_stock_list(candidates, previous_date)
logger.info("[%s] 6条过滤后候选:%d/%d", previous_date, len(buy_list), len(candidates))
# 3) 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters)
positions = _get_positions(context)
buy_list = filters.filter_limitup_stock(
buy_list, self.provider, positions=list(positions.keys())
)
buy_list = filters.filter_limitdown_stock(
buy_list, self.provider, positions=list(positions.keys())
)
buy_list = filters.filter_paused_stock(buy_list, self.provider)
buy_list = _dedup(buy_list)
# 4) 调仓:卖出不在 buy_list 的(原策略 sell 函数)
for stock in list(positions.keys()):
if stock in buy_list:
continue
self._close_position(stock)
# 5) 等额买入 buy_list 里的新股(原策略 buy 函数, cash/countStocks 语义)
positions = _get_positions(context) # 卖出后刷新
target_num = len(buy_list)
if target_num == 0:
return
cash = _available_cash(context)
if cash <= 0:
return
per_value = cash / target_num
for stock in buy_list:
if stock in positions:
continue
if self._open_position(stock, per_value):
positions = _get_positions(context) # 刷新
if len(positions) >= target_num:
break
logger.info("[%s] 月度调仓结束: target=%s", previous_date, buy_list)
# =================== get_stock_list (6 条过滤) ===================
def _get_stock_list(self, stocks: List[str], date_str: str) -> List[str]:
"""6 条过滤取交集(原策略 ``get_stock_list`` 翻译)。
Args:
stocks: 候选池
date_str: 取数日期(YYYY-MM-DD,通常是 context.previous_date)
Returns:
通过全部 6 条过滤的股票列表
"""
cfg = self.config
if not stocks:
return []
# 1) 取所有候选股的多期指标(provider 实现 NOTICE_DATE 过滤)
metrics: dict[str, dict[str, Any]] = {}
for stock in stocks:
m = self._load_value_metrics(stock, date_str)
if m is None:
continue
metrics[stock] = m
if not metrics:
logger.warning("[%s] 所有股票多期指标都为空,返回空列表", date_str)
return []
# 2) 第 1 条:流通市值 > 市场均值
cap_field = "circulating_market_cap"
cap_valid = {s: m for s, m in metrics.items()
if _is_valid_number(m.get(cap_field))}
if not cap_valid:
return []
cap_mean = np.mean([m[cap_field] for m in cap_valid.values()])
l1 = {s for s, m in cap_valid.items() if m[cap_field] > cap_mean}
logger.debug("[%s] L1 流通市值>均值: %d/%d (mean=%.2f亿)",
date_str, len(l1), len(metrics), cap_mean)
# 3) 第 2 条:流动比率 > 市场均值
cr_field = "current_ratio"
cr_valid = {s: m for s, m in metrics.items()
if _is_valid_number(m.get(cr_field))}
if not cr_valid:
return []
cr_mean = np.mean([m[cr_field] for m in cr_valid.values()])
l2 = {s for s, m in cr_valid.items() if m[cr_field] > cr_mean}
logger.debug("[%s] L2 流动比率>均值: %d/%d (mean=%.2f)",
date_str, len(l2), len(cr_valid), cr_mean)
# 4) 第 3 条:近 roe_quarters 季 ROE > 各季市场均值(取交集)
l3 = self._filter_per_quarter_above_market_mean(
metrics, "roe_series", cfg.roe_quarters,
)
# 5) 第 4 条:近 fcf_years 年 FCF 每年为正
l4 = self._filter_all_positive(
metrics, "fcf_series", cfg.fcf_years,
)
# 6) 第 5 条:近 revenue_yoy_quarters 季营收同比 6%~30%
l5 = self._filter_per_quarter_in_range(
metrics, "revenue_yoy_series", cfg.revenue_yoy_quarters,
cfg.revenue_yoy_low, cfg.revenue_yoy_high,
)
# 7) 第 6 条:近 earnings_growth_quarters 季净利润同比增长率 8%~50%
l6 = self._filter_per_quarter_in_range(
metrics, "netprofit_yoy_series", cfg.earnings_growth_quarters,
cfg.earnings_growth_low, cfg.earnings_growth_high,
)
out = list(l1 & l2 & l3 & l4 & l5 & l6)
logger.info(
"[%s] L1=%d L2=%d L3=%d L4=%d L5=%d L6=%d → final=%d",
date_str, len(l1), len(l2), len(l3), len(l4), len(l5), len(l6),
len(out),
)
return out
# =================== 6 条过滤 helper ===================
@staticmethod
def _filter_per_quarter_above_market_mean(
metrics: dict[str, dict[str, Any]],
field: str,
n_quarters: int,
) -> set[str]:
"""原策略第 3 条:近 n 季 field 每季都 > 市场均值的交集。
对齐 source.py 第 118-129 行 ROE 取交集逻辑。
"""
# 只保留至少 n_quarters 期数据的股票
valid = {s: list(m[field]) for s, m in metrics.items()
if isinstance(m.get(field), (list, tuple))
and len(m[field]) >= n_quarters}
if not valid:
return set()
# result 初始 = 所有 valid 股票, 然后逐季取交集
# (原策略 panel.iloc[:,i,:].filter(roe>mean).index 与之前季取交集)
result: set[str] = set(valid.keys())
for i in range(n_quarters):
# 该季所有股票的值
i_vals = {}
for s, series in valid.items():
v = series[i] if i < len(series) else None
if _is_valid_number(v):
i_vals[s] = float(v)
if not i_vals:
continue
market_mean = float(np.mean(list(i_vals.values())))
above = {s for s, v in i_vals.items() if v > market_mean}
result &= above
if not result:
break
return result
@staticmethod
def _filter_all_positive(
metrics: dict[str, dict[str, Any]],
field: str,
n_periods: int,
) -> set[str]:
"""原策略第 4 条:近 n 期 field 每期都 > 0。"""
out: set[str] = set()
for s, m in metrics.items():
series = m.get(field)
if not isinstance(series, (list, tuple)):
continue
if len(series) < n_periods:
continue
recent = series[:n_periods]
if all(_is_valid_number(v) and float(v) > 0 for v in recent):
out.add(s)
return out
@staticmethod
def _filter_per_quarter_in_range(
metrics: dict[str, dict[str, Any]],
field: str,
n_quarters: int,
low: float,
high: float,
) -> set[str]:
"""原策略第 5/6 条:近 n 季 field 每季都 ∈ [low, high](原代码严格 < high)。"""
out: set[str] = set()
for s, m in metrics.items():
series = m.get(field)
if not isinstance(series, (list, tuple)):
continue
if len(series) < n_quarters:
continue
recent = series[:n_quarters]
ok = True
for v in recent:
if not _is_valid_number(v):
ok = False
break
fv = float(v)
# 原代码 ``(x>low) & (x<high)`` 严格不等式,保留语义
if not (fv > low and fv < high):
ok = False
break
if ok:
out.add(s)
return out
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
return order is not None
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, previous_date: str) -> List[str]:
"""成份股 + 过滤 ST/科创北交/次新。"""
try:
stocks = self.provider.get_index_stocks(index_symbol, previous_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks)
if self.config.max_pool > 0:
stocks = stocks[: self.config.max_pool]
stocks = filters.filter_st_stock(stocks, self.provider)
stocks = filters.filter_new_stock(
stocks, self.provider, previous_date, self.config.new_stock_days
)
return stocks
def _load_value_metrics(
self, stock: str, date_str: str,
) -> Optional[dict[str, Any]]:
"""从 provider 取该股的多期价值精选指标。
调用 provider 的 ``get_value_metrics(stock, date_str)`` 接口(由 provider 层
实现 NOTICE_DATE 过滤和聚宽→东财字段映射)。provider 未实现该接口 / 返回
None / 异常 → 该股被跳过(不入选)。
"""
fn = getattr(self.provider, "get_value_metrics", None)
if fn is None:
return None
try:
return fn(stock, date_str)
except Exception as exc:
logger.debug("get_value_metrics(%s) 失败: %s", stock, exc)
return None
# ======================== 数值辅助 ========================
def _is_valid_number(v: Any) -> bool:
"""判 v 是否有效数(非 None / 非 NaN / 非 Inf)。"""
if v is None:
return False
try:
fv = float(v)
except (TypeError, ValueError):
return False
return not math.isnan(fv) and not math.isinf(fv)
__all__ = ["ValueSelectionStrategy", "ValueSelectionConfig"]