Overall Statistics
Total Orders
207
Average Win
0.76%
Average Loss
-1.06%
Compounding Annual Return
2.491%
Drawdown
14.300%
Expectancy
0.060
Start Equity
100000
End Equity
103826.17
Net Profit
3.826%
Sharpe Ratio
-0.307
Sortino Ratio
-0.234
Probabilistic Sharpe Ratio
12.390%
Loss Rate
38%
Win Rate
62%
Profit-Loss Ratio
0.72
Alpha
-0.086
Beta
0.354
Annual Standard Deviation
0.1
Annual Variance
0.01
Information Ratio
-1.624
Tracking Error
0.114
Treynor Ratio
-0.087
Total Fees
$561.75
Estimated Strategy Capacity
$1800000.00
Lowest Capacity Asset
HBAN R735QTJ8XC9X
Portfolio Turnover
11.10%
'''
https://www.quantitativo.com/p/robustness-of-the-211-sharpe-mean
focus on sp500 constituents

'''

from AlgorithmImports import *

from collections import deque
from datetime import time, timedelta
import numpy as np
import requests
import pandas as pd


class SymbolData:
    def __init__(self, symbol, algo):

        self.symbol = symbol
        self.close = 0.0
        self.open = 0.0
        self.high = 0.0
        self.low = 0.0

        self.close_on_open_trigger = False
        self.long_on_open_trigger = False
        self.todays_high = 0.0
        self.todays_low = 999999.9
        self.yesterdays_high = 0.0
        self.yesterdays_close = 0.0

        # Rolling Mean High Minus Low
        self.rolling_mean_length = 25
        self.rolling_mean_high_low_list = deque(maxlen=self.rolling_mean_length)
        
        # SMA
        self.rolling_sma_list = deque(maxlen=200)

        ## Lower band
        self.lower_band_multiple = 2.5
        self.rolling_high_list = deque(maxlen=10)

        ## ATR
        self.atr = None
        self.atr_periods = 10
        self.atr_values_list = deque(maxlen=self.atr_periods)

        ## Volume
        self.volume_history_days = 60
        self.volumes_list = deque(maxlen=self.volume_history_days)
        self.todays_total_volume = 0.0

         
        # Initialize technical indicators
        self.ibs = 0.0
        self.lower_band = 0.0
        self.natr = 0

    def update(self, bar, algo): 

        self.high, self.low, self.close, self.open, self.volume = bar.High, bar.Low, bar.Close, bar.Open, bar.Volume

        ## Daily high/low
        self.todays_high = max(self.todays_high, self.high)
        self.todays_low = min(self.todays_low, self.low)

        self.todays_total_volume = self.todays_total_volume + self.volume
        
        # algo.Debug(f'{algo.Time} {self.symbol} UPDATED {self.high} {self.todays_high}')

    def update_eod(self, algo):
        # algo.Debug(f'{algo.Time} : update_eod running')

        self.rolling_high_list.append(self.todays_high)
        self.rolling_mean_high_low_list.append(self.todays_high - self.todays_low)
        self.rolling_sma_list.append(self.close)
        self.volumes_list.append(self.todays_total_volume)

        ## ATR
        tr = max(self.todays_high - self.todays_low, abs(self.todays_high - self.yesterdays_close), abs(self.todays_low - self.yesterdays_close))
        self.atr_values_list.append(tr)
        self.atr = np.mean(self.atr_values_list)
        # normalized
        self.natr = self.atr / self.close


        ## Rolling Mean High Minus Low
        if len(self.rolling_mean_high_low_list) >= self.rolling_mean_length:
            high_low_mean = np.mean(self.rolling_mean_high_low_list)

            ## Lower band
            self.lower_band = max(self.rolling_high_list) - self.lower_band_multiple * high_low_mean

        ## IBS
        if self.todays_high != self.todays_low:
            self.ibs = (self.close - self.todays_low) / (self.todays_high - self.todays_low)
        else:
            self.ibs = 0.0
    
    def eod_reset(self):
        self.yesterdays_high = self.todays_high
        self.todays_high = 0.0
        self.todays_low = 999999.9
        self.yesterdays_close = self.close
        self.todays_total_volume = 0.0

