Quant Buffet放轻松,别过度思虑

经典股票异常与趋势跟随过滤器相结合

登录后收藏

学术论文

Anomalies Enhanced: A Portfolio Rebalancing Approach

作者Yufeng Han

机构
  • ?Dayong Huang 和 Guofu Zhou. 北卡罗来纳大学夏洛特分校 - 金融学系。北卡罗来纳大学格林斯伯勒分校 - 布莱恩商学院与经济学院。圣路易斯华盛顿大学 - 约翰·M·奥林商学院。
论文摘要

许多异常现象是基于公司特征,并且每年重新平衡一次,忽略了年度中的任何信息。在本文中,我们提供了动态交易策略,每月重新平衡异常投资组合。对于八个主要异常现象,我们发现这些动态交易策略显著提升了它们的经济重要性,在Fama和French(2015)五因素风险调整异常收益的改善幅度在每月0.40%到0.75%之间。结果对多种控制变量具有稳健性。我们的研究结果表明,许多著名的异常现象比之前认为的更有利可图,为它们的理论解释带来了新的挑战。

策略概要

策略每年6月基于前一个财政年度的八个会计变量对所有股票进行排名,并将其分配到十个分位(Deciles)。创建等权重投资组合,设立一个扩展投资组合,在平均回报最高的分位(Decile High)上建立多头仓位,在最低回报的分位(Decile Low)上建立空头仓位。投资组合每年6月重新平衡。每月,投资者计算每只股票的50日和200日移动平均线(MA50和MA200)。如果Decile High的股票MA50低于MA200,则将其剔除;如果Decile Low的股票MA50高于MA200,则将其剔除,从而精细化投资组合。

策略合理性

许多股票异常的一个共同特征是它们基于低频信息形成,这意味着投资组合通常每年进行一次重新平衡。然而,股票价格数据是以更高频率提供的,投资者可以利用这些信息来更新他基于低频信息形成的投资组合观点。

回测表现

波动率15.48%
夏普比率1.05
索提诺比率-0.008
胜率43%

完整 Python 代码

import numpy as np
from AlgorithmImports import *
from functools import reduce
from numpy import isnan
from pandas.core.frame import DataFrame
class ClassicalEquityAnomaliesCombinedwithTrendfollowingFilter(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.financial_statement_names:List[str] = [
    'ValuationRatios.PBRatio',
    'FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths',
    'FinancialStatements.IncomeStatement.TotalOperatingIncomeAsReported.TwelveMonths',
    'OperationRatios.TotalAssetsGrowth.OneYear',
    'FinancialStatements.CashFlowStatement.CapitalExpenditure.TwelveMonths',
    'EarningReports.BasicAverageShares.TwelveMonths',
    # accruals data
    'FinancialStatements.BalanceSheet.CurrentAssets.TwelveMonths',
    'FinancialStatements.BalanceSheet.CashAndCashEquivalents.TwelveMonths',
    'FinancialStatements.BalanceSheet.CurrentLiabilities.TwelveMonths',
    'FinancialStatements.BalanceSheet.CurrentDebt.TwelveMonths',
    'FinancialStatements.BalanceSheet.IncomeTaxPayable.TwelveMonths',
    'FinancialStatements.IncomeStatement.DepreciationAndAmortization.TwelveMonths',
    'FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths'
]
# Daily sma data.
self.data:Dict[Symbol, SymbolData] = {}
self.short_period:int = 50
self.long_period:int = 200
self.performance_period:int = 12*21
self.quantile:int = 10
self.leverage:int = 5
self.long:List[Symbol] = []
self.short:List[Symbol] = []
self.fundamental_count:int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.BeforeMarketClose(market), self.Selection)
self.settings.daily_precise_end_time = False
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)
    
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Update SMA every day.
for stock in fundamental:
    symbol:Symbol = stock.Symbol
    # Store monthly price.
    if symbol in self.data:
        self.data[symbol].update(self.Time, stock.AdjustedPrice)
if not self.selection_flag:
    return Universe.Unchanged
