From 1fe8851554952dc9d668767174c70bbd4d3236d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Rodr=C3=ADguez=20Chatruc?= Date: Mon, 2 Dec 2019 11:22:15 -0300 Subject: [PATCH] Fixed a few things and added exit price thresholds as user input --- backtester/backtester.py | 78 +++++++------------------- backtester/demos/backtester_demo.ipynb | 4 +- backtester/strategy/strategy.py | 67 +++++++++++++++------- 3 files changed, 68 insertions(+), 81 deletions(-) diff --git a/backtester/backtester.py b/backtester/backtester.py index c0ca8c1..dc68c20 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -4,19 +4,20 @@ from operator import add import pandas as pd from .strategy import Strategy -from .strategy.signal import Order from .datahandler import HistoricalOptionsData class Backtest: """Processes signals from the Strategy object""" - def __init__(self, capital=1_000_000, shares_per_contract=100): + def __init__(self, qty=1, capital=1_000_000, shares_per_contract=100): self.capital = capital self.shares_per_contract = shares_per_contract + self.qty = qty self._strategy = None self._data = None self.inventory = pd.DataFrame() + self.stop_if_broke = True @property def strategy(self): @@ -48,84 +49,45 @@ class Backtest: for date, entry_signals, exit_signals in self._strategy.signals( self._data, self): - # self._execute_exit(date, exit_signals) - # self._execute_entry(date, entry_signals) - self._execute_exit_new(date, exit_signals) - self._execute_entry_new(date, entry_signals) + self._execute_exit(date, exit_signals) + self._execute_entry(date, entry_signals) return self.trade_log - 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"].values: - 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 _execute_entry(self, date, entry_signals): """Executes entry orders and updates `self.inventory` and `self.trade_log`""" - - orders = self._process_entry_signals(entry_signals) - - for leg, (idx, qty) in orders.items(): - row = entry_signals[leg].loc[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.strategy.register_entry(contract, price) - self._update_trade_log(date, contract, order, qty, -cost) - - def _execute_entry_new(self, date, entry_signals): - """Executes entry orders and updates `self.inventory` and `self.trade_log`""" if entry_signals.empty: return entry, total_price = self._process_entry_signals(entry_signals) - cost = total_price * 1 * self.shares_per_contract + cost = total_price * self.qty * self.shares_per_contract - if self.capital >= cost: - self.capital -= total_price + if (not self.stop_if_broke) or (self.capital >= cost): + self.capital -= cost self.inventory = self.inventory.append(entry, ignore_index=True) legs = entry_signals.columns.levels[0] for leg in legs: row = entry[leg] contract = row["contract"] order = row["order"] - price = row["cost"] - self._update_trade_log(date, contract, order, 1, -price) + price = row["cost"] * self.shares_per_contract + self._update_trade_log(date, contract, order, self.qty, -price) - def _execute_exit_new(self, date, exit_signals): + def _execute_exit(self, date, exit_signals): """Executes exits and updates `self.inventory` and `self.trade_log`""" for contracts, price in exit_signals: - profit = price * 1 * self.shares_per_contract - for contract, order in contracts: - self._update_trade_log(date, contract, order, 1, profit) + profit = price * self.qty * self.shares_per_contract + for contract, order, individual_price in contracts: + self._update_trade_log( + date, contract, order, self.qty, + individual_price * self.shares_per_contract) self.capital += profit - legs = exit_signals.columns.levels[0] - for leg in legs: - self.inventory = self.inventory.drop(self.inventory[ - self.inventory[leg]['optionroot'] == contract].index) + for leg in self._strategy.legs: + self.inventory = self.inventory.drop( + self.inventory[self.inventory[( + leg.name, 'contract')] == contract].index) def _process_entry_signals(self, entry_signals): """Returns a dictionary containing the orders to execute.""" - # Pass `qty` of contracts to buy/sell to `Backtest.__init__` if not entry_signals.empty: legs = entry_signals.columns.levels[0] diff --git a/backtester/demos/backtester_demo.ipynb b/backtester/demos/backtester_demo.ipynb index 32b1855..98d138f 100644 --- a/backtester/demos/backtester_demo.ipynb +++ b/backtester/demos/backtester_demo.ipynb @@ -894,9 +894,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.7.3" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/backtester/strategy/strategy.py b/backtester/strategy/strategy.py index 7d0dc80..8ae5ed7 100644 --- a/backtester/strategy/strategy.py +++ b/backtester/strategy/strategy.py @@ -5,7 +5,7 @@ import pandas as pd from backtester.datahandler import Schema from backtester.option import Direction from .strategy_leg import StrategyLeg -from .signal import Signal, get_order +from .signal import Signal, get_order, Order Condition = namedtuple('Condition', 'fields legs tolerance') @@ -22,11 +22,14 @@ class Strategy: self.legs = [] self.conditions = [] self.entries = set() + self.exit_thresholds = [] + self.dte_on_exit = 2 def add_leg(self, leg): """Adds leg to the strategy""" assert isinstance(leg, StrategyLeg) assert self.schema == leg.schema + leg.name = "leg_{}".format(len(self.legs) + 1) self.legs.append(leg) return self @@ -35,7 +38,7 @@ class Strategy: for leg in legs: assert isinstance(leg, StrategyLeg) assert self.schema == leg.schema - self.legs.extend(legs) + self.add_leg(leg) return self def remove_leg(self, leg_number): @@ -77,10 +80,8 @@ class Strategy: else: entry_df = pd.concat(entry_legs, axis=1) - # exit_legs = self._filter_legs(group, signal=Signal.EXIT) - # exit_df = pd.concat(exit_legs, axis=1) - exit_df = self._filter_exits(data, bt.inventory, - ['leg_1', 'leg_2']) + exit_df = self._filter_exits(group, bt.inventory) + yield (date, entry_df, exit_df) def _filter_legs(self, data, signal=Signal.ENTRY): @@ -120,28 +121,46 @@ class Strategy: return self._apply_conditions(dfs) - def _filter_exits(self, data, inventory, legs): + def _filter_exits(self, data, inventory): exits = [] - for index, row in inventory.iterrows(): + for _, row in inventory.iterrows(): old_price = 0 current_price = 0 contracts = set() - for leg in legs: - contract = row[leg]['contract'] - order = get_order(~leg.direction, Signal.EXIT) - contracts.add((contract, order)) - old_price += row[leg]['price'] + is_empty = False + filters_exit = False + for leg in self.legs: + contract = row[(leg.name, 'contract')] + order = get_order(leg.direction, Signal.EXIT).name + old_price += row[(leg.name, 'cost')] option = data[data['optionroot'] == contract] - if order[0] == 'B': - current_price -= option['ask'] + + # This was originally to skip (and then remove) entries that are past their expiration and therefore + # don't have a corresponding exit anymore (i.e, option is empty). It doesn't work, however, because + # option might just be empty because of missing data in the middle. Moreover, even if the entry is + # past its expiration the current code will still execute the other exit legs associated with it, + # which is inaccurate. This last point can only be truly resolved by not executing the entry + # in the first place. + if option.empty: + is_empty = True + contracts.add((contract, order, 0)) + continue + # + if order == Order.BTC.name: + ask = option['ask'].values[0] + current_price -= ask + contracts.add((contract, order, -ask)) else: - current_price += option['bid'] - if (current_price <= 0.8 * old_price) & (current_price >= - 1.2 * old_price): + bid = option['bid'].values[0] + current_price += bid + contracts.add((contract, order, bid)) + flt = leg.exit_filter + option = flt(option) + if not option.empty: + filters_exit = True + if is_empty or filters_exit or self._is_past_threshold( + current_price, old_price): exits.append((contracts, current_price)) - else: - # Filter the data according to the exit filters and append to exits the contracts that need to exit - pass return exits def _apply_conditions(self, dfs): @@ -166,6 +185,12 @@ class Strategy: return dfs + def _is_past_threshold(self, current_price, old_price): + current_abs = abs(current_price) + old_abs = abs(old_price) + return (current_abs <= self.exit_thresholds[0] * old_abs) or ( + current_abs >= self.exit_thresholds[1] * old_abs) + def __repr__(self): return "Strategy(legs={}, conditions={})".format( self.legs, self.conditions)