“该策略涉及52种商品。首先,计算每种商品的3年累计回报。其次,对这些商品进行排名,并将其分为等权重的五分位。做空排名前五分之一的商品,做多排名后五分之一的商品。该策略每年重新平衡,投资组合采用等权重。”

I. 策略概要

投资范围包括52种商品。首先,计算每种商品的3年累计回报。然后,对商品进行排名,并将其分为等权重的五分位。该策略涉及做多最低五分位(回报最低)和做空最高五分位(回报最高)。投资组合每年重新平衡,每种商品在投资组合中均等加权。

II. 策略合理性

该研究调查了长期反转效应,挑战了宏观经济风险或投资者过度反应解释该效应的观点。利用英国和美国关于GDP和通胀的数据,研究发现没有显著证据支持宏观经济风险驱动这一效应。最合理的解释是长期供需周期,其中高(低)现货价格表明相对于需求而言供应适中(充足),从而导致随后的价格调整。此外,长期反转效应在具有高特殊波动性的商品中最为显著。波动性大的商品和高回报离散度之后的时期通常在最高和最低五分位投资组合之间显示出更大的差异,从而增强了反转策略的回报。

III. 来源论文

Long-Run Reversal in Commodity Returns: Insights from Seven Centuries of Evidence [点击查看论文]

<摘要>

我们对商品回报的长期反转进行了有史以来最长的研究。我们使用1265年至2017年52种农产品、工业品和能源商品价格的独特数据集,研究了价格行为。研究结果揭示了强大而稳健的长期反转效应。过去一到三年的回报与横截面回报的后续表现呈负相关。长期反转效应在所有世纪的农产品和非农产品商品回报中都存在,并且与市场状况无关。长期反转不能用宏观经济风险来解释。这种现象在波动性更大的商品和高回报离散期更为显著。

IV. 回测表现

年化回报16.03%
波动率19.37%
β值0.048
夏普比率0.83
索提诺比率0.026
最大回撤N/A
胜率56%

V. 完整的 Python 代码

from AlgorithmImports import *
class LongRunReversalinCommodityReturns(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(1991, 1, 1)
        self.SetCash(100000)
        self.symbols = [
            "CME_S1",   # Soybean Futures, Continuous Contract
            "CME_W1",   # Wheat Futures, Continuous Contract
            "CME_SM1",  # Soybean Meal Futures, Continuous Contract
            "CME_BO1",  # Soybean Oil Futures, Continuous Contract
            "CME_C1",   # Corn Futures, Continuous Contract
            "CME_O1",   # Oats Futures, Continuous Contract
            "CME_LC1",  # Live Cattle Futures, Continuous Contract
            "CME_FC1",  # Feeder Cattle Futures, Continuous Contract
            "CME_LN1",  # Lean Hog Futures, Continuous Contract
            "CME_GC1",  # Gold Futures, Continuous Contract
            "CME_SI1",  # Silver Futures, Continuous Contract
            "CME_PL1",  # Platinum Futures, Continuous Contract
            "CME_CL1",  # Crude Oil Futures, Continuous Contract
            "CME_HG1",  # Copper Futures, Continuous Contract
            "CME_LB1",  # Random Length Lumber Futures, Continuous Contract
            "CME_PA1",  # Palladium Futures, Continuous Contract 
            "CME_RR1",  # Rough Rice Futures, Continuous Contract
            "ICE_RS1",  # Canola Futures, Continuous Contract
            "ICE_GO1",  # Gas Oil Futures, Continuous Contract
            "CME_RB2",  # Gasoline Futures, Continuous Contract
            "CME_KW2",  # Wheat Kansas, Continuous Contract
            "ICE_WT1",  # WTI Crude Futures, Continuous Contract
            "ICE_CC1",  # Cocoa Futures, Continuous Contract 
            "ICE_CT1",  # Cotton No. 2 Futures, Continuous Contract
            "ICE_KC1",  # Coffee C Futures, Continuous Contract
            "ICE_O1",   # Heating Oil Futures, Continuous Contract
            "ICE_OJ1",  # Orange Juice Futures, Continuous Contract
            "ICE_SB1"   # Sugar No. 11 Futures, Continuous Contract
            ]
        self.data = {}
        self.period = 3 * 12 * 21
        self.SetWarmUp(self.period)
        
        self.month = 0
        self.quantile:int = 5
        for symbol in self.symbols:
            data = self.AddData(QuantpediaFutures, symbol, Resolution.Daily)
            data.SetFeeModel(CustomFeeModel())
            data.SetLeverage(5)
            
            self.data[symbol] = SymbolData(self, symbol, self.period)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(self.symbols[0]), self.TimeRules.At(0, 0), self.Rebalance)
    
    def Rebalance(self):
        self.month += 1
        if self.month > 12:
            self.month = 1
        
        if self.IsWarmingUp: return
        if self.month != 1: return
        
        last_update_date:Dict[str, datetime.date] = QuantpediaFutures.get_last_update_date()
        performance = { x : self.data[x].roc.Current.Value for x in self.data if \
            self.data[x].is_ready() and \
            x in last_update_date and \
            self.Time.date() < last_update_date[x] }
        
        if len(performance) < 5:
            self.Liquidate()
            return
        
        sorted_by_return = sorted(performance.items(), key = lambda x: x[1], reverse = True)
        quantile = int(len(sorted_by_return) / self.quantile)
        long = [x[0] for x in sorted_by_return[-quantile:]]
        short = [x[0] for x in sorted_by_return[:quantile]]
        stocks_invested = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
        for symbol in stocks_invested:
            if symbol not in long + short:
                self.Liquidate(symbol)
        # trade execution
        long_count = len(long)
        short_count = len(short)
        
        for symbol in long:
            self.SetHoldings(symbol, 1 / long_count)
        for symbol in short:
            self.SetHoldings(symbol, -1 / short_count)
class SymbolData():
    def __init__(self, algorithm, symbol, period:int) -> None:
        self.roc = algorithm.ROC(symbol, period, Resolution.Daily)
        self.roc.Updated += self.roc_updated
        self.last_update_date = None
        self.algorithm = algorithm
        
    def roc_updated(self, sender, bar):
        self.last_update_date = self.algorithm.Time.date()
    
    def is_ready(self) -> bool:
        return self.roc.IsReady and self.last_update_date and (self.algorithm.Time.date() - self.last_update_date).days < 5
        
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFutures(PythonData):
    _last_update_date:Dict[str, datetime.date] = {}
    @staticmethod
    def get_last_update_date() -> Dict[str, datetime.date]:
       return QuantpediaFutures._last_update_date
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaFutures()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
        data['back_adjusted'] = float(split[1])
        data['spliced'] = float(split[2])
        data.Value = float(split[1])
        # store last update date
        if config.Symbol.Value not in QuantpediaFutures._last_update_date:
            QuantpediaFutures._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
        if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol.Value]:
            QuantpediaFutures._last_update_date[config.Symbol.Value] = data.Time.date()
        return data
# Custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读