mirror of
https://github.com/wassname/options_backtester.git
synced 2026-06-27 19:00:03 +08:00
Refactored Strategy and Backtest classes. Moved entry and exit signal filters to Backtest.
This commit is contained in:
+248
-104
@@ -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)
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
from .strategy import Strategy, Condition
|
||||
from .strategy import Strategy
|
||||
from .strategy_leg import StrategyLeg
|
||||
from .strangle import Strangle
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user