459 lines
19 KiB
Python
459 lines
19 KiB
Python
"""聚宽"穿越牛熊基业长青的价值精选"策略(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)
|
|
# 批量预取当日涨跌停/停牌状态(数据 session 判断好),三个 filter 共享一次查询
|
|
positions = _get_positions(context)
|
|
status_map = self._get_limit_status(buy_list, previous_date)
|
|
buy_list = filters.filter_limitup_stock(
|
|
buy_list, self.provider,
|
|
positions=list(positions.keys()), status_map=status_map,
|
|
)
|
|
buy_list = filters.filter_limitdown_stock(
|
|
buy_list, self.provider,
|
|
positions=list(positions.keys()), status_map=status_map,
|
|
)
|
|
buy_list = filters.filter_paused_stock(
|
|
buy_list, self.provider, status_map=status_map,
|
|
)
|
|
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 过滤)
|
|
# 批量一次取(provider 层 ThreadPool 并发),等价于逐只 get_value_metrics 但快 5~8x
|
|
raw_map = self._load_value_metrics_batch(stocks, date_str)
|
|
metrics: dict[str, dict[str, Any]] = {
|
|
s: m for s, m in raw_map.items() if m is not None
|
|
}
|
|
|
|
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,
|
|
)
|
|
|
|
# sorted 定序:set 迭代顺序受 PYTHONHASHSEED 每进程随机化 → 跨进程
|
|
# 回测 trades/stocks_selected 乱序(2026-08-15 TET Phase2 老vs老对照实锤);
|
|
# sorted 恢复可复现性,选股集合与顺序无关的下游语义不变
|
|
out = sorted(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 _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]:
|
|
"""成份股 + 过滤 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_batch(
|
|
self, stocks: List[str], date_str: str,
|
|
) -> dict[str, Optional[dict[str, Any]]]:
|
|
"""从 provider 批量取多期价值精选指标(ThreadPool 并发提速)。
|
|
|
|
调用 provider 的 ``get_value_metrics_batch(stocks, date_str)`` 接口
|
|
(provider 层 ThreadPool 并发逐只 get_value_metrics,NOTICE_DATE 过滤和
|
|
聚宽→东财字段映射不变)。provider 未实现该接口 / 整批异常 → 返回空 dict
|
|
(等价全部跳过)。单只异常由 provider 内部吞为 {stock: None}。
|
|
|
|
纯性能改造:与原逐只 ``_load_value_metrics`` 口径完全一致。
|
|
"""
|
|
fn = getattr(self.provider, "get_value_metrics_batch", None)
|
|
if fn is None:
|
|
return {}
|
|
try:
|
|
return fn(stocks, date_str)
|
|
except Exception as exc:
|
|
logger.debug("get_value_metrics_batch 失败: %s", exc)
|
|
return {}
|
|
|
|
|
|
# ======================== 数值辅助 ========================
|
|
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"]
|