“长期继任投资组合”包括已宣布首席执行官离职但尚未任命新首席执行官的公司。该投资组合采用等权重,每月进行再平衡,并针对卡哈特四因子(市场、价值、规模、动量)进行对冲。”

I. 策略概要

该策略的投资范围是标普1500指数中的公司。“长期继任投资组合”专门针对处于“跛脚鸭”状态的首席执行官所在的公司,即当前首席执行官已宣布离职,但新任首席执行官尚未确定。这些公司会在现任首席执行官离职声明发布后的下个月初被纳入投资组合,并一直保留到新任首席执行官在月底公布为止。该投资组合采用等权重,每月进行再平衡,并针对卡哈特的四因子(市场、价值、规模和动量)进行对冲。

II. 策略合理性

该论文指出了在“跛脚鸭”首席执行官(CEO)时期出现正超额回报的两个原因。首先,投资者对这段时期缺乏关于继任者的消息反应迟钝,导致了这种异常现象。风险率测试表明,这种反应迟钝解释了部分正超额回报。其次,经济机制涉及在现任CEO离职公告后,内部锦标赛会选择继任者。这种竞争有利于公司价值,但其影响会逐渐反映到股价中。具有高内部锦标赛竞争的公司显示出显著的月度阿尔法,达到1.5%。此外,由内部晋升的新CEO领导的公司表现更佳,月度超额回报超过2%。进一步的测试表明,CEO离职动机、临时CEO或董事会绩效等因素并不能解释这些异常回报,这支持了内部锦标赛竞争是主要驱动因素的观点。

III. 来源论文

Lame-Duck CEOs [点击查看论文]

<摘要>

金融当局和投资者对旷日持久的首席执行官(CEO)继任表示担忧。我们发现大约三分之一的CEO继任是旷日持久的,在此期间,“跛脚鸭”CEO会继续管理公司约六个月,然后才宣布继任者。尽管市场对旷日持久的继任公告反应负面,但由“跛脚鸭”CEO管理的公司在多项指标上表现良好:它们产生了每年9.6%的四因子阿尔法,并在盈利公告前后表现出正异常回报。通过测试不同的机制,我们发现当内部候选人之间的竞争更加激烈时,结果会更强劲。我们的研究结果表明,市场对由“跛脚鸭”CEO管理的公司价值存在错误定价,但旷日持久的继任对公司价值并无损害。

IV. 回测表现

年化回报11.35%
波动率14.71%
β值-0.033
夏普比率0.5
索提诺比率-0.009
最大回撤N/A
胜率51%

V. 完整的 Python 代码