selected:List[Fundamental] = [
    x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and \
    all((not isnan(self.rgetattr(x, statement_name)) and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names)
]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
# Ranked stocks.
rank = {}
current_accruals_data = {}
acc = {}
momentum = {}
net_stock_issue = {}
capex = {}
noa = {}
selected_stocks:List[Fundamental] = []
# Warmup price rolling windows.
for stock in selected:
    symbol:Symbol = stock.Symbol
    if symbol not in self.data:
        self.data[symbol] = SymbolData(self.short_period, self.long_period, self.performance_period)
        history:DataFrame = self.History(symbol, self.performance_period, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet.")
            continue
        
        closes:pd.Series = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].update(time, close)
    
    if self.data[symbol].is_ready():
        selected_stocks.append(stock)
        rank[symbol] = 0
        momentum[symbol] = self.data[symbol].performance()
    
        # Accural calc
        current_accruals_data = AccrualsData(stock.FinancialStatements.BalanceSheet.CurrentAssets.TwelveMonths, stock.FinancialStatements.BalanceSheet.CashAndCashEquivalents.TwelveMonths,
                                                    stock.FinancialStatements.BalanceSheet.CurrentLiabilities.TwelveMonths, stock.FinancialStatements.BalanceSheet.CurrentDebt.TwelveMonths, stock.FinancialStatements.BalanceSheet.IncomeTaxPayable.TwelveMonths,
                                                    stock.FinancialStatements.IncomeStatement.DepreciationAndAmortization.TwelveMonths, stock.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths)
        if self.data[symbol].accruals_data:
            acc[symbol] = self.CalculateAccruals(current_accruals_data, self.data[symbol].accruals_data)
    
            # Calculate NOA.
            noa[symbol] = (stock.FinancialStatements.BalanceSheet.CurrentAssets.TwelveMonths - stock.FinancialStatements.BalanceSheet.CurrentLiabilities.TwelveMonths) / self.data[symbol].accruals_data.TotalAssets
        
        # Calculate stock stock issue.
        num_of_shares = stock.EarningReports.BasicAverageShares.TwelveMonths
        if self.data[symbol].shares_outstanding:
            net_stock_issue[symbol] = num_of_shares / self.data[symbol].shares_outstanding - 1
        self.data[symbol].shares_outstanding = num_of_shares
        
        # Calculate stock capital expenditure.
        cap_expenditures = stock.FinancialStatements.CashFlowStatement.CapitalExpenditure.TwelveMonths
        if self.data[symbol].capex:
            capex[symbol] = cap_expenditures / self.data[symbol].capex - 1
        self.data[symbol].capex = cap_expenditures
        
        # Update accruals data for next year's calculation.
        self.data[symbol].accruals_data = current_accruals_data            
    
# Ensure that consecutive accruals and shares data are available.
for symbol in self.data:
    if symbol not in list(map(lambda x: x.Symbol, selected_stocks)):
        self.data[symbol].accruals_data = None
        self.data[symbol].shares_outstanding = None
        self.data[symbol].capex = None

# Sort by book to market.
sorted_by_bm = sorted(selected_stocks, key=lambda x: 1 / x.ValuationRatios.PBRatio)
for index, stock in enumerate(sorted_by_bm):
    rank[stock.Symbol] += (index+1)

# Sort by gross profit.
sorted_by_gp = sorted(selected_stocks, key=lambda x: x.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths / x.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths)
for index, stock in enumerate(sorted_by_gp):
    rank[stock.Symbol] += (index+1)
# Sort by operating profit.
sorted_by_op = sorted(selected_stocks, key=lambda x: x.FinancialStatements.IncomeStatement.TotalOperatingIncomeAsReported.TwelveMonths)
for index, stock in enumerate(sorted_by_op):
    rank[stock.Symbol] += (index+1)
# Sort by asset growth.
sorted_by_ag = sorted(selected_stocks, key=lambda x: x.OperationRatios.TotalAssetsGrowth.OneYear, reverse = True)
for index, stock in enumerate(sorted_by_ag):
    rank[stock.Symbol] += (index+1)
# Sort by investments.
sorted_by_inv = sorted(capex.items(), key=lambda x: x[1], reverse = True)
for index, symbol_si in enumerate(sorted_by_inv):
    rank[symbol_si[0]] += (index+1)
    
# Sort by net stock issue.
sorted_by_si = sorted(net_stock_issue.items(), key=lambda x: x[1], reverse = True)
for index, symbol_si in enumerate(sorted_by_si):
    rank[symbol_si[0]] += (index+1)
# Sort by accruals.
sorted_by_acc = sorted(acc.items(), key=lambda x: x[1])
for index, symbol_acc in enumerate(sorted_by_acc):
    rank[symbol_acc[0]] += (index+1)
# Sort by NOA.
sorted_by_noa = sorted(noa.items(), key=lambda x: x[1], reverse = True)
for index, symbol_noa in enumerate(sorted_by_noa):
    rank[symbol_noa[0]] += (index+1)

if len(rank) > self.quantile:
    # Rank sorting.
    sorted_by_rank:List = sorted(rank.items(), key=lambda x: x[1], reverse = True)
    quantile:int = int(len(sorted_by_rank) / self.quantile)
    top_decile:List[Symbol] = [x[0] for x in sorted_by_rank[:quantile]]
    bottom_decile:List[Symbol] = [x[0] for x in sorted_by_rank[-quantile:]]
    
    top_decile_perf:float = np.mean([momentum[x] for x in top_decile])
    bottom_decile_perf:float = np.mean([momentum[x] for x in bottom_decile])
    
    if top_decile_perf > bottom_decile_perf:
        self.long = top_decile
        self.short = bottom_decile
    else:
        self.long = bottom_decile
        self.short = top_decile

return self.long + self.short
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
    return
self.selection_flag = False
# order execution
targets:List[PortfolioTarget] = []
for i, portfolio in enumerate([self.long, self.short]):
    for symbol in portfolio:
        if symbol in data and data[symbol]:
            targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))

