
“该策略投资于纽约证券交易所、美国证券交易所和纳斯达克股票,按专利市场比率排序,做多高比率公司,做空低比率公司,并每年进行价值加权重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每年 | 市场: 股票 | 关键词: 专利
I. 策略概要
该策略的目标是具有有效会计和回报数据的纽约证券交易所、美国证券交易所和纳斯达克股票,不包括金融公司、基金、信托、美国存托凭证(ADR)、房地产投资信托基金(REIT)和账面价值为负的公司。它仅包括至少一项已授权专利的公司。
新授权专利的市场价值(MTMT)是根据专利授权日两天内的股票市场反应估算的,基于市值超额变化。专利的累计市场价值(CMPCMP)是每年为每家公司递归计算的。专利市场比率(PTM)计算为CMP/MVCMP / MV,其中MVMV是公司的市场价值。
公司按其PTM比率分为十分位数。多空策略投资于最高PTM十分位数并卖空最低十分位数,采用每年重新平衡的价值加权投资组合。这种方法利用专利估值与股票表现之间的关系来捕捉由创新价值驱动的回报。
II. 策略合理性
专利作为无形资产,对公司的增长、生产力和绩效至关重要。虽然衡量无形资产具有挑战性,但专利市场比率(PTM)提供了一种直接的方法来评估公司归因于其专利库存的市场价值。专利在资产定价中的重要性显而易见,平均PTM比率从1965年的6.91%上升到2010年的13.59%,反映出它们日益增长的重要性。与捕获私人和社会发明价值并依赖未来数据的基于引用的衡量标准不同,PTM比率使用历史股价信息,避免了前瞻性偏差。它提供了按市场价值标准化的货币价值,使公司之间易于比较,并提供了专利对公司估值影响的实用且可靠的衡量标准。
III. 来源论文
Patent-to-Market Premium [点击查看论文]
- 邱家平(Jiaping Qiu)、曾凯文(Kevin Tseng)和张超(Chao Zhang)。麦克马斯特大学(McMaster University)——迈克尔·G·德格鲁特商学院(Michael G. DeGroote School of Business);香港中文大学(The Chinese University of Hong Kong,CUHK)——商学院(CUHK Business School);国立台湾大学(National Taiwan University)——金融系(Department of Finance);国立台湾大学——计量经济理论与应用研究中心;上海财经大学(Shanghai University of Finance and Economics)
<摘要>
公司的专利市场比率(PTM)是指公司市场价值中归因于其专利市场价值的百分比。基于PTM比率的对冲投资组合每月产生71个基点的回报。对于PTM比率较低的公司,资本资产定价模型(CAPM)不能被拒绝,但对于PTM比率较高的公司,CAPM被拒绝。PTM比率是一个定价因子,与股票回报横截面中的已知因子不同。PTM比率与未来盈利能力呈正相关。我们的分析表明,实物期权是通过PTM比率预测未来股票回报的渠道。


