约翰·邓普顿逆向投资策略


约翰·邓普顿(John Templeton)是邓普顿集团的创始人,是全球最受尊敬的投资人和最成功的基金经理之一,被福布斯称为“全球投资之父”。 邓普顿以“逆向投资”著称。其最著名的投资案例包括在二战期间借入1万美金购买104只低价股,并在随后几年翻了三倍。邓普顿在1999年互联网泡沫期间大举做空互联网股票。 其投资策略包含了如下列出的几个要点[1]:

  1. 在数据库中选择P/B 最低的40% 个股。
  2. P/E 小于过去5年平均水平。
  3. 过去5年盈利的增长均为正。
  4. EPS 的增长率高于行业平均。
  5. 营业利润率(OPM)高于过去5年平均水平。
  6. 长期负债与权益的比值小于行业平均水平。
  7. 总资产与总负债的比值高于行业平均水平。
  8. ROE高于行业平均。

我试图在策略中包含上述所有要点,但是在回测中发现同时满足这些要求的股票非常少,于是我就挑选了其中几个并在过去10年这样一个周期中进行了回测。通过过去10年的回测可以看出,策略的年化收益率在11.88%,超额收益率133.25%,最大回测为60.23%。可以看出相对来说收益还是可以接受的。


再来看一下最近3年策略的收益。我们看到从2020年开始的最近3年策略年化收益率是11.67%,还是相对比较稳定的。超额收益75.17%,沪深300在该时间段内的收益率是-14.30%。


附上完整的聚宽源代码,读者可以自行删除或者添加因子。

# 导入函数库
import jqdata
from jqlib.technical_analysis  import *
from jqdata import *
import warnings
from datetime import date
import numpy as np

# 初始化函数
def initialize(context):
    # 滑点高(不设置滑点的话用默认的0.00246)
    set_slippage(FixedSlippage(0.02))
    # 沪深300
    set_benchmark('000300.XSHG')
    # 用真实价格交易
    set_option('use_real_price', True)
    set_option("avoid_future_data", True)
    # 过滤order中低于error级别的日志
    log.set_level('order', 'error')
    warnings.filterwarnings("ignore")

    # 选股参数
    g.stock_num = 10 # 持仓数
    g.position = 1 # 仓位
    # 手续费
    set_order_cost(OrderCost(close_tax=0.001, open_commission=0.0005, close_commission=0.0005, min_commission=5), type='stock')
    # 设置交易时间
    run_monthly(my_trade, monthday=-4, time='14:30', reference_security='000300.XSHG')


# 开盘时运行函数
def my_trade(context):
    yesterday = context.previous_date
    check_out_list = get_stock_list(context)
    log.info('今日自选股:%s' % check_out_list)
    adjust_position(context, check_out_list)


