| Overall Statistics |
|
Total Orders 103 Average Win 5.35% Average Loss -4.59% Compounding Annual Return 29.702% Drawdown 30.600% Expectancy 0.139 Start Equity 25000 End Equity 29725.03 Net Profit 18.900% Sharpe Ratio 0.604 Sortino Ratio 0.379 Probabilistic Sharpe Ratio 36.536% Loss Rate 47% Win Rate 53% Profit-Loss Ratio 1.16 Alpha 0.237 Beta 0.014 Annual Standard Deviation 0.396 Annual Variance 0.157 Information Ratio 0.209 Tracking Error 0.409 Treynor Ratio 16.627 Total Fees $959.26 Estimated Strategy Capacity $300000.00 Lowest Capacity Asset GAMC XOA77YCD9UN9 Portfolio Turnover 10.99% |
###############################################################################
# Standard library imports
# from dateutil.relativedelta import *
import datetime as DT
# from dateutil.parser import parse
# import decimal
# import numpy as np
# import pandas as pd
# import pickle
# import pytz
# from System.Drawing import Color
# QuantConnect specific imports
# import QuantConnect as qc
from AlgorithmImports import *
# Import from files
from notes_and_inputs import *
from symbol_data import SymbolData
###############################################################################
class CustomTradingStrategy(QCAlgorithm):
def initialize(self):
"""Initialize algorithm."""
# Set backtest details
self.set_backtest_details()
# Add strategy variables
self.add_strategy_variables()
# Add instrument data to the algo
self.add_instrument_data()
# Schedule functions
self.schedule_functions()
# Warmup
# self.set_warm_up(DT.timedelta(days=5))
#------------------------------------------------------------------------------
def set_backtest_details(self):
"""Set the backtest details."""
self.set_start_date(START_DT.year, START_DT.month, START_DT.day)
if END_DATE:
self.set_end_date(END_DT.year, END_DT.month, END_DT.day)
self.set_cash(CASH)
self.set_time_zone(TIMEZONE)
# Setup trading framework
# Transaction and submit/execution rules will use IB models
# brokerages:
'''https://github.com/QuantConnect/Lean/blob/master/Common/Brokerages
/BrokerageName.cs'''
# account types: AccountType.MARGIN, AccountType.CASH
self.set_brokerage_model(
BrokerageName.INTERACTIVE_BROKERS_BROKERAGE,
AccountType.MARGIN
)
# Configure all universe securities
# This sets the data normalization mode to raw
self.set_security_initializer(self.custom_security_initializer)
# Adjust the cash buffer from the default 2.5% to custom setting
self.settings.free_portfolio_value_percentage = FREE_PORTFOLIO_VALUE_PCT
# Disable margin calls
self.portfolio.margin_call_model = MarginCallModel.NULL
# Use precise daily end times
self.settings.daily_precise_end_time = True
#------------------------------------------------------------------------------
def add_strategy_variables(self):
"""Create required strategy variables."""
# Read algo parameters
self.PH_PCT_CHG = self.GetParameter('PH_PCT_CHG', PH_PCT_CHG)
self.PC_PCT_CHG = self.GetParameter('PC_PCT_CHG', PC_PCT_CHG)
self.PM_PCT_CHG = self.GetParameter('PM_PCT_CHG', PM_PCT_CHG)
self.MIN_PREVIOUS_VOL = \
self.GetParameter('MIN_PREVIOUS_VOL', MIN_PREVIOUS_VOL)
self.MIN_VOL = self.GetParameter('MIN_VOL', MIN_VOL)
self.STOP_PCT = self.GetParameter('STOP_PCT', STOP_PCT)
self.FIXED_DOLLAR_SIZE = self.GetParameter(
'FIXED_DOLLAR_SIZE', FIXED_DOLLAR_SIZE
)
self.MAX_TRADES = self.GetParameter('MAX_TRADES', MAX_TRADES)
# Always update the universe when initializing
self.update_universe = True
# Keep track of SymbolData class instances and Symbols
# These are symbols that have already been filtered to be in our
# desired universe.
self.symbol_data = {}
self.symbol_objects = {}
# Keep track of the last valid market day (for previous day reference)
self.previous_day = None
#------------------------------------------------------------------------------
def add_instrument_data(self):
"""Add instrument data to the algo."""
# Set data resolution
if DATA_RESOLUTION == 'SECOND':
self.resolution = Resolution.SECOND
elif DATA_RESOLUTION == 'MINUTE':
self.resolution = Resolution.MINUTE
else:
raise ValueError(f"Invalid DATA_RESOLUTION: {DATA_RESOLUTION}")
# Set the universe data properties desired
self.universe_settings.resolution = self.resolution
self.universe_settings.extended_market_hours = True
self.universe_settings.minimum_time_in_universe = MIN_TIME_IN_UNIVERSE
# Use custom coarse and filter universe selection
self.add_universe(self.custom_universe_filter)
# Add the benchmark
self.bm = self.add_equity(BENCHMARK, self.resolution).symbol
self.set_benchmark(self.bm)
#------------------------------------------------------------------------------
def schedule_functions(self):
"""Scheduling the functions required by the algo."""
# Update self.update_universe variable True when desired
if UNIVERSE_FREQUENCY == 'DAILY':
date_rules = self.date_rules.every_day(self.bm)
elif UNIVERSE_FREQUENCY == 'WEEKLY':
# Want to schedule at end of the week, so actual update on
# start of the next week
date_rules = self.date_rules.week_end(self.bm)
else: # 'MONTHLY'
# Want to schedule at end of the month, so actual update on
# first day of the next month
date_rules = self.date_rules.month_end(self.bm)
# Timing is after the market closes
self.schedule.on(
date_rules,
self.time_rules.before_market_close(self.bm, -5),
self.on_update_universe
)
# Start trading at the market open
self.schedule.on(
self.date_rules.every_day(self.bm),
self.time_rules.after_market_open(self.bm, 0),
self.start_trading
)
# Exit open trades the desired number of minutes before the close
self.schedule.on(
self.date_rules.every_day(self.bm),
self.time_rules.before_market_close(self.bm, EOD_EXIT_MINUTES),
self.end_of_day_exit
)
# Schedule benchmark end of day event 5 minutes after the close
# Used to plot the benchmark on the equity curve
self.schedule.on(
self.date_rules.every_day(self.bm),
self.time_rules.before_market_close(self.bm, -5),
self.benchmark_on_end_of_day
)
#-------------------------------------------------------------------------------
def on_update_universe(self):
"""Event called when rebalancing is desired."""
# Update the variable to trigger the universe to be updated
self.update_universe = True
#-------------------------------------------------------------------------------
def custom_universe_filter(self, fundamental):
"""
Perform custom filters on universe.
Called once per day.
Returns all stocks meeting the desired criteria.
"""
# Check if the universe doesn't need to be updated
if not self.update_universe and not self.is_warming_up:
# Return unchanged universe
return Universe.UNCHANGED
# First filter based on properties that will never change
# Filter based on allowed exchange
filtered = [
f for f in fundamental \
if f.security_reference.exchange_id in ALLOWED_EXCHANGE
]
# Check if fundamental data is required
if REQUIRE_FUNDAMENTAL_DATA:
# Filter all securities with fundamental data
filtered = [f for f in filtered if f.has_fundamental_data]
# Filter stocks based on primary share class
if PRIMARY_SHARES:
filtered = [
f for f in filtered if f.security_reference.is_primary_share
]
# Now filter based on properties that constantly change
# Filter by price
filtered = [
f for f in filtered \
if f.price >= MIN_PRICE and f.price <= MAX_PRICE
]
# Filter by allowed market cap
filtered = [
f for f in filtered \
if f.market_cap >= MIN_MARKET_CAP \
and f.market_cap <= MAX_MARKET_CAP
]
# Return a unique list of symbol objects
self.symbols = [f.symbol for f in filtered]
# Print universe details when desired
if PRINT_UNIVERSE or self.live_mode:
self.my_log(f"Universe filter returned {len(self.symbols)} stocks")
# tickers = [f.Value for f in self.symbols]
# tickers.sort()
# self.my_log(
# f"Universe filter returned {len(self.symbols)} stocks: {tickers}"
# )
# Set update universe variable back to False and return universe symbols
if not self.is_warming_up:
self.update_universe = False
# # Debugging...
# tickers = [x.value for x in self.symbols]
# if 'CRBU' in tickers:
# print('debug')
return self.symbols
#-------------------------------------------------------------------------------
def my_log(self, message):
"""Add algo time to log if live trading. Otherwise just log message."""
if self.live_mode:
self.log(f'{self.time}: {message}')
else:
self.log(message)
#-------------------------------------------------------------------------------
def on_securities_changed(self, changes: SecurityChanges) -> None:
"""Built-in event handler for securities added and removed."""
# Loop through added securities
for security in changes.added_securities:
symbol = security.symbol
# Create a new SymbolData object for the security
self.symbol_data[symbol] = SymbolData(self, symbol)
# Save a link to the symbol object
self.symbol_objects[symbol.Value] = symbol
# Loop through removed securities
for security in changes.removed_securities:
symbol = security.symbol
# Get the SymbolData class instance
sd = self.symbol_data.get(symbol)
if sd:
# Dispose of the symbol's data handlers and pop off dictionary
sd.dispose()
data = self.symbol_data.pop(symbol, None)
# Remove symbol_object from dictionary
self.symbol_objects.pop(symbol.Value, None)
#-------------------------------------------------------------------------------
def on_splits(self, splits: Splits) -> None:
"""Built-in event handler for split events."""
# Loop through the splits
for symbol, split in splits.items():
# Verify this is not a warning
if split.Type == 1:
# Get the split factor
split = split.SplitFactor
# If this is for the benchmark, update the benchmark price
if symbol.value == BENCHMARK:
try:
# Adjust the first benchmark price for the split
self.bm_first_price *= split
except:
# Benchmark first price not set, so skip
self.my_log(
"Benchmark's first price not set. Trying to "
"adjust it for a split."
)
# Catch the appropriate symbol_data instance
sd = self.symbol_data.get(symbol)
if sd:
# Log message when desired
if PRINT_SPLITS or self.live_mode:
self.my_log(
f"New {symbol.value} split: split factor={split}. "
f"Updating {symbol.value}'s indicators."
)
# Adjust the previous bars by the split adjustment factor
sd.adjust_indicators(split, is_split=True)
#-------------------------------------------------------------------------------
def on_dividends(self, dividends: Dividends) -> None:
"""Built-in event handler for dividend events."""
# Loop through the dividends
for symbol, dividend in dividends.items():
# Get the dividend distribution amount
dividend = dividend.Distribution
# Get last 2 daily prices
hist = self.history([symbol], 2, Resolution.DAILY)
price = hist.iloc[-1]['close'] # [-1] for last
previous_close = hist.iloc[0]['close'] # [0] for first
# Calculate the dividend adjustment factor
af = (previous_close-dividend)/previous_close
# If this is for the benchmark, then we update the benchmark price
if symbol.value == BENCHMARK:
try:
# Adjust the first benchmark price based on the af
self.bm_first_price *= af
except:
# Benchmark first price not set, so skip
self.my_log(
"Benchmark's first price not set. Trying to adjust it "
"for a dividend payment."
)
# Catch the appropriate symbol_data instance
sd = self.symbol_data.get(symbol)
if sd:
# Log message when desired
if PRINT_DIVIDENDS or self.live_mode:
self.my_log(
f"New {symbol.value} dividend={dividend}. Close={price}, "
f"previous close={previous_close}, so dividend "
f"adjustment factor={af}. Updating {symbol.value}'s "
"indicators."
)
# Adjust the previous bars by the dividend adjustment factor
sd.adjust_indicators(af)
#-------------------------------------------------------------------------------
def on_order_event(self, order_event):
"""Built-in event handler for orders."""
# Catch invalid order
if order_event.Status == OrderStatus.INVALID:
order = self.transactions.get_order_by_id(order_event.order_id)
msg = order_event.get_Message()
self.my_log(f"on_order_event() invalid order ({order}): {msg}")
# Check if filled
elif order_event.Status == OrderStatus.FILLED:
# Get the order's symbol
order = self.transactions.get_order_by_id(order_event.order_id)
symbol = order.symbol
# Get the SymbolData class instance
sd = self.symbol_data.get(symbol)
if sd:
# Pass to the SymbolData's handler
sd.on_order_event(order_event)
else:
msg = f"on_order_event() order without SymbolData class " + \
f"instance: ({order})"
self.my_log(msg)
if not self.live_mode:
raise ValueError(msg)
#------------------------------------------------------------------------------
def start_trading(self):
"""At beginning of trading day, reset trading allowed variable."""
# Return if warming up
if self.is_warming_up:
return
# # Debugging...
# if self.time.month == 6 and self.time.day == 3:
# # Catch 'CRBU' ticker/symbol
# symbols = [x for x in self.symbols if x.value == 'CRBU']
# if len(symbols) > 0:
# symbol = symbols[0]
# # Get the symbol data instance
# sd = self.symbol_data[symbol]
# first_price = sd.first_premarket_price
# print('debug')
# Get all stocks that meet the entry criteria
symbols = [x for x, sd in self.symbol_data.items() if sd.entry_filter]
# Get top signals only, when necessary
if len(symbols) > self.MAX_TRADES:
# Sort by largest pct from previous close (ascending order)
pct_from_close = {
x: self.symbol_data[x].pct_chg_from_previous_close() \
for x in symbols
}
pct_from_close = dict(
sorted(pct_from_close.items(), key=lambda x: x[1])
)
symbols = list(pct_from_close.keys())[:self.MAX_TRADES]
# Print info when desired
if PRINT_SIGNALS or self.live_mode:
tickers = [x.value for x in symbols]
self.my_log(f"Filtered entry signals to {tickers}")
# Go short the desired symbols
for symbol in symbols:
sd = self.symbol_data[symbol]
sd.go_short()
#------------------------------------------------------------------------------
def end_of_day_exit(self):
"""At end of trading day, so exit any open positions."""
positions = [
x for x in self.portfolio.Keys if self.portfolio[x].invested
]
for symbol in positions:
self.liquidate(symbol, tag='end of day exit')
#------------------------------------------------------------------------------
def benchmark_on_end_of_day(self):
"""Event handler for end of trading day for the benchmark."""
self.plot_benchmark_on_equity_curve()
# Update prevous day reference
self.previous_day = self.time.date()
#------------------------------------------------------------------------------
def on_end_of_algorithm(self):
"""Built-in event handler for end of the backtest."""
# Plot the benchmark buy and hold value on the equity curve chart
self.plot_benchmark_on_equity_curve(force_plot=True)
#------------------------------------------------------------------------------
def plot_benchmark_on_equity_curve(self, force_plot=False):
"""Plot the benchmark buy & hold value on the strategy equity chart."""
# Initially set percent change to zero
pct_change = 0
# Get today's daily prices
# history algo on QC github shows different formats that can be used:
'''https://github.com/QuantConnect/Lean/blob/master/Algorithm.Python/
HistoryAlgorithm.py'''
hist = self.history([self.bm], timedelta(1), Resolution.DAILY)
# Make sure hist df is not empty
if not hist.empty:
# Get today's closing price
price = hist.iloc[-1]['close']
try:
# Calculate the percent change since the first price
pct_change = (price-self.bm_first_price)/self.bm_first_price
except:
# We have not created the first price variable yet
# Get today's open and save as the first price
self.bm_first_price = hist.iloc[-1]['open']
# Log the benchmark's first price for reference
self.my_log(f"Benchmark first price = {self.bm_first_price}")
# Calculate the percent change since the first price
pct_change = (price-self.bm_first_price)/self.bm_first_price
# Calculate today's ending value if we have the % change from the start
if pct_change != 0:
bm_value = round(CASH*(1+pct_change),2)
# Plot every PLOT_EVERY_DAYS days
try:
# We've previously created the counter, so increment it by 1
self.bm_plot_counter += 1
# same as: self.bm_plot_counter = self.bm_plot_counter + 1
except:
# We've not created the counter, so set it to 1
self.bm_plot_counter = 1
# Check if it's time to plot the benchmark value
if self.bm_plot_counter == PLOT_EVERY_DAYS or force_plot:
# Plot the benchmark's value to the Strategy Equity chart
# Plot function requires passing the chart name, series name,
# then the value to plot
self.plot('Strategy Equity', 'Benchmark', bm_value)
# Plot the account leverage
account_leverage = self.portfolio.total_holdings_value \
/ self.portfolio.total_portfolio_value
self.plot('Leverage', 'Leverge', account_leverage)
# Reset counter to 0
self.bm_plot_counter = 0
# Log benchmark's ending price for reference
if force_plot:
self.my_log(
f"Benchmark's first price = {self.bm_first_price}"
)
self.my_log(f"Benchmark's final price = {price}")
self.my_log(f"Benchmark buy & hold value = {bm_value}")
#------------------------------------------------------------------------------
def custom_security_initializer(self, security: Security) -> None:
"""Configure settings for securities added to our universe."""
# Set data normalization mode
if DATA_MODE == 'ADJUSTED':
# Throw error if live
if self.live_mode:
raise ValueError(f"Must use 'RAW' DATA_MODE for live trading!")
security.set_data_normalization_mode(
DataNormalizationMode.ADJUSTED
)
elif DATA_MODE == 'RAW':
security.set_data_normalization_mode(DataNormalizationMode.RAW)
else:
raise ValueError(f"Invalid DATA_MODE: {DATA_MODE}")
# Check for specific security type
# if security.type == SecurityType.EQUITY:
# Set the margin model
security.margin_model = PatternDayTradingMarginModel()
# Overwrite the security buying power
# security.set_buying_power_model(BuyingPowerModel.NULL)
security.set_buying_power_model(SecurityMarginModel(4.0))
# Overwrite the fee model
# security.set_fee_model(ConstantFeeModel(0))from AlgorithmImports import *
"""
Custom Shorting Day Trading Strategy
Version 1.0.3
Platform: QuantConnect
By: Aaron Eller
r.aaron.eller@gmail.com
Revision Notes:
1.0.0 (08/28/2024) - Initial.
1.0.1 (08/29/2024) - Corrected the first_premarket_price.
1.0.3 (09/02/2024) - Changed to use daily regular session for the
previous day references.
#** -> can be "Parameters" for optimization
References:
-QC (Lean) Class List
https://lean-api-docs.netlify.app/annotated.html
"""
# Standard library imports
import datetime as DT
###############################################################################
# Backtest inputs
# NOTE1: QC index data composition starts in August 2009
# NOTE2: if not using index data, can start in 1998
START_DATE = "01-01-2024" # must be in "MM-DD-YYYY" format
END_DATE = "09-01-2024" # must be in "MM-DD-YYYY" format or None
CASH = 25_000 # starting portfolio value
TIMEZONE = "US/Eastern" # e.g. "US/Eastern", "US/Central", "US/Pacific"
#------------------------------------------------------------------------------
# DATA INPUTS
# Set the data resolution required
# Must be 'SECOND' or 'MINUTE'
DATA_RESOLUTION = 'MINUTE'
# Set the data normalization mode - either 'RAW' or 'ADJUSTED'
# For live trading, must use 'RAW'
DATA_MODE = 'RAW'
# Set the Benchmark
BENCHMARK = 'SPY'
#------------------------------------------------------------------------------
# CUSTOM UNIVERSE INPUTS
# How often to update the universe?
# Options: 'DAILY', 'WEEKLY', 'MONTHLY'
UNIVERSE_FREQUENCY = 'DAILY'
# Set the minimum number of days to leave a stock in the universe
# This helps with making the universe output more stable
MIN_TIME_IN_UNIVERSE = 21 # approximately 1 month
REQUIRE_FUNDAMENTAL_DATA = True # if True, stocks only / no etfs e.g.
MIN_PRICE = 1.50 # set to 0 to disable
MAX_PRICE = 20.0 # set to 1e6 to disable
# Market cap filtering / e6=million/e9=billion/e12=trillion
MIN_MARKET_CAP = 50e6 # 0 to disable
MAX_MARKET_CAP = 1e9 #100e12 # extremely high value like 100e12 to disable
# Turn on/off specific exchanges allowed
ARCX = False # Archipelago Electronic Communications Network
ASE = False # American Stock Exchange
BATS = False # Better Alternative Trading System
NAS = True # Nasdaq Stock Exchange
NYS = True # New York Stock Exchange
# Only allow a stock's primary shares?
# Example is Alphabet (Google):
# A shares = GOOGL (voting rights) -> considered the 'primary share' class.
# C shares = GOOG (no voting rights)
# REF:
'''https://www.investopedia.com/ask/answers/052615/whats-difference-between-
googles-goog-and-googl-stock-tickers.asp'''
PRIMARY_SHARES = True
#------------------------------------------------------------------------------
# ENTRY INPUTS
# Checks for signals at the market open
# All percents below are decimal percents. E.g. 0.05=5.0%
# Set the minimum pct change required from the previous day high
PH_PCT_CHG = 0.20 #**
# Set the minimum pct change required from the previous day close
PC_PCT_CHG = 0.20 #**
# Set the minimum pct change required from the first premarket price
PM_PCT_CHG = 0.20 #**
# Set the previous day's minimum allowed volume
MIN_PREVIOUS_VOL = 100_000 #**
# Set the current day's minimum trading volume
MIN_VOL = 10_000 #**
#------------------------------------------------------------------------------
# EXIT INPUTS
# Set the percentage stop to use (as a decimal percent, e.g. 0.40=40.0%)
STOP_PCT = 0.20 #**
# Set the number of minutes prior to the market close to exit
EOD_EXIT_MINUTES = 1
#------------------------------------------------------------------------------
# POSITION SIZING INPUTS
# Set the desired dollar amount to invest per position
FIXED_DOLLAR_SIZE = 10_000 #**
# Set the max number of trades allowed per day
MAX_TRADES = 10 #**
#------------------------------------------------------------------------------
# LOGGING INPUTS
PRINT_UNIVERSE = False # print universe info
PRINT_DIVIDENDS = False # turn on/off logs for new dividends
PRINT_SPLITS = False # turn on/off logs for new splits
PRINT_SIGNALS = True # print new signal information
PRINT_ORDERS = True # print new orders
################################################################################
############################ END OF ALL USER INPUTS ############################
################################################################################
# VALIDATE USER INPUTS - DO NOT CHANGE BELOW!!!
#-------------------------------------------------------------------------------
# POSITION SIZING INPUTS
# Set the percentage of the portfolio to free up to avoid buying power issues
FREE_PORTFOLIO_VALUE_PCT = 0.025 # decimal percent, e.g. 0.025=2.5% (default)
#-------------------------------------------------------------------------------
# Verify start date
try:
START_DT = DT.datetime.strptime(START_DATE, '%m-%d-%Y')
except:
raise ValueError(
f"Invalid START_DATE format ({START_DATE}). Must be in MM-DD-YYYY "
"format."
)
# Verify end date
try:
if END_DATE:
END_DT = DT.datetime.strptime(END_DATE, '%m-%d-%Y')
except:
raise ValueError(
f"Invalid END_DATE format ({END_DATE}). Must be in MM-DD-YYYY "
"format or set to None to run to date."
)
# Verify universe update frequency
UNIVERSE_FREQUENCY = UNIVERSE_FREQUENCY.upper()
if UNIVERSE_FREQUENCY not in ['DAILY', 'WEEKLY', 'MONTHLY']: #'QUARTERLY'
raise ValueError(
f"Invalid UNIVERSE_FREQUENCY ({UNIVERSE_FREQUENCY}). "
f"Must be ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY']."
)
#-------------------------------------------------------------------------------
# Verify the DATA_MODE input
DATA_MODE = DATA_MODE.upper()
if DATA_MODE not in ['RAW', 'ADJUSTED']:
raise ValueError(
f"Invalid DATA_MODE ({DATA_MODE}). Must be: 'RAW' or 'ADJUSTED'"
)
#-------------------------------------------------------------------------------
# Verify DATA_RESOLUTION input
DATA_RESOLUTION = DATA_RESOLUTION.upper()
if DATA_RESOLUTION not in ['SECOND', 'MINUTE']:
raise ValueError(
f"Invalid DATA_RESOLUTION ({DATA_RESOLUTION}). "
"Must be: 'SECOND' or 'MINUTE'"
)
#-------------------------------------------------------------------------------
# Get list of the allowed exchanges
ALLOWED_EXCHANGE = []
if ARCX:
ALLOWED_EXCHANGE.append('ARCX')
if ASE:
ALLOWED_EXCHANGE.append('ASE')
if BATS:
ALLOWED_EXCHANGE.append('BATS')
if NAS:
ALLOWED_EXCHANGE.append('NAS')
if NYS:
ALLOWED_EXCHANGE.append('NYS')
# Throw error if no exchange is allowed
if len(ALLOWED_EXCHANGE) == 0:
raise ValueError("At least one exchange must be set True.")
#-------------------------------------------------------------------------------
# Calculate the number of days in the backtest
PLOT_LIMIT = 4000
if not END_DATE:
today = DT.datetime.today()
BT_DAYS = (today-START_DT).days
else:
BT_DAYS = (END_DT-START_DT).days
# Convert calendar days to estimated market days
# Round up to the nearest integer
# This uses // for integer division which rounds down
# Take division for negative number to round up, then negate the negative
BT_DAYS = -(-BT_DAYS*252//365)
# Calculate the frequency of days that we can create a new plot
# Use the same approach as above to round up to the nearest integer
PLOT_EVERY_DAYS = -(-BT_DAYS//PLOT_LIMIT)# Standard library imports
import datetime as DT
# from dateutil.relativedelta import relativedelta
# import numpy as np
# import pandas as pd
import pytz
# import statistics
# QuantConnect specific imports
# import QuantConnect as qc
from AlgorithmImports import *
# Import from files
from notes_and_inputs import *
################################################################################
class SymbolData(object):
"""Class to store data for a specific symbol."""
def __init__(self, algo, symbol_object):
"""Initialize SymbolData object."""
# Save a reference to the QCAlgorithm class
self.algo = algo
# Save the .Symbol object
self.symbol_object = symbol_object
self.ticker = symbol_object.Value
self.symbol = str(self.symbol_object.id)
# Get the symbol's exchange market info
self.get_exchange_info()
# Add strategy variables
self.set_strategy_variables()
# Add the bars and indicators required
self.add_bars()
self.add_indicators()
# Schedule functions
self.schedule_functions()
#-------------------------------------------------------------------------------
def get_exchange_info(self):
"""Get the securities exchange info."""
# Get the SecurityExchangeHours Class object for the symbol
security = self.algo.securities[self.symbol_object]
self.exchange_hours = security.exchange.hours
# Create a datetime I know the market was open for the full day
dt = DT.datetime(2021, 1, 4)
# Get the next open datetime from the SecurityExchangeHours Class
mkt_open_dt = self.exchange_hours.get_next_market_open(dt, False)
# Save the typical (regualar session) market open and close times
self.mkt_open = mkt_open_dt.time()
mkt_close_dt = self.exchange_hours.get_next_market_close(dt, False)
self.mkt_close = mkt_close_dt.time()
# Get the exchange timezone
self.mkt_tz = pytz.timezone(str(self.exchange_hours.time_zone))
# Create pytz timezone objects for the exchange tz and local tz
exchange_tz = self.mkt_tz
local_tz = pytz.timezone(TIMEZONE)
# Get the difference in the timezones
# REF: http://pytz.sourceforge.net/#tzinfo-api
# for pytz timezone.utcoffset() method
# 3600 seconds/hour
exchange_utc_offset_hrs = int(exchange_tz.utcoffset(dt).seconds/3600)
local_utc_offset_hrs = int(local_tz.utcoffset(dt).seconds/3600)
self.offset_hrs = exchange_utc_offset_hrs-local_utc_offset_hrs
# NOTE: offset hours are very helpful if you want to schedule functions
# around market open/close times
# Get the market close time for the local time zone
self.mkt_close_local_tz = \
(mkt_close_dt-DT.timedelta(hours=self.offset_hrs)).time()
self.mkt_open_local_tz = \
(mkt_open_dt-DT.timedelta(hours=self.offset_hrs)).time()
# Get the min price
symbol_properties = security.symbol_properties
# Get and save the contract specs
self.min_tick = symbol_properties.minimum_price_variation
#-------------------------------------------------------------------------------
def set_strategy_variables(self):
"""Set strategy specific variables."""
self.warming_up = False
self.reset_trade_variables()
# Get the min tick size
self.tick_size = self.algo.securities[
self.symbol_object
].symbol_properties.minimum_price_variation
#-------------------------------------------------------------------------------
def reset_trade_variables(self):
"""Reset trade specific variables."""
self.stop_price = None
self.stop_loss_order = None
self.best_price = None
# self.take_profit_order = None
#-------------------------------------------------------------------------------
def add_bars(self):
"""Add bars required."""
# Create a daily bar consolidator
self.calendar_initialized = False
consolidator = TradeBarConsolidator(self.daily_calendar)
# Create an event handler to be called on each new consolidated bar
consolidator.data_consolidated += self.on_data_consolidated
# Link the consolidator with our symbol and add it to the algo manager
self.algo.subscription_manager.add_consolidator(
self.symbol_object, consolidator
)
# Save consolidator link so we can remove it when necessary
self.consolidator = consolidator
# Create an intraday minute bar consolidator
consolidator2 = TradeBarConsolidator(DT.timedelta(minutes=1))
# Create an event handler to be called on each new consolidated bar
consolidator2.data_consolidated += self.on_intraday_data_consolidated
# Link the consolidator with our symbol and add it to the algo manager
self.algo.subscription_manager.add_consolidator(
self.symbol_object, consolidator2
)
# Save consolidator link so we can remove it when necessary
self.consolidator2 = consolidator2
#-------------------------------------------------------------------------------
def daily_calendar(self, dt):
"""
Set up daily consolidator calendar info for the US equity market.
This should return a start datetime object that is timezone unaware
with a valid date/time for the desired securities' exchange's time zone.
"""
# Need to handle case where algo initializes and this function is called
# for the first time.
if not self.calendar_initialized:
# Since this doesn't matter, we'll pass dt as start and one day
# as the timedelta until end_dt
start_dt = dt
end_dt = start_dt + DT.timedelta(1)
self.calendar_initialized = True
return CalendarInfo(start_dt, end_dt-start_dt)
# Create a datetime.datetime object to represent the market open for the
# **EXCHANGE** timezone
start = dt.replace(
hour=self.mkt_open.hour,
minute=self.mkt_open.minute,
second=0,
microsecond=0
)
# Get today's end time from the SecurityExchangeHours Class object
end = self.exchange_hours.get_next_market_close(start, False)
# Catch when start is after the passed dt
# QC now throws an error in this case
if start > dt:
# To handle the QC error, pass period for no data
# Set the end to be the next desired start
end = start
# And set start to dt to avoid QC throwing error
start = dt
# This will result in the next dt being the desired start time
# Return the start datetime and the consolidation period
return CalendarInfo(start, end-start)
#-------------------------------------------------------------------------------
def dispose(self):
"""Stop the data consolidators."""
# Remove the consolidators from the algo manager
self.consolidator.data_consolidated -= self.on_data_consolidated
self.algo.subscription_manager.remove_consolidator(
self.symbol_object, self.consolidator
)
self.consolidator2.data_consolidated -= self.on_intraday_data_consolidated
self.algo.subscription_manager.remove_consolidator(
self.symbol_object, self.consolidator2
)
#-------------------------------------------------------------------------------
def add_indicators(self):
"""Add indicators and other required variables."""
# Create an empty list to hold all indicators
# Will add (indicator, update_method) tuples
# where update_method is either 'high', 'low', 'close', 'volume', or
# 'bar'
self.indicators = []
min_intraday_bars = [2]
self.todays_volume = 0 # used for the cumulative volume
# Keep track of bars for the daily indicators
self.min_bars = 5 # only need last 1 daily bar
self.bar_window = RollingWindow[TradeBar](self.min_bars)
# Keep track of bars for the intraday indicators
# We need 1min bars from 4am to 930am -> 330 minutes
self.min_bars_intraday = 330
self.bar_window_intraday = RollingWindow[TradeBar](self.min_bars_intraday)
# Warm up the indicators with historical data
self.warmup_indicators()
self.reset_intraday_indicators()
#-------------------------------------------------------------------------------
def schedule_functions(self):
"""Schedule functions required by the algo."""
# Reset the intraday indicators at midnight every day
s = self.symbol_object
self.algo.schedule.on(
self.algo.date_rules.every_day(s),
self.algo.time_rules.at(0,0),
self.reset_intraday_indicators
)
#-------------------------------------------------------------------------------
def reset_indicators(self):
"""Reset indicators required."""
# Loop through list of indicators
for indicator, _ in self.indicators:
indicator.reset()
# Handle custom indicators - if any
# Reset the rolling windows
self.bar_window.reset()
#-------------------------------------------------------------------------------
def reset_intraday_indicators(self):
"""Reset indicators required."""
# Handle custom indicators - if any
# Reset the rolling windows
self.bar_window_intraday.reset()
self.todays_volume = 0
#-------------------------------------------------------------------------------
def adjust_indicators(self, adjustment_factor, is_split=False):
"""Adjust all indicators for splits or dividends."""
self.warming_up = True
# Get a list of the current bars
bars = list(self.bar_window)
# bars_intraday = list(self.bar_window_intraday)
# Current order is newest to oldest (default for rolling window)
# Reverse the list to be oldest to newest
bars.reverse()
# bars_intraday.reverse()
# Reset all indicators
self.reset_indicators()
self.reset_intraday_indicators()
# Loop through the daily bars from oldest to newest
for bar in bars:
# Adjust the bar by the adjustment factor
bar.Open *= adjustment_factor
bar.High *= adjustment_factor
bar.Low *= adjustment_factor
bar.Close *= adjustment_factor
# Update volume on split adjustment
if is_split:
vol_adjustment = 1.0/adjustment_factor
bar.Volume *= vol_adjustment
# Use the bar to update the indicators
# This also adds the bar to the rolling window
self.update_indicators(bar)
self.warming_up = False
#-------------------------------------------------------------------------------
def warmup_indicators(self):
"""Warm up indicators using historical data."""
# Update warmup variable
self.warming_up = True
# Get historical daily trade bars for the symbol
min_days = int(10.0*self.min_bars)
bars = self.algo.history[TradeBar](
self.symbol_object,
DT.timedelta(days=min_days),
Resolution.DAILY
)
# Loop through the bars and update the indicators
for bar in bars:
# Pass directly to the event handler (no consolidating necessary)
self.on_data_consolidated(None, bar)
# We don't need to warm up intraday indicators
# Update warmup variable back to False
self.warming_up = False
#-------------------------------------------------------------------------------
def on_data_consolidated(self, sender, bar):
"""Event handler for desired daily bar."""
# Skip if not regular session
if not self.warming_up:
if bar.time.time() != self.mkt_open_local_tz:
return
# Manually update all of the indicators
self.update_indicators(bar)
#-------------------------------------------------------------------------------
def update_indicators(self, bar):
"""Manually update all of the symbol's indicators."""
# Loop through the indicators
for indicator, update_method in self.indicators:
if update_method == 'close':
indicator.update(bar.end_time, bar.close)
elif update_method == 'high':
indicator.update(bar.end_time, bar.high)
elif update_method == 'low':
indicator.update(bar.end_time, bar.low)
elif update_method == 'bar':
indicator.update(bar)
elif update_method == 'volume':
indicator.update(bar.end_time, bar.volume)
# Add bar to the rolling window
self.bar_window.add(bar)
#-------------------------------------------------------------------------------
def on_intraday_data_consolidated(self, sender, bar):
"""Event handler for desired intraday bar."""
# Manually update the intraday indicators
self.update_intraday_indicators(bar)
#-------------------------------------------------------------------------------
def update_intraday_indicators(self, bar):
"""Manually update all of the symbol's intraday indicators."""
# Update cummulative volume for today
self.todays_volume += bar.volume
# Add bar to the rolling window
self.bar_window_intraday.add(bar)
#-------------------------------------------------------------------------------
# @property
# def indicators_ready(self):
# """Return whether the indicators are warmed up or not."""
# # Loop through list of indicators
# for indicator, _ in self.indicators:
# if not indicator.is_ready:
# return False
# if not self.bar_window.is_ready:
# return False
# if not self.bar_window_intraday.is_ready:
# return False
# # Otherwise True
# return True
#-------------------------------------------------------------------------------
@property
def current_qty(self):
"""Return the current traded symbol quantity held in the portfolio."""
return self.algo.portfolio[self.symbol_object].quantity
#-------------------------------------------------------------------------------
@property
def price(self):
"""Return the current traded symbol quantity held in the portfolio."""
return self.algo.securities[self.symbol_object].price
#-------------------------------------------------------------------------------
@property
def entry_filter(self):
"""
Return whether the stock meets all of the entry requirements.
"""
# Check if the symbol is in the universe
if self.symbol_object not in self.algo.symbols:
return False
# Check for min/max price
price = self.price
if price < MIN_PRICE:
return False
elif price > MAX_PRICE:
return False
# Check yesterday's volume
if self.previous_volume < self.algo.MIN_PREVIOUS_VOL:
return False
# Check today's volume
if self.todays_volume < self.algo.MIN_VOL:
return False
# Check if price is min percentage from previous high
pct_high = self.pct_chg_from_previous_high(price)
if pct_high > -self.algo.PH_PCT_CHG:
return False
# Check if price is min percentage from previous close
pct_close = self.pct_chg_from_previous_close()
if pct_close > -self.algo.PC_PCT_CHG:
return False
# Check if price is min percentage from first premarket price
first_price = self.first_premarket_price
if first_price == 0:
return False
pct_first = (price-first_price)/first_price
if pct_first > -self.algo.PM_PCT_CHG:
return False
# Check yesterday's date
if self.previous_date != self.algo.previous_day \
and self.algo.previous_day is not None:
self.algo.my_log(
f"{self.ticker} Previously a signal, but now filtered out. The "
f"previous date is {self.previous_date} vs. "
f"{self.algo.previous_day}"
)
return False
# Otherwise valid
# Print info
if PRINT_SIGNALS or self.algo.live_mode:
self.algo.my_log(
f"{self.ticker} SHORT ENTRY SIGNAL: price={price}, "
f"PH={self.previous_high} ({round(100*pct_high,2)}%), "
f"PC={self.previous_close} ({round(100*pct_close,2)}%), "
f"PV={self.previous_volume}, "
f"PM={self.first_premarket_price} ({round(100*pct_first,2)}%), "
f"today's vol={self.todays_volume}"
)
return True
#-------------------------------------------------------------------------------
@property
def previous_date(self):
"""Return the previous day's date."""
try:
return self.bar_window[0].end_time.date()
except:
return 0
#-------------------------------------------------------------------------------
@property
def previous_volume(self):
"""Return the previous day's volume."""
try:
return self.bar_window[0].volume
except:
return 0
#-------------------------------------------------------------------------------
@property
def previous_high(self):
"""Return the previous day's high."""
try:
return self.bar_window[0].high
except:
return 0
#-------------------------------------------------------------------------------
@property
def previous_close(self):
"""Return the previous day's close."""
try:
return self.bar_window[0].close
except:
return 0
#-------------------------------------------------------------------------------
@property
def first_premarket_price(self):
"""Return the first premarket price for the day."""
try:
# First bar is the last in the rolling window
count = self.bar_window_intraday.count
return self.bar_window_intraday[count-1].open
except:
return 0
#-------------------------------------------------------------------------------
def pct_chg_from_previous_high(self, price):
"""Return the current percent change from the previous high."""
try:
previous_high = self.bar_window[0].high
pct_chg = (price-previous_high)/previous_high
return pct_chg
except:
return 0
#-------------------------------------------------------------------------------
def pct_chg_from_previous_close(self):
"""Return the current percent change from the previous close."""
try:
price = self.price
previous_close = self.bar_window[0].close
pct_chg = (price-previous_close)/previous_close
return pct_chg
except:
return 0
#-------------------------------------------------------------------------------
def update_stop_order(self, price, tag):
"""Update the desired stop order."""
# Get the stop order ticket
ticket = self.stop_loss_order
if ticket is None:
return
# Update the price
# price = self.round_price(price)
ticket.UpdateStopPrice(price, tag=tag)
self.stop_price = price
# Print details when desired
if PRINT_ORDERS or self.algo.live_mode:
self.algo.my_log(
f"{self.ticker} trailing stop price updated to {price}"
)
#-------------------------------------------------------------------------------
def round_price(self, price):
"""Round the given price to the nearest tick value."""
# Get the priced rounded to the nearest tick value
return round(price, 2)
# price = round(price/self.min_tick)*self.min_tick
# # Only return the desired number of decimals
# try:
# num_left_decimal = str(price).index('.')
# except:
# # min tick uses exponential format
# # Create a decimal with max of 12 decimals
# tmp = round(decimal.Decimal(price),12).normalize()
# num_left_decimal = '{:f}'.format(tmp).index('.')
# length = num_left_decimal + self.min_tick_decimals + 1
# # return float(str(price)[:length])
# return float('{:f}'.format(price)[:length])
#-------------------------------------------------------------------------------
def valid_order(self, order):
"""Return True / False if the order placed is valid."""
# Check order status
if order.Status == OrderStatus.INVALID:
return False
else:
return True
#-------------------------------------------------------------------------------
def go_short(self):
"""Go short the desired amount."""
# Get the desired order qty
price = self.price
order_qty = -int(self.algo.FIXED_DOLLAR_SIZE/price)
# Place the entry order
order = self.algo.market_order(
self.symbol_object, order_qty, tag='short entry'
)
# Check order status
if not self.valid_order(order):
self.algo.my_log(f"Invalid {self.ticker} short entry order!")
#-------------------------------------------------------------------------------
def go_flat(self):
"""Go short the desired amount."""
# Cancel any open exit orders
self.cancel_exit_orders()
# Get the desired order qty
order_qty = -self.current_qty
# Place the exit order
order = self.algo.market_order(
self.symbol_object, order_qty, tag='exit'
)
# Check order status
if not self.valid_order(order):
if not self.algo.live_mode:
raise ValueError(f"Invalid {self.ticker} exit order!")
#-------------------------------------------------------------------------------
def cancel_exit_orders(self):
"""Cancel any open exit orders."""
if self.stop_loss_order:
self.stop_loss_order.cancel()
# if self.take_profit_order:
# self.take_profit_order.cancel()
#-------------------------------------------------------------------------------
def on_order_event(self, order_event):
"""Built-in event handler for orders."""
# Get the order's info
order = self.algo.transactions.get_order_by_id(order_event.order_id)
symbol = order.symbol
tag = order.tag
qty = int(order.quantity)
avg_fill = order_event.fill_price
# Log message when desired
if PRINT_ORDERS or self.algo.live_mode:
self.algo.my_log(
f"{symbol.Value} {tag} order for {qty} shares filled @ "
f"{avg_fill:.2f}"
)
# Catch entry order
if 'entry' in tag: # don't do tag == 'entry bla bla'
# Set entry order back to None
# self.entry_order = None
# Calculate the stop loss price
if qty > 0: # long
# not valid for strategy!
if self.algo.live_mode:
self.go_flat()
else:
raise ValueError(f"Invalid long {symbol.value} position!")
else: # short
stop_price = round((1+self.algo.STOP_PCT)*avg_fill,2)
self.stop_price = stop_price
self.best_price = avg_fill
# Place the stop loss order
order_qty = -qty
order = self.algo.stop_market_order(
symbol, order_qty, stop_price, tag='stop loss exit'
)
if not self.valid_order(order) and not self.algo.live_mode:
raise
# Save the stop order
self.stop_loss_order = order
# Add a take profit
# Same process calculate limit take profit price
# limit_price = ?
# self.take_profit_order = self.algo.limit_order(
# symbol, order_qty, limit_price, tag='take profit exit'
# )
# Catch exit order
elif 'exit' in tag:
# Catch stop order
if 'stop loss' in tag:
self.stop_loss_order = None
# # Catch take profit
# elif 'take profit' in tag:
# self.take_profit_order = None
# Reset trade if qty 0
if self.current_qty == 0:
# Cancel any open exit order
self.cancel_exit_orders()
self.reset_trade_variables()