Catching “Falling Knife” Stocks
Log in to collectAcademic paper
Strategy in a nutshell
The strategy invests in U.S. stocks that lost ≥50% over the past 500 days relative to the S&P 500, selecting those with low industry-relative debt. Equally weighted portfolios are rebalanced monthly to capture recovery potential.
Economic rationale
Investor overreaction to extreme losses creates mispricing. Stocks with strong financial stability (low debt) are more likely to recover, allowing the strategy to exploit behavioral-driven undervaluation.
Backtest performance
Annualised return23.09%
Beta0.978
Sortino ratio0.33
Win rate55%
Full Python code
from AlgorithmImports import *
from typing import List, Dict
import numpy as np
from numpy import isnan
class CatchingFallingKnifeStocks(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.fundamental_sorting_key = lambda x: x.MarketCap
self.fundamental_count:int = 3000
self.period:int = 500
self.long:List[Symbol] = []
# Daily data
self.data:Dict[Symbol, SymbolData] = {}
self.min_share_price:int = 5
self.leverage:int = 3
self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
if self.symbol not in self.data:
self.data[self.symbol] = SymbolData(self.symbol, self.period)
history:DataFrame = self.History(self.symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Note enough data for {symbol} yet")
else:
closes:Series = history.loc[self.symbol].close[:-1]
for time, close in closes.items():
self.data[self.symbol].update(close)
self.last_month:int = -1
self.selection_flag:bool = True
self.settings.daily_precise_end_time = False
self.settings.minimum_order_margin_portfolio_percentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.schedule.on(self.date_rules.month_start(self.market),
self.time_rules.after_market_open(self.market),
self.selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store daily price.
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected:List[Fundamental] = [
x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Price > self.min_share_price and x.MarketCap != 0 \
and not isnan(x.OperationRatios.TotalDebtEquityRatio.ThreeMonths) and x.OperationRatios.TotalDebtEquityRatio.ThreeMonths > 0 \
and not isnan(x.AssetClassification.MorningstarIndustryGroupCode) and x.AssetClassification.MorningstarIndustryGroupCode != 0
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
group:Dict[str, List[Symbol]] = {}
# Warmup price rolling windows.
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(symbol, self.period)
history = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].update(close)
if not self.data[symbol].is_ready():
continue
industry_group_code = stock.AssetClassification.MorningstarIndustryGroupCode
# Debt to equity ratio.
debt_to_equity = stock.OperationRatios.TotalDebtEquityRatio.ThreeMonths
self.data[symbol]._debt_to_equity = debt_to_equity
# Adding stocks in groups
if not industry_group_code in group:
group[industry_group_code] = []
group[industry_group_code].append(self.data[symbol])
if self.symbol in self.data and self.data[self.symbol].is_ready():
spy_ret:float = self.data[self.symbol].performance()
for industry_code in group:
industry_debt_to_equity_10th_percentile:float = np.percentile([symbol_data._debt_to_equity for symbol_data in group[industry_code]], 10)
# Stocks that suffered losses of 50 percent or more than s&p
# and
# stocks that have a Debt/Equity ratio within at least 10% of the lowest in the industry
long:List[Symbol] = [symbol_data._symbol for symbol_data in group[industry_code] if symbol_data.performance() <= (spy_ret - 0.5) \
and symbol_data._debt_to_equity <= industry_debt_to_equity_10th_percentile]
for symbol in long:
self.long.append(symbol)
return self.long
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# Trade execution.
targets:List[PortfolioTarget] = []
for symbol in self.long:
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, 1 / len(self.long)))
self.SetHoldings(targets, True)
self.long.clear()
def selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, symbol:Symbol, period:int) -> None:
self._symbol:Symbol = symbol
self._price:RollingWindow = RollingWindow[float](period)
self._debt_to_equity:float = 0.
def is_ready(self) -> bool:
return self._price.IsReady
def update(self, close:float) -> None:
self._price.Add(close)
def performance(self) -> float:
return (self._price[0] / self._price[self._price.Count - 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"))