“该策略投资于专利驱动型公司,利用技术邻近度计算回报,做多最高十分位数的TECHRET公司,做空最低十分位数的公司,每月重新平衡。”

I. 策略概要

该投资策略的目标是来自谷歌专利数据、CRSP和COMPUSTAT交叉的公司股票,重点关注具有有效市场权益、SIC分类和非负账面权益的普通股。符合条件的公司必须在过去五年内至少获得一项专利授权,不包括金融公司、价格低于1美元的股票和微市值股票。技术关联回报(TECHRET)计算为技术关联公司的平均回报,按技术邻近度(TECH)加权,TECH衡量427个USPTO技术类别中技术活动的重叠。TECH定义为专利向量的标准化标量积,范围从0到1,并将TECHRET偏向于技术空间中更接近的公司。公司的TECHRET计算为其技术关联公司的加权平均回报,权重与技术邻近度成正比。公司每月根据上个月的TECHRET分为十分位数。该策略在最高十分位数做多,在最低十分位数做空,形成每月重新平衡的价值加权投资组合。这种方法利用技术空间中的技术邻近度和回报可预测性,强调专利驱动的创新作为股票表现的预测指标。

II. 策略合理性

技术动量效应源于投资者注意力有限、信息处理限制、估值不确定性和套利成本(尤其是对于被忽视的公司)造成的市场低效率。即使在控制规模、账面市值比、盈利能力、资产增长、研发强度和价格动量等变量的情况下,这种效应仍然存在,并且无法用行业、客户或集团动量来解释。研究表明,技术关联公司的回报可靠地预测了焦点公司的股票回报,并且没有证据表明回报会随着时间的推移而逆转。这表明技术动量的可预测性源于创新被低估,而不是投资者反应过度,突显了其稳健性和长期可靠性。

III. 来源论文

Technological Links and Predictable Returns [点击查看论文]

<摘要>

利用公司之间技术邻近度的经典衡量标准,我们表明,技术关联公司的回报对焦点公司回报具有很强的预测能力。基于这种效应的多空策略产生了每月117个基点的阿尔法。这种效应不同于行业动量,并且不容易归因于基于风险的解释。对于以下焦点公司,这种效应更为明显:(a)具有更强烈和特定的技术重点,(b)受到投资者关注较少,以及(c)更难套利。我们的结果与对更细致的技术新闻的价格调整缓慢基本一致。

IV. 回测表现

年化回报8.6%
波动率18.17%
β值0.066
夏普比率0.25
索提诺比率0.016
最大回撤N/A
胜率49%

V. 完整的 Python 代码

