diff --git a/backtester/__init__.py b/backtester/__init__.py index e29223e..0bd50a9 100644 --- a/backtester/__init__.py +++ b/backtester/__init__.py @@ -1 +1 @@ -from .backtester import * +from .backtester import Backtest diff --git a/backtester/__main__.py b/backtester/__main__.py deleted file mode 100644 index d674076..0000000 --- a/backtester/__main__.py +++ /dev/null @@ -1,15 +0,0 @@ -import argparse -import os -import logging -from .backtester import run -from .utils import get_data_dir - -parser = argparse.ArgumentParser(prog="backtester.py") -parser.add_argument( - "-t", "--symbols", nargs="+", help="Symbols to fetch", required=True) -parser.add_argument("-s", "--scraper", choices=["cboe"]) -args = parser.parse_args() - -data_dir = get_data_dir() -spx_data = os.path.join(data_dir, "SPX_2008-2018.csv") -run(spx_data) diff --git a/backtester/backtester.py b/backtester/backtester.py index 1efdea9..2c960c1 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -1,45 +1,115 @@ -"""Event based backtester""" +import pandas as pd -from queue import Queue -from .datahandler import BalancedDataHandler -from .strategy import Balanced -from .portfolio import BalancedPortfolio +from .strategy import Strategy +from .strategy.signal import Order +from .datahandler import HistoricalOptionsData -def run(data_path, - data_handler=BalancedDataHandler, - port_class=BalancedPortfolio, - strat_class=Balanced, - **strat_args): - events = Queue() - bars = data_handler(data_path, events) +class Backtest: + """Processes signals from the Strategy object""" - weights = { - "VOO": 0.3, - "GLD": 0.1, - "VNQ": 0.05, - "VNQI": 0.05, - "TLT": 0.2, - "TIP": 0.1, - "BNDX": 0.1, - "RJI": 0.1 - } - port = port_class(bars, events, weights=weights) - strat = strat_class(bars, events, **strat_args) + def __init__(self, capital=1_000_000, shares_per_contract=100): + self.capital = capital + self.shares_per_contract = shares_per_contract + self._strategy = None + self._data = None + self._inventory = set() - while True: - bars.update_bars() - if not bars.continue_backtest: - break + @property + def strategy(self): + return self._strategy - while True: - if events.empty(): - break - event = events.get() - if event.type == "MARKET": - strat.generate_signals(event) - port.update_timeindex(event) - elif event.type == "SIGNAL": - port.update_signal(event) + @strategy.setter + def strategy(self, strat): + assert isinstance(strat, Strategy) + self._strategy = strat + return self - return port + @property + def data(self): + return self._data + + @data.setter + def data(self, data): + assert isinstance(data, HistoricalOptionsData) + self._data = data + return self + + def run(self): + """Runs the backtest and returns a `pd.DataFrame` of the orders executed.""" + assert self._data is not None + assert self._strategy is not None + + self.trade_log = pd.DataFrame( + columns=["date", "contract", "order", "qty", "profit", "capital"]) + + for date, entry_signals, exit_signals in self._strategy.signals( + self._data): + self._execute_exit(date, exit_signals) + entry_orders = self.process_entry_signals(entry_signals) + self._execute_entry(date, entry_orders, entry_signals) + + return self.trade_log + + def process_entry_signals(self, entry_signals): + """Returns the a dictionary containing the orders to execute.""" + # TODO: Move this logic to Strategy. + # Pass `qty` of contracts to buy/sell to `Backtest.__init__` + + orders = {} + + if not entry_signals.empty: + for leg in entry_signals.legs: + leg_signals = entry_signals[leg] + # Filter out zero priced options + leg_signals = leg_signals.query("price > 0.0") + if leg_signals.empty: + return {} + if (leg_signals["order"] == Order.BTO.name).any(): + orders[leg] = (leg_signals["price"].idxmin(), 1) + else: + orders[leg] = (leg_signals["price"].idxmax(), 1) + return orders + + def _execute_entry(self, date, orders, entry_signals): + """Executes entry orders and updates `self.inventory` and `self.trade_log`""" + for leg, (idx, qty) in orders.items(): + row = entry_signals[leg].iloc[idx, :] + contract = row["contract"] + order = row["order"] + price = row["price"] + expiration = row["expiration"] + cost = price * qty * self.shares_per_contract + cost *= -1 if order == Order.STO.name else 1 + if self.capital >= cost: + self.capital -= cost + self._inventory.add((contract, leg, qty, expiration)) + self._update_trade_log(date, contract, order, qty, -cost) + + def _execute_exit(self, date, exit_signals): + """Executes exits and updates `self.inventory` and `self.trade_log`""" + remove_set = set() + for contract, leg, qty, expiration in self._inventory: + if contract in exit_signals[leg]["contract"]: + row = exit_signals[leg].query("contract == @contract") + price = row["price"].values[0] + order = row["order"].values[0] + profit = price * qty * self.shares_per_contract + profit *= 1 if order == Order.STC.name else -1 + self.capital += profit + self._update_trade_log(date, contract, order, qty, profit) + remove_set.add((contract, leg, qty, expiration)) + elif expiration <= date: + remove_set.add((contract, leg, qty, expiration)) + + self._inventory.difference_update(remove_set) + + def _update_trade_log(self, date, contract, order, qty, profit): + """Adds entry for the given order to `self.trade_log`.""" + self.trade_log.loc[len(self.trade_log)] = [ + date, contract, order, qty, profit, self.capital + ] + + def __repr__(self): + return "Backtest(capital={}, strategy={})".format( + self._strategy, self.capital) diff --git a/backtester/datahandler/schema.py b/backtester/datahandler/schema.py index 5c60964..14df704 100644 --- a/backtester/datahandler/schema.py +++ b/backtester/datahandler/schema.py @@ -1,5 +1,7 @@ class Schema: - """Data schema class (used to run validations)""" + """Data schema class. + Used to run validations and provide uniform access to fields in the data set. + """ columns = [ "underlying", "underlying_last", "date", "contract", "type", diff --git a/backtester/demos/backtester_demo.ipynb b/backtester/demos/backtester_demo.ipynb new file mode 100644 index 0000000..ee4bcab --- /dev/null +++ b/backtester/demos/backtester_demo.ipynb @@ -0,0 +1,739 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from backtester.datahandler import HistoricalOptionsData\n", + "from backtester.strategy import Strategy, StrategyLeg\n", + "from backtester.option import Type, Direction\n", + "from backtester import Backtest" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "data = HistoricalOptionsData(\"data/options_data_v2.h5\",\n", + " key=\"/SPX\",\n", + " where=\"quotedate < 1991\")\n", + "schema = data.schema" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Schema([Field(name='underlying', mapping='underlying'), Field(name='underlying_last', mapping='underlying_last'), Field(name='date', mapping='quotedate'), Field(name='contract', mapping='optionroot'), Field(name='type', mapping='type'), Field(name='expiration', mapping='expiration'), Field(name='strike', mapping='strike'), Field(name='bid', mapping='bid'), Field(name='ask', mapping='ask'), Field(name='volume', mapping='volume'), Field(name='open_interest', mapping='openinterest'), Field(name='last', mapping='last'), Field(name='impliedvol', mapping='impliedvol'), Field(name='delta', mapping='delta'), Field(name='gamma', mapping='gamma'), Field(name='theta', mapping='theta'), Field(name='vega', mapping='vega'), Field(name='dte', mapping='dte')])" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "schema" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Strategy(legs=[])" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "strat = Strategy(schema)\n", + "strat" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We'll implement a simple [short straddle](https://www.optionseducation.org/strategies/all-strategies/short-straddle), selling calls and puts 10% otm between 30 and 20 days prior to expiration, and covering the position 2 days before expiration." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Strategy(legs=[StrategyLeg(type=Type.CALL, direction=Direction.SELL, entry_filter=Filter(query='(type == 'call') & ((((underlying == 'SPX') & (strike >= 1.1 * underlying_last)) & (dte >= 20)) & (dte <= 30))'), exit_filter=Filter(query='(type == 'call') & ((underlying == 'SPX') & (dte <= 2))')), StrategyLeg(type=Type.PUT, direction=Direction.SELL, entry_filter=Filter(query='(type == 'put') & ((((underlying == 'SPX') & (strike <= underlying_last * 0.9)) & (dte >= 20)) & (dte <= 30))'), exit_filter=Filter(query='(type == 'put') & ((underlying == 'SPX') & (dte <= 2))'))])" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "leg1 = StrategyLeg(schema, option_type=Type.CALL, direction=Direction.SELL)\n", + "leg1.entry_filter = (schema.underlying == \"SPX\") & (\n", + " schema.strike >= 1.1 * schema.underlying_last) & (schema.dte >=\n", + " 20) & (schema.dte <= 30)\n", + "leg1.exit_filter = (schema.underlying == \"SPX\") & (schema.dte <= 2)\n", + "\n", + "leg2 = StrategyLeg(schema, option_type=Type.PUT, direction=Direction.SELL)\n", + "leg2.entry_filter = (schema.underlying == \"SPX\") & (\n", + " schema.strike <= schema.underlying_last * 0.9) & (schema.dte >=\n", + " 20) & (schema.dte <= 30)\n", + "leg2.exit_filter = (schema.underlying == \"SPX\") & (schema.dte <= 2)\n", + "strat.add_leg(leg1)\n", + "strat.add_leg(leg2)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Backtest(capital=Strategy(legs=[StrategyLeg(type=Type.CALL, direction=Direction.SELL, entry_filter=Filter(query='(type == 'call') & ((((underlying == 'SPX') & (strike >= 1.1 * underlying_last)) & (dte >= 20)) & (dte <= 30))'), exit_filter=Filter(query='(type == 'call') & ((underlying == 'SPX') & (dte <= 2))')), StrategyLeg(type=Type.PUT, direction=Direction.SELL, entry_filter=Filter(query='(type == 'put') & ((((underlying == 'SPX') & (strike <= underlying_last * 0.9)) & (dte >= 20)) & (dte <= 30))'), exit_filter=Filter(query='(type == 'put') & ((underlying == 'SPX') & (dte <= 2))'))]), strategy=1000000)" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bt = Backtest()\n", + "bt.strategy = strat\n", + "bt.data = data\n", + "bt" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/jamoroso/work/backtester_options/backtester/strategy/strategy.py:59: UserWarning: Pandas doesn't allow columns to be created via a new attribute name - see https://pandas.pydata.org/pandas-docs/stable/indexing.html#attribute-access\n", + " entry_df.legs = exit_df.legs = exit_df.columns.levels[0]\n" + ] + }, + { + "data": { + "text/html": [ + "
| \n", + " | date | \n", + "contract | \n", + "order | \n", + "qty | \n", + "profit | \n", + "capital | \n", + "
|---|---|---|---|---|---|---|
| 0 | \n", + "1990-01-19 | \n", + "SPX900217C00375000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000010.0 | \n", + "
| 1 | \n", + "1990-01-19 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000020.0 | \n", + "
| 2 | \n", + "1990-01-22 | \n", + "SPX900217C00365000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000030.0 | \n", + "
| 3 | \n", + "1990-01-22 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000040.0 | \n", + "
| 4 | \n", + "1990-01-23 | \n", + "SPX900217C00365000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000050.0 | \n", + "
| 5 | \n", + "1990-01-23 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000060.0 | \n", + "
| 6 | \n", + "1990-01-24 | \n", + "SPX900217C00365000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000070.0 | \n", + "
| 7 | \n", + "1990-01-24 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000080.0 | \n", + "
| 8 | \n", + "1990-01-25 | \n", + "SPX900217C00360000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000090.0 | \n", + "
| 9 | \n", + "1990-01-25 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000100.0 | \n", + "
| 10 | \n", + "1990-01-26 | \n", + "SPX900217C00360000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000110.0 | \n", + "
| 11 | \n", + "1990-01-26 | \n", + "SPX900217P00225000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000120.0 | \n", + "
| 12 | \n", + "1990-02-20 | \n", + "SPX900317C00365000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000130.0 | \n", + "
| 13 | \n", + "1990-02-20 | \n", + "SPX900317P00275000 | \n", + "STO | \n", + "1 | \n", + "50.0 | \n", + "1000180.0 | \n", + "
| 14 | \n", + "1990-08-23 | \n", + "SPX900922C00340000 | \n", + "STO | \n", + "1 | \n", + "80.0 | \n", + "1000260.0 | \n", + "
| 15 | \n", + "1990-08-23 | \n", + "SPX900922P00275000 | \n", + "STO | \n", + "1 | \n", + "320.0 | \n", + "1000580.0 | \n", + "
| 16 | \n", + "1990-08-24 | \n", + "SPX900922C00345000 | \n", + "STO | \n", + "1 | \n", + "20.0 | \n", + "1000600.0 | \n", + "
| 17 | \n", + "1990-08-24 | \n", + "SPX900922P00275000 | \n", + "STO | \n", + "1 | \n", + "220.0 | \n", + "1000820.0 | \n", + "
| 18 | \n", + "1990-08-27 | \n", + "SPX900922C00355000 | \n", + "STO | \n", + "1 | \n", + "20.0 | \n", + "1000840.0 | \n", + "
| 19 | \n", + "1990-08-27 | \n", + "SPX900922P00275000 | \n", + "STO | \n", + "1 | \n", + "110.0 | \n", + "1000950.0 | \n", + "
| 20 | \n", + "1990-08-28 | \n", + "SPX900922C00355000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1000960.0 | \n", + "
| 21 | \n", + "1990-08-28 | \n", + "SPX900922P00275000 | \n", + "STO | \n", + "1 | \n", + "90.0 | \n", + "1001050.0 | \n", + "
| 22 | \n", + "1990-08-29 | \n", + "SPX900922C00360000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1001060.0 | \n", + "
| 23 | \n", + "1990-08-29 | \n", + "SPX900922P00290000 | \n", + "STO | \n", + "1 | \n", + "140.0 | \n", + "1001200.0 | \n", + "
| 24 | \n", + "1990-08-31 | \n", + "SPX900922C00355000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1001210.0 | \n", + "
| 25 | \n", + "1990-08-31 | \n", + "SPX900922P00290000 | \n", + "STO | \n", + "1 | \n", + "160.0 | \n", + "1001370.0 | \n", + "
| 26 | \n", + "1990-09-20 | \n", + "SPX901020C00345000 | \n", + "STO | \n", + "1 | \n", + "20.0 | \n", + "1001390.0 | \n", + "
| 27 | \n", + "1990-09-20 | \n", + "SPX901020P00250000 | \n", + "STO | \n", + "1 | \n", + "60.0 | \n", + "1001450.0 | \n", + "
| 28 | \n", + "1990-09-21 | \n", + "SPX901020C00345000 | \n", + "STO | \n", + "1 | \n", + "40.0 | \n", + "1001490.0 | \n", + "
| 29 | \n", + "1990-09-21 | \n", + "SPX901020P00250000 | \n", + "STO | \n", + "1 | \n", + "80.0 | \n", + "1001570.0 | \n", + "
| 30 | \n", + "1990-09-24 | \n", + "SPX901020C00340000 | \n", + "STO | \n", + "1 | \n", + "30.0 | \n", + "1001600.0 | \n", + "
| 31 | \n", + "1990-09-24 | \n", + "SPX901020P00250000 | \n", + "STO | \n", + "1 | \n", + "80.0 | \n", + "1001680.0 | \n", + "
| 32 | \n", + "1990-09-25 | \n", + "SPX901020C00340000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1001690.0 | \n", + "
| 33 | \n", + "1990-09-25 | \n", + "SPX901020P00275000 | \n", + "STO | \n", + "1 | \n", + "120.0 | \n", + "1001810.0 | \n", + "
| 34 | \n", + "1990-09-26 | \n", + "SPX901020C00340000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1001820.0 | \n", + "
| 35 | \n", + "1990-09-26 | \n", + "SPX901020P00250000 | \n", + "STO | \n", + "1 | \n", + "60.0 | \n", + "1001880.0 | \n", + "
| 36 | \n", + "1990-09-27 | \n", + "SPX901020C00335000 | \n", + "STO | \n", + "1 | \n", + "20.0 | \n", + "1001900.0 | \n", + "
| 37 | \n", + "1990-09-27 | \n", + "SPX901020P00250000 | \n", + "STO | \n", + "1 | \n", + "60.0 | \n", + "1001960.0 | \n", + "
| 38 | \n", + "1990-09-28 | \n", + "SPX901020C00340000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1001970.0 | \n", + "
| 39 | \n", + "1990-09-28 | \n", + "SPX901020P00275000 | \n", + "STO | \n", + "1 | \n", + "120.0 | \n", + "1002090.0 | \n", + "
| 40 | \n", + "1990-10-18 | \n", + "SPX901117C00340000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1002100.0 | \n", + "
| 41 | \n", + "1990-10-18 | \n", + "SPX901117P00275000 | \n", + "STO | \n", + "1 | \n", + "210.0 | \n", + "1002310.0 | \n", + "
| 42 | \n", + "1990-10-19 | \n", + "SPX901117C00345000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1002320.0 | \n", + "
| 43 | \n", + "1990-10-19 | \n", + "SPX901117P00280000 | \n", + "STO | \n", + "1 | \n", + "190.0 | \n", + "1002510.0 | \n", + "
| 44 | \n", + "1990-10-23 | \n", + "SPX901117C00345000 | \n", + "STO | \n", + "1 | \n", + "20.0 | \n", + "1002530.0 | \n", + "
| 45 | \n", + "1990-10-23 | \n", + "SPX901117P00280000 | \n", + "STO | \n", + "1 | \n", + "110.0 | \n", + "1002640.0 | \n", + "
| 46 | \n", + "1990-10-24 | \n", + "SPX901117C00345000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1002650.0 | \n", + "
| 47 | \n", + "1990-10-24 | \n", + "SPX901117P00280000 | \n", + "STO | \n", + "1 | \n", + "110.0 | \n", + "1002760.0 | \n", + "
| 48 | \n", + "1990-10-25 | \n", + "SPX901117C00345000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1002770.0 | \n", + "
| 49 | \n", + "1990-10-25 | \n", + "SPX901117P00275000 | \n", + "STO | \n", + "1 | \n", + "110.0 | \n", + "1002880.0 | \n", + "
| 50 | \n", + "1990-10-26 | \n", + "SPX901117C00340000 | \n", + "STO | \n", + "1 | \n", + "10.0 | \n", + "1002890.0 | \n", + "
| 51 | \n", + "1990-10-26 | \n", + "SPX901117P00250000 | \n", + "STO | \n", + "1 | \n", + "40.0 | \n", + "1002930.0 | \n", + "