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"))