# 2-2 选股模块
def get_stock_list(context):
    # type: (Context) -> list
    curr_data = get_current_data()
    yesterday = context.previous_date
    year = yesterday.year - 1
    df_stocknum = pd.DataFrame(columns=['当前符合条件股票数量'])
    # 过滤次新股
    by_date =  yesterday - datetime.timedelta(days=365*5)  # 五年
    last_five_dates = [yesterday - datetime.timedelta(days=365), yesterday - datetime.timedelta(days=365*2),
    yesterday - datetime.timedelta(days=365*3), yesterday - datetime.timedelta(days=365*4),
    yesterday - datetime.timedelta(days=365*5)]
    initial_list = get_all_securities(date=by_date).index.tolist()
    # 过滤当日涨跌停,st,科创板,停牌
    stock_list = filter_limit_stock(context, initial_list)
    stock_list = filter_st_stock(stock_list)
    stock_list = filter_kcb_stock(stock_list)
    stock_list = filter_paused_stock(stock_list)

    # 1. 选出PB 最低的40% 个股
    q = query(
            valuation.code,
            valuation.pb_ratio
        ).filter(valuation.pb_ratio > 0,
                 valuation.code.in_(stock_list)
            ).order_by(
                valuation.pb_ratio.asc()
            )
    df = get_fundamentals(q).dropna()
    low_pb_ratio_list = list(df.code)[:int(0.4*len(list(df.code)))]

    3. 过去五年盈利增长均为正数
    df_list=[]
    for date in last_five_dates:
        df_year= get_fundamentals(query(
                 indicator.code, indicator.inc_net_profit_year_on_year
              ).filter(
                 indicator.code.in_(low_pb_ratio_list)
              ), date=date).set_index('code')
        df_list.append(df_year)
    df=pd.concat(df_list,axis=1)
    df.columns=['year1', 'year2', 'year3', 'year4', 'year5']
    #print(df)
    inc_list = df[(df['year1'] > 0) & (df['year2'] > 0) & (df['year3'] > 0) & (df['year4'] > 0)
    & (df['year5'] > 0)]
    inc_list = inc_list.index.tolist()

    # 2. P/E 小于过去五年的平均P/E. swap step 2 and 3 to save computing time
    end_d = yesterday
    start_d = end_d - datetime.timedelta(days=356*5)  # 3 years
    df_pe = get_valuation(inc_list, start_date=start_d, 
                      end_date=end_d, fields=['code', 'pe_ratio'], count=None)
    df_pe = df_pe.sort_values(by=['day'], ascending=False)
    a = df_pe[:len(inc_list)][['code', 'pe_ratio']].set_index('code')
    b = df_pe.groupby('code').mean()
    a['mean_pe'] = b['pe_ratio']
    a = a[a['pe_ratio'] > 0]
    low_pe_list = a[a['pe_ratio'] < a['mean_pe']].index.tolist()

    # 4. EPS 增长率大于行业平均水平, 这里用净利润增长率代替
    eps_list = []
    try: # some times it will give zjw keyError, so add this try except block
        for stock_code in low_pe_list:
            ind_code = get_industry(stock_code)[stock_code]['zjw']['industry_code']
            good_list = get_industry_npy_mean(ind_code, yesterday)
            if stock_code in good_list:
                eps_list.append(stock_code)
    except:
        eps_list = low_pe_list

    eps_list = low_pb_ratio_list
    # 5. OPM 高于过去五年的平均OPM, (OPM 为营业利润率)
    df_list=[]
    for date in last_five_dates:
        df_year= get_fundamentals(query(
                 indicator.code, indicator.operation_profit_to_total_revenue
              ).filter(
                 indicator.code.in_(eps_list)
              ),date=date).set_index('code')
        df_list.append(df_year)
    df=pd.concat(df_list,axis=1)
    df.columns=['year1', 'year2', 'year3', 'year4', 'year5']
    df['mean_opm'] = df.mean(axis=1)
    opm_list = df[df['year1'] > df['mean_opm']]
    opm_list = opm_list.index.tolist()

    # 6. 长期负债率和权益的比值小于行业平均水平
    non_current_liability_list = []
    try:
        for stock_code in opm_list:
            ind_code = get_industry(stock_code)[stock_code]['zjw']['industry_code']
            good_list = get_industry_non_current_liability_mean(ind_code, yesterday)
            if stock_code in good_list:
                non_current_liability_list.append(stock_code)
    except:
        non_current_liability_list = opm_list

    # 7. 负债率低于行业平均水平
    liability_list = []
    try:
        for stock_code in non_current_liability_list:
            ind_code = get_industry(stock_code)[stock_code]['zjw']['industry_code']
            good_list = get_industry_liability_mean(ind_code, yesterday)
            if stock_code in good_list:
                liability_list.append(stock_code)
    except:
        liability_list = non_current_liability_list

    # 8. ROE 高于行业平均水平
    roe_list = []
    try:
        for stock_code in liability_list:
            ind_code = get_industry(stock_code)[stock_code]['zjw']['industry_code']
            good_list = get_industry_roe_mean(ind_code, yesterday)
            if stock_code in good_list:
                roe_list.append(stock_code)
    except:
        log.info('Get roe_list error, use the previous list:%s' % liability_list)
        roe_list = liability_list
    return roe_list[:g.stock_num]


