Refactored Strategy and Backtest classes. Moved entry and exit signal filters to Backtest.

This commit is contained in:
Juan Pablo Amoroso
2020-03-04 14:20:58 -03:00
parent b64043dae3
commit 65a8ca1d76
4 changed files with 259 additions and 332 deletions
+248 -104
View File
@@ -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)
+4 -4
View File
@@ -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'})
+1 -1
View File
@@ -1,3 +1,3 @@
from .strategy import Strategy, Condition
from .strategy import Strategy
from .strategy_leg import StrategyLeg
from .strangle import Strangle
+6 -223
View File
@@ -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)