Files
sanguo_vnpy_v2/sanguo_portfolio/strategies/value_selection.py
T

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"]