Files
options_backtester/backtester/strategy/strategy.py
T

298 lines
11 KiB
Python

import math
from collections import namedtuple
from functools import reduce
import pandas as pd
import numpy as np
from backtester.datahandler import Schema
from backtester.option import Direction
from .strategy_leg import StrategyLeg
from .signal import Signal, get_order
Condition = namedtuple('Condition', 'fields legs tolerance')
class Strategy:
"""Options strategy class.
Takes in a number of `StrategyLeg`'s (option contracts), and filters that determine
entry and exit conditions.
"""
def __init__(self, schema, shares_per_contract=100, initial_capital=1_000_000):
assert isinstance(schema, Schema)
self.schema = schema
self._shares_per_contract = shares_per_contract
self.initial_capital = initial_capital
self.legs = []
self.conditions = []
self.exit_thresholds = (math.inf, math.inf)
def add_leg(self, leg):
"""Adds leg to the strategy"""
assert isinstance(leg, StrategyLeg)
assert self.schema == leg.schema
leg.name = "leg_{}".format(len(self.legs) + 1)
self.legs.append(leg)
return self
def add_legs(self, legs):
"""Adds legs to the strategy"""
for leg in legs:
self.add_leg(leg)
return self
def remove_leg(self, leg_number):
"""Removes leg from the strategy"""
self.legs.pop(leg_number)
return self
def clear_legs(self):
"""Removes *all* legs from the 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
Args:
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):
"""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)
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):
"""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_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)
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):
"""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 = self.initial_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
order = get_order(direction, Signal.EXIT)
candidates['order'] = order
# 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):
"""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 _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)