
“该策略以主要交易所的‘冷门IPO’为目标,排除低价发行股票,对其持有两个月,采用等权重配置,并通过做空相关行业的ETF或股票对冲行业风险。”
资产类别:股票 | 地区:美国 | 频率:每月 | 市场:股票市场 | 关键词:IPO
I. 策略概述
该投资策略聚焦于NYSE、AMEX和NASDAQ的IPO,排除低价股(股价低于5美元)、单位发行、房地产投资信托(REITs)、美国存托凭证(ADRs)、封闭式基金等类型的股票。仅选择发行价低于初始备案价格区间的“冷门IPO”。投资者在发行后的第一个月末买入这些IPO,并持有两个月。投资组合采用等权重配置,并通过做空相关行业的ETF或股票篮子对冲行业特定风险,以减少系统性风险的影响。
II. 策略合理性
“冷门IPO”通常因其低于预期的定价而被市场低估。研究表明,这些IPO在中期内的表现可能优于市场,反映出市场对冷门发行的潜在价值评估不足。通过对行业风险进行对冲,该策略能够剥离市场系统性风险,专注于个股的潜在回报。同时,短期低估的定价机制为投资者提供了超额收益的机会。
III. 论文来源
‘Cold’ IPOs or Hidden Gems? On the Medium-Run Performance of IPOs [点击浏览原文]
- 作者:Bakke
- 机构:挪威经济学院(NHH);金融研究中心
<摘要>
超过三分之一的IPO在1981年到研究结束期间在NYSE、AMEX和NASDAQ上市,其中冷门IPO的发行价低于初始备案价格区间。这些冷门IPO在中期内表现显著优于其他IPO。研究还发现,与热门IPO相比,冷门IPO的市场表现更具弹性,尤其是在抑制风险因素后。这表明,市场可能低估了冷门IPO的潜力,而这些股票在发行后适度持有的策略能够为投资者创造超额收益。


