投资范围包括29种商品期货合约,涵盖农业、能源、牲畜和金属,数据来自芝加哥商品交易所。历史地缘政治风险指数(GPRH)基于主要报纸中的相关文章比例构建。通过滚动回归估算GPRH的贝塔系数,将合约按贝塔排序为低、中、高风险组合,做多高风险,做空低风险,每月重新平衡。

策略概述

投资范围包括29种商品期货合约,涵盖农业、能源、牲畜和金属四大类商品,数据来自芝加哥商品交易所(CME)。此外,历史地缘政治风险指数(GPRH)通过计算《纽约时报》、《芝加哥论坛报》和《华盛顿邮报》三家报纸中描述地缘政治风险的文章比例构建,数据可追溯至1900年,来源于Caldara和Iacoviello。 为了估算GPRH的贝塔系数,采用滚动回归,独立变量为每月超额收益,因变量包括t月与t-1月之间的GPRH差值、平均因子(所有可用商品期货的多头超额收益)、套息因子和商品动量因子。使用固定60个月窗口和至少24个观察值进行估算。每月根据商品期货合约的地缘政治风险贝塔(GPRH)将其排序为三个投资组合。低风险组合包括上个月贝塔系数最低的五个合约;高风险组合包括贝塔系数最高的五个合约;中间组合则包含其他所有合约。做多高风险组合,做空低风险组合。策略是等权重的,并每月重新平衡。

策略合理性

该策略的主要依据是额外不确定性对投资市场预期的影响。投资者清楚地缘政治局势及其对市场的影响,尤其是在投资市场中。因此,他们自然会关注均值方差风险和未来经济的不确定性。战争、恐怖袭击等地缘政治事件会通过导致经济不确定性,改变投资回报的分布,因此可用基于偏好的理论来解释地缘政治风险溢价。 风险厌恶型投资者在持有与地缘政治风险正相关的资产时,会要求更高的预期回报作为补偿。另一方面,投资者愿意为与地缘政治风险负相关的资产支付更高的价格,因为这些资产在地缘政治风险中被视为更安全、更稳定。最后,风险溢价与跨期资本资产定价模型(ICAPM)的发现一致,即当不确定性增加时,投资者倾向于持有高收益资产以保护未来的投资和消费机会。

论文来源

The pricing of geopolitical risk in cross-sectional commodity returns [点击浏览原文]

<摘要>

本研究探讨了地缘政治风险是否是商品期货横截面收益的定价因素。通过估算商品期货收益对地缘政治风险指数的敞口,我们发现高风险贝塔的商品期货合约每年比低风险贝塔合约产生高出7.92%的年化收益。这表明与地缘政治风险相关的商品期货合约需要额外的补偿。移动平均程序显示,地缘政治风险贝塔具有大约10年周期的规律性变化,并且相对风险溢价在经济衰退前趋于高于平均水平,且在衰退期间进一步增加。最后,我们发现地缘政治威胁比地缘政治行动更能解释商品期货收益的变动。

回测表现

年化收益率8.21%
波动率8.62%
Beta-0.004
夏普比率0.95
索提诺比率N/A
最大回撤N/A
胜率50%

完整python代码

from AlgorithmImports import *
#endregion

import data_tools
import statsmodels.api as sm

