Quant Buffet放轻松,别过度思虑

抄底“坠刀”股票策略

登录后收藏

学术论文

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"))