Applying Research
Airline Buybacks
Create Hypothesis
Buyback represents a company buy back its own stocks in the market, as (1) management is confident on its own future, and (2) wants more control over its development. Since usually buyback is in large scale on a schedule, the price of repurchasing often causes price fluctuation.
Airlines is one of the largest buyback sectors. Major US Airlines use over 90% of their free cashflow to buy back their own stocks in the recent years.[1] Therefore, we can use airline companies to test the hypothesis of buybacks would cause price action. In this particular exmaple, we're hypothesizing that difference in buyback price and close price would suggest price change in certain direction. (we don't know forward return would be in momentum or mean-reversion in this case!)
Import Libraries
We'll need to import libraries to help with data processing, validation and visualization. Import SmartInsiderTransaction
class, statsmodels
, sklearn
, numpy
, pandas
and seaborn
libraries by the following:
from QuantConnect.DataSource import SmartInsiderTransaction from statsmodels.discrete.discrete_model import Logit from sklearn.metrics import confusion_matrix import numpy as np import pandas as pd import seaborn as sns
Get Historical Data
To begin, we retrieve historical data for researching.
- Instantiate a
QuantBook
. - Select the airline tickers for research.
- Call the
AddEquity
add_equity
method with the tickers, and its corresponding resolution. Then callAddData
add_data
withSmartInsiderTransaction
to subscribe to their buyback transaction data. Save theSymbol
s into a dictionary. - Call the
History
history
method with a list ofSymbol
s for all tickers, time argument(s), and resolution to request historical data for the symbols. - Call SPY history as reference.
- Call the
History
history
method with a list ofSmartInsiderTransaction
Symbol
s for all tickers, time argument(s), and resolution to request historical data for the symbols.
qb = QuantBook()
assets = ["LUV", # Southwest Airlines "DAL", # Delta Airlines "UAL", # United Airlines Holdings "AAL", # American Airlines Group "SKYW", # SkyWest Inc. "ALGT", # Allegiant Travel Co. "ALK" # Alaska Air Group Inc. ]
symbols = {} for ticker in assets: symbol = qb.add_equity(ticker, Resolution.MINUTE).symbol symbols[symbol] = qb.add_data(SmartInsiderTransaction, symbol).symbol
If you do not pass a resolution argument, Resolution.Minute
Resolution.MINUTE
is used by default.
history = qb.history(list(symbols.keys()), datetime(2019, 1, 1), datetime(2021, 12, 31), Resolution.DAILY)
spy = qb.history(qb.add_equity("SPY").symbol, datetime(2019, 1, 1), datetime(2021, 12, 31), Resolution.DAILY)
history_buybacks = qb.history(list(symbols.values()), datetime(2019, 1, 1), datetime(2021, 12, 31), Resolution.DAILY)
Prepare Data
We'll have to process our data to get the buyback premium/discount% vs forward return data.
- Select the close column and then call the
unstack
method. - Call
pct_change
to get the daily return of close price, then shift 1-step backward as prediction. - Get the active forward return.
- Select the ExecutionPrice column and then call the
unstack
method to get the buyback dataframe. - Convert buyback history into daily mean data.
- Get the buyback premium/discount %.
- Create a
Dataframe
to hold the buyback and 1-day forward return data. - Append the data into the
Dataframe
. - Call
dropna
to drop NaNs.
df = history['close'].unstack(level=0) spy_close = spy['close'].unstack(level=0)
ret = df.pct_change().shift(-1).iloc[:-1] ret_spy = spy_close.pct_change().shift(-1).iloc[:-1]
active_ret = ret.sub(ret_spy.values, axis=0)
df_buybacks = history_buybacks['executionprice'].unstack(level=0)
df_buybacks = df_buybacks.groupby(df_buybacks.index.date).mean() df_buybacks.columns = df.columns
df_close = df.reindex(df_buybacks.index)[~df_buybacks.isna()] df_buybacks = (df_buybacks - df_close)/df_close
data = pd.DataFrame(columns=["Buybacks", "Return"])
for row, row_buyback in zip(active_ret.reindex(df_buybacks.index).itertuples(), df_buybacks.itertuples()): index = row[0] for i in range(1, df_buybacks.shape[1]+1): if row_buyback[i] != 0: data = pd.concat([data, pd.DataFrame({"Buybacks": row_buyback[i], "Return":row[i]}, index=[index])])
data.dropna(inplace=True)
Test Hypothesis
We would test (1) if buyback has statistically significant effect on return direction, and (2) buyback could be a return predictor.
- Get binary return (+/-).
- Construct a logistic regression model.
- Display logistic regression results.
- Plot the results.
- Get in-sample prediction result.
- Call
confusion_matrix
to contrast the results. - Display the result.
binary_ret = data["Return"].copy() binary_ret[binary_ret < 0] = 0 binary_ret[binary_ret > 0] = 1
model = Logit(binary_ret.values, data["Buybacks"].values).fit()
display(model.summary())
We can see a p-value of < 0.05 in the logistic regression model, meaning the separation of positive and negative using buyback premium/discount% is statistically significant.
plt.figure(figsize=(10, 6)) sns.regplot(x=data["Buybacks"]*100, y=binary_ret, logistic=True, ci=None, line_kws={'label': " Logistic Regression Line"}) plt.plot([-50, 50], [0.5, 0.5], "r--", label="Selection Cutoff Line") plt.title("Buyback premium vs Profit/Loss") plt.xlabel("Buyback premium %") plt.xlim([-50, 50]) plt.ylabel("Profit/Loss") plt.legend() plt.show()
Interesting, from the logistic regression line, we observe that when the airlines brought their stock in premium price, the price tended to go down, while the opposite for buying back in discount.
Let's also study how good is the logistic regression.
predictions = model.predict(data["Buybacks"].values) for i in range(len(predictions)): predictions[i] = 1 if predictions[i] > 0.5 else 0
cm = confusion_matrix(binary_ret, predictions)
df_result = pd.DataFrame(cm, index=pd.MultiIndex.from_tuples([("Prediction", "Positive"), ("Prediction", "Negative")]), columns=pd.MultiIndex.from_tuples([("Actual", "Positive"), ("Actual", "Negative")]))
The logistic regression is having a 55.8% accuracy (55% sensitivity and 56.3% specificity), this can suggest a > 50% win rate before friction costs, proven our hypothesis.
Set Up Algorithm
Once we are confident in our hypothesis, we can export this code into backtesting. One way to accomodate this model into backtest is to create a scheduled event which uses our model to predict the expected return.
def initialize(self) -> None: #1. Required: Five years of backtest history self.set_start_date(2017, 1, 1) #2. Required: Alpha Streams Models: self.set_brokerage_model(BrokerageName.ALPHA_STREAMS) #3. Required: Significant AUM Capacity self.set_cash(1000000) #4. Required: Benchmark to SPY self.set_benchmark("SPY") self.set_portfolio_construction(EqualWeightingPortfolioConstructionModel()) self.set_execution(ImmediateExecutionModel()) # Set our strategy to be take 5% profit and 5% stop loss. self.add_risk_management(MaximumUnrealizedProfitPercentPerSecurity(0.05)) self.add_risk_management(MaximumDrawdownPercentPerSecurity(0.05)) # Select the airline tickers for research. self.symbols = {} assets = ["LUV", # Southwest Airlines "DAL", # Delta Airlines "UAL", # United Airlines Holdings "AAL", # American Airlines Group "SKYW", # SkyWest Inc. "ALGT", # Allegiant Travel Co. "ALK" # Alaska Air Group Inc. ] # Call the AddEquity method with the tickers, and its corresponding resolution. Then call AddData with SmartInsiderTransaction to subscribe to their buyback transaction data. for ticker in assets: symbol = self.add_equity(ticker, Resolution.MINUTE).symbol self.symbols[symbol] = self.add_data(SmartInsiderTransaction, symbol).symbol self.add_equity("SPY") # Initialize the model self.build_model() # Set Scheduled Event Method For Our Model Recalibration every month self.schedule.on(self.date_rules.month_start(), self.time_rules.at(0, 0), self.build_model) # Set Scheduled Event Method For Trading self.schedule.on(self.date_rules.every_day(), self.time_rules.before_market_close("SPY", 5), self.every_day_before_market_close)
We'll also need to create a function to train and update the logistic regression model from time to time.
def BuildModel(self) -> None: qb = self # Call the History method with list of tickers, time argument(s), and resolution to request historical data for the symbol. history = qb.History(list(self.symbols.keys()), datetime(2015, 1, 1), datetime.now(), Resolution.Daily) # Call SPY history as reference spy = qb.History(["SPY"], datetime(2015, 1, 1), datetime.now(), Resolution.Daily) # Call the History method with list of buyback tickers, time argument(s), and resolution to request buyback data for the symbol. history_buybacks = qb.History(list(self.symbols.values()), datetime(2015, 1, 1), datetime.now(), Resolution.Daily) # Select the close column and then call the unstack method to get the close price dataframe. df = history['close'].unstack(level=0) spy_close = spy['close'].unstack(level=0) # Call pct_change to get the daily return of close price, then shift 1-step backward as prediction. ret = df.pct_change().shift(-1).iloc[:-1] ret_spy = spy_close.pct_change().shift(-1).iloc[:-1] # Get the active return active_ret = ret.sub(ret_spy.values, axis=0) # Select the ExecutionPrice column and then call the unstack method to get the dataframe. df_buybacks = history_buybacks['executionprice'].unstack(level=0) # Convert buyback history into daily mean data df_buybacks = df_buybacks.groupby(df_buybacks.index.date).mean() df_buybacks.columns = df.columns # Get the buyback premium/discount df_close = df.reindex(df_buybacks.index)[~df_buybacks.isna()] df_buybacks = (df_buybacks - df_close)/df_close # Create a dataframe to hold the buyback and 1-day forward return data data = pd.DataFrame(columns=["Buybacks", "Return"]) # Append the data into the dataframe for row, row_buyback in zip(active_ret.reindex(df_buybacks.index).itertuples(), df_buybacks.itertuples()): index = row[0] for i in range(1, df_buybacks.shape[1]+1): if row_buyback[i] != 0: data = pd.concat([data, pd.DataFrame({"Buybacks": row_buyback[i], "Return":row[i]}, index=[index])]) # Call dropna to drop NaNs data.dropna(inplace=True) # Get binary return (+/-) binary_ret = data["Return"].copy() binary_ret[binary_ret < 0] = 0 binary_ret[binary_ret > 0] = 1 # Construct a logistic regression model self.model = Logit(binary_ret.values, data["Buybacks"].values).fit()
Now we export our model into the scheduled event method. We will switch qb
with self
and replace methods with their QCAlgorithm
counterparts as needed. In this example, this is not an issue because all the methods we used in research also exist in QCAlgorithm
.
def EveryDayBeforeMarketClose(self) -> None: qb = self # Get any buyback event today history_buybacks = qb.History(list(self.symbols.values()), timedelta(days=1), Resolution.Daily) if history_buybacks.empty or "executionprice" not in history_buybacks.columns: return # Select the ExecutionPrice column and then call the unstack method to get the dataframe. df_buybacks = history_buybacks['executionprice'].unstack(level=0) # Convert buyback history into daily mean data df_buybacks = df_buybacks.groupby(df_buybacks.index.date).mean() # ============================== insights = [] # Iterate the buyback data, thne pass to the model for prediction row = df_buybacks.iloc[-1] for i in range(len(row)): prediction = self.model.predict(row[i]) # Long if the prediction predict price goes up, short otherwise. Do opposite for SPY (active return) if prediction > 0.5: insights.append( Insight.Price(row.index[i].split(".")[0], timedelta(days=1), InsightDirection.Up) ) insights.append( Insight.Price("SPY", timedelta(days=1), InsightDirection.Down) ) else: insights.append( Insight.Price(row.index[i].split(".")[0], timedelta(days=1), InsightDirection.Down) ) insights.append( Insight.Price("SPY", timedelta(days=1), InsightDirection.Up) ) self.EmitInsights(insights)