From 3de145c5bfa03126071eee6de6b6db4e3459a843 Mon Sep 17 00:00:00 2001 From: Juan Pablo Amoroso Date: Wed, 8 Jan 2020 16:11:45 -0300 Subject: [PATCH] Added daily/monthly balance (pd.DataFrame that keeps track of the value of current positions) --- backtester/backtester.py | 64 +++++++++++++++---- .../datahandler/historical_options_data.py | 36 ++++++----- backtester/strategy/strategy.py | 15 +---- 3 files changed, 76 insertions(+), 39 deletions(-) diff --git a/backtester/backtester.py b/backtester/backtester.py index 82016b5..edba0be 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -11,7 +11,6 @@ class Backtest: def __init__(self): self._strategy = None self._data = None - self.inventory = pd.DataFrame() self.stop_if_broke = True @property @@ -22,7 +21,7 @@ class Backtest: def strategy(self, strat): assert isinstance(strat, Strategy) self._strategy = strat - self.current_capital = strat.initial_capital + self.current_cash = strat.initial_capital @property def data(self): @@ -37,22 +36,27 @@ class Backtest: """Runs the backtest and returns a `pd.DataFrame` of the orders executed (`self.trade_log`) Args: - monthly (bool, optional): Iterates through data monthly rather than daily. Defaults to False. + monthly (bool, optional): Iterates through data monthly rather than daily. Defaults to False. Returns: - pd.DataFrame: Log of the trades executed. + pd.DataFrame: Log of the trades executed. """ assert self._data is not None assert self._strategy is not None assert self._data.schema == self._strategy.schema - index = pd.MultiIndex.from_product( + columns = pd.MultiIndex.from_product( [[l.name for l in self._strategy.legs], ['contract', 'underlying', 'expiration', 'type', 'strike', 'cost', 'order']]) - index_totals = pd.MultiIndex.from_product([['totals'], ['cost', 'qty', 'date']]) - self.inventory = pd.DataFrame(columns=index.append(index_totals)) + totals = pd.MultiIndex.from_product([['totals'], ['cost', 'qty', 'date']]) + self.inventory = pd.DataFrame(columns=columns.append(totals)) self.trade_log = pd.DataFrame() + self.balance = pd.DataFrame({ + 'capital': self.current_cash, + 'cash': self.current_cash + }, + index=[self.data.start_date - pd.Timedelta(1, unit='day')]) data_iterator = self._data.iter_months() if monthly else self._data.iter_dates() bar = pyprind.ProgBar(data_iterator.ngroups, bar_char='█') @@ -63,19 +67,23 @@ class Backtest: self._execute_exit(exit_signals) self._execute_entry(entry_signals) + self._update_balance(date, options) bar.update() + self.balance['% change'] = self.balance['capital'].pct_change() + self.balance['accumulated return'] = (1.0 + self.balance['% change']).cumprod() - 1 + return self.trade_log 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) - if (not self.stop_if_broke) or (self.current_capital >= total_price): + if (not self.stop_if_broke) or (self.current_cash >= total_price): self.inventory = self.inventory.append(entry, ignore_index=True) self.trade_log = self.trade_log.append(entry, ignore_index=True) - self.current_capital -= total_price + self.current_cash -= total_price def _execute_exit(self, exit_signals): """Executes exits and updates `self.inventory` and `self.trade_log`""" @@ -83,10 +91,10 @@ class Backtest: self.trade_log = self.trade_log.append(exits, ignore_index=True) self.inventory.drop(self.inventory[exits_mask].index, inplace=True) - self.current_capital -= sum(total_costs) + self.current_cash -= sum(total_costs) def _process_entry_signals(self, entry_signals): - """Returns a dictionary containing the orders to execute.""" + """Returns the entry signals to execute and their cost.""" if not entry_signals.empty: # costs = entry_signals['totals']['cost'] @@ -96,6 +104,38 @@ class Backtest: else: return entry_signals, 0 + def _update_balance(self, date, options): + """Updates positions and calculates statistics for the current date. + + Args: + date (pd.Timestamp): Current date. + options (pd.DataFrame): DataFrame of (daily/monthly) options. + """ + + leg_candidates = [ + self._strategy._exit_candidates(l.direction, self.inventory[l.name], options) for l in self._strategy.legs + ] + + calls_value = -np.sum( + np.sum(leg['cost'] * self.inventory['totals']['qty'] + for leg in leg_candidates if (leg['type'] == 'call').any())) + puts_value = -np.sum( + np.sum(leg['cost'] * self.inventory['totals']['qty'] + for leg in leg_candidates if (leg['type'] == 'put').any())) + + capital = calls_value + puts_value + self.current_cash + + row = pd.Series( + { + 'qty': self.inventory['totals']['qty'].sum(), + 'calls value': calls_value, + 'puts value': puts_value, + 'cash': self.current_cash, + 'capital': capital, + }, + name=date) + self.balance = self.balance.append(row) + def summary(self): """Returns a table with summary statistics about the trade log""" df = self.trade_log @@ -153,4 +193,4 @@ class Backtest: return summary def __repr__(self): - return "Backtest(capital={}, strategy={})".format(self.current_capital, self._strategy) + return "Backtest(capital={}, strategy={})".format(self.current_cash, self._strategy) diff --git a/backtester/datahandler/historical_options_data.py b/backtester/datahandler/historical_options_data.py index 9679228..1158b71 100644 --- a/backtester/datahandler/historical_options_data.py +++ b/backtester/datahandler/historical_options_data.py @@ -16,14 +16,20 @@ class HistoricalOptionsData: if file_extension == '.h5': self._data = pd.read_hdf(file, **params) elif file_extension == '.csv': - params["parse_dates"] = [self.schema.expiration.mapping, self.schema.date.mapping] + params['parse_dates'] = [self.schema.expiration.mapping, self.schema.date.mapping] self._data = pd.read_csv(file, **params) columns = self._data.columns assert all((col in columns for _key, col in self.schema)) - self._data["dte"] = (self._data["expiration"] - self._data["quotedate"]).dt.days - self.schema.update({"dte": "dte"}) + date_col = self.schema['date'] + expiration_col = self.schema['expiration'] + + self._data['dte'] = (self._data[expiration_col] - self._data[date_col]).dt.days + self.schema.update({'dte': 'dte'}) + + self.start_date = self._data[date_col].min() + self.end_date = self._data[date_col].max() def apply_filter(self, f): """Apply Filter `f` to the data. Returns a `pd.DataFrame` with the filtered rows.""" @@ -31,11 +37,11 @@ class HistoricalOptionsData: def iter_dates(self): """Returns `pd.DataFrameGroupBy` that groups contracts by date""" - return self._data.groupby(self.schema["date"]) + return self._data.groupby(self.schema['date']) def iter_months(self): """Returns `pd.DataFrameGroupBy` that groups contracts by month""" - date_col = self.schema["date"] + date_col = self.schema['date'] iterator = self._data.groupby(pd.Grouper( key=date_col, freq="MS")).apply(lambda g: g[g[date_col] == g[date_col].min()]).reset_index(drop=True).groupby(date_col) @@ -45,7 +51,7 @@ class HistoricalOptionsData: """Pass method invocation to `self._data`""" method = getattr(self._data, attr) - if hasattr(method, "__call__"): + if hasattr(method, '__call__'): def df_method(*args, **kwargs): return method(*args, **kwargs) @@ -76,14 +82,14 @@ class HistoricalOptionsData: """Returns default schema for Historical Options Data""" schema = Schema.canonical() schema.update({ - "contract": "optionroot", - "date": "quotedate", - "last": "last", - "open_interest": "openinterest", - "impliedvol": "impliedvol", - "delta": "delta", - "gamma": "gamma", - "theta": "theta", - "vega": "vega" + 'contract': 'optionroot', + 'date': 'quotedate', + 'last': 'last', + 'open_interest': 'openinterest', + 'impliedvol': 'impliedvol', + 'delta': 'delta', + 'gamma': 'gamma', + 'theta': 'theta', + 'vega': 'vega' }) return schema diff --git a/backtester/strategy/strategy.py b/backtester/strategy/strategy.py index 50b340c..8ace0ca 100644 --- a/backtester/strategy/strategy.py +++ b/backtester/strategy/strategy.py @@ -101,14 +101,7 @@ class Strategy: 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_candidates = [ - self._exit_candidates(l.direction, inventory[l.name], options, spot_prices) for l in self.legs - ] - + leg_candidates = [self._exit_candidates(l.direction, inventory[l.name], options) for l in self.legs] total_costs = sum([l['cost'] for l in leg_candidates]) threshold_exits = self._filter_thresholds(inventory['totals']['cost'], total_costs) @@ -219,18 +212,16 @@ class Strategy: return pd.concat(dfs, axis=1) - def _exit_candidates(self, direction, inventory_leg, options, spot_prices): + def _exit_candidates(self, direction, inventory_leg, options): """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` 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` + pd.DataFrame: DataFrame with the cost for the contracts in `inventory_leg` """ # FIXME: Leaky abstraction (inventory schema)