Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[run]
omit =
pytradebacktest/stats.py
12 changes: 9 additions & 3 deletions pytradebacktest/backtest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from typing import Type

import pandas as pd
from pytrade.indicator import Indicator
from pytrade.strategy import FxStrategy

from pytradebacktest.broker import BacktestBroker
from pytradebacktest.data import MarketData
from pytradebacktest.stats import Stats


class Backtest:
Expand Down Expand Up @@ -46,10 +48,14 @@ def increment_indicator(self):
broker.next()
strategy.next()

# Increment data points
# Update indicators
# Update trades
# Close any open trades:
broker.close_trades()
# Call broker one last time to clean up any outstanding orders from strategy
broker.next()

# Claculate results/stats
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
return Stats(broker.closed_trades, equity, self.data, strategy)

def plot(self):
pass
101 changes: 66 additions & 35 deletions pytradebacktest/broker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from math import copysign
from typing import Optional

import numpy as np
Expand Down Expand Up @@ -48,6 +47,10 @@ def margin_available(self) -> float:
margin_used = sum(trade.value / self._leverage for trade in self.trades)
return max(0, self.equity - margin_used)

@property
def leverage(self) -> float:
return self._leverage

def order(self, order: Order):

# If exclusive orders (each new order auto-closes previous orders/position),
Expand All @@ -69,6 +72,13 @@ def subscribe(
def get_position(self, instrument: Instrument) -> Position:
return Position(instrument, self.trades)

def close_position(self, instrument: Instrument):
position = self.get_position(instrument)
for trade in position.trades:
self.orders.insert(
0, Order(trade.instrument, -trade.size, parent_trade=trade)
)

def next(self):
self._process_orders()
self._update_equity()
Expand All @@ -77,6 +87,7 @@ def _update_equity(self):
# Update equity
equity = self.equity
i = self._data.i

self._equity[i] = equity
# If equity is negative, set all to 0 and stop the simulation
if equity <= 0:
Expand All @@ -95,32 +106,37 @@ def _process_orders(self):
for order in list(self.orders):
_data = self._get_instrument_data(order.instrument)

with OrderContext(order, _data, self._trade_on_close) as ctx:
# Related SL/TP order already removed
if order not in self.orders:
continue
ctx = OrderContext(
order,
_data,
self._trade_on_close,
self._commission,
self._leverage,
self.margin_available,
)
# Related SL/TP order already removed
if order not in self.orders:
continue

if order.stop:
stop_hit = self._evaluate_stop_order(ctx)
if not stop_hit:
continue
if order.stop:
stop_hit = self._evaluate_stop_order(ctx)
if not stop_hit:
continue

if order.limit:
limit_hit, limit_hit_before_stop = self._evaluate_limit_order(ctx)
if not limit_hit or limit_hit_before_stop:
continue
if order.limit:
limit_hit, limit_hit_before_stop = self._evaluate_limit_order(ctx)
if not limit_hit or limit_hit_before_stop:
continue

if order.is_contingent:
self._process_contingent_order(ctx)
else:
new_trade = self._process_market_order(ctx)
if order.is_contingent:
self._process_contingent_order(ctx)
else:
new_trade = self._process_market_order(ctx)

if new_trade and (
order.stop_loss_on_fill or order.take_profit_on_fill
):
# Need to reprocess since we created a contingent order that could
# hit within the same bar
reprocess_orders = True
if new_trade and (order.stop_loss_on_fill or order.take_profit_on_fill):
# Need to reprocess since we created a contingent order that could
# hit within the same bar
reprocess_orders = True

if reprocess_orders:
self._process_orders()
Expand All @@ -147,40 +163,48 @@ def _evaluate_limit_order(self, ctx: OrderContext):
def _process_contingent_order(self, ctx: OrderContext):
order = ctx.order
trade = ctx.order.parent_trade
size = int(copysign(min(abs(trade.size), abs(order.size)), order.size))
_order_size = ctx.adjusted_size

if trade in self.trades:
closed = self._reduce_trade(trade, size, ctx.entry_price, ctx.entry_time)
closed = self._reduce_trade(
trade, _order_size, ctx.entry_price, ctx.entry_time
)
if closed:
self.orders.remove(order)

def _process_market_order(self, ctx: OrderContext):
order = ctx.order
_need_size = ctx.order.size
_order_size = ctx.adjusted_size
new_trade = False
if not self._hedging:
_need_size = self._update_position(ctx)
_order_size = self._update_position(ctx)
order.resize(_order_size)

# IF we have the margin to cover the order
_insufficent_funds = (
abs(_need_size) * ctx.entry_price > self.margin_available * self._leverage
abs(_order_size) * ctx.entry_price > self.margin_available * self._leverage
)
if _need_size and not _insufficent_funds:
if _order_size and not _insufficent_funds:
self._open_trade(ctx)
new_trade = True

if _insufficent_funds:
pass

self.orders.remove(order)

return new_trade

def _update_position(self, ctx: OrderContext):
order = ctx.order
_need_size = ctx.order.size
_need_size = ctx.adjusted_size
# Fill position by FIFO closing/reducing existing opposite-facing trades.
# Existing trades are closed at unadjusted price, because the adjustment
# was already made when buying.
for trade in list(self.trades):
if trade.is_long and order.is_long:
opposing = trade.is_long != order.is_long
same_instrument = trade.instrument == order.instrument
if not opposing or not same_instrument:
continue

# Order is equal or larger so close trade
Expand All @@ -198,8 +222,9 @@ def _update_position(self, ctx: OrderContext):

def _open_trade(self, ctx: OrderContext, tag: Optional[str] = None):
order = ctx.order
size = ctx.adjusted_size
trade = Trade(
order.instrument, order.size, ctx.entry_price, ctx.entry_time, tag
order.instrument, size, ctx.entry_price, ctx.entry_time, ctx.data, tag
)
self.trades.append(trade)

Expand Down Expand Up @@ -235,18 +260,24 @@ def _reduce_trade(
else:
trade.reduce(size_left)
if trade.sl:
trade.sl.reduce(-size_left)
trade.sl.resize(-size_left)
if trade.tp:
trade.tp.reduce(-size_left)
trade.tp.resize(-size_left)

close_trade = Trade(
trade.instrument, size, trade.entry_price, trade.entry_time
trade.instrument, size, trade.entry_price, trade.entry_time, trade.data
)
self.trades.append(close_trade)

self._close_trade(close_trade, price, timestamp)
return closed

def close_trades(self):
for trade in self.trades:
self.orders.insert(
0, Order(trade.instrument, -trade.size, parent_trade=trade)
)

def _close_trade(self, trade: Trade, price: float, timestamp: Timestamp):
self.trades.remove(trade)
if trade.sl:
Expand Down
4 changes: 2 additions & 2 deletions pytradebacktest/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import numpy as np
import pandas as pd
from pandas import Index, Timestamp
from pandas import DatetimeIndex, Timestamp
from pytrade.events.event import Event
from pytrade.instruments import Granularity, Instrument
from pytrade.interfaces.data import IDataContext, IInstrumentData
Expand Down Expand Up @@ -140,7 +140,7 @@ def __len__(self):
return len(self._market_index)

def _init_index(self):
_market_index: Index[Timestamp] = pd.Index([])
_market_index: DatetimeIndex = DatetimeIndex([])
for source in self._sources:
_market_index = _market_index.union(source.df.index)

Expand Down
40 changes: 33 additions & 7 deletions pytradebacktest/order.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from math import copysign

import numpy as np
from pytrade.broker import Order

Expand All @@ -6,16 +8,21 @@

class OrderContext:

def __init__(self, order: Order, data: InstrumentData, trade_on_close: bool):
def __init__(
self,
order: Order,
data: InstrumentData,
trade_on_close: bool,
commission: float,
leverage: float,
margin_available: float,
):
self._order = order
self._data = data
self._trade_on_close = trade_on_close

def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
pass
self._commission = commission
self._leverage = leverage
self._margin_availalbe = margin_available

@property
def order(self):
Expand Down Expand Up @@ -72,3 +79,22 @@ def entry_price(self):
stop_default = -np.inf if self.order.is_long else np.inf
price = func(price, self.order.stop or stop_default)
return price

@property
def adjusted_entry_price(self):
return self.entry_price * (1 + copysign(self._commission, self.order.size))

@property
def adjusted_size(self):
order = self.order
_size = order.size
if -1 < _size < 1:
_size = copysign(
int(
(self._margin_availalbe * self._leverage * abs(order.size))
// self.adjusted_entry_price
),
order.size,
)

return _size
Loading