from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
import data_tools
from typing import List, Dict
# endregion
class TechnologyMomentum(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100_000)
        self.international_data: bool = False
        self.tickers: List[str] = [
            'ABAX', 'ARAY', 'ACTU', 'ADBE', 'ADVS', 'AFFX', 'A', 'ALGN', 'AOSL', 'ALIN-A', 'AMZN', 'AMD', 'AAPL', 'AMAT',
            'AMCC', 'ARBA', 'ARUN', 'T', 'ATML', 'AXTI', 'BRCD', 'CDNS', 'CALD', 'CAVM', 'CPHD', 'CSCO', 'C', 'CDXS', 'COHR',
            'CPTS', 'CY', 'DEPO', 'DLGC', 'DIS', 'DLB', 'DWA', 'DSPG', 'EBAY', 'ELON', 'EA', 'EFII', 'EPOC', 'EQIX', 'EXAR',
            'EXEL', 'EXTR', 'FB', 'FCS', 'FNSR', 'F', 'FORM', 'FTNT', 'GE', 'GM', 'GDHX', 'GILD', 'GOOGL', 'GSIT', 'HLIT',
            'HPQ', 'HMC', 'IBM', 'IGTE', 'IKAN', 'IPXL', 'INFN', 'INFA', 'IDIT', 'ISSI', 'INTC', 'ISIL', 'IVAC', 'INTU',
            'ISRG', 'INVN', 'IPA', 'IXYS', 'JDSU', 'JNPR', 'KEYN', 'KLAC', 'LRCX', 'LF', 'LLTC', 'LSI', 'LLC', 'MTSN',
            'MXIM', 'MERU', 'MCRL', 'MSFT', 'MPWR', 'ONTO', 'NTUS', 'NPTN', 'NTAP', 'NFLX', 'NTGR', 'N', 'NSANY', 'NVLS',
            'NVDA', 'OCLR', 'OCZ', 'OMCL', 'OVTI', 'ONXX', 'OPEN_old', 'OPWV', 'OPLK', 'OPXT', 'ORCL', 'P', 'PSEM', 'PLXT',
            'PMCS', 'PLCM', 'POWI', 'QMCO', 'QNST', 'RMBS', 'MKTG', 'RVBD', 'ROVI', 'SABA', 'CRM', 'SNDK', 'SANM', 'SCLN',
            'SREV', 'SHOR', 'SFLY', 'SIGM', 'SGI', 'SIMG', 'SLTM', 'CODE', 'SPWR', 'SMCI', 'NLOK', 'SYMM', 'SYNA', 'SNX',
            'SNPS', 'TNAV', 'TSLA', 'XPER', 'THOR', 'TIBX', 'TIVO', 'TYO', 'TRID', 'TRMB', 'TWTR', 'UI', 'UCTT', 'ULTRACEMCO',
            'VAR', 'PAY', 'VMW', 'VLTR', 'WMT', 'XLNX', 'YAHOO', 'DZSI', 'ZNGA'
        ]
        self.years_period: int = 5
        self.min_prices: int = 15
        self.quantile: int = 10
        self.leverage: int = 5
        self.weights: Dict[Symbol, float] = {}
        self.count_by_class_by_date: Dict[datetime.date, Dict[str, int]] = {}
        self.data: Dict[str, data_tools.SymbolData] = {}
        self.classification_symbol_by_ticker: Dict[str, Symbol] = {}
        for ticker in self.tickers:
            classification_symbol: Symbol = self.AddData(data_tools.QuantpediaPatentClassification, ticker, Resolution.Daily).Symbol
            self.classification_symbol_by_ticker[ticker] = classification_symbol
            self.data[ticker] = data_tools.SymbolData()
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        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(market), self.TimeRules.BeforeMarketClose(market), self.Selection)
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(data_tools.CustomFeeModel())
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # daily update prices required in stock's monthly return calculation
        for equity in fundamental:
            ticker: str = equity.Symbol.Value
            if ticker in self.data:
                self.data[ticker].update_prices(equity.AdjustedPrice)
        if not self.selection_flag:
            return Universe.Unchanged
        start_boundry_date: datetime.date = self.Time.date() - relativedelta(years=self.years_period)
        for _, symbol_obj in self.data.items():
            # reset of prices and update of monthly return, which is required for each stock on monthly basis, to keep valid data
            if symbol_obj.prices_ready(self.min_prices):
                symbol_obj.update_monthly_return()
            else:
                symbol_obj.reset_monthly_return()
            symbol_obj.reset_prices()
            
            # remove stock's patents classifications, which won't be needed anymore
            symbol_obj.remove_needless_classifications(start_boundry_date)
        selected: List[Fundamental] = [
            x for x in fundamental
            if x.Symbol.Value in self.data
            and x.MarketCap != 0
            and self.data[x.Symbol.Value].monthly_return_ready() 
            and self.data[x.Symbol.Value].classifications_ready()]
        if len(selected) == 0:
            return Universe.Unchanged    
        dates_to_remove: datetime.date = []
        companies_patents_classes: List[str] = []
        start_boundry_date: datetime.date = self.Time.date() - relativedelta(years=self.years_period)
        for date, count_by_class in self.count_by_class_by_date.items():
            if date < start_boundry_date:
                dates_to_remove.append(date)
                continue
            for patent_class, count in count_by_class.items():
                if patent_class not in companies_patents_classes:
                    companies_patents_classes.append(patent_class)
        # these days won't be needed anymore
        for date in dates_to_remove:
            del self.count_by_class_by_date[date]
        monthly_return_by_stock: Dict[Fundamental, float] = {}
        vector_by_stock: Dict[Fundamental, np.ndarray] = {}
        for stock in selected:
            ticker: str = stock.Symbol.Value
            proportional_vector: List[int] = self.data[ticker].get_proportional_vector(companies_patents_classes)
            vector_by_stock[stock] = np.array(proportional_vector)
            monthly_return: float = self.data[ticker].get_monthly_return()
            monthly_return_by_stock[stock] = monthly_return
        TECH_RET: Dict[Fundamental, float] = {}
        for stock1, vector1 in vector_by_stock.items():
            TECH: float = 0
            RET: float = 0
            for stock2, vector2 in vector_by_stock.items():
                if stock1 != stock2:
                    scala_product: float = vector1.dot(vector2)
                    if scala_product == 0:
                        continue
                    TECH += scala_product / ((scala_product ** 0.5) * (scala_product ** 0.5))
                    RET += monthly_return_by_stock[stock2]
            if TECH != 0 and RET != 0:
                TECH_RET[stock1] = (TECH * RET) / TECH
        if len(TECH_RET) < self.quantile:
            return Universe.Unchanged
        quantile: int = int(len(TECH_RET) / self.quantile)
        sorted_by_TECH_RET: List[Fundamental] = [x[0] for x in sorted(TECH_RET.items(), key=lambda item: item[1])]
        long_leg: List[Fundamental] = sorted_by_TECH_RET[-quantile:]
        short_leg: List[Fundamental] = sorted_by_TECH_RET[:quantile]
        for i, portfolio in enumerate([long_leg, short_leg]):
            mc_sum: float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
            for stock in portfolio:
                self.weights[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum
        return list(self.weights.keys())
        
    def OnData(self, data: Slice) -> None:
        curr_date: datetime.date = self.Time.date()
        custom_data_last_update_date: Dict[Symbol, datetime.date] = data_tools.QuantpediaPatentClassification.get_last_update_date()
        latest_update_date: datetime.date = max(date for date in custom_data_last_update_date.values())
        stock_to_delete: List[str] = []
        # if self.time.date() > latest_update_date:
        #     self.liquidate()
        #     return
        for stock_ticker, classification_symbol in self.classification_symbol_by_ticker.items():
            if self.Securities[stock_ticker].GetLastData() and self.Time.date() > custom_data_last_update_date[stock_ticker]:
                self.Liquidate(stock_ticker)
                stock_to_delete.append(stock_ticker)
                continue
            
            if data.contains_key(classification_symbol) and data[classification_symbol]:
                if curr_date not in self.count_by_class_by_date:
                    self.count_by_class_by_date[curr_date] = {}
                symbol_obj: SymbolData = self.data[stock_ticker]
                wanted_property: str = 'international' if self.international_data else 'u.s.'
                patent_classes: List[str] = list(data[classification_symbol].GetProperty(wanted_property))
                for patent_class in patent_classes:
                    if patent_class not in self.count_by_class_by_date[curr_date]:
                        self.count_by_class_by_date[curr_date][patent_class] = 0
                    self.count_by_class_by_date[curr_date][patent_class] += 1
                    symbol_obj.update_patents(curr_date, patent_class)
        for ticker in stock_to_delete:
            self.classification_symbol_by_ticker.pop(ticker)
        # rebalance monthly
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        portfolio: List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weights.items() if data.contains_key(symbol) and data[symbol]]
        self.SetHoldings(portfolio, True)
        self.weights.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读