mirror of
https://github.com/wassname/options_backtester.git
synced 2026-08-11 11:22:33 +08:00
Removed code to manage possible missing contracts and changed trade log structure to resemble the inventory
This commit is contained in:
+14
-32
@@ -40,8 +40,7 @@ class Backtest:
|
||||
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"])
|
||||
self.trade_log = pd.DataFrame()
|
||||
|
||||
for date, options in self._data.iter_dates():
|
||||
entry_signals = self._strategy.filter_entries(options)
|
||||
@@ -54,50 +53,33 @@ class Backtest:
|
||||
|
||||
def _execute_entry(self, date, entry_signals):
|
||||
"""Executes entry orders and updates `self.inventory` and `self.trade_log`"""
|
||||
if entry_signals.empty:
|
||||
return
|
||||
entry, total_price = self._process_entry_signals(entry_signals)
|
||||
cost = total_price * self.qty * self.shares_per_contract
|
||||
|
||||
if (not self.stop_if_broke) or (self.capital >= cost):
|
||||
entry['totals']['cost'] = cost
|
||||
if (not self.stop_if_broke) or (self.capital >= total_price):
|
||||
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.qty * self.shares_per_contract
|
||||
self.capital -= price
|
||||
self._update_trade_log(date, contract, order, self.qty, -price)
|
||||
self.trade_log = self.trade_log.append(entry, ignore_index=True)
|
||||
self.capital -= total_price
|
||||
|
||||
def _execute_exit(self, date, exit_signals):
|
||||
"""Executes exits and updates `self.inventory` and `self.trade_log`"""
|
||||
for contracts, price in exit_signals:
|
||||
for contract, order, individual_price in contracts:
|
||||
profit = individual_price * self.qty * self.shares_per_contract
|
||||
self.capital += profit
|
||||
self._update_trade_log(date, contract, order, self.qty, profit)
|
||||
for leg in self._strategy.legs:
|
||||
self.inventory = self.inventory.drop(
|
||||
self.inventory[self.inventory[(
|
||||
leg.name, 'contract')] == contract].index)
|
||||
if exit_signals is None:
|
||||
return
|
||||
exits, exits_mask, total_costs = exit_signals
|
||||
|
||||
self.trade_log = self.trade_log.append(exits, ignore_index=True)
|
||||
self.inventory.drop(self.inventory[exits_mask].index, inplace=True)
|
||||
self.capital -= sum(total_costs)
|
||||
|
||||
def _process_entry_signals(self, entry_signals):
|
||||
"""Returns a dictionary containing the orders to execute."""
|
||||
|
||||
if not entry_signals.empty:
|
||||
legs = entry_signals.columns.levels[0]
|
||||
costs = sum((entry_signals[leg]["cost"] for leg in legs))
|
||||
return entry_signals.loc[costs.idxmin()], costs.min()
|
||||
costs = entry_signals['totals']['cost']
|
||||
return entry_signals.loc[costs.idxmin():costs.idxmin()], costs.min(
|
||||
)
|
||||
else:
|
||||
return entry_signals, 0
|
||||
|
||||
def _update_trade_log(self, date, contract, order, qty, profit):
|
||||
"""Adds entry for the given order to `self.trade_log`."""
|
||||
self.trade_log.loc[len(self.trade_log)] = [
|
||||
date, contract, order, qty, profit, self.capital
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
return "Backtest(capital={}, strategy={})".format(
|
||||
self.capital, self._strategy)
|
||||
|
||||
@@ -16,5 +16,5 @@ class Direction(Enum):
|
||||
SELL = 'bid' # Schema field for SELL price
|
||||
|
||||
def __invert__(self):
|
||||
flip = Direction.SELL if self == Direction.BUY else Direction.SELL
|
||||
flip = Direction.SELL if self == Direction.BUY else Direction.BUY
|
||||
return flip
|
||||
|
||||
@@ -8,7 +8,24 @@ Signal = Enum("Signal", "ENTRY EXIT")
|
||||
# BTC: Buy to Close
|
||||
# STO: Sell to Open
|
||||
# STC: Sell to Close
|
||||
Order = Enum("Order", "BTO BTC STO STC")
|
||||
# Order = Enum("Order", "BTO BTC STO STC")
|
||||
|
||||
|
||||
class Order(Enum):
|
||||
BTO = 'BTO'
|
||||
BTC = 'BTC'
|
||||
STO = 'STO'
|
||||
STC = 'STC'
|
||||
|
||||
def __invert__(self):
|
||||
if self == Order.BTO:
|
||||
return Order.STC
|
||||
elif self == Order.BTC:
|
||||
return Order.STO
|
||||
elif self == Order.STO:
|
||||
return Order.BTC
|
||||
elif self == Order.STC:
|
||||
return Order.BTO
|
||||
|
||||
|
||||
def get_order(direction, signal):
|
||||
|
||||
@@ -3,10 +3,11 @@ from collections import namedtuple
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from functools import reduce
|
||||
from backtester.datahandler import Schema
|
||||
from backtester.option import Direction
|
||||
from .strategy_leg import StrategyLeg
|
||||
from .signal import Signal, get_order, Order
|
||||
from .signal import Signal, get_order
|
||||
|
||||
Condition = namedtuple('Condition', 'fields legs tolerance')
|
||||
|
||||
@@ -92,27 +93,44 @@ class Strategy:
|
||||
pd.DataFrame: Exit signals
|
||||
"""
|
||||
|
||||
# inventory could be empty, in which case this function breaks.
|
||||
if inventory.empty:
|
||||
return
|
||||
|
||||
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
|
||||
leg_candidates = [
|
||||
self._exit_candidates(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 = sum([l['cost'] for l in leg_candidates])
|
||||
threshold_exits = self._filter_thresholds(inventory['totals']['cost'],
|
||||
total_costs)
|
||||
|
||||
# Only check exits for options in inventory
|
||||
subset = options[self.schema['contract']].isin(inventory['contract'])
|
||||
options_in_inventory = options[subset]
|
||||
filter_mask = []
|
||||
for i, leg in enumerate(self.legs):
|
||||
flt = leg.exit_filter
|
||||
filter_mask.append(flt(leg_candidates[i]))
|
||||
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])
|
||||
|
||||
exit_df = self._filter_legs(options_in_inventory, Signal.EXIT)
|
||||
return total_costs & threshold_exits
|
||||
totals = pd.DataFrame.from_dict({"cost": total_costs})
|
||||
totals.columns = pd.MultiIndex.from_product([["totals"],
|
||||
totals.columns])
|
||||
leg_candidates.append(totals)
|
||||
filter_mask = reduce(lambda x, y: x | y, filter_mask)
|
||||
exits_mask = threshold_exits | filter_mask
|
||||
|
||||
exits = pd.concat([l[exits_mask] for l in leg_candidates], axis=1)
|
||||
|
||||
return (exits, exits_mask, total_costs[exits_mask])
|
||||
|
||||
def _filter_legs(self, options, signal):
|
||||
"""Returns a hierarchically indexed `pd.DataFrame` containing signals for each
|
||||
@@ -137,7 +155,7 @@ class Strategy:
|
||||
|
||||
df = options[flt(options)]
|
||||
fields = self._signal_fields(cost_field)
|
||||
subset_df = df.loc[:, fields.keys()]
|
||||
subset_df = df.reindex(columns=fields.keys())
|
||||
subset_df.rename(columns=fields, inplace=True)
|
||||
|
||||
order = get_order(leg.direction, signal)
|
||||
@@ -147,6 +165,11 @@ class Strategy:
|
||||
if leg.direction == Direction.SELL:
|
||||
subset_df['cost'] = -subset_df['cost']
|
||||
|
||||
# shares_per_contract_ hardcoded, we calculate this here so that inventory['totals']['cost'] shows the
|
||||
# actual value that was paid to enter (we should multiply by qty as well but its default value is 1).
|
||||
# This should probably be moved?
|
||||
subset_df['cost'] *= 100
|
||||
|
||||
dfs.append(subset_df.reset_index(drop=True))
|
||||
|
||||
return self._apply_conditions(dfs)
|
||||
@@ -158,7 +181,9 @@ class Strategy:
|
||||
self.schema['expiration']: 'expiration',
|
||||
self.schema['type']: 'type',
|
||||
self.schema['strike']: 'strike',
|
||||
self.schema[cost_field]: 'cost'
|
||||
self.schema[cost_field]: 'cost',
|
||||
self.schema['date']: 'date',
|
||||
'order': 'order'
|
||||
}
|
||||
|
||||
return fields
|
||||
@@ -179,14 +204,24 @@ class Strategy:
|
||||
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)
|
||||
totals = pd.DataFrame.from_dict({"cost": cost})
|
||||
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])
|
||||
|
||||
return dfs
|
||||
dfs.append(totals)
|
||||
|
||||
def _exit_costs(self, direction, inventory_leg, options, spot_prices):
|
||||
"""Returns the exit cost (positive for STC orders) for the given inventory leg.
|
||||
return pd.concat(dfs, axis=1)
|
||||
|
||||
def _exit_candidates(self, direction, inventory_leg, options, spot_prices):
|
||||
"""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`
|
||||
@@ -199,36 +234,27 @@ class Strategy:
|
||||
(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'])
|
||||
# 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')
|
||||
|
||||
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)
|
||||
order = get_order(direction, Signal.EXIT)
|
||||
candidates['order'] = order.name
|
||||
|
||||
# Change sign of cost for SELL orders
|
||||
if direction == Direction.SELL:
|
||||
leg_cost['current_cost'] = -leg_cost['current_cost']
|
||||
if ~direction == Direction.SELL:
|
||||
candidates['cost'] = -candidates['cost']
|
||||
|
||||
return leg_cost
|
||||
# This is shares_per_contract hardcoded because it is currently an attribute of the backtester. See the comment
|
||||
# on _filter_legs.
|
||||
candidates['cost'] *= 100
|
||||
|
||||
return candidates
|
||||
|
||||
def _filter_thresholds(self, entry_cost, current_cost):
|
||||
"""Returns a `pd.Series` of booleans indicating where profit (loss) levels
|
||||
|
||||
Reference in New Issue
Block a user