From 2c5100cd86e6a6d17834a259b8f6db50dd228989 Mon Sep 17 00:00:00 2001 From: Juan Pablo Amoroso Date: Tue, 17 Dec 2019 10:13:47 -0300 Subject: [PATCH] Refactored filter_exits method in Strategy. Moved date iteration to Backtester --- backtester/backtester.py | 19 ++- backtester/option.py | 4 +- backtester/strategy/strategy.py | 225 +++++++++++++++++----------- backtester/strategy/strategy_leg.py | 12 +- 4 files changed, 160 insertions(+), 100 deletions(-) diff --git a/backtester/backtester.py b/backtester/backtester.py index 1f1563f..cea5c34 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -1,6 +1,3 @@ -from functools import reduce -from operator import add - import pandas as pd from .strategy import Strategy @@ -27,7 +24,6 @@ class Backtest: def strategy(self, strat): assert isinstance(strat, Strategy) self._strategy = strat - return self @property def data(self): @@ -37,18 +33,20 @@ class Backtest: 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.""" + """Runs the backtest and returns a `pd.DataFrame` of the orders executed (`self.trade_log`)""" assert self._data is not None assert self._strategy is not None + assert self._data.schema == self._strategy.schema 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): + for date, options in self._data.iter_dates(): + entry_signals = self._strategy.filter_entries(options) + exit_signals = self._strategy.filter_exits(options, self.inventory) + self._execute_exit(date, exit_signals) self._execute_entry(date, entry_signals) @@ -62,12 +60,13 @@ class Backtest: cost = total_price * self.qty * self.shares_per_contract if (not self.stop_if_broke) or (self.capital >= cost): + entry['totals']['cost'] = cost self.inventory = self.inventory.append(entry, ignore_index=True) for leg in self._strategy.legs: row = entry[leg.name] contract = row["contract"] order = row["order"] - price = row["cost"] * self.shares_per_contract + price = row["cost"] * self.qty * self.shares_per_contract self.capital -= price self._update_trade_log(date, contract, order, self.qty, -price) @@ -88,7 +87,7 @@ class Backtest: if not entry_signals.empty: legs = entry_signals.columns.levels[0] - costs = reduce(add, (entry_signals[leg]["cost"] for leg in legs)) + costs = sum((entry_signals[leg]["cost"] for leg in legs)) return entry_signals.loc[costs.idxmin()], costs.min() else: return entry_signals, 0 diff --git a/backtester/option.py b/backtester/option.py index d382aa0..34a143f 100644 --- a/backtester/option.py +++ b/backtester/option.py @@ -12,8 +12,8 @@ class Type(Enum): class Direction(Enum): - BUY = 'ask' - SELL = 'bid' + BUY = 'ask' # Schema field for BUY price + SELL = 'bid' # Schema field for SELL price def __invert__(self): flip = Direction.SELL if self == Direction.BUY else Direction.SELL diff --git a/backtester/strategy/strategy.py b/backtester/strategy/strategy.py index 8ae5ed7..6c4613e 100644 --- a/backtester/strategy/strategy.py +++ b/backtester/strategy/strategy.py @@ -1,6 +1,7 @@ from collections import namedtuple import pandas as pd +import numpy as np from backtester.datahandler import Schema from backtester.option import Direction @@ -12,7 +13,7 @@ Condition = namedtuple('Condition', 'fields legs tolerance') class Strategy: """Options strategy class. - Takes in a number of `legs` (option contracts), and filters that determine + Takes in a number of `StrategyLeg`'s (option contracts), and filters that determine entry and exit conditions. """ @@ -21,9 +22,7 @@ class Strategy: self.schema = schema self.legs = [] self.conditions = [] - self.entries = set() - self.exit_thresholds = [] - self.dte_on_exit = 2 + self.exit_thresholds = (0.0, 0.0) def add_leg(self, leg): """Adds leg to the strategy""" @@ -36,8 +35,6 @@ class Strategy: def add_legs(self, legs): """Adds legs to the strategy""" for leg in legs: - assert isinstance(leg, StrategyLeg) - assert self.schema == leg.schema self.add_leg(leg) return self @@ -60,53 +57,84 @@ class Strategy: legs = self.legs self.conditions.append(Condition(fields, legs, tolerance)) + return self - def register_entry(self, contract, price): - """Allows the Backtester to register entries in order to allow exiting on - given profit/loss levels""" - self.entries.add(contract) + def add_exit_thresholds(self, profit_pct=0.0, loss_pct=0.0): + """Adds maximum profit/loss thresholds. - def signals(self, data, bt): - """Iterates over `data` and yields a tuple of - `(date, entry_signals, exit_signals)` for each time step. + Args: + profit_pct (float, optional): Max profit level. Defaults to 0.0 + loss_pct (float, optional): Max loss level. Defaults to 0.0 """ - assert self.schema == data.schema + self.exit_thresholds = (profit_pct, loss_pct) - for date, group in data.iter_dates(): - entry_legs = self._filter_legs(group, signal=Signal.ENTRY) + def filter_entries(self, options): + """Returns the entry signals chosen by the strategy for the given + (daily) options. - if any(df.empty for df in entry_legs): - entry_df = pd.DataFrame() - else: - entry_df = pd.concat(entry_legs, axis=1) - - exit_df = self._filter_exits(group, bt.inventory) - - yield (date, entry_df, exit_df) - - def _filter_legs(self, data, signal=Signal.ENTRY): - """Returns a list of `pd.DataFrame`. - Each dataframe contains signals for each leg in the strategy. + Args: + options (pd.DataFrame): DataFrame of (daily) options + Returns: + pd.DataFrame: Entry signals """ - schema = self.schema + return self._filter_legs(options, Signal.ENTRY) + + def filter_exits(self, options, inventory): + """Returns the exit signals chosen by the strategy for the given + (daily) options. + + Args: + options (pd.DataFrame): DataFrame of (daily) options + inventory (pd.DataFrame): Inventory of current positions + Returns: + pd.DataFrame: Exit signals + """ + + underlying_col, spot_col = self.schema['underlying'], self.schema[ + 'underlying_last'] + underlying_symbols = options.loc[:, ( + underlying_col, spot_col)].drop_duplicates(underlying_col) + spot_prices = underlying_symbols.set_index(underlying_col).to_dict() + + leg_costs = [ + self._exit_costs(~l.direction, inventory[l.name], options, + spot_prices) for l in self.legs + ] + + total_costs = sum((l['current_cost'] for l in leg_costs)) + threshold_exits = self._filter_thresholds(inventory['cost'], + total_costs) + + # Only check exits for options in inventory + subset = options[self.schema['contract']].isin(inventory['contract']) + options_in_inventory = options[subset] + + exit_df = self._filter_legs(options_in_inventory, Signal.EXIT) + return total_costs & threshold_exits + + def _filter_legs(self, options, signal): + """Returns a hierarchically indexed `pd.DataFrame` containing signals for each + leg in the strategy. + + Args: + options (pd.DataFrame): DataFrame of (daily) options + signal (Signal): Either `Signal.ENTRY` or `Signal.EXIT` + + Returns: + pd.DataFrame: DataFrame of signals, with `pd.MultiIndex` columns + """ + dfs = [] for leg in self.legs: if signal == Signal.ENTRY: flt = leg.entry_filter - cost = leg.direction.value + cost_field = leg.direction.value else: flt = leg.exit_filter - cost = (~leg.direction).value + cost_field = (~leg.direction).value - df = flt(data) - fields = { - schema["contract"]: "contract", - schema["underlying"]: "underlying", - schema["expiration"]: "expiration", - schema["type"]: "type", - schema["strike"]: "strike", - schema[cost]: "cost" - } + df = flt(options) + fields = self._signal_fields(cost_field) subset_df = df.loc[:, fields.keys()] subset_df.rename(columns=fields, inplace=True) @@ -121,47 +149,17 @@ class Strategy: return self._apply_conditions(dfs) - def _filter_exits(self, data, inventory): - exits = [] - for _, row in inventory.iterrows(): - old_price = 0 - current_price = 0 - contracts = set() - 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] + def _signal_fields(self, cost_field): + fields = { + self.schema['contract']: 'contract', + self.schema['underlying']: 'underlying', + self.schema['expiration']: 'expiration', + self.schema['type']: 'type', + self.schema['strike']: 'strike', + self.schema[cost_field]: 'cost' + } - # 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: - 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)) - return exits + return fields def _apply_conditions(self, dfs): """Applies conditions on the specified legs.""" @@ -185,11 +183,68 @@ 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 _exit_costs(self, direction, inventory_leg, options, spot_prices): + """Returns the exit cost (positive for STC orders) for the given inventory leg. + + Args: + direction (option.Direction): Direction of the leg for `Signal.EXIT` + inventory_leg (pd.DataFrame): DataFrame of contracts in the inventory leg + options (pd.DataFrame): Options in the current time step + spot_prices (dict): Dictionary mapping underlying symbols to their spot prices + + Returns: + pd.DataFrame: DataFrame with a `current_cost` column with the + (possibly imputed) cost for the contracts in `inventory_leg` + """ + + options_cost = options[[ + self.schema['contract'], self.schema[direction.value] + ]] + + # FIXME: Leaky abstraction (inventory schema) + leg_cost = inventory_leg[['underlying', 'contract', 'cost' + ]].merge(options_cost, + how='left', + left_on='contract', + right_on=self.schema['contract']) + + def calculate_cost(row): + price = row[self.schema[direction.value]] + if pd.isna(price): + # Impute contract price from the difference between spot and strike + imputed = spot_prices[row['underlying']] - row['strike'] + if row['type'] == 'put': + imputed = -imputed + + price = max(imputed, 0) + + return price + + leg_cost['current_cost'] = leg_cost.apply(calculate_cost, axis=1) + + # Change sign of cost for SELL orders + if direction == Direction.SELL: + leg_cost['current_cost'] = -leg_cost['current_cost'] + + return leg_cost + + def _filter_thresholds(self, entry_cost, current_cost): + """Returns a `pd.Series` of booleans indicating where profit (loss) levels + exceed the given thresholds. + + Args: + entry_cost (pd.Series): Total _entry_ cost of inventory row + current_cost (pd.Series): Present cost of inventory row + + Returns: + pd.Series: Indicator series with `True` for every row that + exceeds the specified profit (loss) thresholds + """ + + profit_pct, loss_pct = self.exit_thresholds + + excess_return = (current_cost / entry_cost + 1) * -np.sign(entry_cost) + return (excess_return >= profit_pct) | (excess_return <= -loss_pct) def __repr__(self): return "Strategy(legs={}, conditions={})".format( diff --git a/backtester/strategy/strategy_leg.py b/backtester/strategy/strategy_leg.py index be6ab0f..f0d4f27 100644 --- a/backtester/strategy/strategy_leg.py +++ b/backtester/strategy/strategy_leg.py @@ -5,11 +5,16 @@ from backtester.datahandler import Schema class StrategyLeg: """Strategy Leg data class""" - def __init__(self, schema, option_type=Type.CALL, direction=Direction.BUY): + def __init__(self, + name, + schema, + option_type=Type.CALL, + direction=Direction.BUY): assert isinstance(schema, Schema) assert isinstance(option_type, Type) assert isinstance(direction, Direction) + self.name = name self.schema = schema self.type = option_type self.direction = direction @@ -49,5 +54,6 @@ class StrategyLeg: return self.schema.type == self.type.value def __repr__(self): - return "StrategyLeg(type={}, direction={}, entry_filter={}, exit_filter={})".format( - self.type, self.direction, self._entry_filter, self._exit_filter) + return "StrategyLeg(name={}, type={}, direction={}, entry_filter={}, exit_filter={})".format( + self.name, self.type, self.direction, self._entry_filter, + self._exit_filter)