Refactored filter_exits method in Strategy. Moved date iteration to Backtester

This commit is contained in:
Juan Pablo Amoroso
2019-12-17 10:13:47 -03:00
parent 9ff866e1d9
commit 2c5100cd86
4 changed files with 160 additions and 100 deletions
+9 -10
View File
@@ -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
+2 -2
View File
@@ -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
+140 -85
View File
@@ -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(
+9 -3
View File
@@ -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)