
“该策略投资于专利驱动型公司,利用技术邻近度计算回报,做多最高十分位数的TECHRET公司,做空最低十分位数的公司,每月重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 技术、动量
I. 策略概要
该投资策略的目标是来自谷歌专利数据、CRSP和COMPUSTAT交叉的公司股票,重点关注具有有效市场权益、SIC分类和非负账面权益的普通股。符合条件的公司必须在过去五年内至少获得一项专利授权,不包括金融公司、价格低于1美元的股票和微市值股票。技术关联回报(TECHRET)计算为技术关联公司的平均回报,按技术邻近度(TECH)加权,TECH衡量427个USPTO技术类别中技术活动的重叠。TECH定义为专利向量的标准化标量积,范围从0到1,并将TECHRET偏向于技术空间中更接近的公司。公司的TECHRET计算为其技术关联公司的加权平均回报,权重与技术邻近度成正比。公司每月根据上个月的TECHRET分为十分位数。该策略在最高十分位数做多,在最低十分位数做空,形成每月重新平衡的价值加权投资组合。这种方法利用技术空间中的技术邻近度和回报可预测性,强调专利驱动的创新作为股票表现的预测指标。
II. 策略合理性
技术动量效应源于投资者注意力有限、信息处理限制、估值不确定性和套利成本(尤其是对于被忽视的公司)造成的市场低效率。即使在控制规模、账面市值比、盈利能力、资产增长、研发强度和价格动量等变量的情况下,这种效应仍然存在,并且无法用行业、客户或集团动量来解释。研究表明,技术关联公司的回报可靠地预测了焦点公司的股票回报,并且没有证据表明回报会随着时间的推移而逆转。这表明技术动量的可预测性源于创新被低估,而不是投资者反应过度,突显了其稳健性和长期可靠性。
III. 来源论文
Technological Links and Predictable Returns [点击查看论文]
- 查尔斯·M·C·李、史蒂芬·邓·孙、荣飞·王和冉·张。华盛顿大学福斯特商学院;斯坦福大学商学院。香港城市大学(CityU)经济与金融系;香港城市大学(CityU)会计学系。中国投资公司(CIC)。中国人民大学商学院
<摘要>
利用公司之间技术邻近度的经典衡量标准,我们表明,技术关联公司的回报对焦点公司回报具有很强的预测能力。基于这种效应的多空策略产生了每月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