
“该策略利用双边城市距离和GDP规模计算每个国家的引力得分。国家根据此得分进行排名,并做多排名前五分之一的国家,每月重新平衡。”
资产类别: ETF、股票 | 地区: 全球 | 周期: 每月 | 市场: 股票 | 关键词: 地理、动量
I. 策略概要
投资范围包括44个国家的股票市场指数。该策略首先计算两国最大城市之间的双边距离,并根据城市人口份额进行调整。在每个月末,从国家d的角度计算国家i的引力Z分数,同时考虑GDP规模和两国之间的距离。总引力分数通过从规模的Z分数中减去距离的Z分数来确定。周围国家的权重根据此引力分数计算。引力衡量标准是过去回报的加权和,不包括本国。然后,国家根据引力衡量标准分为五分位,做多排名前五分之一的国家。该策略每月重新平衡,投资组合采用价值加权。
II. 策略合理性
该论文支持引力假说,发现合并质量更大的国家之间存在更强的同期联动。当预测国经济规模大于被预测国时,可预测性更高。此外,距离更近的国家在现金流和贴现率新闻方面都具有更高的贝塔载荷。即使在控制了公司或行业规模之后,国家规模和邻近性在塑造股票回报关系方面仍然至关重要。该策略基于这样一个理念:国家之间的经济联系可以通过地理距离来捕捉,地理距离代表了语言和文化差异等障碍。较大的经济体影响周围较小的国家。通过利用距离和经济规模对过去回报进行加权,该策略有效地预测了未来回报。
III. 来源论文
Gravity in International Equity Markets [点击查看论文]
- 裴俊宇(Joon Woo Bae),多伦多大学罗特曼管理学院
<摘要>
经济规模和地理距离是国际股票市场回报跨国同期和交叉序列相关性的重要决定因素。大国引领小国回报,这种跨国可预测性随着两国地理距离的增加而减小。利用这种关系的做多-做空交易策略每年产生10%的风险调整回报。领先-滞后关系并非由公司平均规模或流动性、股票市场发展程度或行业构成方面的跨国差异驱动。将股票市场回报分解为现金流和贴现率新闻表明,贴现率新闻的国际传导比现金流新闻更为显著,并且经济规模和地理距离是回报这两个组成部分的重要决定因素。



IV. 回测表现
| 年化回报 | 12.69% |
| 波动率 | 19.83% |
| β值 | 0.912 |
| 夏普比率 | 0.64 |
| 索提诺比率 | N/A |
| 最大回撤 | N/A |
| 胜率 | 58% |
V. 完整的 Python 代码
from AlgorithmImports import *
import numpy as np
#endregion
class GeographicalCountryMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.symbols = {
"Australia" : "EWA", # iShares MSCI Australia Index ETF
"Austria" : "EWO", # iShares MSCI Austria Investable Mkt Index ETF
"Belgium" : "EWK", # iShares MSCI Belgium Investable Market Index ETF
"Brazil" : "EWZ", # iShares MSCI Brazil Index ETF
"Canada" : "EWC", # iShares MSCI Canada Index ETF
"China" : "FXI", # iShares China Large-Cap ETF
"France" : "EWQ", # iShares MSCI France Index ETF
"Germany" : "EWG", # iShares MSCI Germany ETF
"Hong Kong" : "EWH", # iShares MSCI Hong Kong Index ETF
"Italy" : "EWI", # iShares MSCI Italy Index ETF
"Japan" : "EWJ", # iShares MSCI Japan Index ETF
"Malaysia" : "EWM", # iShares MSCI Malaysia Index ETF
"Mexico" : "EWW", # iShares MSCI Mexico Inv. Mt. Idx
"Netherlands" : "EWN", # iShares MSCI Netherlands Index ETF
"Singapore" : "EWS", # iShares MSCI Singapore Index ETF
"South Africa" : "EZA", # iShares MSCI South Africa Index ETF
"South Korea" : "EWY", # iShares MSCI South Korea ETF
"Spain" : "EWP", # iShares MSCI Spain Index ETF
"Sweden" : "EWD", # iShares MSCI Sweden Index ETF
"Switzerland" : "EWL", # iShares MSCI Switzerland Index ETF
"Taiwan" : "EWT", # iShares MSCI Taiwan Index ETF
"Thailand" : "THD", # iShares MSCI Thailand Index ETF
"United Kingdom" : "EWU", # iShares MSCI United Kingdom Index ETF
"United States" : "SPY", # SPDR S&P 500 ETF
}
self.country_data = {}
self.period = 21 # performance period
self.SetWarmUp(self.period, Resolution.Daily)
self.quantile = 5
self.max_missing_days = 365
csv_string_file = self.Download('data.quantpedia.com/backtesting_data/economic/city_distance_420.csv')
lines = csv_string_file.split('\r\n')
# header and line example:
# country;country_population;biggest_city;city_population;GDP_symbol;sydney_d;vienna_d;brussel_d;sao_paulo_d;toronto_d;shanghai_d;paris_d;berlin+_d;hong_kong_d;rome_d;tokyo_d;kota_bharu_d;mexico_city_d;amsterdam_d;singapore_d;cape_town_d;seoul_d;madrid_d;stockholm_d;zurich_d;taipei_d;bangkok_d;london_d;new_york_d
# Australia;24990000;Sydney;4627345;ODA/AUS_PPPGDP;0;15996;16734;13349;15558;7876;16950;16084;7371;16311;7819;6794;12965;16632;6302;11005;8324;17674;15586;16557;7255;7532;16983;15979
# skip header.
for line in lines[1:]:
split_line = line.split(';')
country = split_line[0]
country_pop = float(split_line[1])
agglomeration = split_line[2]
agglomeration_pop = float(split_line[3])
gdp_symbol = split_line[4]
self.country_data[country] = CountryData(self.period, country, country_pop, agglomeration, agglomeration_pop, gdp_symbol)
line_index = 5
for symbol_index, symbol in enumerate(self.symbols):
self.country_data[country].AgglomerationDistance[symbol] = float(split_line[line_index + symbol_index])
# etf data.
data = self.AddEquity(self.symbols[country], Resolution.Daily)
data.SetFeeModel(CustomFeeModel())
data.SetLeverage(5)
# gdp quandl data.
self.AddData(QuandlValue, gdp_symbol, Resolution.Daily)
# aggregate distance calc.
for country_i in self.country_data:
for country_d in self.country_data[country_i].AgglomerationDistance:
country_i_ratio = self.country_data[country_i].AgglomerationPopulation / self.country_data[country_i].CountryPopulation
country_d_ratio = self.country_data[country_d].AgglomerationPopulation / self.country_data[country_d].CountryPopulation
distance_ratio = country_i_ratio * country_d_ratio * self.country_data[country_i].AgglomerationDistance[country_d]
self.country_data[country_i].AgglomerationDistance[country_d] = distance_ratio
self.recent_month = -1
def OnData(self, data):
for country in self.country_data:
etf_obj = self.Symbol(self.symbols[country])
if etf_obj in data.Bars and data[etf_obj]:
price = data.Bars[etf_obj].Value
self.country_data[country].update(price)
if self.recent_month == self.Time.month:
return
self.recent_month = self.Time.month
# size zscore calc.
size = { x : np.log(self.Securities[self.country_data[x].GDPSymbol].Price) \
for x in self.country_data if self.Securities.ContainsKey(self.country_data[x].GDPSymbol) and self.Securities[self.country_data[x].GDPSymbol].GetLastData() and (self.Time.date() - self.Securities[self.country_data[x].GDPSymbol].GetLastData().Time.date()).days < self.max_missing_days}
size_values = [x[1] for x in size.items()]
size_avg = np.average(size_values)
size_std = np.std(size_values)
z_score_size = { x[0] : (x[1] - size_avg) / size_std for x in size.items() }
# distance zscore calc.
z_score_dist = {}
dist_avg = { x : np.average([np.log(y[1]) for y in self.country_data[x].AgglomerationDistance.items() if y[0] != x]) for x in self.country_data }
dist_std = { x : np.std([np.log(y[1]) for y in self.country_data[x].AgglomerationDistance.items() if y[0] != x]) for x in self.country_data }
for country_i in self.country_data:
z_score_dist[country_i] = {}
for country_d in self.country_data[country_i].AgglomerationDistance:
if country_i != country_d:
z_score_dist[country_i][country_d] = (self.country_data[country_i].AgglomerationDistance[country_d] - dist_avg[country_i]) / dist_std[country_i]
# gravity score calc.
z_score_grav = {}
for country_i in z_score_dist:
z_score_grav[country_i] = {}
for country_d in z_score_dist[country_i]:
if country_i in z_score_size:
if country_i != country_d:
z_score_grav[country_i][country_d] = z_score_size[country_i] - z_score_dist[country_i][country_d]
# weight calc.
w_i = {}
for country_i in z_score_grav:
if len(z_score_grav[country_i]) != 0:
min_grav_i = min([x[1] for x in z_score_grav[country_i].items() if x[0] != country_i])
adj_grav_i = np.array([x[1] + min_grav_i for x in z_score_grav[country_i].items() if x[0] != country_i])
w_i[country_i] = adj_grav_i / sum(adj_grav_i)
gravity = {x[0] : sum(x[1] * self.country_data[x[0]].performance()) for x in w_i.items() if self.country_data[x[0]].is_ready()}
if len(gravity) < self.quantile:
self.Liquidate()
return
sorted_by_gravity = sorted(gravity.items(), key = lambda x: x[1], reverse = True)
quintile = int(len(sorted_by_gravity) / self.quantile)
long = [self.symbols[x[0]] for x in sorted_by_gravity][:quintile]
# Trade execution.
invested = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long:
self.Liquidate(symbol)
for etf in long:
if etf in data and data[etf]:
self.SetHoldings(etf, 1 / len(long))
class CountryData():
def __init__(self, period, country, country_pop, agglomeration, agglomeration_pop, gdp_symbol):
self.Country = country
self.CountryPopulation = country_pop
self.Agglomeration = agglomeration
self.AgglomerationPopulation = agglomeration_pop
self.GDPSymbol = gdp_symbol
self.AgglomerationDistance = {}
self.Price = RollingWindow[float](period)
def update(self, price):
self.Price.Add(price)
def is_ready(self) -> bool:
return self.Price.IsReady
def performance(self, values_to_skip = 0) -> float:
closes = [x for x in self.Price][values_to_skip:]
return (closes[0] / closes[-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"))
# Quandl "value" data
class QuandlValue(NasdaqDataLink):
def __init__(self):
self.ValueColumnName = 'Value'