Refactoring Backtest class

This commit is contained in:
Juan Pablo Amoroso
2020-02-17 11:25:19 -03:00
parent 5477004184
commit 549b8f1a88
+112 -58
View File
@@ -1,15 +1,15 @@
import pandas as pd
import numpy as np
import pandas as pd
import pyprind
from .strategy import Strategy
from .enums import Order, Stock
from .datahandler import HistoricalOptionsData, TiingoData
from .strategy import Strategy
class Backtest:
"""Processes signals from the Strategy object"""
def __init__(self, allocation, initial_capital=1_000_000, options_percentaje=0.01, stocks_percentaje=0.99):
def __init__(self, allocation, initial_capital=1_000_000, shares_per_contract=100):
assert isinstance(allocation, dict)
assets = ('stocks', 'options', 'cash')
@@ -19,34 +19,23 @@ class Backtest:
for asset in assets:
self.allocation[asset] = allocation.get(asset, 0.0) / total_allocation
self.total_current_cash = self.initial_capital = initial_capital
self.options_percentaje = options_percentaje
self.stocks_percentaje = stocks_percentaje
self.current_stocks_cash = initial_capital * stocks_percentaje
self.current_options_cash = initial_capital * options_percentaje
self.options_capital = self.total_current_cash * options_percentaje
self.stock_capital = self.total_current_cash * stocks_percentaje
self.total_capital = initial_capital
self.current_cash = self.initial_capital = initial_capital
self.stop_if_broke = True
self.shares_per_contract = shares_per_contract
self._stocks = []
self._options_strategy = None
self._stock_data = None
self._stocks_data = None
self._options_data = None
@property
def stocks(self):
return self._stocks
def add_stock(self, stock):
"""Adds stock to the backtest"""
assert isinstance(stock, Stock)
self._stocks.append(stock)
return self
def add_stocks(self, stocks):
"""Adds stocks to the backtest"""
for stock in stocks:
self.add_stock(stock)
@stocks.setter
def stocks(self, stocks):
assert all(isinstance(stock, Stock) for stock in stocks), 'Invalid stocks'
assert sum(stock.percentage for stock in stocks) == 1.0, 'Stock percentages must sum to 1.0'
self._stocks = list(stocks)
return self
@property
@@ -60,13 +49,14 @@ class Backtest:
self.current_cash = strat.initial_capital
@property
def stock_data(self):
return self._stock_data
def stocks_data(self):
return self._stocks_data
@stock_data.setter
def stock_data(self, data):
@stocks_data.setter
def stocks_data(self, data):
assert isinstance(data, TiingoData)
self._stock_data = data
self._stocks_schema = data.schema
self._stocks_data = data
@property
def options_data(self):
@@ -75,6 +65,7 @@ class Backtest:
@options_data.setter
def options_data(self, data):
assert isinstance(data, HistoricalOptionsData)
self._options_schema = data.schema
self._options_data = data
def run(self, rebalance_freq=0, monthly=False):
@@ -88,7 +79,7 @@ class Backtest:
pd.DataFrame: Log of the trades executed.
"""
assert self._stock_data, 'Stock data not set'
assert self._stocks_data, 'Stock data not set'
assert self._options_data, 'Options data not set'
assert self._options_strategy, 'Options Strategy not set'
assert self._options_data.schema == self._options_strategy.schema
@@ -97,18 +88,7 @@ class Backtest:
stock_dates = self._stock_data['date'].unique()
assert np.array_equal(stock_dates, option_dates), 'Stock and options dates do not match'
columns = pd.MultiIndex.from_product(
[[l.name for l in self._options_strategy.legs],
['contract', 'underlying', 'expiration', 'type', 'strike', 'cost', 'order']])
totals = pd.MultiIndex.from_product([['totals'], ['cost', 'qty', 'date']])
self.options_inventory = pd.DataFrame(columns=columns.append(totals))
self.stocks_inventory = pd.DataFrame(columns=['symbol', 'cost', 'qty'])
rebalancing_days = pd.date_range(
self.stock_data.start_date, self.stock_data.end_date, freq=str(rebalance_freq) +
'BMS') if rebalance_freq else []
self._initialize_inventories()
self.trade_log = pd.DataFrame()
self.balance = pd.DataFrame({
'total_capital': self.current_cash,
@@ -116,27 +96,42 @@ class Backtest:
},
index=[self.stock_data.start_date - pd.Timedelta(1, unit='day')])
data_iterator = self._data_iterator(monthly)
#bar = pyprind.ProgBar(data_iterator.ngroups, bar_char='█')
for date, stocks, options in data_iterator:
if (date == self.stock_data.start_date) or (date in rebalancing_days):
self._rebalance_portfolio(date, stocks, options)
self._update_balance(date, stocks, options)
rebalancing_days = pd.date_range(
self.stock_data.first_date, self.stock_data.end_date, freq=str(rebalance_freq) +
'BMS') if rebalance_freq else []
#bar.update()
data_iterator = self._data_iterator(monthly)
bar = pyprind.ProgBar(len(stock_dates), bar_char='')
for date, stocks, options in data_iterator:
if date in rebalancing_days or date == self.stock_data.start_date:
self._rebalance_portfolio(date, stocks, options)
self._update_balance(date, stocks, options)
bar.update()
self.balance['% change'] = self.balance['total_capital'].pct_change()
self.balance['accumulated return'] = (1.0 + self.balance['% change']).cumprod()
return self.trade_log
def _initialize_inventories(self):
"""Initialize empty stocks and options inventories."""
columns = pd.MultiIndex.from_product(
[[l.name for l in self._options_strategy.legs],
['contract', 'underlying', 'expiration', 'type', 'strike', 'cost', 'order']])
totals = pd.MultiIndex.from_product([['totals'], ['cost', 'qty', 'date']])
self._options_inventory = pd.DataFrame(columns=columns.append(totals))
self._stocks_inventory = pd.DataFrame(columns=['symbol', 'price', 'qty'])
def _data_iterator(self, monthly):
"""Returns combined iterator for stock and options data.
Each step, it produces a tuple like the following:
(date, stocks, options)
Returns:
generator: Daily/monthly iterator over `self.stock_data` and `self.options_data`
generator: Daily/monthly iterator over `self.stock_data` and `self.options_data`.
"""
if monthly:
it = zip(self._stock_data.iter_months(), self._options_data.iter_months())
@@ -145,6 +140,63 @@ class Backtest:
return ((date, stocks, options) for (date, stocks), (_, options) in it)
def _rebalance_portfolio(self, date, stocks, options):
"""Rebalances the portfolio according to `self.allocation`."""
stock_capital = self._current_stock_capital(stocks)
options_capital = self._current_options_capital(options)
total_capital = self.current_cash + stock_capital + options_capital
options_allocation = self.allocation['options'] * total_capital
stocks_allocation = self.allocation['stocks'] * total_capital
# Clear inventories
self._initialize_inventories()
for stock in self._stocks:
query = '{} == "{}"'.format(self.schema['symbol'], stock.symbol)
stock_row = stocks.query(query)
stock_price = stock_row[self._stocks_schema['adjClose']].values[0]
qty = (stocks_allocation * stock.percentage) // stock_price
stock_entry = pd.Series([stock.symbol, stock_price, qty], index=self._stocks_inventory.columns)
self._stocks_inventory = self._stocks_inventory.append(stock_entry, ignore_index=True)
self._sell_options()
entry_signals = self._strategy.filter_entries(options, self.inventory, date)
self._execute_entry(entry_signals)
options_value = sum(self.options_inventory['totals']['cost'] * self.options_inventory['totals']['qty'])
# Update current cash
invested_capital = sum(self.inventory['cost'] * self.inventory['qty'])
self.current_cash = money_total - invested_capital
def _current_stock_capital(self, stocks):
"""Return the current value of the stocks inventory.
Args:
stocks (pd.DataFrame): Stocks data for the current time step.
Returns:
float: Total capital in stocks.
"""
current_stocks = self._stocks_inventory.merge(stocks,
how='left',
left_on='symbol',
right_on=self._stock_schema['symbol'])
return (current_stocks[self._stocks_schema['adjClose']] * current_stocks['qty']).sum()
def _current_options_capital(self, options):
total_cost = 0.0
for leg in self._options_strategy.legs:
current_options = self._options_inventory[leg.name].merge(options,
how='left',
left_on='contract',
right_on=self._options_schema['contract'])
price_col = ~(leg.direction).value
total_cost += current_options[price_col].fillna(0.0).iloc[0] * current_options['qty']
return total_cost
def _execute_entry(self, entry_signals):
"""Executes entry orders and updates `self.inventory` and `self.trade_log`"""
entry, total_price = self._process_entry_signals(entry_signals)
@@ -250,13 +302,13 @@ class Backtest:
Args:
date (pd.Timestamp): Current date.
stocks (pd.DataFrame): DataFrame of stocks
stocks (pd.DataFrame): DataFrame of stocks
options (pd.DataFrame): DataFrame of (daily/monthly) options.
"""
exit_signals = self._options_strategy.filter_exits(options, self.options_inventory, date)
self._execute_exit(exit_signals)
#update options
# update options
leg_candidates = [
self._options_strategy._exit_candidates(l.direction, self.options_inventory[l.name], options,
self.options_inventory.index) for l in self._options_strategy.legs
@@ -295,13 +347,14 @@ class Backtest:
row = pd.Series(
{
'options_qty': self.options_inventory['totals']['qty'].sum(),
'options_capital': options_capital,
'calls_value': calls_value,
'puts_value': puts_value,
'stocks_capital': self.stock_capital,
'total_cash': self.current_stocks_cash + self.current_options_cash,
'total_capital': self.stock_capital + self.options_capital,
'total capital': self.stock_capital + self.options_capital,
'cash': self.current_stocks_cash + self.current_options_cash,
'stocks capital': self.stock_capital,
'stocks qty': self._stocks_inventory['qty'].sum(),
'options capital': options_capital,
'options qty': self._options_inventory['totals']['qty'].sum(),
'calls capital': calls_capital,
'puts capital': puts_capital
},
name=date)
self.balance = self.balance.append(row)
@@ -388,4 +441,5 @@ class Backtest:
return styler
def __repr__(self):
return "Backtest(capital={}, strategy={})".format(self.current_cash, self._strategy)
return "Backtest(capital={}, allocation={}, stocks={}, strategy={})".format(
self.current_cash, self.allocation, self._stocks, self._options_strategy)