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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user