| Overall Statistics |
|
Total Orders 115918 Average Win 0.01% Average Loss -0.01% Compounding Annual Return -15.590% Drawdown 33.300% Expectancy -0.105 Start Equity 100000 End Equity 67171.35 Net Profit -32.829% Sharpe Ratio -8.592 Sortino Ratio -10.274 Probabilistic Sharpe Ratio 0% Loss Rate 54% Win Rate 46% Profit-Loss Ratio 0.95 Alpha -0.162 Beta -0.021 Annual Standard Deviation 0.019 Annual Variance 0 Information Ratio -1.793 Tracking Error 0.139 Treynor Ratio 7.794 Total Fees $9217.03 Estimated Strategy Capacity $17000000.00 Lowest Capacity Asset ESLTF R735QTJ8XC9X Portfolio Turnover 256.67% |
# https://quantpedia.com/strategies/interday-cross-sectional-momentum/
#
# The investment universe for this strategy consists of stocks from major international markets, including Europe, North America, Asia, and Australia. (The
# selection process involves identifying stocks that are part of the benchmark stock indices of 15 countries, as covered in the research.)
# Coarse Selection: This presented version focuses on the U.S. sample, encompassing equities traded on the New York Stock Exchange (NYSE) and NASDAQ.
# (The data is collected from LSEG, focusing on stocks with available 1-minute transaction price and volume data. The universe is filtered to include stocks
# that have consistent trading records, excluding non-trading days and those with recording errors.)
# Decision Variable Calculation: The strategy utilizes the interday cross-sectional momentum effect observed in the last half-hour of trading. The methodology
# involves calculating simple returns for 30-minute intervals throughout the trading day. Stocks traded are selected based on their performance in the last
# half-hour, identifying the top 10% with the highest returns and the bottom 10% with the lowest returns of the prior (previous) day.
# The formation period is the last half-hour of the previous day: The top and bottom 10% of stocks determine their positions based on their last half-hour
# performance.
# Trading Strategy Execution: The portfolios are formed of winners (losers) that had the 10% highest (lowest) returns during the formation period: buy (long)
# the winners and simultaneously sell short the losers over the next trading session.
# Rebalancing & Weighting: The strategy involves rebalancing the portfolio intraday, focusing on the last half-hour of trading. So, the holding period is the
# last half-hour interval (LH). An equal-weighted approach is used to diversify risk across multiple positions.
#
# QC implementation changes:
# - The investment universe consists of 500 largest stocks from NYSE, AMEX and NASDAQ.
# region imports
from AlgorithmImports import *
from typing import List, Dict
from pandas.core.frame import DataFrame
# endregion
class InterdayCrossSectionalMomentum(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2023, 1, 1)
self.set_cash(100_000)
self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
leverage: int = 5
market_offset: int = 30
self._quantile: int = 10
self._period: int = 2
self._history_period: int = 30
self._last_selection: List[Symbol] = []
self._data: Dict[Symbol, RollingWindow] = {}
market: Symbol = self.add_equity('SPY', Resolution.MINUTE).Symbol
self._fundamental_count: int = 500
self._fundamental_sorting_key = lambda x: x.market_cap
self._selection_flag: bool = False
self._rebalance_flag: bool = False
self.universe_settings.leverage = leverage
self.universe_settings.resolution = Resolution.MINUTE
self.add_universe(self.fundamental_selection_function)
self.settings.minimum_order_margin_portfolio_percentage = 0.
self.schedule.on(
self.date_rules.every_day(market),
self.time_rules.before_market_close(market, market_offset),
self.last_half_hour
)
self.schedule.on(
self.date_rules.every_day(market),
self.time_rules.before_market_close(market),
self.before_close
)
def on_securities_changed(self, changes: SecurityChanges) -> None:
for security in changes.added_securities:
security.set_fee_model(CustomFeeModel())
# security.set_fee_model(ConstantFeeModel(0))
for security in changes.removed_securities:
if security.symbol in self._data:
self._data.pop(security.symbol)
def fundamental_selection_function(self, fundamental: List[Fundamental]) -> List[Symbol]:
selected: List[Fundamental] = [
x for x in fundamental
if x.has_fundamental_data
and x.market == 'usa'
and x.dollar_volume != 0
and x.security_reference.exchange_id in self.exchange_codes
]
if len(selected) > self._fundamental_count:
selected = [x for x in sorted(selected, key=self._fundamental_sorting_key, reverse=True)[:self._fundamental_count]]
for stock in selected:
symbol: Symbol = stock.symbol
if symbol not in self._data:
self._data[symbol] = RollingWindow[float](self._period)
history: DataFrame = self.history(symbol, self._history_period, Resolution.MINUTE)
if history.empty:
self.log(f"Not enough data for {symbol} yet.")
continue
data: DataFrame = history.loc[symbol].resample('30T').first()
for time, row in data.iterrows():
self._data[symbol].add(row.close)
if len(selected) == 0:
return Universe.UNCHANGED
self._last_selection = [x.symbol for x in selected]
return self._last_selection
def on_data(self, slice: Slice) -> None:
# Save end of day prices.
if self._selection_flag:
self._selection_flag = False
for symbol, prices in self._data.items():
if slice.contains_key(symbol) and slice[symbol]:
prices.add(slice[symbol].close)
# order execution
if self._rebalance_flag:
self._rebalance_flag = False
performance: Dict[Symbol, float] = {
symbol: prices[0] / prices[1] - 1 for symbol, prices in self._data.items() if prices.is_ready and symbol in self._last_selection
}
# sort and divide
if len(performance) > self._quantile:
sorted_performance: List[Symbol] = sorted(performance, key=performance.get, reverse=True)
quantile: int = int(len(performance) / self._quantile)
long: List[Symbol] = sorted_performance[:quantile]
short: List[Symbol] = sorted_performance[-quantile:]
for i, portfolio in enumerate([long, short]):
for symbol in portfolio:
if slice.contains_key(symbol) and slice[symbol]:
self.market_order(symbol, self.calculate_order_quantity(symbol, ((-1) ** i) / len(portfolio)), tag='MarketOrder')
else:
self.log('Not enough data for further calculation.')
# Save last half-hour prices.
for symbol, prices in self._data.items():
if slice.contains_key(symbol) and slice[symbol]:
prices.add(slice[symbol].close)
def last_half_hour(self) -> None:
self._rebalance_flag = True
def before_close(self) -> None:
self._selection_flag = True
def on_order_event(self, orderEvent: OrderEvent) -> None:
order_ticket: OrderTicker = self.transactions.get_order_ticket(orderEvent.order_id)
symbol: Symbol = order_ticket.symbol
if orderEvent.status == OrderStatus.FILLED:
if 'MarketOrder' in order_ticket.tag:
self.market_on_close_order(symbol, -order_ticket.quantity)
# 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"))