from AlgorithmImports import *
from scipy import stats
from typing import List, Dict
from pandas.core.frame import dataframe
from pandas.core.series import Series
# endregion
class LameDuckCEOs(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        
        # Source: https://zenodo.org/record/4543893#.YwNo1xxBw2w
        self.ceo_departure_dates: Symbol = self.AddData(CEODepartureDates, "CEODepartureDates", Resolution.Daily).Symbol
        self.departure_company_tickers: List[str] = []           # recent month CEO departures
        self.departure_company_ticker_universe: set = set()      # ticker universe from the whole dataset
        self.selected_universe: List[Symbol] = []                # currently monthly selected stock universe
        
        self.price_data: Dict[Symbol, RollingWindow] = {}        # daily price data
        self.period: int = 12 * 21
        self.leverage: int = 10
        self.min_share_price: int = 5
        self.market: Symbol = self.AddEquity("SPY", Resolution.Daily, leverage=self.leverage).Symbol
        self.price_data[self.market] = RollingWindow[float](self.period)
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        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(CustomFeeModel())
            
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update the rolling window every day
        for stock in fundamental:
            symbol = stock.Symbol
            # Store monthly price.
            if symbol in self.price_data:
                self.price_data[symbol].Add(stock.AdjustedPrice)
        if not self.selection_flag:
            return Universe.Unchanged
                
        selected: List[Symbol] = [
            x.Symbol for x in fundamental 
            if x.Market == 'usa' 
            and x.Price > self.min_share_price 
            and x.Symbol.Value in self.departure_company_tickers
        ]
        for symbol in selected:
            if symbol in self.price_data:
                continue
            
            self.price_data[symbol] = RollingWindow[float](self.period)
            history: dataframe = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet.")
                continue
            closes: Series = history.loc[symbol].close
            for time, close in closes.items():
                self.price_data[symbol].Add(close)
        
        if self.price_data[self.market].IsReady:
            self.selected_universe = [x for x in selected if self.price_data[x].IsReady]
        
        return self.selected_universe
    def OnData(self, data: Slice) -> None:
        custom_data_last_update_date: Dict[Symbol, datetime.date] = CEODepartureDates.get_last_update_date()
        if self.Securities[self.ceo_departure_dates].GetLastData() and self.Time.date() > custom_data_last_update_date[self.ceo_departure_dates]:
            self.Liquidate()
            return Universe.Unchanged
        # store new ceo departure data
        if data.ContainsKey(self.ceo_departure_dates):
            # store whole ticker universe
            if len(self.departure_company_ticker_universe) == 0:
                self.departure_company_ticker_universe = CEODepartureDates._ticker_universe
            departure_tickers:str = data[self.ceo_departure_dates].GetProperty('stocks')
            for t in departure_tickers:
                self.departure_company_tickers.append(t)
        # monthly rebalance
        if not self.selection_flag:
            return
        self.selection_flag = False
        # select long leg
        long: List[Symbol] = []
        for symbol in self.selected_universe:
            if symbol.Value in self.departure_company_tickers:
                long.append(symbol)
        # reset ceo departure for the recent month
        self.departure_company_tickers.clear()
        if len(long) == 0:
            if self.Portfolio.Invested:
                self.Liquidate()
            return
        # order execution
        total_beta: float = 0.
        market_prices: np.ndarray = np.array(list(self.price_data[self.market]))
        market_perf_data: np.ndarray = market_prices[:-1] / market_prices[1:] - 1
        targets: List[PortfolioTarget] = []
        for symbol in long:
            # calculate beta to market for each stock
            stocks_prices: np.ndarray = np.array(list(self.price_data[symbol]))
            stock_perf_data: np.ndarray = stocks_prices[:-1] / stocks_prices[1:] - 1
            slope, intercept, r_value, p_value, std_err = stats.linregress(market_perf_data, stock_perf_data)
            total_beta += slope
            if data.contains_key(symbol) and data[symbol]:
                targets.append(PortfolioTarget(symbol, 1 / len(long)))
        self.SetHoldings(targets, True)
        avg_beta_to_market: float = total_beta / len(long)
        # market hedge
        self.SetHoldings(self.market, -avg_beta_to_market)
    def Selection(self) -> None:
        if len(self.departure_company_ticker_universe) != 0:
            self.selection_flag = True
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
# CEO departure dates.
# SOURCE: https://zenodo.org/record/4543893#.YwNo1xxBw2w
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class CEODepartureDates(PythonData):
    _ticker_universe:Set[str] = set()
    _last_update_date:Dict[Symbol, datetime.date] = {}
    def GetSource(self, config:SubscriptionDataConfig, date:datetime, isLiveMode:bool) -> SubscriptionDataSource:
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/economic/ceo_departure_dates.json", SubscriptionTransportMedium.RemoteFile, FileFormat.UnfoldingCollection)
   
    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return CEODepartureDates._last_update_date
    @staticmethod
    def get_ticker_universe() -> list:
       return list(CEODepartureDates._ticker_universe)
    def Reader(self, config:SubscriptionDataConfig, line:str, date:datetime, isLiveMode:bool) -> BaseData:
        objects:list[CEODepartureDates] = []
        data:list[dict] = json.loads(line)
        end_time:datetime.date|None = None
        for index, sample in enumerate(data):
            custom_data:CEODepartureDates = CEODepartureDates()
            custom_data.Symbol = config.Symbol
            
            departure_date:datetime.date = datetime.strptime(sample['departure_date'], '%Y-%m-%d')
            custom_data.Time = departure_date
            custom_data.EndTime = custom_data.Time + timedelta(days=1)
            custom_data['stocks'] = sample['stocks']
            custom_data.Value = 1
            end_time = custom_data.EndTime
            # store last date of the symbol
            if config.Symbol not in CEODepartureDates._last_update_date:
                CEODepartureDates._last_update_date[config.Symbol] = datetime(1,1,1).date()
            if custom_data.Time.date() > CEODepartureDates._last_update_date[config.Symbol]:
                CEODepartureDates._last_update_date[config.Symbol] = custom_data.Time.date()
            for ticker in sample['stocks']:
                CEODepartureDates._ticker_universe.add(ticker)
            objects.append(custom_data)
        return BaseDataCollection(end_time, config.Symbol, objects)

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读