投资范围包括所有在NYSE、AMEX和NASDAQ上市的美国股票,排除股价低于1美元的股票,数据来自证券价格研究中心(CRSP)。为了避免异常值,数据在0.5%和99.5%处进行分位修正。首先,确定每只股票在t月份内的收益率,并计算收益的权重w_j = exp[r(j-1)],其中r = 0.13。接着,根据公式计算相对权重w_(j,t)和加权亏损频率f_(i,t),后者基于上个月的指数加权日收益率,仅考虑负收益天数。每月末,基于加权亏损频率对股票进行十分位排序,做多底部十分位(亏损频率最高)股票,做空顶部十分位(亏损频率最低)股票,策略按市值加权并每月重新平衡。

策略概述

投资范围包括所有在 NYSE、AMEX 和 NASDAQ 上市的美国股票,股价低于1美元的股票被排除在外。数据来自证券价格研究中心(CRSP)。为了避免异常值,数据在0.5%和99.5%处进行分位修正。首先,对于每只股票 i,确定 t 月份内的 j-th 日收益率。接着,计算股票 i 在 t 月内 j 次收益的权重 w_j = exp[r(j-1)],其中 r = 0.13。随后,根据第8页的方程(3.2)计算 t 月内股票 i 的 j 次收益的相对权重 (w_(j,t))。最后,根据第8页的方程(3.1),计算股票 i 在 t 月的加权亏损频率 (f_(i,t)),其基于上个月的指数加权日收益率,但只考虑负收益的天数。在每个月结束时,基于加权亏损频率对股票进行十分位排序,做多加权亏损频率最高的股票(底部十分位),做空加权亏损频率最低的股票(顶部十分位)。该策略按市值加权,每月重新平衡。

策略合理性

该策略的核心在于投资者的启发式思维——他们试图尽量减少决策时间,寻找最简单的解决方案,而不是花大量时间进行分析。因此,作者没有研究收益率的幅度,只关注上个月的正负收益天数。研究结果表明,人们倾向于二元思考——他们将数据分为两个对立的群体,简单地将收益识别为正或负,而不考虑其大小。因此,上个月高频的负收益会导致股票的异常抛售和随后的低估。此外,作者发现,指数加权比等权重表现更好,表明最新的信息最为重要。最后,研究还表明该策略的收益对于散户投资者持有的股票更为显著,证实了散户投资者在做投资决策时往往采用简单的启发式方法。

论文来源

Do investors care about negative returns? [点击浏览原文]

<摘要>

本文分析了频繁负收益和近期偏差对未来股市回报的影响。我们提出了一种基于上个月内日负收益次数的策略,投资者可以年化收益率达11.9%。我们的研究表明,指数加权收益率优于等权重,并且在多种现有的风险因素和公司特征下具有稳健性,表明当月的最新观察受到投资者更多的关注,并对未来表现最为相关。即使在交易成本之后,该指数加权策略在标普500成分股中仍然表现显著。虽然指数加权策略对机构和散户投资者持有的股票均有正向收益,但在散户交易者持有的股票中表现尤为突出。

回测表现

年化收益率11.9%
波动率12.34%
Beta0.171
夏普比率0.96
索提诺比率0.237
最大回撤N/A
胜率50%

完整python代码

from AlgorithmImports import *
from pandas.core.frame import DataFrame
from typing import List, Dict
import numpy as np
from collections import deque
# endregion

class WeightedFrequencyofLosses(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)  

        self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.tickers_to_ignore:List[str] = ['CDLB']

        self.weight:Dict[Symbol, float] = {}
        self.data:Dict[Symbol, deque] = {}

        self.r:float = 0.13
        self.quantile:int = 10
        self.leverage:int = 5
        self.period = 30

        self.fundamental_count:int = 3000
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0
        self.AddUniverse(self.FundamentalSelectionFunction)
        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.SetLeverage(self.leverage)
            security.SetFeeModel(CustomFeeModel())

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update the rolling window every day
        for stock in fundamental:
            symbol:Symbol = stock.Symbol

            # store monthly price
            if symbol in self.data:
                self.data[symbol].append((self.Time, 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.Symbol.Value not in self.tickers_to_ignore and x.MarketCap != 0 and \
                (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')]
        # selected:List[Fundamental] = [x
        #     for x in sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Symbol.Value not in self.tickers_to_ignore],
        #         key = lambda x: x.DollarVolume, reverse = True)[:self.fundamental_count]]
        
        # warmup price rolling windows
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = deque(maxlen=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:pd.Series = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].append((time, close))

        # selected = [x for x in selected if x.MarketCap != 0 and \
        #         (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')]

        if len(selected) > self.fundamental_count:
            selected = sorted(selected, key=lambda x: x.MarketCap, reverse=True)[:self.fundamental_count]

        selected:Dict[Symbol, Fundamental] = {x.Symbol: x for x in selected if len(self.data[x.Symbol]) == self.data[x.Symbol].maxlen}

        if len(selected) != 0:
            # create dataframe from saved prices
            selected_stocks:Dict[Symbol, List[float]] = {symbol: [i[1] for i in value] for symbol, value in self.data.items() if symbol in selected.keys()}
            df_stocks:DataFrame = pd.DataFrame(selected_stocks, index=[i[0] for i in list(self.data.values())[0]])
            
            # trim dataframe to most recent month period
            last_month_start:datetime.date = (self.Time.date() - timedelta(self.Time.day + 1)).replace(day=1)
            df_stocks = df_stocks.pct_change()
            df_stocks = df_stocks[df_stocks.index.date >= last_month_start]
            
            # indicator function
            df_stocks[df_stocks >= 0.] = 0.
            df_stocks[df_stocks < 0.] = 1.
            
            # weighted frequency
            weights:np.ndarray = np.array([np.exp(self.r * (j - 1)) for j in range(1, len(df_stocks) + 1)])
            weights = weights / sum(weights)

            df_stocks = df_stocks.mul(weights, axis=0)
            df_stocks = df_stocks.sum(axis=0)

            VFL:Dict[Symbol, float] = df_stocks.to_dict()

            # sort and divide to upper decile and lower decile
            if len(VFL) >= self.quantile:
                sorted_VFL:List[Symbol] = sorted(VFL, key=VFL.get, reverse=True)
                quantile:int = int(len(sorted_VFL) / self.quantile)
                long:List[Symbol] = sorted_VFL[:quantile]
                short:List[Symbol] = sorted_VFL[-quantile:]

                # calculate weights based on marketcap
                for i, portfolio in enumerate([long, short]):
                    mc_sum:float = sum([selected[x].MarketCap for x in portfolio])
                    for symbol in portfolio:
                        self.weight[symbol] = ((-1) ** i) * (selected[symbol].MarketCap / mc_sum)

        return list(self.weight.keys())
    
    def OnData(self, data: Slice) -> None:
        # monthly rebalance
        if not self.selection_flag:
            return
        self.selection_flag = False

        # trade execution
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)

        self.weight.clear()

    def Selection(self) -> None:
        self.selection_flag = True
    
# custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading