投资范围包括来自23个发达股票市场的股票,数据来源于CRSP和Compustat。根据前20年相同日历月份的平均回报对因子进行排序,并将因子分为四分位数组。策略为:对平均回报最高的因子四分位做多,对平均回报最低的因子四分位做空。投资组合为市值加权,并每月重新平衡。

策略概述

投资范围包括来自23个发达股票市场的股票。(您可以使用CRSP获取美国的价格和市场数据,并使用Compustat获取会计和国际数据。)将所有因子根据前20年相同日历月份的平均回报进行排序。将因子分为四分位数组。

开始多空策略

a) 对平均相同日历月份回报最高的因子四分位进行做多;

b) 对平均相同日历月份回报最低的因子四分位进行做空。

假设投资组合为市值加权,并每月进行再平衡。

策略合理性

作者的研究结果展示了全球范围内显著的季节性因子效应。在39个市场中有15个市场显现出显著的季节性模式,这种现象在全球和区域样本中都得到了验证,且无法归因于常见的风险因子。此外,该现象并非由无条件回报的横截面差异或因子动量驱动,而是源于个股层面的模式:股票价格的动量传递至因子投资组合,导致其季节性。因此,因子季节性并不是独立的效应,而是其证券层面效应的反映。

论文来源

Factor Seasonalities: International and Further Evidence [点击浏览原文]

<摘要>

我们研究了国际市场中的因子回报季节性现象。通过对来自39个国家的最多143个特征排序投资组合进行分析,我们记录了普遍存在的横截面模式:在相同日历月份平均回报较高的异常表现优于平均回报较低的异常。这种效应在各个市场和全球样本中均持续存在,且无法归因于常见的风险因子。因子动量或无条件溢价的横截面差异也无法解释这一现象。相反,该效应源于价格的季节性,这一效应传递至因子投资组合,导致其回报的季节性。因此,因子季节性并不是一种独立的资产定价现象,而只是其股票层面等价物的反映。

回测表现

年化收益率2.92%
波动率5.51%
Beta0.017
夏普比率0.53
索提诺比率-0.056
最大回撤N/A
胜率50%

完整python代码

from AlgorithmImports import *
from typing import List, Dict
import numpy as np
# endregion

class SeasonalityinEquityLongShortFactorStrategies(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        # daily price data
        self.perf:Dict[str, float] = {}
        self.period:int = 21
        self.leverage:int = 10
        self.quantile:int = 4
        self.SetWarmUp(self.period, Resolution.Daily)

        # monthly returns
        self.monthly_returns:Dict[str, float] = {}
        self.min_seasonal_period:int = 5
        
        csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/backtest_end_year.csv')
        lines:str = csv_string_file.split('\r\n')
        last_id:None|str = None
        for line in lines[1:]:
            split:str = line.split(';')
            id:str = str(split[0])
            
            data:QuantpediaEquity = self.AddData(QuantpediaEquity, id, Resolution.Daily)
            data.SetLeverage(self.leverage)
            data.SetFeeModel(CustomFeeModel())

            self.perf[id] = self.ROC(id, self.period, Resolution.Daily)
            self.monthly_returns[id] = []
            
            if not last_id:
                last_id = id

        self.recent_month:int = -1
    
    def OnData(self, data):
        if self.IsWarmingUp:
            return
        if self.Time.month == self.recent_month:
            return
        self.recent_month = self.Time.month
        
        seasonal_return:Dict[str, float] = {}
        _last_update_date:Dict[str, datetime.date] = QuantpediaEquity.get_last_update_date()

        for id in self.perf:
            if self.perf[id].IsReady:# and id in data and data[id]:
                if _last_update_date[id] > self.Time.date():
                    # store monthly returns
                    perf:float = self.perf[id].Current.Value
                    self.monthly_returns[id].append((perf, self.Time.month - 1))
                    
                    # calculate seasonal performance of those strategies
                    seasonal_monthly_returns:List[float] = [x[0] for x in self.monthly_returns[id] if x[1] == self.Time.month]
                        
                    # monthly data for at least 5 years is ready
                    if len(seasonal_monthly_returns) >= self.min_seasonal_period:
                        seasonal_return[id] = np.average(seasonal_monthly_returns[-self.min_seasonal_period:])

        long:List[str] = []
        short:List[str] = []
        
        # seasonal return sorting
        if len(seasonal_return) >= self.quantile:
            sorted_by_perf:List[str] = sorted(seasonal_return.items(), key = lambda x: x[1], reverse = True)
            quantile:int = len(sorted_by_perf) // self.quantile
            long = [x[0] for x in sorted_by_perf[:quantile]]
            short = [x[0] for x in sorted_by_perf[-quantile:]]

        # trade execution
        invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in long + short:
                self.Liquidate(symbol)
        
        long_count:int = len(long)                
        short_count:int = len(short)
        
        for symbol in long:
            self.SetHoldings(symbol, 1 / long_count)
        for symbol in short:
            self.SetHoldings(symbol, -1 / short_count)
            
# Quantpedia strategy equity curve data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaEquity(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    _last_update_date:Dict[str, datetime.date] = {}

    @staticmethod
    def get_last_update_date() -> Dict[str, datetime.date]:
       return QuantpediaEquity._last_update_date

    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaEquity()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
        data['close'] = float(split[1])
        data.Value = float(split[1])
        
        # store last update date
        if config.Symbol.Value not in QuantpediaEquity._last_update_date:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()

        if data.Time.date() > QuantpediaEquity._last_update_date[config.Symbol.Value]:
            QuantpediaEquity._last_update_date[config.Symbol.Value] = data.Time.date()
        
        return data

# 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