mirror of
https://github.com/wassname/options_backtester.git
synced 2026-08-05 13:10:21 +08:00
Refactored filter_exits method in Strategy. Moved date iteration to Backtester
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user