class MyAlgorithm(QCAlgorithm):
    def Initialize(self):
        '''
        This method is the entry point of your algorithm where you define a series of settings.
        LEAN only calls this method one time, at the start of your algorithm.
        '''
        self.Debug(f'--- Initializing Algorithm ----')
        
        self.SetStartDate(2023, 1, 1)
        self.SetEndDate(2024, 7, 10)
        self.SetCash(100000)
        self.set_benchmark("SPY")
        
        # Define universe selection method
        self.AddEquity("SPY", Resolution.Minute)

        # Use Filter to select universe based on market cap and price
        self.universe_settings.asynchronous = True
        self.UniverseSettings.Resolution = Resolution.Minute
        self.add_universe(self.universe.etf("SPY"))
        
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.MARGIN)
        self.portfolio.set_positions(SecurityPositionGroupModel.NULL)
        self.universe_settings.leverage = 10
        
        # self.set_brokerage_model(BrokerageName.ALPACA)

        # Initialize market open and close times
        self.market_open_time = time(9, 31)
        self.market_close_time = time(15, 59)
        
        self.number_of_stocks = 3
        self.symbol_data = {}
        self.buy_on_open_list = []

        self.store_trades = ''
        self.store_decisions = ''
        self.store_holdings = ''
        self.store_longlist = ''
        self.store_longlist_entries = ''
        self.errors = ''

        ## IBS
        self.ibs_threshold = 0.3


    def OnData(self, data):
        
        if self.Time.hour == 0:
            return

        # Update the indicator values
        for symbol in self.symbol_data:

            try:
                bar = data.Bars[symbol]

                symboldata = self.symbol_data[symbol]
                symboldata.update(bar, self)
            except:
                continue
                # maybe newly added to universe and no tradebar?
        
        # Check if we need to close

        ## MARKET OPEN
        if self.Time.time() == self.market_open_time:
            self.check_close_on_open()
            self.check_buy_on_open()

        ## MARKET CLOSE
        elif self.Time.time() == self.market_close_time:
            # self.Debug(f'{self.Time} market close time')
            self.update_eod_data() # update last data and calculate EOD
            self.check_closure_close_sma()
            self.check_closure_eod() 
            self.check_long_eod()
            self.eod_reset_data()

    def check_closure_close_sma(self):
        # self.Debug(f'{self.Time} checking closure hourly')

        currInvested = [x.Symbol for x in self.Portfolio.Values if x.Invested]
        
        for symbol in currInvested:
            try:
                symboldata = self.symbol_data[symbol]
                sma_list = symboldata.rolling_sma_list
                sma_length = len(sma_list)
                sma_value = np.mean(sma_list)
                low = symboldata.low
                close = symboldata.close

                # We will close the trade whenever the price is lower than the 200-day SMA;
                if close < sma_value:
                    # self.Debug(f'{self.Time} !! closing symbol {symbol}, low {low} < sma {sma}')
                    self.Liquidate(symbol)

                    ## DATASTORE
                    # Trades
                    data = {'time': self.Time, 'symbol': symbol.value, 'low': low, 'close': close, 'sma': sma_value, 'direction': 'liquidate'}
                    self.store_trades += f'{data},'

                    # Decision
                    data = {'time': self.Time, 'symbol': symbol.value, 'reason': 'closing_because_low<sma', 'low': low, 'close': close, 'sma': sma_value, 'sma_length': sma_length}
                    self.store_decisions += f'{data},'
            except:
                self.Debug(f'{self.Time} {symbol} couldnt close hourly as symbol data missing. Maybe delisted?')

    def check_buy_on_open(self):
        # self.Debug(f'{self.Time} checking long on open')
        for symbol in self.buy_on_open_list:
            ## DATASTORE
            data = {'time': self.Time, 'symbol': symbol.value}
            self.store_longlist += f'{data},'

            if symbol in self.symbol_data:
                holding_percentage = (1 / self.number_of_stocks) * 0.9
                # self.Debug(f'{symbol} setting holding for {holding_percentage}%')

                self.set_holdings(symbol, holding_percentage)

                ## DATASTORE
                symboldata = self.symbol_data[symbol]
                sma_list = symboldata.rolling_sma_list
                sma_value = np.mean(sma_list)
                low = symboldata.low
                close = symboldata.close

                num_holdings = sum(x.Invested for x in self.Portfolio.Values)

                data = {'time': self.Time, 'symbol': symbol.value, 'low': low, 'close': close, 'sma': sma_value, 'direction': 'long', 'amount': holding_percentage, 'num_holdings': num_holdings}
                self.store_trades += f'{data},'
            else:
                data = {'time': self.Time, 'symbol': symbol.value, 'error': 'symbol in self.buyonopen list but not in symobl_data'}
                self.errors += f'{data},'

        self.buy_on_open_list = []

    def check_close_on_open(self):
        # self.Debug(f'{self.Time} checking close on open')

        currInvested = [x.Symbol for x in self.Portfolio.Values if x.Invested]

        for symbol in currInvested:
            
            if symbol not in self.symbol_data:
                # self.Debug(f'{self.Time} !! 350 {symbol} no longer found in symbol_data. CLosing.')
                self.Liquidate(symbol)

                ## DATASTORE
                # Trades
                data = {'time': self.Time, 'symbol': symbol.value, 'direction': 'liquidate'}
                self.store_trades += f'{data},'

                # Decision
                data = {'time': self.Time, 'symbol': symbol.value, 'reason': 'closing as dropped from symbol_data'}
                self.store_decisions += f'{data},'

            else:
                symboldata = self.symbol_data[symbol]

                if symboldata.close_on_open_trigger:
                    # self.Debug(f'{self.Time} !! {symbol} Closing on market open!')
                    self.Liquidate(symbol)
                    
                    ## DATASTORE
                    # Trades
                    data = {'time': self.Time, 'symbol': symbol.value, 'direction': 'liquidate'}
                    self.store_trades += f'{data},'

                    symboldata.close_on_open_trigger = False

    def update_eod_data(self):
        # self.Debug(f'{self.Time} running update_eod_data')
        for symbol in self.symbol_data:
            self.symbol_data[symbol].update_eod(self)

    def check_closure_eod(self):
        holdings_list = [str(symbol) for symbol in self.Portfolio.Keys if self.Portfolio[symbol].Invested]
        self.Debug(f"{self.Time} EOD: Securities Held: {holdings_list}")
        
        ## OBJECT SORE
        data = {'time': self.Time, 'holdings': holdings_list}
        self.store_holdings += f'{data},'

        currInvested = [x.Symbol for x in self.Portfolio.Values if x.Invested]

        for symbol in currInvested:
            symboldata = self.symbol_data[symbol]

            close = symboldata.close
            yesterdays_high = symboldata.yesterdays_high

            # When the stock closes above yesterday's high, we will exit on the next open;
            if close > yesterdays_high:
                # self.Debug(f'{self.Time} {symbol} will close on tomorrows open as close {close} > yesterday high {yesterdays_high}')
                symboldata.close_on_open_trigger = True
                sma_list = symboldata.rolling_sma_list
                sma_length = len(sma_list)
                sma_value = np.mean(sma_list)

                # Decision
                data = {'time': self.Time, 'symbol': symbol.value, 'reason': 'closing as close>yesterday_hight', 'yesterdays_high':yesterdays_high, 'close': close, 'sma': sma_value, 'sma_length': sma_length}
                self.store_decisions += f'{data},'

    def check_long_eod(self):
        symbols_long_list = {}

        num_holdings = sum(x.Invested for x in self.Portfolio.Values)

        for symbol in self.symbol_data:

            if not self.Portfolio[symbol].Invested:

                symboldata = self.symbol_data[symbol]
                sma_list = symboldata.rolling_sma_list
                sma_length = len(sma_list)

                # for i, value in enumerate(sma.Window):
                #     self.Debug(f"{i}: {value.Time}, {value.Value}")

                # self.Debug(f"{self.Time} sma ready {sma.is_ready}")

                close = symboldata.close

                if sma_length == 200 and close > 10:
                        
                    # self.Debug(f"{self.Time} {symbol} close: {symboldata.close} high: {symboldata.todays_high} low: {symboldata.todays_low} yesthigh: {symboldata.yesterdays_high} yest close: {symboldata.yesterdays_close} vol: {symboldata.todays_total_volume} sma: {sma.current.value} ")

                    lower_band = symboldata.lower_band
                    ibs = symboldata.ibs
                    natr = symboldata.natr
                    sma_value = np.mean(sma_list)

                    # Only trade the stock if the allocated capital for the trade does not exceed 5% of the stock's median ADV of the past 3 months
                    allocation_per_symbol = self.portfolio.total_portfolio_value / self.number_of_stocks
                    adv_median = np.median(symboldata.volumes_list)             

                    if adv_median > allocation_per_symbol:
                            
                        # closes under the lower band, and IBS is lower than 0.3, we go long at the next open
                        if close < lower_band and ibs < self.ibs_threshold and close > sma_value:

                            # self.Debug(f'{self.Time} {symbol} meets entry criteria: close {close} lower band {lower_band} ibs {ibs} sma {sma}. adv_median {adv_median} allocation per symbol {allocation_per_symbol}')
                            
                            symbols_long_list[symbol] = natr

                            ## DATASTORE
                            data = {'time': self.Time, 'symbol': symbol.value, 'lower_band': lower_band,'ibs':ibs,'sma':sma_value,'natr':natr, 'adv_median':adv_median,'close':close,'sma_length':sma_length, 'num_holdings': num_holdings}
                            self.store_longlist_entries += f'{data},'

        # Sort them by volatility (Normalized Average True Range) and prioritize the most volatile ones;
        symbols_long_list_sorted = sorted(symbols_long_list.items(), key=lambda x: x[1], reverse=True)
        num_holdings = sum(x.Invested for x in self.Portfolio.Values)
        number_of_available_stocks = self.number_of_stocks - num_holdings
        top_symbols = [item[0] for item in symbols_long_list_sorted[:number_of_available_stocks]]

        self.buy_on_open_list = top_symbols

        # self.Debug(f'{self.Time} length of long symbols list {len(self.buy_on_open_list)}')

    def eod_reset_data(self):
        # self.Debug(f'{self.Time} running eod_reset_data')
        for symbol in self.symbol_data:
            self.symbol_data[symbol].eod_reset()

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for security in changes.added_securities:

            if security.symbol == 'SPY':
                continue

            if security.symbol not in self.symbol_data:
           
                self.symbol_data[security.symbol] = SymbolData(security.symbol, self)


        for security in changes.removed_securities:
            # self.debug(f"{self.time}: Universe: Removed {security.symbol}")
            
            if security.symbol in self.symbol_data:
                self.symbol_data.pop(security.symbol)

        # self.Debug(f'{self.Time} - len of entire universe = {len(self.symbol_data)}')

    def on_end_of_algorithm(self):
        self.object_store.save('store_trades', self.store_trades)
        self.object_store.save('store_decisions', self.store_decisions)
        self.object_store.save('store_holdings', self.store_holdings)
        self.object_store.save('store_longlist_entries', self.store_longlist_entries)
        self.object_store.save('store_longlist', self.store_longlist)
        self.object_store.save('errors', self.errors)