问题描述
解决方案
这是一个基于格林布拉特(Greenblatt)“神奇公式”(Magic Formula)的PTrade量化选股策略。
策略原理
神奇公式的核心思想是用便宜的价格买入好公司。
- 好公司(高资本回报率):使用 ROIC(投入资本回报率) 来衡量。ROIC越高,说明公司利用资本赚钱的能力越强。
- 便宜的价格(高盈利率):使用 盈利率(Earnings Yield) 来衡量。通常用 EBIT / EV,但在A股量化中,为了数据获取的稳定性,常用 1 / PE_TTM(市盈率倒数)或者 EP_TTM 作为近似替代。本策略使用
PE_TTM从低到高排序(即盈利率从高到低)。
策略逻辑
- 选股范围:全市场A股,剔除ST、停牌、退市股票。
- 排序因子:
- 因子A:ROIC(从高到低排名)。
- 因子B:PE_TTM(从低到高排名,且需大于0)。
- 综合评分:将因子A的排名和因子B的排名相加,总排名越小越好。
- 调仓周期:按月调仓(每月第一个交易日)。
- 交易执行:买入综合排名前N只股票,等权重持有。
PTrade 策略代码
import pandas as pd
import numpy as np
def initialize(context):
"""
初始化函数,设置策略参数
"""
# 设置持仓数量
g.stock_num = 30
# 设定按月运行,每月第一个交易日进行调仓
run_daily(context, monthly_rebalance, time='09:35')
# 记录上一次调仓的月份,用于控制调仓频率
g.last_month = 0
def monthly_rebalance(context):
"""
月度调仓主函数
"""
# 获取当前回测日期的月份
current_month = context.blotter.current_dt.month
# 如果当前月份与上一次调仓月份相同,则跳过(确保每月只调一次)
if current_month == g.last_month:
return
# 更新调仓月份
g.last_month = current_month
log.info("开始执行神奇公式选股调仓...")
# 1. 获取股票池:全A股
# 注意:get_Ashares返回的是list
all_stocks = get_Ashares()
# 2. 过滤掉ST、停牌、退市的股票
# filter_stock_by_status 默认过滤 ST, HALT, DELISTING
target_list = filter_stock_by_status(all_stocks)
if not target_list:
log.warning("当前市场无符合条件的股票")
return
# 3. 获取财务数据
# 我们需要获取 PE_TTM (用于计算盈利率) 和 ROIC (用于计算资本回报率)
# get_trading_day(-1) 获取前一个交易日,避免未来函数
yesterday = get_trading_day(-1)
date_str = yesterday.strftime('%Y%m%d')
# 分批获取数据以防超时或数据量过大,这里演示直接获取,如果股票过多建议分批
# 获取估值数据:PE_TTM
df_val = get_fundamentals(target_list, 'valuation', ['pe_ttm', 'total_value'], date=date_str)
# 获取盈利能力数据:ROIC
df_prof = get_fundamentals(target_list, 'profit_ability', ['roic'], date=date_str)
# 如果数据获取失败,则退出
if df_val is None or df_prof is None:
log.warning("财务数据获取失败")
return
# 4. 数据处理与合并
# 将两个DataFrame合并,索引通常是股票代码
df = pd.concat([df_val, df_prof], axis=1, join='inner')
# 剔除数据缺失的行
df = df.dropna()
# 剔除亏损股(PE_TTM <= 0)
df = df[df['pe_ttm'] > 0]
# 5. 计算排名
# 因子1:ROIC 从高到低排名 (ascending=False)
df['rank_roic'] = df['roic'].rank(ascending=False)
# 因子2:PE_TTM 从低到高排名 (ascending=True),即盈利率从高到低
df['rank_pe'] = df['pe_ttm'].rank(ascending=True)
# 综合排名:两个排名相加
df['total_score'] = df['rank_roic'] + df['rank_pe']
# 按综合排名从小到大排序
df = df.sort_values(by='total_score', ascending=True)
# 取前N只股票
buy_list = df.index[:g.stock_num].tolist()
log.info("本期选中股票数量: %d" % len(buy_list))
log.info("选股列表: %s" % buy_list)
# 6. 执行交易
adjust_position(context, buy_list)
def adjust_position(context, buy_list):
"""
交易执行函数
"""
# 获取当前持仓
current_positions = list(context.portfolio.positions.keys())
# 卖出不在买入列表中的股票
for stock in current_positions:
if stock not in buy_list:
order_target_value(stock, 0)
log.info("卖出: %s" % stock)
# 买入/调整在买入列表中的股票
# 采用等权重买入
if len(buy_list) > 0:
# 计算每只股票的目标市值 = 当前总资产 / 计划持仓数
target_value = context.portfolio.portfolio_value / len(buy_list)
for stock in buy_list:
order_target_value(stock, target_value)
# log.info("买入/调整: %s, 目标市值: %.2f" % (stock, target_value))
def handle_data(context, data):
"""
盘中运行函数,本策略为日线级选股,此处留空即可
"""
pass
代码详细解析
-
initialize:- 设定了持仓数量
g.stock_num = 30。 - 使用
run_daily配合monthly_rebalance函数中的月份判断逻辑,实现了月度调仓。这里设置在09:35执行,是为了避开开盘集合竞价的剧烈波动,且确保能取到最新的行情快照(虽然选股用的是昨日数据)。
- 设定了持仓数量
-
monthly_rebalance:- 股票池过滤:使用
get_Ashares()获取全市场代码,然后通过filter_stock_by_status剔除 ST、停牌和退市股票,这是实盘中非常重要的一步。 - 数据获取:
- 使用
get_fundamentals获取财务数据。 valuation表中的pe_ttm(滚动市盈率)用来代表估值。profit_ability表中的roic(投入资本回报率)用来代表公司质量。
- 使用
- 数据清洗:剔除了
pe_ttm <= 0的亏损公司,因为神奇公式通常只针对有盈利的企业。 - 排名算法:
- 利用 Pandas 的
rank函数。 - ROIC 越高越好,所以
ascending=False。 - PE 越低越好,所以
ascending=True。 - 两者排名相加得到
total_score。
- 利用 Pandas 的
- 股票池过滤:使用
-
adjust_position:- 卖出逻辑:遍历当前持仓,如果持仓股票不在新的
buy_list中,直接清仓(order_target_value(stock, 0))。 - 买入逻辑:计算每只股票的理论持仓市值(总资产 / 股票数量),使用
order_target_value自动调整仓位。如果已持有且上涨了,会卖出部分止盈;如果未持有,会买入。
- 卖出逻辑:遍历当前持仓,如果持仓股票不在新的
注意事项
- 回测速度:由于涉及全市场几千只股票的财务数据查询和排序,回测速度可能会稍慢。
- 数据滞后:策略中使用了
get_trading_day(-1)获取前一交易日的数据进行选股,这是为了避免“未来函数”(即在回测时用到了当时还未发布的数据)。 - 行业中性:原始的神奇公式建议剔除金融股(银行、保险)和公用事业股,因为它们的财务报表结构与其他行业差异较大。上述代码为了保持简洁未做行业剔除,如果需要,可以使用
get_industry_stocks获取特定行业代码并在target_list中剔除。
Q&A
Q: 为什么使用 PE_TTM 而不是 EBIT/EV?
A: 原版神奇公式使用 EBIT/EV(息税前利润/企业价值)。在A股数据中,直接获取准确的 EV(企业价值)需要计算市值+净债务,处理起来比较复杂且容易出错。PE_TTM(滚动市盈率)是盈利率(Earnings Yield)的倒数,在数学逻辑上是等价的(寻找低PE即寻找高盈利率),且 PTrade 直接提供了该字段,数据更稳定。
Q: 如何剔除银行和非银金融行业?
A: 可以在 monthly_rebalance 中添加如下逻辑:
# 获取金融行业股票列表 (证监会行业代码 J)
financial_stocks = get_industry_stocks('J00000.XBHS')
# 在 target_list 中剔除
target_list = [s for s in target_list if s not in financial_stocks]
Q: 策略可以改为周度调仓吗?
A: 可以。将 monthly_rebalance 中的月份判断逻辑去掉,直接在 initialize 中使用 run_weekly(context, weekly_rebalance, weekday=1, time='09:35') 即可实现每周一调仓。但基本面策略通常不需要如此高频的调仓。