抄底“坠刀”股票策略
登录后收藏学术论文
Tactical allocation in falling stocks: Combining momentum and solvency ratio signals
作者:Piotr Arendarski
- ?机构:华沙大学经济科学学院
我们在2001年至2011年期间筛选了4500只年终亏损达到50%或以上的美国股票。为了提高复苏可能性并尽量减少幸存者偏差,我们对这些“坠刀”股票进行了财务状况筛选。筛选条件包括Altman Z分数、债务权益比等约束指标。研究结果表明,通过结合动量信号和偿债比率筛选,投资者能够有效识别具备复苏潜力的股票,显著提高风险调整后的回报率,同时降低投资组合的整体风险。
策略概要
该策略专注于美国股票(排除流动性差的股票,如价格低于0.5美元的股票和封闭式基金)。每月筛选在过去500个交易日中相对于标普500指数基准下跌50%或以上的股票。从中选择债务权益比(Debt/Equity)接近行业最低10%的股票构建投资组合。低债务比率表明这些股票财务状况更稳定,尽管经历了显著的历史亏损,但更可能实现复苏。投资组合采用等权重配置,每月重新平衡,旨在抓住被低估且在行业中财务稳健的投资机会。
策略合理性
该策略利用投资者对极端历史表现的过度反应,这种行为偏差可能导致对“坠刀”股票的过度抛售,进而创造低估值的机会。通过关注债务水平较低的股票,策略优先选择财务状况更稳定、复苏可能性更高的标的。这样的组合既能降低破产风险,也能提高抄底的成功率,为投资者创造长期回报潜力。
回测表现
年化收益23.09%
贝塔0.978
索提诺比率0.33
胜率55%
完整 Python 代码
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"))