Quant Buffet放轻松,别过度思虑

套利交易择时 v2

登录后收藏

学术论文

作者套利交易多头和空头的可预测性 [点击查看论文]

策略概要

该策略使用等权重的套利交易方法交易G-10货币。在月末,货币按即期汇率和远期汇率的利率差进行排名。投资者做多高利率货币,做空低利率货币,为期一个月。为了对套利交易进行择时,投资者采用三个预测因子:

平均货币波动率:计算为G-10货币兑美元每日汇率百分比变化的平均标准差。波动率的变化(月度tt与t−1t-1)代表全球货币市场的不确定性。

MSCI世界股票指数变化:用于预测空头收益。

原材料工业现货商品指数变化:用于预测多头收益。

两个回归模型预测收益:一个用于多头,使用三个月滞后的商品指数变化和货币波动率,一个用于空头,使用三个月滞后的股票指数变化和两个月滞后的货币波动率。

交易决策依赖于套利交易收益的单步提前预测。仅当每个腿的预测收益为正时,投资者才执行套利交易。这种动态方法确保选择性参与,基于市场波动率、股票指数和商品趋势的预测信号优化回报。

II. 策略合理性

研究表明,投资者有限的处理能力和解释预测因子变化的挑战导致信息在市场和参与者之间逐渐流动,从而创造了回报可预测性的机会。

回测表现

波动率9.07%
夏普比率1.01
索提诺比率-0.111
胜率48%

完整 Python 代码

from AlgorithmImports import *
import data_tools
import numpy as np
import pandas as pd
import statsmodels.formula.api as sm
#endregion
class TimingCarryTradeV2(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2009, 1, 1)
self.SetCash(100000)
self.tickers:dict[str, str] = {
    "CME_AD1" : Futures.Currencies.AUD, # Australian Dollar Futures, Continuous Contract #1
    "CME_BP1" : Futures.Currencies.GBP, # British Pound Futures, Continuous Contract #1
    "CME_CD1" : Futures.Currencies.CAD, # Canadian Dollar Futures, Continuous Contract #1
    "CME_EC1" : Futures.Currencies.EUR, # Euro FX Futures, Continuous Contract #1
    "CME_JY1" : Futures.Currencies.JPY, # Japanese Yen Futures, Continuous Contract #1
    "CME_MP1" : Futures.Currencies.MXN, # Mexican Peso Futures, Continuous Contract #1
    "CME_NE1" : Futures.Currencies.NZD, # New Zealand Dollar Futures, Continuous Contract #1
    "CME_SF1" : Futures.Currencies.CHF, # Swiss Franc Futures, Continuous Contract #1
}
                
self.month_period:int = 21
self.avg_vol_period:int = 4
self.regression_period:int = 12
self.max_missing_days:int = 5
self.min_expiration_days:int = 2
self.max_expiration_days:int = 360
self.SetWarmUp(12 * self.month_period, Resolution.Daily) # one year warm up

self.data:dict[Symbol, data_tools.SymbolData] = {}
self.futures_data:dict[str, data_tools.FuturesData] = {}

# Monthly average currency volatility.
self.avg_vol:RollingWindow = RollingWindow[float](self.avg_vol_period)

# MSCI world equity price index
self.msci:Symbol = self.AddEquity('URTH',  Resolution.Daily).Symbol
self.data[self.msci] = data_tools.SymbolData(4 * self.month_period) # 4 months

# 12 months of regression data.
self.regression_data:data_tools.RegressionData = data_tools.RegressionData(self.regression_period)

# Raw Industrials Spot Commodity Index.
self.dbb:Symbol = self.AddEquity('DBB',  Resolution.Daily).Symbol
self.data[self.dbb] = data_tools.SymbolData(4 * self.month_period) # 4 months

for qp_ticker, qc_ticker in self.tickers.items():
    security:Security = self.AddData(data_tools.QuantpediaFutures, qp_ticker, Resolution.Daily)
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(5)
    qp_symbol:Symbol = security.Symbol
    self.data[qp_symbol] = data_tools.SymbolData(12 * self.month_period) # one year
    # QC futures
    future:Future = self.AddFuture(qc_ticker, Resolution.Daily, dataNormalizationMode=DataNormalizationMode.Raw)
    future.SetFilter(timedelta(days=self.min_expiration_days), timedelta(days=self.max_expiration_days))
    self.futures_data[future.Symbol.Value] = data_tools.FuturesData(qp_symbol)
self.recent_month:int = -1
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
def FindAndUpdateContracts(self, futures_chain, ticker) -> None:
near_contract:FuturesContract = None
dist_contract:FuturesContract = None
if ticker in futures_chain:
    contracts:list[:FuturesContract] = [contract for contract in futures_chain[ticker] if contract.Expiry.date() > self.Time.date()]
    if len(contracts) >= 2:
        contracts:list[:FuturesContract] = sorted(contracts, key=lambda x: x.Expiry, reverse=False)
        near_contract = contracts[0]
        dist_contract = contracts[1]
