🚀 AI 一键生成 ptrade 策略代码

PTrade 实战:格林布拉特“神奇公式”选股策略实现

ptrade | 发布时间: 2025/12/24
以下内容由 EasyQuant 生成。

问题描述

能帮我写一个格林布拉特的神奇公式选股策略吗?

解决方案

这是一个基于格林布拉特(Greenblatt)“神奇公式”(Magic Formula)的PTrade量化选股策略。

策略原理

神奇公式的核心思想是用便宜的价格买入好公司

  1. 好公司(高资本回报率):使用 ROIC(投入资本回报率) 来衡量。ROIC越高,说明公司利用资本赚钱的能力越强。
  2. 便宜的价格(高盈利率):使用 盈利率(Earnings Yield) 来衡量。通常用 EBIT / EV,但在A股量化中,为了数据获取的稳定性,常用 1 / PE_TTM(市盈率倒数)或者 EP_TTM 作为近似替代。本策略使用 PE_TTM 从低到高排序(即盈利率从高到低)。

策略逻辑

  1. 选股范围:全市场A股,剔除ST、停牌、退市股票。
  2. 排序因子
    • 因子A:ROIC(从高到低排名)。
    • 因子B:PE_TTM(从低到高排名,且需大于0)。
  3. 综合评分:将因子A的排名和因子B的排名相加,总排名越小越好。
  4. 调仓周期:按月调仓(每月第一个交易日)。
  5. 交易执行:买入综合排名前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

代码详细解析

  1. initialize:

    • 设定了持仓数量 g.stock_num = 30
    • 使用 run_daily 配合 monthly_rebalance 函数中的月份判断逻辑,实现了月度调仓。这里设置在 09:35 执行,是为了避开开盘集合竞价的剧烈波动,且确保能取到最新的行情快照(虽然选股用的是昨日数据)。
  2. 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
  3. 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') 即可实现每周一调仓。但基本面策略通常不需要如此高频的调仓。