# 过滤涨跌停股票
def filter_limit_stock(context, stock_list):
    current_data = get_current_data()
    holdings = list(context.portfolio.positions)
    return [stock for stock in stock_list if (stock in holdings) or
            (current_data[stock].low_limit < current_data[stock].last_price and
            current_data[stock].last_price < current_data[stock].high_limit)]


# 过滤停牌股票
def filter_paused_stock(stock_list):
    current_data = get_current_data()
    return [stock for stock in stock_list if not current_data[stock].paused]


# 过滤ST及其他具有退市标签的股票
def filter_st_stock(stock_list):
    current_data = get_current_data()
    return [stock for stock in stock_list if not (
            current_data[stock].is_st or
            'ST' in current_data[stock].name or
            '*' in current_data[stock].name or
            '退' in current_data[stock].name)]


# 过滤科创板
def filter_kcb_stock(stock_list):
    return [stock for stock in stock_list if not stock.startswith('688')]


#过滤模块-创业板
def filter_cyb_stock(stock_list):
    return [stock for stock in stock_list  if stock[0:3] != '300']


def adjust_position(context, buy_stocks):
    for stock in context.portfolio.positions:
        if stock not in buy_stocks:
            order_target(stock, 0)

    position_count = len(context.portfolio.positions)
    if g.stock_num > position_count:
        value = context.portfolio.cash * g.position / (g.stock_num - position_count)
        for stock in buy_stocks:
            if stock not in context.portfolio.positions:
                order_target_value(stock, value)
                if len(context.portfolio.positions) == g.stock_num:
                    break


def get_industry_eps_mean(ind_code, date):
    stock_list = get_industry_stocks(ind_code)
    q = query(
        indicator.code, indicator.eps
    ).filter(
        indicator.code.in_(stock_list)
    )
    df = get_fundamentals(q, date=date).set_index('code')
    mean_eps = df['eps'].mean()
    filtered_list = df[df['eps'] > mean_eps].index.tolist()
    return filtered_list


def get_industry_npy_mean(ind_code, date):
    stock_list = get_industry_stocks(ind_code)
    q = query(
        indicator.code, indicator.inc_net_profit_year_on_year   
    ).filter(
        indicator.code.in_(stock_list)
    )
    df = get_fundamentals(q, date=date).set_index('code')
    mean_npy = df['inc_net_profit_year_on_year  '].mean()
    filtered_list = df[df['inc_net_profit_year_on_year  '] > mean_npy].index.tolist()
    return filtered_list


def get_industry_roe_mean(ind_code, date):
    stock_list = get_industry_stocks(ind_code)
    q = query(
        indicator.code, indicator.roe
    ).filter(
        indicator.code.in_(stock_list)
    )
    df = get_fundamentals(q, date=date).set_index('code')
    mean_roe = df['roe'].mean()
    filtered_list = df[df['roe'] > mean_roe].index.tolist()
    return filtered_list


def get_industry_non_current_liability_mean(ind_code, date):
    stock_list = get_industry_stocks(ind_code)
    q = query(
        balance.code, balance.total_non_current_liability, balance.total_owner_equities
    ).filter(
        balance.code.in_(stock_list)
    )
    df = get_fundamentals(q, date=date).set_index('code')
    df['ratio'] = df['total_non_current_liability'] / df['total_owner_equities']
    # df = df.sort_values(by = 'ratio',ascending=False)
    mean_liability = df['ratio'].mean()
    filtered_list = df[df['ratio'] < mean_liability].index.tolist()
    return filtered_list


def get_industry_liability_mean(ind_code, date):
    stock_list = get_industry_stocks(ind_code)
    q = query(
        balance.code, balance.total_liability, balance.total_assets
    ).filter(
        balance.code.in_(stock_list)
    )
    df = get_fundamentals(q, date=date).set_index('code')
    df['ratio'] = df['total_liability'] / df['total_assets']  # 资产负债率
    # df = df.sort_values(by = 'ratio',ascending=False)
    mean_liability = df['ratio'].mean()
    filtered_list = df[df['ratio'] < mean_liability].index.tolist()
    return filtered_list

参考文献:

[1] Chincarini, L. B. (2006). Quantitative equity portfolio management: An active approach to portfolio construction and management. McGraw-Hill.