self.futures_data[ticker].update_contracts(near_contract, dist_contract) 
def OnData(self, data):
curr_date:datetime.date = self.Time.date()
# daily update qc future data
if data.FutureChains.Count > 0:
    for ticker, future_obj in self.futures_data.items():
        # check if near contract is expired or is not initialized
        if not future_obj.is_initialized() or \
            (future_obj.is_initialized() and future_obj.near_contract.Expiry.date() == curr_date):
            self.FindAndUpdateContracts(data.FutureChains, ticker)
        # update QC futures rolling return
        if future_obj.is_initialized():
            near_c:FuturesContract = future_obj.near_contract
            dist_c:FuturesContract = future_obj.distant_contract
            if near_c.Symbol in data and data[near_c.Symbol] and dist_c.Symbol in data and data[dist_c.Symbol]:
                raw_price1:float = data[near_c.Symbol].Value * self.Securities[ticker].SymbolProperties.PriceMagnifier
                raw_price2:float = data[dist_c.Symbol].Value * self.Securities[ticker].SymbolProperties.PriceMagnifier
                if raw_price1 != 0 and raw_price2 != 0:
                    future_obj.update_roll_return(raw_price1, raw_price2, curr_date)
# update daily prices of QP futures and indexes
for symbol, symbol_obj in self.data.items():
    if symbol in data and data[symbol]:
        price:float = data[symbol].Value
        symbol_obj.update(price, curr_date)
# rebalacne monthly
if self.IsWarmingUp or (self.recent_month == self.Time.month):
    return
self.recent_month = self.Time.month
roll_return:dict[Symbol, float] = {}
volatility:dict[Symbol, float] = {}
last_update_date:Dict[str, datetime.date] = data_tools.QuantpediaFutures.get_last_update_date()
for ticker, future_obj in self.futures_data.items():
    qp_symbol:Symbol = future_obj.quantpedia_future
    
    if qp_symbol.Value in last_update_date and self.Time.date() < last_update_date[qp_symbol.Value]:
        if self.data[qp_symbol].is_ready() and future_obj.is_ready():
            # last month of daily closes
            closes:list[float] = self.data[qp_symbol].get_prices(self.month_period)
            volatility[qp_symbol] = data_tools.Volatility(closes)
            roll_return[qp_symbol] = future_obj.get_roll_return()
    else:
        future_obj.reset_data()
# volatility is required for avg_vol calculation
if len(volatility) == 0:
    self.regression_data.reset_data()
    self.avg_vol.Reset()
    self.Liquidate()
    return
avg_vol:float = np.mean([x[1] for x in volatility.items()])
self.avg_vol.Add(avg_vol)
# these data are required for regression variables
if not self.avg_vol.IsReady or not self.data[self.msci].is_ready() or not self.data[self.dbb].is_ready():
    self.regression_data.reset_data()
    self.Liquidate()
    return

# change in average currency volatility.
monthly_diff:np.ndarray = np.diff([x for x in self.avg_vol])
# change in average currency volatility two months ago.
vol_change_two_months:float = monthly_diff[1]
# change in average currency volatility three months ago.
vol_change_three_months:float = monthly_diff[2]
# monthly change in commodity index three months ago.
msci_monthly_returns:list[float] = self.MonthlyReturns(self.data[self.msci].prices)
msci_change:float = msci_monthly_returns[2]

# monthly change in equity index three months ago.
dbb_monthly_returns:list[float] = self.MonthlyReturns(self.data[self.dbb].prices)
dbb_change:float = dbb_monthly_returns[2]
long_part:list[Symbol] = []
short_part:list[Symbol] = []
# regression data has to be ready
if self.regression_data.is_ready():
    # regression data
    msci_changes, dbb_changes, vol_two_months_changes, vol_three_months_changes = self.regression_data.get_series()
    for qp_symbol, roll_return_value in roll_return.items():
        monthly_returns:list[float] = self.MonthlyReturns(self.data[qp_symbol].prices)
        monthly_returns:pd.Series = pd.Series(monthly_returns)
        
        if roll_return_value > 0:
            Y:float = self.RegressionPrediction(
                list_of_series=[msci_changes, vol_three_months_changes, monthly_returns],
                columns=['msci_changes', 'vol_three_months_changes', 'monthly_returns'],
                formula='monthly_returns ~ msci_changes + vol_three_months_changes',
                X1=msci_change,
                X2=vol_change_three_months
            )
            if Y > 0:
                long_part.append(qp_symbol)
            
        else:
            Y:float = self.RegressionPrediction(
                list_of_series=[dbb_changes, vol_two_months_changes, monthly_returns],
                columns=['dbb_changes', 'vol_two_months_changes', 'monthly_returns'],
                formula='monthly_returns ~ dbb_changes + vol_two_months_changes',
                X1=dbb_change,
                X2=vol_change_two_months
            )
            if Y < 0:
                short_part.append(qp_symbol)
    
# update regression data for current month
self.regression_data.update(msci_change, dbb_change, vol_change_two_months, vol_change_three_months)
       
# 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 i, portfolio in enumerate([long_part, short_part]):
    for symbol in portfolio:
        if symbol in data and data[symbol]:
            self.SetHoldings(symbol, ((-1) ** i) / len(portfolio))
def MonthlyReturns(self, prices_roll_window: RollingWindow) -> list:
prices:list[float] = [x for x in prices_roll_window]
monthly_returns:list[float] = [data_tools.Return(prices[x:x+self.month_period]) for x in range(0, len(prices), self.month_period)]
return monthly_returns
def RegressionPrediction(self, list_of_series:list, columns:list, formula:str, X1:pd.Series, X2:pd.Series) -> float:
data_frame:pd.DataFrame = pd.concat(list_of_series, axis=1).dropna()
data_frame.columns = columns
model = sm.ols(formula=formula, data=data_frame).fit()

alpha:float = model.params[0]
beta:float = model.params[1]

# Expected symbol return.
return alpha + (beta * X1) + (beta * X2)