self.SetHoldings(targets, True)
self.long.clear()
self.short.clear()

def Selection(self) -> None:
# Drop stocks according to trend filter.
symbols_invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]

for symbol in symbols_invested:
    if self.Portfolio[symbol].IsLong:
        if not self.data[symbol].is_in_uptrend():
            self.Liquidate(symbol)
    
    elif self.Portfolio[symbol].IsShort:
        if self.data[symbol].is_in_uptrend():
            self.Liquidate(symbol)

if self.Time.month == 6:
    self.selection_flag = True

def CalculateAccruals(self, current_accrual_data, prev_accrual_data) -> float:
delta_assets = current_accrual_data.CurrentAssets - prev_accrual_data.CurrentAssets
delta_cash = current_accrual_data.CashAndCashEquivalents - prev_accrual_data.CashAndCashEquivalents
delta_liabilities = current_accrual_data.CurrentLiabilities - prev_accrual_data.CurrentLiabilities
delta_debt = current_accrual_data.CurrentDebt - prev_accrual_data.CurrentDebt
delta_tax = current_accrual_data.IncomeTaxPayable - prev_accrual_data.IncomeTaxPayable
dep = current_accrual_data.DepreciationAndAmortization
avg_total = (current_accrual_data.TotalAssets + prev_accrual_data.TotalAssets) / 2

bs_acc = ((delta_assets - delta_cash) - (delta_liabilities - delta_debt-delta_tax) - dep) / avg_total
return bs_acc
# https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
def rgetattr(self, obj, attr, *args):
def _getattr(obj, attr):
    return getattr(obj, attr, *args)
return reduce(_getattr, [obj] + attr.split('.'))

class AccrualsData():
def __init__(self, current_assets, cash_and_cash_equivalents, current_liabilities, current_debt, income_tax_payable, depreciation_and_amortization, total_assets):
self.CurrentAssets = current_assets
self.CashAndCashEquivalents = cash_and_cash_equivalents
self.CurrentLiabilities = current_liabilities
self.CurrentDebt = current_debt
self.IncomeTaxPayable = income_tax_payable
self.DepreciationAndAmortization = depreciation_and_amortization
self.TotalAssets = total_assets

class SymbolData():
def __init__(self, short_sma_period, long_sma_period, performance_period):
self.price = RollingWindow[float](performance_period)
self.short_sma = SimpleMovingAverage(short_sma_period)
self.long_sma = SimpleMovingAverage(long_sma_period)

self.accruals_data = None
self.shares_outstanding = None
self.capex = None

def update(self, time: datetime, value: float):
self.short_sma.Update(time, value)
self.long_sma.Update(time, value)
self.price.Add(value)

def is_ready(self) -> bool:
return (self.short_sma.IsReady and self.long_sma.IsReady and self.price.IsReady)

def is_in_uptrend(self) -> bool:
return (self.short_sma.Current.Value > self.long_sma.Current.Value)

def performance(self) -> float:
return self.price[0] / self.price[self.price.Count - 1] - 1

# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))