class GeopoliticalRiskAndCommodities(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.leverage:int = 5
        self.part_length = 5
        self.max_missing_days = 5
        
        self.month_period = 21
        self.regression_period = 60
        self.period = 6 * self.month_period
        
        self.data = {}
        
        self.symbols = [
            "CME_S1",   # Soybean Futures, Continuous Contract #1
            "CME_W1",   # Wheat Futures, Continuous Contract #1
            "CME_SM1",  # Soybean Meal Futures, Continuous Contract #1
            "CME_BO1",  # Soybean Oil Futures, Continuous Contract #1
            "CME_C1",   # Corn Futures, Continuous Contract #1
            "CME_O1",   # Oats Futures, Continuous Contract #1
            "CME_LC1",  # Live Cattle Futures, Continuous Contract #1
            "CME_FC1",  # Feeder Cattle Futures, Continuous Contract #1
            "CME_LN1",  # Lean Hog Futures, Continuous Contract #1
            "CME_GC1",  # Gold Futures, Continuous Contract #1
            "CME_SI1",  # Silver Futures, Continuous Contract #1
            "CME_PL1",  # Platinum Futures, Continuous Contract #1
            "CME_CL1",  # Crude Oil Futures, Continuous Contract #1
            "CME_HG1",  # Copper Futures, Continuous Contract #1
            "CME_LB1",  # Random Length Lumber Futures, Continuous Contract #1
            "CME_NG1",  # Natural Gas (Henry Hub) Physical Futures, Continuous Contract #1
            "CME_PA1",  # Palladium Futures, Continuous Contract  #1
            "CME_RR1",  # Rough Rice Futures, Continuous Contract #1
            "CME_CU1",  # Chicago Ethanol (Platts) Futures
            "CME_DA1",  # Class III Milk Futures
            
            "ICE_CC1",  # Cocoa Futures, Continuous Contract  #1
            "ICE_CT1",  # Cotton No. 2 Futures, Continuous Contract #1
            "ICE_KC1",  # Coffee C Futures, Continuous Contract #1
            "ICE_O1",   # Heating Oil Futures, Continuous Contract #1
            "ICE_OJ1",  # Orange Juice Futures, Continuous Contract #1
            "ICE_SB1"   # Sugar No. 11 Futures, Continuous Contract #1
        ]
        
        for symbol in self.symbols:
            # subscribe price data
            data = self.AddData(data_tools.QuantpediaFutures, symbol, Resolution.Daily)
            data.SetFeeModel(data_tools.CustomFeeModel())
            data.SetLeverage(self.leverage)
            
            # quandl data
            quandl_symbol_1 = 'CHRIS/' + symbol
            quandl_symbol_2 = 'CHRIS/' + symbol.replace('1', '2')
            
            # subscribe to quandl data
            self.AddData(data_tools.QuandlFutures, quandl_symbol_1, Resolution.Daily)
            self.AddData(data_tools.QuandlFutures, quandl_symbol_2, Resolution.Daily)
            
            # store SymbolData
            self.data[symbol] = data_tools.SymbolData([quandl_symbol_1, quandl_symbol_2], self.month_period, self.period, self.regression_period)
        
        # subscribe to geopolitical risk data    
        self.geo_risk_index_symbol = self.AddData(data_tools.QuantpediaGeopoliticalRisk, 'GEOPOLITICAL_RISK_INDEX', Resolution.Daily).Symbol
        self.geo_risk_index_data = data_tools.IndexData(self.month_period)
        
        self.regression_x_data = data_tools.RegressionData(self.regression_period)
        
        # rebalancing monthly based on self.curr_month variable
        self.curr_month = -1

    def OnData(self, data):
        curr_date = self.Time.date()
        
        # rebalance monthly
        if self.curr_month != self.Time.month:
            self.curr_month = self.Time.month
            self.Rebalance(curr_date)
        
        # check if geopolitical risk index data are ready
        if self.geo_risk_index_symbol in data and data[self.geo_risk_index_symbol]:
            # collect index data
            index_value = data[self.geo_risk_index_symbol].Value
            self.geo_risk_index_data.update(index_value, curr_date)
        
        for symbol in self.data:
            q_sym1, q_sym2 = self.data[symbol].quandl_symbols
            
            # update futures daily prices 
            if symbol in data and data[symbol]:
                close = data[symbol].Value
                self.data[symbol].update(close, curr_date)
            
            # calculate carry factor only when carry value wasn't set in this month
            if self.data[symbol].carry_value == None and q_sym1 in data and q_sym2 in data and data[q_sym1] and data[q_sym2]:
                value1 = data[q_sym1].Value
                value2 = data[q_sym2].Value
                self.data[symbol].update_carry_value(np.log(value2 / value1))
                    
    def Rebalance(self, curr_date):
        ''' collects needed data for regression, then perform regresion, selection and trade '''
        
        # continue only if geopolitical index values are ready and new geopolitical index values are still coming
        if not self.geo_risk_index_data.is_ready(curr_date, self.max_missing_days):
            self.Liquidate()
            return 
        
        symbol_values = {}
        
        for symbol, symbol_obj in self.data.items():
            # make sure future data are ready and are still coming
            if symbol_obj.is_ready(curr_date, self.max_missing_days):
                future_closes = [x for x in symbol_obj.closes]
                
                # calculate future's monthly performance
                short_term_momentum = self.CalculatePerformance(future_closes, self.month_period)
                
                # calculate long term momentum for momentum factor
                long_term_momentum = self.CalculatePerformance(future_closes, self.period)
                
                # carry
                carry_value = symbol_obj.carry_value
                
                symbol_values[symbol] = data_tools.SymbolValue(short_term_momentum, carry_value, long_term_momentum)
                
                # update future's performance
                symbol_obj.update_momentum_values(short_term_momentum)
                
            symbol_obj.reset_carry_value()
        
        # to make sure X regression data has same length update only when condition isn't true
        if len(symbol_values) < (self.part_length * 2):
            self.Liquidate()
            return
        
        # update factor values needed for regression    
        average_factor_perf = np.mean([symbol_values[symbol].short_term_performance for symbol in symbol_values])
        self.regression_x_data.update_average_factor_perf(average_factor_perf)
        
        carry_factor_perf = self.CarryFactorCalculation(symbol_values)
        self.regression_x_data.update_carry_factor_perf(carry_factor_perf)
        
        momentum_factor_perf = self.MomentumFactorCalculation(symbol_values)
        self.regression_x_data.update_momentum_factor_perf(momentum_factor_perf)
        
        # update index change
        index_values = [x for x in self.geo_risk_index_data.index_values]
        index_change = self.CalculatePerformance(index_values, self.month_period)
        self.regression_x_data.update_index_changes(index_change)
        
        # make sure data, for variable X in regression, are ready
        if not self.regression_x_data.is_ready():
            self.Liquidate()
            return
        
        # create data structure from data for variable X in regression
        regression_x: list[list[float]] = [
            [x for x in self.regression_x_data.index_changes],
            [x for x in self.regression_x_data.average_factor_perf],
            [x for x in self.regression_x_data.carry_factor_perf],
            [x for x in self.regression_x_data.momentum_factor_perf]
        ]
        
        betas = {}
        
        for symbol in symbol_values:
            # make sure future has enough data for regression
            if not self.data[symbol].are_momentum_values_ready():
                continue
            
            regression_y = [x for x in self.data[symbol].momentum_values]
            regression_model = self.MultipleLinearRegression(regression_x, regression_y)
            
            # get geopolitical risk index beta
            beta = regression_model.params[1]
            betas[symbol] = beta
            
        # make sure there are enough futures for selection
        if len(betas) < (self.part_length * 2):
            self.Liquidate()
            return
        
        sorted_by_beta = [x[0] for x in sorted(betas.items(), key=lambda item: item[1])]
        
        # long high portfolio
        long_part = sorted_by_beta[-self.part_length:]
        
        # short low portfolio
        short_part = sorted_by_beta[:self.part_length]
        
        # trade execution
        invested = [x.Key for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in long_part + short_part:
                self.Liquidate(symbol)
        
        for symbol in long_part:
            self.SetHoldings(symbol, 1 / self.part_length)
            
        for symbol in short_part:
            self.SetHoldings(symbol, -1 / self.part_length)
            
    def CalculatePerformance(self, values: list, period: int) -> float:
        values = values[:period]
        performance = (values[0] - values[-1]) / values[-1]
        
        return performance
        
    def CarryFactorCalculation(self, symbol_values: dict) -> float:
        ''' based on carry factor change value select backwardation and contango futures and calculate total performance for current month '''
        
        sorted_by_carry_value = [x[0] for x in sorted(symbol_values.items(), key=lambda item: item[1].carry_value)]
        
        # long commodities that are most in backwardation
        long_part = sorted_by_carry_value[:self.part_length]
        
        # short the ones that are most in contango
        short_part = sorted_by_carry_value[-self.part_length:]
        
        carry_factor_perf = self.CalculateFactorReturn(symbol_values, long_part, short_part)
        
        return carry_factor_perf
        
    def MomentumFactorCalculation(self, symbol_values: dict) -> float:
        ''' select top n and low n futures based on momentum factor value and calculate their total performance for current month '''
        
        sorted_by_mom = [x[0] for x in sorted(symbol_values.items(), key=lambda item: item[1].long_term_performance)]
        
        # long highest n futures
        long_part = sorted_by_mom[-self.part_length:]
        
        # short lowest n futures
        short_part = sorted_by_mom[:self.part_length]
        
        momentum_factor_perf = self.CalculateFactorReturn(symbol_values, long_part, short_part)
        
        return momentum_factor_perf
        
    def CalculateFactorReturn(self, symbol_values: dict, long_part: list, short_part: list) -> float:
        ''' calculate total performance of factor for current month '''
        
        long_value = np.mean([symbol_values[symbol].short_term_performance for symbol in long_part])
        short_value = np.mean([-symbol_values[symbol].short_term_performance for symbol in short_part])
        
        factor_value = (long_value + short_value) / 2
        
        return factor_value
        
    def MultipleLinearRegression(self, x, y):
        x = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading