How Satellite Launches Influence Stock Returns
Log in to collectAcademic paper
Aerospace Competition, Investor Attention, and Stock Return Comovement
Hung Xuan; Nhut H. Nguyen; Quan M.P. Nguyen; Cameron Truong
- NZMassey University
- ?Massey University, Albany campus
- NZAuckland University of Technology
- University of Sussex
- Australian Regenerative Medicine Institute
- Monash University
- Research Network (United States)
- ?Financial Research Network (FIRN)
Strategy in a nutshell
Trade U.S. stocks around satellite launches using one-year historical data to estimate “underpriced” and “overpriced” betas. Go long underpriced and short overpriced portfolios, rebalancing shortly before and after launches.
Economic rationale
Investor attention shifts to market-level info during satellite launches, causing mispricing in affected stocks. Exploiting this by long underpriced and short overpriced stocks generates abnormal returns.
Backtest performance
Annualised return17.2%
Volatility20.26%
Beta-0.036
Sharpe ratio0.85
Win rate49%
Full Python code
from AlgorithmImports import *
from pandas.tseries.offsets import BDay
from collections import deque
import data_tools
import statsmodels.api as sm
import numpy as np
# endregion
class HowSatelliteLaunchesInfluenceStockReturns(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
ff_tickers:List[str] = [
'fama_french_5_market_eq',
'fama_french_5_investment_eq',
'fama_french_5_profitability_eq',
'fama_french_5_size_eq',
'fama_french_5_value_eq',
]
self.ff_factors:List[Symbol] = [self.AddData(data_tools.FFFactorsEQ, ff_ticker, Resolution.Daily).Symbol for ff_ticker in ff_tickers]
self.market_ff:Symbol = self.ff_factors[0]
self.traded_portfolio_portion:Dict[Symbol, float] = {}
self.data:Dict[Symbol, deque] = {}
self.overpriced_betas:Dict[Symbol, float] = {}
self.underpriced_betas:Dict[Symbol, float] = {}
self.stocks_to_liquidate:List[data_tools.HoldingItem] = []
self.active_coarse:list[CoarseFundamental] = []
# we employ one-year data up to 10 days prior to the event (i.e., from t – 375 to t – 10)
self.market_period:int = 375
self.pre_launch_period:int = 2 # [-2, 0] holding period @table 8
self.t_10:int = 10 - self.pre_launch_period
self.holding_period:int = 3 # days [-2, -1, 0] before launch
self.quantile:int = 5
self.leverage:int = 5
self.coarse_count:int = 500
# load Satelite launch dates
# Source: https://en.wikipedia.org/wiki/2000_in_spaceflight
csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/calendar/spaceflight_dates.csv')
dates:str = csv_string_file.split('\r\n')
self.launch_dates:List[datetime.date] = [datetime.strptime(x, "%Y-%m-%d") - BDay(self.pre_launch_period) for x in dates[:-1]]
self.selection_flag:bool = False
self.rebalance_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)
def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(self.leverage)
def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
# update the returns every day
ff_factors_last_update_date:datetime.date = data_tools.FFFactorsEQ._last_update_date
# FF data is still comming in
if all([self.Securities[x].GetLastData() for x in self.ff_factors]) and self.Time.date() >= ff_factors_last_update_date:
self.Liquidate()
return Universe.Unchanged
# store daily FF prices
for symbol_ff in self.ff_factors:
if symbol_ff in self.data:
price = self.Securities[symbol_ff].Price
if symbol_ff == self.market_ff:
self.data[symbol_ff].update_daily_return(self.Time, price)
else:
self.data[symbol_ff].update_value(self.Time, price)
# store daily stock prices
for stock in coarse:
symbol:Symbol = stock.Symbol
if symbol in self.data:
self.data[symbol].update_daily_return(self.Time , stock.AdjustedPrice)
if self.Time in self.launch_dates:
self.rebalance_flag = True
return self.active_coarse
# selection on month start
if not self.selection_flag:
return Universe.Unchanged
self.selection_flag = False
self.active_coarse:List[Symbol] = [x.Symbol
for x in sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa'],
key = lambda x: x.DollarVolume, reverse = True)[:self.coarse_count]]
# selected = [x.Symbol for x in coarse if x.HasFundamentalData and x.Market == 'usa']
# price warmup
for symbol in self.active_coarse:
if symbol in self.data:
continue
self.data[symbol] = data_tools.SymbolData(self.market_period)
history:DataFrame = self.History(symbol, self.market_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.iteritems():
self.data[symbol].update_daily_return(time, close)
for symbol_ff in self.ff_factors:
if symbol_ff not in self.data:
self.data[symbol_ff] = data_tools.SymbolData(self.market_period)
history:DataFrame = self.History(symbol_ff, self.market_period, Resolution.Daily)
if not history.empty:
values:pd.Series = history.loc[symbol_ff].value
for time, value in values.iteritems():
if symbol_ff == self.market_ff:
self.data[symbol_ff].update_daily_return(time, value)
else:
self.data[symbol_ff].update_value(time, value)
else:
self.Log(f"Not enough data for {symbol_ff} yet.")
return [x for x in self.active_coarse if self.data[x].is_ready()]
def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
fine = [x for x in fine if x.MarketCap != 0 and \
(x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')]
fine:Dict[Symbol, FineFundamental] = {x.Symbol: x for x in fine}
if len(fine) != 0:
last_start:datetime.date = (self.Time.date() - timedelta(self.market_period))
last_end:datetime.date = (self.Time.date() - timedelta(self.t_10))
if not all([self.data[x].is_ready() for x in self.ff_factors]):
return Universe.Unchanged
# stock returns
daily_returns_by_stock:Dict[Symbol, List[Tuple[datetime, float]]] = {sym : sym_data.get_daily_returns(last_start, last_end) for sym, sym_data in self.data.items() if sym_data.is_ready() and sym in fine}
stock_daily_returns:List = list(zip(*[[i[1] for i in x] for x in daily_returns_by_stock.values()]))
# FF factors values
launch_dates:List[datetime] = pd.to_datetime(self.launch_dates)
ff_returns_with_dt, ff_size_with_dt, ff_investment_with_dt, ff_profitability_with_dt, ff_book_to_market_with_dt = [np.array(self.data[x].get_daily_returns(last_start, last_end)) for x in self.ff_factors]
ff_returns = ff_returns_with_dt[:, 1]
ff_size = ff_size_with_dt[:, 1]
ff_investment = ff_investment_with_dt[:, 1]
ff_profitability = ff_profitability_with_dt[:, 1]
ff_book_to_market = ff_book_to_market_with_dt[:, 1]
# regression X variables
transformed_array = np.column_stack((
ff_returns,
ff_returns * np.where(ff_returns >= 0, 1, 0),
ff_returns * np.where(ff_returns < 0, 1, 0),
np.isin(ff_returns_with_dt[:, 0], self.launch_dates).astype(int), # satellite_w
ff_size,
ff_investment,
ff_profitability,
ff_book_to_market))
# apply satellite_w to particular variable columns
transformed_array[:, 1] *= transformed_array[:, 3]
transformed_array[:, 2] *= transformed_array[:, 3]
transformed_array = np.delete(transformed_array, 3, axis=1)
transformed_array = np.array(transformed_array, dtype=float)
# run stock regression
x:np.ndarray = transformed_array
y:np.ndarray = stock_daily_returns
if len(x) != len(y) or len(x) == 0 or len(y) == 0:
self.Log('Data missing')
return Universe.Unchanged
model = self.multiple_linear_regression(x, y)
beta_values_overpriced:np.ndarray = model.params[1]
beta_values_underpriced:np.ndarray = model.params[2]
overpriced_betas:Dict[Symbol, float] = {}
underpriced_betas:Dict[Symbol, float] = {}
# fetch beta parameters for each stock
for i, asset in enumerate(daily_returns_by_stock):
asset_s:Symbol = self.Symbol(asset)
# fill data
if asset in fine:
if beta_values_overpriced[i] != 0 and beta_values_overpriced[i] is not None and \
beta_values_underpriced[i] != 0 and beta_values_underpriced[i] is not None:
overpriced_betas[fine[asset]] = beta_values_overpriced[i]
underpriced_betas[fine[asset]] = beta_values_underpriced[i]
# sort by beta and divide to upper decile and lower quintile
if len(overpriced_betas) >= self.quantile:
sorted_overprized_betas:List[FineFundamental] = sorted(overpriced_betas, key=overpriced_betas.get)
sorted_underprized_betas:List[FineFundamental] = sorted(underpriced_betas, key=underpriced_betas.get)
quantile:int = int(len(sorted_overprized_betas) / self.quantile)
long_underprized:List[FineFundamental] = sorted_underprized_betas[-quantile:]
short_overprized:List[FineFundamental] = sorted_overprized_betas[-quantile:]
# calculate weights based on values
sum_long:float = sum([x.MarketCap for x in long_underprized])
for stock in long_underprized:
self.traded_portfolio_portion[stock.Symbol] = (stock.MarketCap / sum_long) * (self.Portfolio.TotalPortfolioValue / self.holding_period)
sum_short:float = sum([x.MarketCap for x in short_overprized])
for stock in short_overprized:
self.traded_portfolio_portion[stock.Symbol] = (-stock.MarketCap / sum_short) * (self.Portfolio.TotalPortfolioValue / self.holding_period)
# return list(fine.keys())
return list(self.traded_portfolio_portion.keys())
def OnData(self, data: Slice) -> None:
items_to_remove:List[data_tools.HoldingItem] = []
# execute order and hold for holding period
for item in self.stocks_to_liquidate:
item._holding_period += 1
if item._holding_period >= self.holding_period:
self.MarketOrder(item._symbol, -item._quantity)
items_to_remove.append(item)
# remove from collection
for item in items_to_remove:
self.stocks_to_liquidate.remove(item)
if not self.rebalance_flag:
return
self.rebalance_flag = False
# execute order
for price_symbol, portfolio_portion in self.traded_portfolio_portion.items():
if price_symbol in data and data[price_symbol]:
final_quantity:int = portfolio_portion // data[price_symbol].Price
if portfolio_portion != 0:
self.MarketOrder(price_symbol, final_quantity)
self.stocks_to_liquidate.append(data_tools.HoldingItem(price_symbol, final_quantity))
self.traded_portfolio_portion.clear()
def Selection(self) -> None:
self.selection_flag = True
def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
# x:np.ndarray = np.array(x).T
x = sm.add_constant(x, prepend=True)
result = sm.OLS(endog=y, exog=x).fit()
return result