IV. 回测表现
| 年化收益率 | 24.57% |
| 波动率 | N/A |
| Beta | 0.263 |
| 夏普比率 | N/A |
| 索提诺比率 | 0.043 |
| 最大回撤 | N/A |
| 胜率 | 43% |
V. 完整python代码
from AlgorithmImports import *
from typing import List, Dict, Union, Tuple
from dateutil.relativedelta import relativedelta
from dataclasses import dataclass
import numpy as np
import datetime
#endregion
class ColdIPOsEffect(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100_000)
tickers_to_ignore: List[str] = ['EVOK', 'SGNL', 'VRDN', 'NRBO', 'GEMP', 'CCCR']
self.UniverseSettings.Leverage = 10
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0
self.settings.daily_precise_end_time = False
self.holding_period: int = 6 # Months
self.min_ipo_price: int = 5
self.selection_flag: bool = False
self.last_update_date: datetime.date = datetime.date(1700, 1, 1)
self.traded_percentage: float = 0.3
self.etf_symbols: List[Symbol] = []
# Tuple (stock_ticker: str, is_cold_IPO: bool) in a list keyed by date
self.ipo_dates: Dict[datetime.date, List[Tuple[str, bool]]] = {}
self.price_data: Dict[Symbol, float] = {}
self.rebalancing_queue: List[RebalanceQueueItem] = []
self.sector_etfs: Dict[int, Union[str, Symbol]] = {
104: 'VNQ', # Vanguard Real Estate Index Fund
311: 'XLK', # Technology Select Sector SPDR Fund
309: 'XLE', # Energy Select Sector SPDR Fund
206: 'XLV', # Health Care Select Sector SPDR Fund
103: 'XLF', # Financial Select Sector SPDR Fund
310: 'XLI', # Industrials Select Sector SPDR Fund
101: 'XLB', # Materials Select Sector SPDR Fund
102: 'XLY', # Consumer Discretionary Select Sector SPDR Fund
105: 'XLP', # Consumer Staples Select Sector SPDR Fund
207: 'XLU' # Utilities Select Sector SPDR Fund
}
# Subscribe sector ETFs
for sector_num, ticker in self.sector_etfs.items():
security = self.AddEquity(ticker, Resolution.Daily)
security.SetFeeModel(CustomFeeModel())
# Change sector etf's ticker to sector etf's symbols
self.sector_etfs[sector_num] = security.Symbol
self.etf_symbols.append(security.Symbol)
csv_string: str = self.Download('data.quantpedia.com/backtesting_data/equity/cold_ipos_formatted.csv')
lines: List[str] = csv_string.split('\r\n')
# Skip csv header
lines = lines[1:]
for line in lines:
if line == '': continue
splitted_line: List[str] = line.split(';')
# csv header: date;ticker;offer_price;opening_price
date: datetime.date = datetime.datetime.strptime(splitted_line[0], "%d.%m.%Y").date()
ticker: str = splitted_line[1]
if ticker in tickers_to_ignore:
continue
offer_price: float = float(splitted_line[2])
opening_price: float = float(splitted_line[3])
if offer_price < self.min_ipo_price or opening_price < self.min_ipo_price:
continue
if date not in self.ipo_dates:
self.ipo_dates[date] = []
if date > self.last_update_date:
self.last_update_date = date
# Check if stock has cold IPO (offering price is greater than opening price)
if offer_price > opening_price:
self.ipo_dates[date].append((ticker, True))
else:
self.ipo_dates[date].append((ticker, False))
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
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(CustomFeeModel())
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Rebalance monthly
if not self.selection_flag:
return Universe.Unchanged
current_date: datetime.date = self.Time.date()
prev_month_date: datetime.date = current_date - relativedelta(months=1)
cold_IPO_tickers: List[str] = []
for date in self.ipo_dates:
if date >= prev_month_date and date < current_date:
# Select stocks, which had cold IPO
for ticker, is_cold_IPO in self.ipo_dates[date]:
if is_cold_IPO:
cold_IPO_tickers.append(ticker)
# Store prices of stocks, which had cold IPO for weight calculations
for f in fundamental:
symbol: Symbol = f.Symbol
ticker: str = symbol.Value
if ticker in cold_IPO_tickers or symbol in self.etf_symbols:
self.price_data[symbol] = f.Price
# Select only stocks, which have MorningsartSectorCode and exclude sector ETFs
filtered: List[Fundamental] = [
f for f in fundamental if f.HasFundamentalData
and f.Symbol in self.price_data
and f.Symbol not in self.etf_symbols
and not f.CompanyReference.IsREIT
and not f.SecurityReference.IsDepositaryReceipt
and not np.isnan(f.AssetClassification.MorningstarSectorCode)
]
# Storing symbols of cold IPO stocks, which have sector value
cold_IPOs_stocks_symbols: List[Symbol] = []
# Storing total count of cold IPOs stocks keyed by sector number
sectors_total_cold_IPOs: Dict[int, int] = {}
for f in filtered:
sector: int = f.AssetClassification.MorningstarSectorCode
# Check if there is etf for stock's sector and sector etf has price
if sector not in self.sector_etfs or self.sector_etfs[sector] not in self.price_data:
continue
# Initialize sector's total count of cold IPOs stocks
if sector not in sectors_total_cold_IPOs:
sectors_total_cold_IPOs[sector] = 0
# Increase total count of cold IPOs stocks for specific sector
sectors_total_cold_IPOs[sector] += 1
# Store symbol of cold IPO stock to list
cold_IPOs_stocks_symbols.append(f.Symbol)
long_symbol_q: List[Tuple[Symbol, float]] = []
short_symbol_q: List[Tuple[Symbol, float]] = []
# Calculate weights for stocks, which were selected
if len(cold_IPOs_stocks_symbols) > 0:
portfolio_portion: float = (self.Portfolio.TotalPortfolioValue * self.traded_percentage) / self.holding_period / len(cold_IPOs_stocks_symbols)
long_symbol_q: List[Tuple[Symbol, float]] = [
(symbol, np.floor(portfolio_portion / self.price_data[symbol])) for symbol in cold_IPOs_stocks_symbols
]
total_cold_IPOs_count: int = sum(list(sectors_total_cold_IPOs.values()))
for sector_num, total_sector_IPOs_count in sectors_total_cold_IPOs.items():
sector_symbol: Symbol = self.sector_etfs[sector_num]
price: float = self.price_data[sector_symbol]
# Calculate sector weight:
# Divide weight by sector price, then multiply it by total count of cold IPO
# stocks in this sector. This makes sure, that sectors are equally weigted based
# on number of stocks, which had cold IPO in specific sector.
sector_weight: float = -np.floor((portfolio_portion / price) * (total_sector_IPOs_count / total_cold_IPOs_count))
short_symbol_q.append((sector_symbol, sector_weight))
self.rebalancing_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))
self.price_data.clear()
return cold_IPOs_stocks_symbols + self.etf_symbols
def OnData(self, slice: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
if self.Time.date() > self.last_update_date + relativedelta(months=self.holding_period):
self.Liquidate()
# Rebalance portfolio
for item in self.rebalancing_queue:
if item.holding_period == self.holding_period:
for symbol, quantity in item.opened_symbol_quantity:
self.MarketOrder(symbol, -quantity)
# Trade execution
if item.holding_period == 0:
opened_symbol_quantity: List[Tuple[Symbol, float]] = []
for symbol, quantity in item.opened_symbol_quantity:
if slice.contains_key(symbol) and slice[symbol] is not None:
self.MarketOrder(symbol, quantity)
opened_symbol_quantity.append((symbol, quantity))
# only opened orders will be closed
item.opened_symbol_quantity = opened_symbol_quantity
item.holding_period += 1
# Remove closed part of portfolio after loop
self.rebalancing_queue = [
item for item in self.rebalancing_queue if item.holding_period <= self.holding_period
]
def Selection(self) -> None:
self.selection_flag = True
@dataclass
class RebalanceQueueItem:
opened_symbol_quantity: List[Tuple[Symbol, float]]
holding_period: int = 0
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))