Skip to content

Commit e43a671

Browse files
committed
Include final-bar open trades in results
1 parent 9c2b8e1 commit e43a671

3 files changed

Lines changed: 43 additions & 13 deletions

File tree

backtesting/_stats.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ def compute_stats(
6161
trades_df = pd.DataFrame({
6262
'Size': [t.size for t in trades],
6363
'EntryBar': [t.entry_bar for t in trades],
64-
'ExitBar': [t.exit_bar for t in trades],
64+
'ExitBar': [t.exit_bar if t.exit_bar is not None else np.nan for t in trades],
6565
'EntryPrice': [t.entry_price for t in trades],
66-
'ExitPrice': [t.exit_price for t in trades],
66+
'ExitPrice': [t.exit_price if t.exit_price is not None else np.nan for t in trades],
6767
'SL': [t.sl for t in trades],
6868
'TP': [t.tp for t in trades],
6969
'PnL': [t.pl for t in trades],
@@ -72,7 +72,10 @@ def compute_stats(
7272
'EntryTime': [t.entry_time for t in trades],
7373
'ExitTime': [t.exit_time for t in trades],
7474
})
75-
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
75+
trades_df['Duration'] = [
76+
exit_time - entry_time if pd.notna(exit_time) else pd.NaT
77+
for entry_time, exit_time in zip(trades_df['EntryTime'], trades_df['ExitTime'])
78+
]
7679
trades_df['Tag'] = [t.tag for t in trades]
7780

7881
# Add indicator values
@@ -82,7 +85,10 @@ def compute_stats(
8285
for i, values in enumerate(ind): # multi-d indicators
8386
suffix = f'_{i}' if len(ind) > 1 else ''
8487
trades_df[f'Entry_{ind.name}{suffix}'] = values[trades_df['EntryBar'].values]
85-
trades_df[f'Exit_{ind.name}{suffix}'] = values[trades_df['ExitBar'].values]
88+
trades_df[f'Exit_{ind.name}{suffix}'] = [
89+
values[int(exit_bar)] if pd.notna(exit_bar) else np.nan
90+
for exit_bar in trades_df['ExitBar']
91+
]
8692

8793
commissions = sum(t._commissions for t in trades)
8894
del trades
@@ -104,7 +110,8 @@ def _round_timedelta(value, _period=_data_period(index)):
104110

105111
have_position = np.repeat(0, len(index))
106112
for t in trades_df[['EntryBar', 'ExitBar']].itertuples(index=False):
107-
have_position[t.EntryBar:t.ExitBar + 1] = 1
113+
exit_bar = len(index) - 1 if pd.isna(t.ExitBar) else int(t.ExitBar)
114+
have_position[t.EntryBar:exit_bar + 1] = 1
108115

109116
s['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time
110117
s['Equity Final [$]'] = equity[-1]

backtesting/backtesting.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,8 @@ class Backtest:
11841184
If `finalize_trades` is `True`, the trades that are still
11851185
[active and ongoing] at the end of the backtest will be closed on
11861186
the last bar and will contribute to the computed backtest statistics.
1187+
Otherwise, they remain open and are included in the trade history with
1188+
missing exit values.
11871189
11881190
.. tip:: Fractional trading
11891191
See also `backtesting.lib.FractionalBacktest` if you want to trade
@@ -1353,28 +1355,31 @@ def run(self, **kwargs) -> pd.Series:
13531355
# Next tick, a moment before bar close
13541356
strategy.next()
13551357
else:
1358+
# HACK: Re-run broker one last time to handle orders placed in the last
1359+
# strategy iteration. Use the same OHLC values as in the last broker iteration.
1360+
if start < len(self._data):
1361+
try_(broker.next, exception=_OutOfMoneyError)
1362+
13561363
if self._finalize_trades is True:
13571364
# Close any remaining open trades so they produce some stats
13581365
for trade in reversed(broker.trades):
13591366
trade.close()
13601367

1361-
# HACK: Re-run broker one last time to handle close orders placed in the last
1362-
# strategy iteration. Use the same OHLC values as in the last broker iteration.
13631368
if start < len(self._data):
13641369
try_(broker.next, exception=_OutOfMoneyError)
13651370
elif len(broker.trades):
13661371
warnings.warn(
13671372
'Some trades remain open at the end of backtest. Use '
1368-
'`Backtest(..., finalize_trades=True)` to close them and '
1369-
'include them in stats.', stacklevel=2)
1373+
'`Backtest(..., finalize_trades=True)` to close them.',
1374+
stacklevel=2)
13701375

13711376
# Set data back to full length
13721377
# for future `indicator._opts['data'].index` calls to work
13731378
data._set_length(len(self._data))
13741379

13751380
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
13761381
self._results = compute_stats(
1377-
trades=broker.closed_trades,
1382+
trades=[*broker.closed_trades, *broker.trades],
13781383
equity=equity,
13791384
ohlc_data=self._data,
13801385
risk_free_rate=0.0,

backtesting/test/_test.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,28 @@ def next(self):
473473
elif len(self.data) == len(SHORT_DATA):
474474
self.position.close()
475475

476-
with self.assertWarnsRegex(UserWarning, 'finalize_trades'):
477-
self.assertTrue(Backtest(SHORT_DATA, S, finalize_trades=False).run()._trades.empty)
476+
self.assertFalse(Backtest(SHORT_DATA, S, finalize_trades=False).run()._trades.empty)
478477
self.assertFalse(Backtest(SHORT_DATA, S, finalize_trades=True).run()._trades.empty)
479478

479+
def test_open_orders_from_last_strategy_iteration(self):
480+
class S(_S):
481+
def init(self):
482+
self.price = self.I(lambda values: values, self.data.Close, name='Price')
483+
484+
def next(self):
485+
if len(self.data) == len(SHORT_DATA):
486+
self.buy()
487+
488+
with self.assertWarnsRegex(UserWarning, 'finalize_trades'):
489+
stats = Backtest(SHORT_DATA, S, finalize_trades=False).run()
490+
491+
trade = stats._trades.iloc[0]
492+
self.assertEqual(trade.EntryBar, len(SHORT_DATA) - 1)
493+
self.assertTrue(np.isnan(trade.ExitBar))
494+
self.assertTrue(np.isnan(trade.ExitPrice))
495+
self.assertTrue(np.isnan(trade.Exit_Price))
496+
self.assertTrue(pd.isna(trade.ExitTime))
497+
480498
def test_check_adjusted_price_when_placing_order(self):
481499
class S(_S):
482500
def next(self):
@@ -1018,7 +1036,7 @@ def init(self):
10181036

10191037
with self.assertWarnsRegex(UserWarning, 'margin'):
10201038
stats = Backtest(GOOG, S).run()
1021-
self.assertIn(stats['# Trades'], (1179, 1182)) # varies on different archs?
1039+
self.assertIn(stats['# Trades'], (1180, 1183)) # varies on different archs?
10221040

10231041
def test_TrailingStrategy(self):
10241042
class S(TrailingStrategy):

0 commit comments

Comments
 (0)