diff --git a/backtester/backtester.py b/backtester/backtester.py index bb4aaf9..ed9f61c 100644 --- a/backtester/backtester.py +++ b/backtester/backtester.py @@ -1,14 +1,16 @@ +from functools import reduce + import numpy as np import pandas as pd import pyprind -from .enums import Order, Stock +from .enums import Order, Stock, Signal, Direction, get_order from .datahandler import HistoricalOptionsData, TiingoData from .strategy import Strategy class Backtest: - """Processes signals from the Strategy object""" + """Backtest runner class.""" def __init__(self, allocation, initial_capital=1_000_000, shares_per_contract=100): assert isinstance(allocation, dict) @@ -19,12 +21,12 @@ class Backtest: for asset in assets: self.allocation[asset] = allocation.get(asset, 0.0) / total_allocation - self.current_cash = self.initial_capital = initial_capital + self.initial_capital = initial_capital self.stop_if_broke = True self.shares_per_contract = shares_per_contract self._stocks = [] self._options_strategy = None - self._stock_data = None + self._stocks_data = None self._options_data = None @property @@ -47,19 +49,16 @@ class Backtest: def strategy(self, strat): assert isinstance(strat, Strategy) self._options_strategy = strat - self.current_cash = strat.initial_capital @property - def stock_data(self): - return self._stock_data + def stocks_data(self): + return self._stocks_data - @stock_data.setter - def stock_data(self, data): + @stocks_data.setter + def stocks_data(self, data): assert isinstance(data, TiingoData) self._stocks_schema = data.schema - self._stock_data = data - self._stock_data.first_date = data['date'].min() - self._stock_data.end_date = data['date'].max() + self._stocks_data = data @property def options_data(self): @@ -82,35 +81,37 @@ class Backtest: pd.DataFrame: Log of the trades executed. """ - assert self._stock_data, 'Stock data not set' + assert self._stocks_data, 'Stock data not set' assert self._options_data, 'Options data not set' assert self._options_strategy, 'Options Strategy not set' assert self._options_data.schema == self._options_strategy.schema option_dates = self._options_data['date'].unique() - stock_dates = self._stock_data['date'].unique() - assert np.array_equal(stock_dates, option_dates), 'Stock and options dates do not match' + stock_dates = self._stocks_data['date'].unique() + assert np.array_equal(stock_dates, + option_dates), 'Stock and options dates do not match (check that TZ are equal)' self._initialize_inventories() + self.current_cash = self.initial_capital self.trade_log = pd.DataFrame() self.balance = pd.DataFrame({ 'total capital': self.current_cash, 'cash': self.current_cash }, - index=[self.stock_data.start_date - pd.Timedelta(1, unit='day')]) + index=[self._stocks_data.start_date - pd.Timedelta(1, unit='day')]) if sma_days: - self._stock_data.sma(sma_days) + self._stocks_data.sma(sma_days) rebalancing_days = pd.date_range( - self.stock_data.first_date, self.stock_data.end_date, freq=str(rebalance_freq) + + self._stocks_data.start_date, self._stocks_data.end_date, freq=str(rebalance_freq) + 'BMS') if rebalance_freq else [] data_iterator = self._data_iterator(monthly) bar = pyprind.ProgBar(len(stock_dates), bar_char='█') for date, stocks, options in data_iterator: - if date in rebalancing_days or date == self.stock_data.start_date: + if date in rebalancing_days or date == self._stocks_data.start_date: self._rebalance_portfolio(date, stocks, options, sma_days) self._update_balance(date, stocks, options) @@ -137,17 +138,26 @@ class Backtest: (date, stocks, options) Returns: - generator: Daily/monthly iterator over `self.stock_data` and `self.options_data`. + generator: Daily/monthly iterator over `self._stocks_data` and `self.options_data`. """ + if monthly: - it = zip(self._stock_data.iter_months(), self._options_data.iter_months()) + it = zip(self._stocks_data.iter_months(), self._options_data.iter_months()) else: - it = zip(self._stock_data.iter_dates(), self._options_data.iter_dates()) + it = zip(self._stocks_data.iter_dates(), self._options_data.iter_dates()) return ((date, stocks, options) for (date, stocks), (_, options) in it) def _rebalance_portfolio(self, date, stocks, options, sma_days): - """Rebalances the portfolio according to `self.allocation`.""" + """Reabalances the portfolio according to `self.allocation` weights. + + Args: + date (pd.Timestamp): Current date. + stocks (pd.DataFrame): Stocks data for the current date. + options (pd.DataFrame): Options data for the current date. + sma_days (int): SMA window size + """ + # Sell all the options currently in the inventory self._sell_options(options, date) @@ -162,14 +172,13 @@ class Backtest: self._buy_stocks(stocks, stocks_allocation, sma_days) entry_signals = self._options_strategy.filter_entries(options, self._options_inventory, date, options_allocation) - self._execute_entry(entry_signals) + self._execute_entry(entry_signals, options_allocation) stocks_value = sum(self._stocks_inventory['price'] * self._stocks_inventory['qty']) options_value = sum(self._options_inventory['totals']['cost'] * self._options_inventory['totals']['qty']) # Update current cash - invested_capital = options_value + stocks_value - self.current_cash = total_capital - invested_capital + self.current_cash = total_capital - options_value - stocks_value def _current_stock_capital(self, stocks): """Return the current value of the stocks inventory. @@ -180,6 +189,7 @@ class Backtest: Returns: float: Total capital in stocks. """ + current_stocks = self._stocks_inventory.merge(stocks, how='left', left_on='symbol', @@ -208,99 +218,43 @@ class Backtest: return total_cost - def _sell_options(self, options, date): - # This method essentially recycles most of the code in the filter_exits method in Strategy. - # The whole thing needs a refactor. - - leg_candidates = [ - self._options_strategy._exit_candidates(l.direction, self._options_inventory[l.name], options, - self._options_inventory.index) for l in self._options_strategy.legs - ] - - for i, leg in enumerate(self._options_strategy.legs): - fields = self._options_strategy._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]) - - candidates = pd.concat(leg_candidates, axis=1) - - # If a contract is missing we replace the NaN values with those of the inventory - # except for cost, which we imput as zero. - imputed_inventory = self._options_strategy._imput_missing_data(self._options_inventory) - candidates = candidates.fillna(imputed_inventory) - total_costs = sum([candidates[l.name]['cost'] for l in self._options_strategy.legs]) - - # Append the 'totals' column to candidates - qtys = self._options_inventory['totals']['qty'] - dates = [date] * len(self._options_inventory) - totals = pd.DataFrame.from_dict({"cost": total_costs, "qty": qtys, "date": dates}) - totals.columns = pd.MultiIndex.from_product([["totals"], totals.columns]) - candidates = pd.concat([candidates, totals], axis=1) - - exits_mask = pd.Series([True] * len(self._options_inventory)) - exits_mask.index = self._options_inventory.index - - total_costs *= candidates['totals']['qty'] - - self._execute_exit((candidates, exits_mask, total_costs)) - def _buy_stocks(self, stocks, allocation, sma_days): - for stock in self._stocks: - query = '{} == "{}"'.format(self._stocks_schema['symbol'], stock.symbol) - stock_row = stocks.query(query) - stock_price = stock_row[self._stocks_schema['adjClose']].values[0] + """Buys stocks according to their given weight, optionally using an SMA entry filter. - if sma_days is not None: - if stock_row['sma'].values[0] < stock_price: - qty = (allocation * stock.percentage) // stock_price - else: - qty = 0 - else: - qty = (allocation * stock.percentage) // stock_price + Args: + stocks (pd.DataFrame): Stocks data for the current time step. + allocation (float): Total capital allocation for stocks. + sma_days (int): SMA window. + """ - stock_entry = pd.Series([stock.symbol, stock_price, qty], index=self._stocks_inventory.columns) - self._stocks_inventory = self._stocks_inventory.append(stock_entry, ignore_index=True) + stock_symbols = [stock.symbol for stock in self.stocks] + query = '{} in {}'.format(self._stocks_schema['symbol'], stock_symbols) + inventory_stocks = stocks.query(query) + stock_percentages = np.array([stock.percentage for stock in self.stocks]) + stock_prices = inventory_stocks[self._stocks_schema['adjClose']] - 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) - - self._options_inventory = self._options_inventory.append(entry, ignore_index=True) - self.trade_log = self.trade_log.append(entry, ignore_index=True) - - def _execute_exit(self, exit_signals): - """Executes exits and updates `self.inventory` and `self.trade_log`""" - exits, exits_mask, total_costs = exit_signals - - self.trade_log = self.trade_log.append(exits, ignore_index=True) - self._options_inventory.drop(self._options_inventory[exits_mask].index, inplace=True) - self.current_cash -= sum(total_costs) - - def _process_entry_signals(self, entry_signals): - """Returns the entry signals to execute and their cost.""" - - if not entry_signals.empty: - entry = entry_signals.iloc[0] - return entry, entry['totals']['cost'] * entry['totals']['qty'] + if sma_days: + qty = np.where(inventory_stocks['sma'] < stock_prices, (allocation * stock_percentages) // stock_prices, 0) else: - return entry_signals, 0 + qty = (allocation * stock_percentages) // stock_prices + + self._stocks_inventory = pd.DataFrame({'symbol': stock_symbols, 'price': stock_prices, 'qty': qty}) def _update_balance(self, date, stocks, options): """Updates positions and calculates statistics for the current date. Args: date (pd.Timestamp): Current date. - stocks (pd.DataFrame): DataFrame of stocks + stocks (pd.DataFrame): DataFrame of stocks. options (pd.DataFrame): DataFrame of (daily/monthly) options. """ - exit_signals = self._options_strategy.filter_exits(options, self._options_inventory, date) + exit_signals = self.filter_exits(options, self._options_inventory, date) self._execute_exit(exit_signals) # update options leg_candidates = [ - self._options_strategy._exit_candidates(l.direction, self._options_inventory[l.name], options, - self._options_inventory.index) for l in self._options_strategy.legs + self._exit_candidates(l.direction, self._options_inventory[l.name], options, self._options_inventory.index) + for l in self._options_strategy.legs ] # If a contract is missing we replace the NaN values with those of the inventory @@ -321,9 +275,9 @@ class Backtest: # update stocks portfolio information due to change in price over time costs = [] for stock in self.stocks: - query = '{} == "{}"'.format(self.stock_data.schema['symbol'], stock.symbol) + query = '{} == "{}"'.format(self._stocks_data.schema['symbol'], stock.symbol) stock_current = stocks.query(query) - cost = stock_current[self.stock_data.schema['adjClose']].values[0] + cost = stock_current[self._stocks_data.schema['adjClose']].values[0] stock_inventory = self._stocks_inventory.query(query) try: qty = stock_inventory['qty'].values[0] @@ -431,6 +385,196 @@ class Backtest: return styler + def _execute_option_entries(self, date, options, options_allocation): + """Enters option positions according to `self._options_strategy`. + Calls `self._pick_entry_signals` to select from the entry signals given by the strategy. + + Args: + date (pd.Timestamp): Current date. + options (pd.DataFrame): Options data for the current time step. + options_allocation (float): Capital amount allocated to options. + """ + + # Remove contracts already in inventory + inventory_contracts = pd.concat( + [self._options_inventory[leg.name]['contract'] for leg in self._options_strategy.legs]) + subset_options = options[~options[self.schema['contract']].isin(inventory_contracts)] + + entry_signals = [] + for leg in self._options_strategy.legs: + flt = leg.entry_filter + cost_field = leg.direction.value + + leg_entries = subset_options[flt(subset_options)] + # Exit if no entry signals for the current leg + if leg_entries.empty: + return pd.DataFrame() + + fields = self._signal_fields(cost_field) + leg_entries = leg_entries.reindex(columns=fields.keys()) + leg_entries.rename(columns=fields, inplace=True) + + order = get_order(leg.direction, Signal.ENTRY) + leg_entries['order'] = order + + # Change sign of cost for SELL orders + if leg.direction == Direction.SELL: + leg_entries['cost'] = -leg_entries['cost'] + + leg_entries['cost'] *= self._shares_per_contract + leg_entries.columns = pd.MultiIndex.from_product([[leg.name], leg_entries.columns]) + entry_signals.append(leg_entries.reset_index(drop=True)) + + # Append the 'totals' column to entry_signals + total_costs = sum(leg_entries['cost'] for leg_entries in entry_signals) + qty = np.abs(options_allocation // total_costs) + totals = pd.DataFrame.from_dict({'cost': total_costs, 'qty': qty, 'date': date}) + totals.columns = pd.MultiIndex.from_product([['totals'], totals.columns]) + entry_signals.append(totals) + entry_signals = pd.concat(entry_signals, axis=1) + + entries = self._pick_entry_signals(entry_signals) + + # Update options inventory, trade log and current cash + self._options_inventory = self._options_inventory.append(entries, ignore_index=True) + self.trade_log = self.trade_log.append(entries, ignore_index=True) + self.current_cash -= sum(total_costs) + + def _execute_option_exits(self, date, options): + """Exits option positions according to `self._options_strategy`. + Option positions are closed whenever the strategy signals an exit, when the profit/loss thresholds + are exceeded or whenever the contracts in `self._options_inventory` are not found in `options`. + + Args: + date (pd.Timestamp): Current date. + options (pd.DataFrame): Options data for the current time step. + """ + + strategy = self._options_strategy + current_options_quotes = self._get_current_option_quotes(options) + + filter_masks = [] + for i, leg in enumerate(strategy.legs): + flt = leg.exit_filter + + # This mask is to ensure that legs with missing contracts exit. + missing_contracts_mask = current_options_quotes[i]['cost'].isna() + + filter_masks.append(flt(current_options_quotes[i]) | missing_contracts_mask) + fields = self._signal_fields((~leg.direction).value) + current_options_quotes[i] = current_options_quotes[i].reindex(columns=fields.keys()) + current_options_quotes[i].rename(columns=fields, inplace=True) + current_options_quotes[i].columns = pd.MultiIndex.from_product([[leg.name], + current_options_quotes[i].columns]) + + exit_candidates = pd.concat(current_options_quotes, axis=1) + + # If a contract is missing we replace the NaN values with those of the inventory + # except for cost, which we imput as zero. + exit_candidates = self._impute_missing_option_values(exit_candidates) + + # Append the 'totals' column to exit_candidates + qtys = self._options_inventory['totals']['qty'] + total_costs = sum([exit_candidates[l.name]['cost'] for l in self.legs]) + totals = pd.DataFrame.from_dict({'cost': total_costs, 'qty': qtys, 'date': date}) + totals.columns = pd.MultiIndex.from_product([['totals'], totals.columns]) + exit_candidates = pd.concat([exit_candidates, totals], axis=1) + + # Compute which contracts need to exit, either because of price thresholds or user exit filters + threshold_exits = strategy.filter_thresholds(self._options_inventory['totals']['cost'], total_costs) + filter_mask = reduce(lambda x, y: x | y, filter_masks) + exits_mask = threshold_exits | filter_mask + + exits = exit_candidates[exits_mask] + total_costs = total_costs[exits_mask] * exits['totals']['qty'] + + # Update options inventory, trade log and current cash + self._options_inventory.drop(self._options_inventory[exits_mask].index, inplace=True) + self.trade_log = self.trade_log.append(exits, ignore_index=True) + self.current_cash -= sum(total_costs) + + def _pick_entry_signals(self, entry_signals): + """Returns the entry signals to execute. + + Args: + entry_signals (pd.DataFrame): DataFrame of option entry signals chosen by the strategy. + + Returns: + pd.DataFrame: DataFrame of entries to execute. + """ + + if not entry_signals.empty: + # FIXME: This is a naive signal selection criterion, it simply picks the first one in `entry_singals` + return entry_signals.iloc[0] + else: + return entry_signals + + 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', + 'order': 'order' + } + + return fields + + def _get_current_option_quotes(self, options): + """Returns the current quotes for all the options in `self._options_inventory` as a list of DataFrames. + It also adds a `cost` column with the cost of closing the position in each contract and an `order` + column with the corresponding exit order type. + + Args: + options (pd.DataFrame): Options data in the current time step. + + Returns: + [pd.DataFrame]: List of DataFrames, one for each leg in `self._options_inventory`, + with the exit cost for the contracts. + """ + + current_options_quotes = [] + for leg in self._options_strategy.legs: + inventory_leg = self._options_inventory[leg.name] + + # 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. + leg_options = inventory_leg[['contract']].merge(options, + how='left', + left_on='contract', + right_on=leg.schema['contract']) + + # leg_options.index needs to be the same as the inventory's so that the exit masks that are constructed + # from it can be correctly applied to the inventory. + leg_options.index = self._options_inventory.index + leg_options['order'] = get_order(leg.direction, Signal.EXIT) + + # Change sign of cost for SELL orders + if ~leg.direction == Direction.SELL: + leg_options['cost'] = -leg_options['cost'] + leg_options['cost'] *= self._shares_per_contract + + current_options_quotes.append(leg_options) + + return current_options_quotes + + def _impute_missing_option_values(self, exit_candidates): + """Returns a copy of the inventory with the cost of all its contracts set to zero. + + Args: + exit_candidates (pd.DataFrame): DataFrame of exit candidates with possible missing values. + + Returns: + pd.DataFrame: Exit candidates with imputed values. + """ + df = self._options_inventory.copy() + for leg in self._options_strategy.legs: + df.at[:, (leg.name, 'cost')] = 0 + + return exit_candidates.fillna(df) + def __repr__(self): return "Backtest(capital={}, allocation={}, stocks={}, strategy={})".format( self.current_cash, self.allocation, self._stocks, self._options_strategy) diff --git a/backtester/datahandler/tiingo_data.py b/backtester/datahandler/tiingo_data.py index 08b1910..aaae994 100644 --- a/backtester/datahandler/tiingo_data.py +++ b/backtester/datahandler/tiingo_data.py @@ -78,9 +78,9 @@ class TiingoData: """Returns default schema for Tiingo Data""" return Schema.stocks() - def sma(self, months): - sma = self._data.groupby('symbol').rolling(months)['adjClose'].mean() - sma = sma.reset_index('symbol').sort_index() + def sma(self, periods): + sma = self._data.groupby('symbol', as_index=False).rolling(periods)['adjClose'].mean() sma = sma.fillna(0) - self._data['sma'] = sma['adjClose'] + sma.index = sma.index.levels[1] + self._data['sma'] = sma self.schema.update({'sma': 'sma'}) diff --git a/backtester/strategy/__init__.py b/backtester/strategy/__init__.py index 7ec0bf8..cdf1ca6 100644 --- a/backtester/strategy/__init__.py +++ b/backtester/strategy/__init__.py @@ -1,3 +1,3 @@ -from .strategy import Strategy, Condition +from .strategy import Strategy from .strategy_leg import StrategyLeg from .strangle import Strangle diff --git a/backtester/strategy/strategy.py b/backtester/strategy/strategy.py index 3c0db3f..5e14abc 100644 --- a/backtester/strategy/strategy.py +++ b/backtester/strategy/strategy.py @@ -1,16 +1,10 @@ import math -from collections import namedtuple -from functools import reduce -import pandas as pd import numpy as np from ..datahandler import Schema -from ..enums import Direction, Signal, get_order from .strategy_leg import StrategyLeg -Condition = namedtuple('Condition', 'fields legs tolerance') - class Strategy: """Options strategy class. @@ -49,17 +43,6 @@ class Strategy: self.legs = [] return self - def add_condition(self, fields, legs=None, tolerance=0.0): - """Adds a condition that all legs in `legs` should have the same value for `fields`""" - assert all((f in self.schema for f in fields)) - if legs: - assert all(legs, lambda l: l in self.legs) - else: - legs = self.legs - - self.conditions.append(Condition(fields, legs, tolerance)) - return self - def add_exit_thresholds(self, profit_pct=math.inf, loss_pct=math.inf): """Adds maximum profit/loss thresholds. Both **must** be >= 0.0 @@ -67,208 +50,22 @@ class Strategy: profit_pct (float, optional): Max profit level. Defaults to math.inf loss_pct (float, optional): Max loss level. Defaults to math.inf """ + assert profit_pct >= 0 assert loss_pct >= 0 self.exit_thresholds = (profit_pct, loss_pct) - def filter_entries(self, options, inventory, date, capital): - """Returns the entry 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: Entry signals - """ - - # Remove contracts already in inventory - inventory_contracts = pd.concat([inventory[leg.name]['contract'] for leg in self.legs]) - subset_options = options[~options[self.schema['contract']].isin(inventory_contracts)] - - return self._filter_legs(subset_options, Signal.ENTRY, date, capital) - - def filter_exits(self, options, inventory, date): - """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 - """ - - leg_candidates = [ - self._exit_candidates(l.direction, inventory[l.name], options, inventory.index) for l in self.legs - ] - - filter_masks = [] - for i, leg in enumerate(self.legs): - flt = leg.exit_filter - - # This mask is to ensure that legs with missing contracts exit. - missing_contracts_mask = leg_candidates[i]['cost'].isna() - - filter_masks.append(flt(leg_candidates[i]) | missing_contracts_mask) - 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]) - - candidates = pd.concat(leg_candidates, axis=1) - - # If a contract is missing we replace the NaN values with those of the inventory - # except for cost, which we imput as zero. - imputed_inventory = self._imput_missing_data(inventory) - candidates = candidates.fillna(imputed_inventory) - total_costs = sum([candidates[l.name]['cost'] for l in self.legs]) - - # Append the 'totals' column to candidates - qtys = inventory['totals']['qty'] - dates = [date] * len(inventory) - totals = pd.DataFrame.from_dict({"cost": total_costs, "qty": qtys, "date": dates}) - totals.columns = pd.MultiIndex.from_product([["totals"], totals.columns]) - candidates = pd.concat([candidates, totals], axis=1) - - # Compute which contracts need to exit, either because of price thresholds or user exit filters - threshold_exits = self._filter_thresholds(inventory['totals']['cost'], total_costs) - filter_mask = reduce(lambda x, y: x | y, filter_masks) - exits_mask = threshold_exits | filter_mask - - exits = candidates[exits_mask] - total_costs = total_costs[exits_mask] * exits['totals']['qty'] - - return (exits, exits_mask, total_costs) - - def _filter_legs(self, options, signal, date, capital): - """Returns a hierarchically indexed `pd.DataFrame` containing signals for each - leg in the strategy. -s - 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_field = leg.direction.value - else: - flt = leg.exit_filter - cost_field = (~leg.direction).value - - df = options[flt(options)] - fields = self._signal_fields(cost_field) - subset_df = df.reindex(columns=fields.keys()) - subset_df.rename(columns=fields, inplace=True) - - order = get_order(leg.direction, signal) - subset_df['order'] = order - - # Change sign of cost for SELL orders - if leg.direction == Direction.SELL: - subset_df['cost'] = -subset_df['cost'] - - subset_df['cost'] *= self._shares_per_contract - - dfs.append(subset_df.reset_index(drop=True)) - - return self._apply_conditions(dfs, date, capital) - - 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', - 'order': 'order' - } - - return fields - - def _apply_conditions(self, dfs, date, capital): - """Applies conditions on the specified legs.""" - - for condition in self.conditions: - condition_idx = None - for df in dfs: - df.set_index(condition.fields, inplace=True) - if condition_idx is not None: - condition_idx = condition_idx.intersection(df.index) - else: - condition_idx = df.index - - for i in range(len(dfs)): - 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) - # Put qty of contracts to buy/sell in ['totals']['qty'] - qty = capital // cost - qty = np.abs(qty) - totals = pd.DataFrame.from_dict({"cost": cost, "qty": qty, "date": date}) - 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]) - - dfs.append(totals) - - return pd.concat(dfs, axis=1) - - def _exit_candidates(self, direction, inventory_leg, options, inventory_index): - """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 - - Returns: - pd.DataFrame: DataFrame with the cost for the contracts in `inventory_leg` - """ - - # FIXME: Leaky abstraction (inventory schema) - # 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') - - # candidates.index needs to be the same as the inventory's so that the exit masks that are constructed - # from it can be correctly applied to the inventory. - candidates.index = inventory_index - candidates['order'] = get_order(direction, Signal.EXIT) - - # Change sign of cost for SELL orders - if ~direction == Direction.SELL: - candidates['cost'] = -candidates['cost'] - - candidates['cost'] *= self._shares_per_contract - - return candidates - - def _filter_thresholds(self, entry_cost, current_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 + 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 + exceeds the specified profit/loss thresholds. """ profit_pct, loss_pct = self.exit_thresholds @@ -276,19 +73,5 @@ s excess_return = (current_cost / entry_cost + 1) * -np.sign(entry_cost) return (excess_return >= profit_pct) | (excess_return <= -loss_pct) - def _imput_missing_data(self, inventory): - """Returns a copy of the inventory with the cost of all its contracts set to zero. - - Args: - inventory (pd.DataFrame): current inventory - - Returns: - pd.DataFrame: imputed version of current inventory - """ - df = inventory.copy() - for l in self.legs: - df.at[:, (l.name, 'cost')] = 0 - return df - def __repr__(self): - return "Strategy(legs={}, conditions={})".format(self.legs, self.conditions) + return "Strategy(legs={}, exit_thresholds={})".format(self.legs, self.exit_thresholds)