| Overall Statistics |
|
Total Orders 6100 Average Win 0.14% Average Loss -0.12% Compounding Annual Return -6.222% Drawdown 31.800% Expectancy -0.087 Start Equity 1000000 End Equity 722906.82 Net Profit -27.709% Sharpe Ratio -0.919 Sortino Ratio -0.563 Probabilistic Sharpe Ratio 0.000% Loss Rate 59% Win Rate 41% Profit-Loss Ratio 1.22 Alpha -0.064 Beta 0.066 Annual Standard Deviation 0.063 Annual Variance 0.004 Information Ratio -0.94 Tracking Error 0.159 Treynor Ratio -0.882 Total Fees $54325.55 Estimated Strategy Capacity $22000000.00 Lowest Capacity Asset SPY R735QTJ8XC9X Portfolio Turnover 154.25% |
#region imports
from AlgorithmImports import *
#endregion
class CustomBollingerBand(PythonIndicator):
"""
An extension of the BollingerBands indicator where the indicator value is
(close - middle_band) / (2 * std)
"""
def __init__(self, period, k):
"""
Input:
- period
Period of BollingerBands indicator
- k
k of BollingerBands indicator
"""
self.bb = BollingerBands(period, k)
self.time = datetime.min
self.value = 0
self.warm_up_period = self.bb.warm_up_period
def update(self, *args):
"""
Called each time an indicator should be updated with new data
Input:
- *args
(1) IndicatorDataPoint
(2) Timestamp, Float
"""
if len(args) == 1: # Called with IndicatorDataPoint
input = args[0]
self.bb.update(input.time, input.close)
self.time = input.end_time
self.set_value()
return self.bb.is_ready
else: # Called with time and close arguments
time, close = args[0], args[1]
self.bb.update(time, close)
self.set_value()
@property
def is_ready(self):
"""
Signals if the indicator is ready
"""
return self.bb.is_ready
def set_value(self):
"""
Sets the current value of the indicator
"""
std = self.bb.standard_deviation.current.value
if std == 0:
self.value = 0
else:
close = self.bb.current.value
middle_band = self.bb.middle_band.current.value
self.value = (close - middle_band) / (2 * std)
#region imports
from AlgorithmImports import *
#endregion
from SymbolData import SymbolData
class GradientBoostingAlphaModel(AlphaModel):
"""
Emits insights in the direction of the prediction made by the Symbol Data objects.
"""
_symbol_data_by_symbol = {}
def __init__(self, hold_duration = 10):
"""
Input:
- hold_duration
The duration of the insights emitted
"""
self._hold_duration = hold_duration
self._weight = 1
def update(self, algorithm, data):
"""
Called each time the alpha model receives a new data slice.
Input:
- algorithm
Algorithm instance running the backtest
- data
A data structure for all of an algorithm's data at a single time step
Returns a list of Insights to the portfolio construction model.
"""
insights = []
for symbol, symbol_data in self._symbol_data_by_symbol.items():
direction = symbol_data.predict_direction()
if direction:
hold_duration = timedelta(minutes=self._hold_duration) # Should match universe resolution
insights.append(Insight.price(symbol, hold_duration, direction, None, None, None, self._weight))
return insights
def on_securities_changed(self, algorithm, changes):
"""
Called each time the universe has changed.
Input:
- algorithm
Algorithm instance running the backtest
- changes
The additions and removals of the algorithm's security subscriptions
"""
for security in changes.added_securities:
symbol = security.symbol
self._symbol_data_by_symbol[symbol] = SymbolData(symbol, algorithm, self._hold_duration)
for security in changes.removed_securities:
symbol_data = self._symbol_data_by_symbol.pop(security.symbol, None)
if symbol_data:
symbol_data.dispose()
self._weight = 1 / len(self._symbol_data_by_symbol)
#region imports
from AlgorithmImports import *
#endregion
from CustomBollingerBand import CustomBollingerBand
import lightgbm as lgb
import numpy as np
import pandas as pd
class SymbolData:
"""
This class holds all of the data for a security. It's responsible for training the
gradient boosting model and making predictions.
"""
def __init__(self, symbol, algorithm, hold_duration, k_start=0.5, k_end=5,
k_step=0.25, training_weeks=4, max_depth=1, num_leaves=2, num_trees=20,
commission=0.02, spread_cost=0.03):
"""
Input:
- symbol
Represents a unique security identifier
- algorithm
Algorithm instance running the backtest
- hold_duration
Number of timesteps ahead to predict
- k_start
Starting k for indicator parameter loop
- k_end
Ending k for indicator parameter loop
- k_step
Stepping k for indicator parameter loop
- training_weeks
Number of weeks of historical data to train on
- max_depth
Maximum depth of the trees built
- num_leaves
Number of leaves for each tree
- num_trees
Number of trees to build
- commission
Commission cost of trading round-trip
- spread_cost
Spread cost of trading round-trip
"""
self._symbol = symbol
self._algorithm = algorithm
self._hold_duration = hold_duration
self._resolution = algorithm.universe_settings.resolution
self._training_length = int(training_weeks * 5 * 6.5 * 60) # training_weeks in minutes
self._max_depth = max_depth
self._num_leaves = num_leaves
self._num_trees = num_trees
self._cost = commission + spread_cost
self._indicator_consolidators = []
# Train a model at the end of each month
self._model = None
algorithm.train(algorithm.date_rules.month_end(symbol),
algorithm.time_rules.before_market_close(symbol),
self._train)
# Avoid overnight holds
self._allow_predictions = False
self._events = [
algorithm.schedule.on(algorithm.date_rules.every_day(symbol),
algorithm.time_rules.after_market_open(symbol, 0),
self._start_predicting),
algorithm.schedule.on(algorithm.date_rules.every_day(symbol),
algorithm.time_rules.before_market_close(symbol, hold_duration + 1),
self._stop_predicting)
]
self._setup_indicators(k_start, k_end, k_step)
self._train()
def _setup_indicators(self, k_start, k_end, k_step):
"""
Initializes all the technical indicators and their historical windows.
Input:
- k_start
Starting k for indicator parameter loop
- k_end
Ending k for indicator parameter loop
- k_step
Stepping k for indicator parameter loop
"""
self._indicators_by_indicator_type = {}
self._indicators_history_by_indicator_type = {}
self._max_warm_up_period = 0
for k in np.arange(k_start, k_end + k_step, k_step):
indicators = {
'rsi' : RelativeStrengthIndex(int(14*k)),
'macd': MovingAverageConvergenceDivergence(int(12*k), int(26*k), 9),
'bb' : CustomBollingerBand(int(20*k), 2)
}
for indicator_type, indicator in indicators.items():
# Register indicators for automatic updates
consolidator = self._algorithm.resolve_consolidator(self._symbol, self._resolution)
self._algorithm.register_indicator(self._symbol, indicator, consolidator)
self._indicator_consolidators.append(consolidator)
# Save reference to indicators
if indicator_type not in self._indicators_by_indicator_type:
self._indicators_by_indicator_type[indicator_type] = []
self._indicators_history_by_indicator_type[indicator_type] = []
self._indicators_by_indicator_type[indicator_type].append(indicator)
# Create empty lookback window for indicator history
self._indicators_history_by_indicator_type[indicator_type].append(np.array([]))
# Find max warmup period
self._max_warm_up_period = max(self._max_warm_up_period, indicator.warm_up_period)
self._history_length = self._training_length + self._max_warm_up_period
def _reset_state(self):
"""
Resets all the technical indicators and their histories.
"""
for indicator_type, indicators_history in self._indicators_history_by_indicator_type.items():
self._indicators_history_by_indicator_type[indicator_type] = [np.array([]) for _ in range(len(indicators_history))]
for indicator in self._indicators_by_indicator_type[indicator_type]:
indicator.reset()
def _train(self):
"""
Trains the gradient boosting model using indicator values as input and
future return as output.
"""
self._reset_state()
# Request history for indicator warm up
history = self._algorithm.history(self._symbol, self._history_length, self._resolution)
if history.empty or history.shape[0] < self._history_length:
self._algorithm.log(f"Not enough history for {self._symbol} to train yet.")
return
history = history.loc[self._symbol].close
# Warm up indicators and history of indicators
for indicator_type, indicators in self._indicators_by_indicator_type.items():
for idx, indicator in enumerate(indicators):
warm_up_length = self._training_length + indicator.warm_up_period - 1
warm_up_data = history.iloc[-warm_up_length:]
for time, close in warm_up_data.items():
# Update indicator
indicator.update(time, close)
# Update indicator history
if indicator.is_ready:
current_history = self._indicators_history_by_indicator_type[indicator_type][idx]
appended = np.append(current_history, indicator.current.value)
self._indicators_history_by_indicator_type[indicator_type][idx] = appended
history = history.iloc[self._max_warm_up_period:]
label = history.shift(-self._hold_duration) - history
##################
## Clean Training Data
##################
# Remove last `hold_duration` minutes of each day to avoid overnight holdings
# Get clean indices
data_points_per_day = [len(g) for _, g in label.groupby(pd.Grouper(freq='D')) if g.shape[0] > 0]
clean_indices = []
for i in range(len(data_points_per_day)):
from_index = 0 if i == 0 else data_points_per_day[i-1]
to_index = sum(data_points_per_day[:i+1]) - self._hold_duration
clean_indices.append((from_index, to_index))
# Clean label history
label = pd.concat([label[from_index:to_index] for from_index, to_index in clean_indices])
# Clean indicator history
for indicator_type, indicators_history in self._indicators_history_by_indicator_type.items():
for idx, indicator_history in enumerate(indicators_history):
clean_indicator = np.concatenate([indicator_history[from_index:to_index] for from_index, to_index in clean_indices])
self._indicators_history_by_indicator_type[indicator_type][idx] = clean_indicator
##################
## Format data for training
##################
data = np.empty(shape=(len(label), 0))
feature_name = []
for indicator_type, indicators_history in self._indicators_history_by_indicator_type.items():
for k_step, indicator_history in enumerate(indicators_history):
data = np.append(data, indicator_history.reshape(len(indicator_history), 1), axis=1)
feature_name.append(f"{indicator_type}-{k_step}")
data_set = lgb.Dataset(data=data, label=label, feature_name=feature_name, free_raw_data=False).construct()
######################
## Training
######################
params = {'max_depth' : self._max_depth, 'num_leaves': self._num_leaves, 'seed' : 1234}
self._model = lgb.train(params, train_set = data_set, num_boost_round = self._num_trees, feature_name = feature_name)
def predict_direction(self):
"""
Predicts the direction of future returns
"""
if self._model is None or not self._allow_predictions:
return 0
input_data = [[]]
for _, indicators in self._indicators_by_indicator_type.items():
for indicator in indicators:
input_data[0].append(indicator.current.value)
return_prediction = self._model.predict(input_data)
if return_prediction > self._cost:
return 1
if return_prediction < -self._cost:
return -1
return 0
def dispose(self):
"""
Removes the indicator consolidators
Input:
- remove_events
Flag to remove scheduled events
"""
for consolidator in self._indicator_consolidators:
self._algorithm.subscription_manager.remove_consolidator(self._symbol, consolidator)
for event in self._events:
self._algorithm.schedule.remove(event)
def _start_predicting(self):
"""
Enable the gradient boosting model to generate predictions
"""
self._allow_predictions = True
def _stop_predicting(self):
"""
Disable the gradient boosting model from generating predictions
"""
self._allow_predictions = False
#region imports
from AlgorithmImports import *
from GradientBoostingAlphaModel import GradientBoostingAlphaModel
#endregion
class GradientBoostingModelAlgorithm(QCAlgorithm):
def initialize(self):
self.set_start_date(2015, 9, 1)
self.set_end_date(2020, 9, 17)
self.set_cash(1000000)
symbols = [ Symbol.create("SPY", SecurityType.EQUITY, Market.USA) ]
self.set_universe_selection( ManualUniverseSelectionModel(symbols) )
self.universe_settings.resolution = Resolution.MINUTE
self.set_alpha(GradientBoostingAlphaModel())
self.set_portfolio_construction(InsightWeightingPortfolioConstructionModel())
self.set_execution(ImmediateExecutionModel())