IV. 回测表现
| 年化回报 | 5.91% |
| 波动率 | 11.7% |
| β值 | 0.175 |
| 夏普比率 | 0.16 |
| 索提诺比率 | 0.103 |
| 最大回撤 | N/A |
| 胜率 | 52% |
V. 完整的 Python 代码
from AlgorithmImports import *
from enum import Enum
from dateutil.relativedelta import relativedelta
from pandas.tseries.offsets import BDay
from collections import deque
from typing import List, Dict
#endregion
class PortfolioWeighting(Enum):
EQUALLY_WEIGHTED = 1
VALUE_WEIGHTED = 2
INVERSE_VOLATILITY_WEIGHTED = 3
class PatentToMarketEquityFactor(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2005, 1, 1)
self.SetCash(100_000)
# parameters
self.reaction_period_after_patent: int = 2 # check for reaction of n days after patent grant
self.d_period_after_patent: int = self.reaction_period_after_patent + 1 # n of needed daily prices for performance after patent grant calculation
self.d_volatility_period: int = 60 # daily volatility calculation period
self.m_cumulative_period: int = 12 # calculate CPM value using n-month cumulative patent performance history
self.m_rebalance_period: int = 12 # rebalance once a n months
self.quantile: int = 10 # portfolio percentile selection (3-tercile; 4-quartile; 5-quintile; 10-decile and so on)
self.leverage: int = 20
self.portfolio_weighting: PortfolioWeighting = PortfolioWeighting.EQUALLY_WEIGHTED
# assign larger daily period if volatility weighting is set
if self.portfolio_weighting == PortfolioWeighting.INVERSE_VOLATILITY_WEIGHTED:
self.max_period: int = max(self.d_volatility_period, self.d_period_after_patent)
else:
self.max_period: int = self.d_period_after_patent
self.required_exchanges: List[str] = ['NYS', 'NAS', 'ASE']
self.CMPs: Dict[str, float] = {} # recent CPM value storage
self.weights: Dict[Symbol, float] = {} # recent portfolio selection traded weights
self.patent_dates: Dict[datetime.datetime, list[str]] = {} # storing list of stocks keyed by their patent date
self.market_moves: Dict[str, list[tuple(float, datetime.datetime.date)]] = {} # storing all market moves in one year keyed by stock's ticker
# Source: https://companyprofiles.justia.com/companies
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/economic/patents.csv')
lines: List[str] = csv_string_file.split('\r\n')
# select header, then exclude 'date'
tickers: List[str] = lines[0].split(';')[1:]
# store RollingWindow object keyed by stock ticker
self.prices: Dict[str, deque] = { ticker : deque(maxlen=self.max_period) for ticker in tickers }
for line in lines[1:]:
if line == '':
continue
line_split: List[str] = line.split(';')
date: datetime.date = datetime.strptime(line_split[0], "%d.%m.%Y").date()
# initialize empty list for stock's tickers, which have patent in current date
self.patent_dates[date] = []
length: int = len(line_split)
for index in range(1, length):
# store stock's ticker into list, when stock has patent in current date
if line_split[index] != '0.0' and line_split[index] != '0':
self.patent_dates[date].append(tickers[index - 1])
self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
# add market to prices dictionary
self.prices[self.market.Value] = deque(maxlen=self.max_period)
self.symbol_by_ticker:dict[str, Symbol] = {}
self.month_counter: int = 0
self.selection_flag: bool = False
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.BeforeMarketClose(self.market), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# update daily prices
for stock in fundamental:
ticker:str = stock.Symbol.Value
if ticker in self.prices:
self.symbol_by_ticker[ticker] = stock.Symbol
if stock.AdjustedPrice != 0:
self.prices[ticker].append((self.Time.date(), stock.AdjustedPrice))
days_before: datetime.datetime = (self.Time - BDay(self.reaction_period_after_patent)).date()
# check if there was any patent granted in d_period_after_patent days before todays date
# market has to have price data ready
if days_before in self.patent_dates and len(self.prices[self.market.Value]) == self.prices[self.market.Value].maxlen:
if self.prices[self.market.Value][-self.d_period_after_patent][0] == days_before:
# calculate market's return for last d_period_after_patent days
market_return: float = self.prices[self.market.Value][-1][1] / self.prices[self.market.Value][-self.d_period_after_patent][1] - 1
tickers: List[str] = self.patent_dates[days_before]
# calc market moves
for ticker in tickers:
# if not self.prices[ticker].IsReady:
if len(self.prices[ticker]) != self.prices[ticker].maxlen:
continue
if self.prices[ticker][-self.d_period_after_patent][0] == days_before:
# calc stock's return for last d_period_after_patent days
stock_return: float = self.prices[ticker][-1][1] / self.prices[ticker][-self.d_period_after_patent][1] - 1
# calc excess market move value
market_move_value: float = stock_return - market_return
if ticker not in self.market_moves:
self.market_moves[ticker] = []
self.market_moves[ticker].append((days_before, market_move_value))
# rebalance yearly
if not self.selection_flag:
return Universe.Unchanged
# select stocks, which has at least one market move value
selected: List[Fundamental] = [
x for x in fundamental
if x.MarketCap != 0
and x.SecurityReference.ExchangeId in self.required_exchanges
and x.CompanyReference.IsREIT != 1
and x.Symbol.Value in self.market_moves
]
PMT:dict[Fundamental, float] = {} # stores stock's PMT value keyed by stock's object
volatility:dict[Symbol, float] = {} # stores volatility values for each symbol in current selection
for stock in selected:
symbol: Symbol = stock.Symbol
ticker: str = symbol.Value
market_cap: float = stock.MarketCap
# fetch only market moves stored within cumulative period window
sum_market_move: float = sum([x[1] for x in self.market_moves[ticker] if x[0] >= (self.Time - relativedelta(months=self.m_cumulative_period)).date()])
# in case there isn't last_CMP use formula: CMP = MP / (g + gama), otherwise use formula: # CMP = (1 - gama) * last_CMP + MP
curr_CMP_value: float = 0.85 * self.CMPs[ticker] + sum_market_move if ticker in self.CMPs else sum_market_move / (0.20 + 0.15)
# store new current CMP value keyed by stock's ticker
self.CMPs[ticker] = curr_CMP_value
# calc stock's PMT value
PMT_value: float = curr_CMP_value / market_cap
# store stock's PMT value keyed by stock's object
PMT[stock] = PMT_value
# volatility calculation - self.d_volatility_period
daily_prices: np.ndarray = np.array([x[1] for x in self.prices[ticker]][-self.d_volatility_period:])
daily_returns: np.ndarray = daily_prices[1:] / daily_prices[:-1] - 1
volatility[symbol] = np.std(daily_returns) * np.sqrt(252) # annualized volatility
# make sure, there are enough stocks for selection
if len(PMT) < self.quantile:
return Universe.Unchanged
# make percentile selection
quantile: int = int(len(PMT) / self.quantile)
sorted_by_PMT: List[Fundamental] = [x[0] for x in sorted(PMT.items(), key=lambda item: item[1])]
# long highest decile
long: List[Fundamental] = sorted_by_PMT[-quantile:]
# short lowest decile
short: List[Fundamental] = sorted_by_PMT[:quantile]
# portfolio weighting
# calculate weights for long and short portfolio part
if self.portfolio_weighting == PortfolioWeighting.EQUALLY_WEIGHTED:
for i, portfolio in enumerate([long, short]):
for stock in portfolio:
self.weights[stock.Symbol] = ((-1) ** i) / len(portfolio)
elif self.portfolio_weighting == PortfolioWeighting.VALUE_WEIGHTED:
for i, portfolio in enumerate([long, short]):
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
elif self.portfolio_weighting == PortfolioWeighting.INVERSE_VOLATILITY_WEIGHTED:
for i, portfolio in enumerate([long, short]):
inv_vol_sum: float = sum(list(map(lambda stock: 1 / volatility[stock.Symbol], portfolio)))
for stock in portfolio:
self.weights[stock.Symbol] = ((-1)**i) * volatility[stock.Symbol] / inv_vol_sum
# return stocks symbols
return list(self.weights.keys())
def OnData(self, data: Slice) -> None:
# wait for selection flag to be set
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 symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.weights.clear()
def Selection(self) -> None:
# wait for self.m_cumulative_period months to elapse from the start of the algorithm before first selection. It gives the chance to self.market_moves to potentially fill up.
if self.Time.date() < (self.StartDate + relativedelta(months=self.m_cumulative_period)).date():
return
# rebalance once a rebalance period
if self.month_counter % self.m_rebalance_period == 0:
self.selection_flag = True
self.month_counter += 1
# 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"))