diff --git a/backtester/backtester.py b/backtester/backtester.py index cea5c34..22ae18d 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -40,8 +40,7 @@ class Backtest: 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"]) + self.trade_log = pd.DataFrame() for date, options in self._data.iter_dates(): entry_signals = self._strategy.filter_entries(options) @@ -54,50 +53,33 @@ class Backtest: def _execute_entry(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 * self.qty * self.shares_per_contract - if (not self.stop_if_broke) or (self.capital >= cost): - entry['totals']['cost'] = cost + if (not self.stop_if_broke) or (self.capital >= total_price): 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.qty * self.shares_per_contract - self.capital -= price - self._update_trade_log(date, contract, order, self.qty, -price) + self.trade_log = self.trade_log.append(entry, ignore_index=True) + self.capital -= total_price def _execute_exit(self, date, exit_signals): """Executes exits and updates `self.inventory` and `self.trade_log`""" - for contracts, price in exit_signals: - for contract, order, individual_price in contracts: - profit = individual_price * self.qty * self.shares_per_contract - self.capital += profit - self._update_trade_log(date, contract, order, self.qty, profit) - for leg in self._strategy.legs: - self.inventory = self.inventory.drop( - self.inventory[self.inventory[( - leg.name, 'contract')] == contract].index) + if exit_signals is None: + return + exits, exits_mask, total_costs = exit_signals + + self.trade_log = self.trade_log.append(exits, ignore_index=True) + self.inventory.drop(self.inventory[exits_mask].index, inplace=True) + self.capital -= sum(total_costs) def _process_entry_signals(self, entry_signals): """Returns a dictionary containing the orders to execute.""" if not entry_signals.empty: - legs = entry_signals.columns.levels[0] - costs = sum((entry_signals[leg]["cost"] for leg in legs)) - return entry_signals.loc[costs.idxmin()], costs.min() + costs = entry_signals['totals']['cost'] + return entry_signals.loc[costs.idxmin():costs.idxmin()], costs.min( + ) else: return entry_signals, 0 - 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.capital, self._strategy) diff --git a/backtester/option.py b/backtester/option.py index 34a143f..02e6919 100644 --- a/backtester/option.py +++ b/backtester/option.py @@ -16,5 +16,5 @@ class Direction(Enum): SELL = 'bid' # Schema field for SELL price def __invert__(self): - flip = Direction.SELL if self == Direction.BUY else Direction.SELL + flip = Direction.SELL if self == Direction.BUY else Direction.BUY return flip diff --git a/backtester/strategy/signal.py b/backtester/strategy/signal.py index 10568cd..4731bb3 100644 --- a/backtester/strategy/signal.py +++ b/backtester/strategy/signal.py @@ -8,7 +8,24 @@ Signal = Enum("Signal", "ENTRY EXIT") # BTC: Buy to Close # STO: Sell to Open # STC: Sell to Close -Order = Enum("Order", "BTO BTC STO STC") +# Order = Enum("Order", "BTO BTC STO STC") + + +class Order(Enum): + BTO = 'BTO' + BTC = 'BTC' + STO = 'STO' + STC = 'STC' + + def __invert__(self): + if self == Order.BTO: + return Order.STC + elif self == Order.BTC: + return Order.STO + elif self == Order.STO: + return Order.BTC + elif self == Order.STC: + return Order.BTO def get_order(direction, signal): diff --git a/backtester/strategy/strategy.py b/backtester/strategy/strategy.py index 86bf741..a1c5f6c 100644 --- a/backtester/strategy/strategy.py +++ b/backtester/strategy/strategy.py @@ -3,10 +3,11 @@ from collections import namedtuple import pandas as pd import numpy as np +from functools import reduce from backtester.datahandler import Schema from backtester.option import Direction from .strategy_leg import StrategyLeg -from .signal import Signal, get_order, Order +from .signal import Signal, get_order Condition = namedtuple('Condition', 'fields legs tolerance') @@ -92,27 +93,44 @@ class Strategy: pd.DataFrame: Exit signals """ + # inventory could be empty, in which case this function breaks. + if inventory.empty: + return + 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 + leg_candidates = [ + self._exit_candidates(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 = sum([l['cost'] for l in leg_candidates]) + threshold_exits = self._filter_thresholds(inventory['totals']['cost'], total_costs) - # Only check exits for options in inventory - subset = options[self.schema['contract']].isin(inventory['contract']) - options_in_inventory = options[subset] + filter_mask = [] + for i, leg in enumerate(self.legs): + flt = leg.exit_filter + filter_mask.append(flt(leg_candidates[i])) + fields = self._signal_fields((~leg.direction).value) + leg_candidates[i] = leg_candidates[i].loc[:, fields.values()] + leg_candidates[i].columns = pd.MultiIndex.from_product( + [["leg_{}".format(i + 1)], leg_candidates[i].columns]) - exit_df = self._filter_legs(options_in_inventory, Signal.EXIT) - return total_costs & threshold_exits + totals = pd.DataFrame.from_dict({"cost": total_costs}) + totals.columns = pd.MultiIndex.from_product([["totals"], + totals.columns]) + leg_candidates.append(totals) + filter_mask = reduce(lambda x, y: x | y, filter_mask) + exits_mask = threshold_exits | filter_mask + + exits = pd.concat([l[exits_mask] for l in leg_candidates], axis=1) + + return (exits, exits_mask, total_costs[exits_mask]) def _filter_legs(self, options, signal): """Returns a hierarchically indexed `pd.DataFrame` containing signals for each @@ -137,7 +155,7 @@ class Strategy: df = options[flt(options)] fields = self._signal_fields(cost_field) - subset_df = df.loc[:, fields.keys()] + subset_df = df.reindex(columns=fields.keys()) subset_df.rename(columns=fields, inplace=True) order = get_order(leg.direction, signal) @@ -147,6 +165,11 @@ class Strategy: if leg.direction == Direction.SELL: subset_df['cost'] = -subset_df['cost'] + # shares_per_contract_ hardcoded, we calculate this here so that inventory['totals']['cost'] shows the + # actual value that was paid to enter (we should multiply by qty as well but its default value is 1). + # This should probably be moved? + subset_df['cost'] *= 100 + dfs.append(subset_df.reset_index(drop=True)) return self._apply_conditions(dfs) @@ -158,7 +181,9 @@ class Strategy: self.schema['expiration']: 'expiration', self.schema['type']: 'type', self.schema['strike']: 'strike', - self.schema[cost_field]: 'cost' + self.schema[cost_field]: 'cost', + self.schema['date']: 'date', + 'order': 'order' } return fields @@ -179,14 +204,24 @@ class Strategy: dfs[i] = dfs[i].loc[condition_idx] dfs[i].reset_index(inplace=True) + if any(df.empty for df in dfs): + return pd.DataFrame() + + cost = sum(leg["cost"] for leg in dfs) + totals = pd.DataFrame.from_dict({"cost": cost}) + totals.columns = pd.MultiIndex.from_product([["totals"], + totals.columns]) + for i in range(len(dfs)): dfs[i].columns = pd.MultiIndex.from_product( [["leg_{}".format(i + 1)], dfs[i].columns]) - return dfs + dfs.append(totals) - def _exit_costs(self, direction, inventory_leg, options, spot_prices): - """Returns the exit cost (positive for STC orders) for the given inventory leg. + return pd.concat(dfs, axis=1) + + def _exit_candidates(self, direction, inventory_leg, options, spot_prices): + """Returns the exit candidates for the given inventory leg with their order and cost (positive for STC orders). Args: direction (option.Direction): Direction of the leg for `Signal.EXIT` @@ -199,36 +234,27 @@ class Strategy: (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']) + # This is a left join to ensure that the result has the same length as the inventory. If the contract isn't in + # the daily data the values will all be NaN and the filters should all yield False. + fields = self._signal_fields((~direction).value) + options = options.rename(columns=fields) + candidates = inventory_leg[['contract']].merge(options, + how='left', + on='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) + order = get_order(direction, Signal.EXIT) + candidates['order'] = order.name # Change sign of cost for SELL orders - if direction == Direction.SELL: - leg_cost['current_cost'] = -leg_cost['current_cost'] + if ~direction == Direction.SELL: + candidates['cost'] = -candidates['cost'] - return leg_cost + # This is shares_per_contract hardcoded because it is currently an attribute of the backtester. See the comment + # on _filter_legs. + candidates['cost'] *= 100 + + return candidates def _filter_thresholds(self, entry_cost, current_cost): """Returns a `pd.Series` of booleans indicating where profit (loss) levels