Skip to content

Commit fa7507a

Browse files
authored
Merge branch 'master' into feature/fix_auto_close
2 parents fe17ca8 + 0ce24d8 commit fa7507a

File tree

8 files changed

+90
-30
lines changed

8 files changed

+90
-30
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ first [fork the project]. Then:
3333

3434
git clone git@github.com:YOUR_USERNAME/backtesting.py
3535
cd backtesting.py
36-
pip3 install -e .[doc,test,dev]
36+
pip3 install -e '.[doc,test,dev]'
3737

3838
[fork the project]: https://help.github.com/articles/fork-a-repo/
3939

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Backtesting.py
44
==============
5-
[![Build Status](https://img.shields.io/github/workflow/status/kernc/backtesting.py/CI/master?style=for-the-badge)](https://github.com/kernc/backtesting.py/actions)
5+
[![Build Status](https://img.shields.io/github/actions/workflow/status/kernc/backtesting.py/ci.yml?branch=master&style=for-the-badge)](https://github.com/kernc/backtesting.py/actions)
66
[![Code Coverage](https://img.shields.io/codecov/c/gh/kernc/backtesting.py.svg?style=for-the-badge)](https://codecov.io/gh/kernc/backtesting.py)
77
[![Backtesting on PyPI](https://img.shields.io/pypi/v/backtesting.svg?color=blue&style=for-the-badge)](https://pypi.org/project/backtesting)
88
[![PyPI downloads](https://img.shields.io/pypi/dd/backtesting.svg?color=skyblue&style=for-the-badge)](https://pypi.org/project/backtesting)

backtesting/_plotting.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def f(s, new_index=pd.Index(df.index.view(int)), bars=trades[column]):
145145
if s.size:
146146
# Via int64 because on pandas recently broken datetime
147147
mean_time = int(bars.loc[s.index].view(int).mean())
148-
new_bar_idx = new_index.get_loc(mean_time, method='nearest')
148+
new_bar_idx = new_index.get_indexer([mean_time], method='nearest')[0]
149149
return new_bar_idx
150150
return f
151151

@@ -166,7 +166,7 @@ def plot(*, results: pd.Series,
166166
indicators: List[_Indicator],
167167
filename='', plot_width=None,
168168
plot_equity=True, plot_return=False, plot_pl=True,
169-
plot_volume=True, plot_drawdown=False,
169+
plot_volume=True, plot_drawdown=False, plot_trades=True,
170170
smooth_equity=False, relative_equity=True,
171171
superimpose=True, resample=True,
172172
reverse_indicators=True,
@@ -609,7 +609,8 @@ def __eq__(self, other):
609609
_plot_superimposed_ohlc()
610610

611611
ohlc_bars = _plot_ohlc()
612-
_plot_ohlc_trades()
612+
if plot_trades:
613+
_plot_ohlc_trades()
613614
indicator_figs = _plot_indicators()
614615
if reverse_indicators:
615616
indicator_figs = indicator_figs[::-1]

backtesting/_stats.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def compute_stats(
6565
'ReturnPct': [t.pl_pct for t in trades],
6666
'EntryTime': [t.entry_time for t in trades],
6767
'ExitTime': [t.exit_time for t in trades],
68+
'Tag': [t.tag for t in trades],
6869
})
6970
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
7071
del trades
@@ -127,7 +128,8 @@ def _round_timedelta(value, _period=_data_period(index)):
127128
s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
128129
s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
129130
s.loc['# Trades'] = n_trades = len(trades_df)
130-
s.loc['Win Rate [%]'] = np.nan if not n_trades else (pl > 0).sum() / n_trades * 100 # noqa: E501
131+
win_rate = np.nan if not n_trades else (pl > 0).mean()
132+
s.loc['Win Rate [%]'] = win_rate * 100
131133
s.loc['Best Trade [%]'] = returns.max() * 100
132134
s.loc['Worst Trade [%]'] = returns.min() * 100
133135
mean_return = geometric_mean(returns)
@@ -137,8 +139,7 @@ def _round_timedelta(value, _period=_data_period(index)):
137139
s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
138140
s.loc['Expectancy [%]'] = returns.mean() * 100
139141
s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
140-
win_prob = (pl > 0).sum() / n_trades
141-
s.loc['Kelly Criterion'] = win_prob - (1 - win_prob) / (pl[pl > 0].mean() / pl[pl < 0].mean()) # noqa: E501
142+
s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
142143

143144
s.loc['_strategy'] = strategy_instance
144145
s.loc['_equity_curve'] = equity_df

backtesting/_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _update(self):
136136
self.__arrays['__index'] = index
137137

138138
def __repr__(self):
139-
i = min(self.__i, len(self.__df) - 1)
139+
i = min(self.__i, len(self.__df)) - 1
140140
index = self.__arrays['__index'][i]
141141
items = ', '.join(f'{k}={v}' for k, v in self.__df.iloc[i].items())
142142
return f'<Data i={i} ({index}) {items}>'

backtesting/backtesting.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def I(self, # noqa: E743
8080
name=None, plot=True, overlay=None, color=None, scatter=False,
8181
**kwargs) -> np.ndarray:
8282
"""
83-
Declare indicator. An indicator is just an array of values,
83+
Declare an indicator. An indicator is just an array of values,
8484
but one that is revealed gradually in
8585
`backtesting.backtesting.Strategy.next` much like
8686
`backtesting.backtesting.Strategy.data` is.
@@ -133,7 +133,7 @@ def init():
133133

134134
if value is not None:
135135
value = try_(lambda: np.asarray(value, order='C'), None)
136-
is_arraylike = value is not None
136+
is_arraylike = bool(value is not None and value.shape)
137137

138138
# Optionally flip the array if the user returned e.g. `df.values`
139139
if is_arraylike and np.argmax(value.shape) == 0:
@@ -142,7 +142,7 @@ def init():
142142
if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.Close):
143143
raise ValueError(
144144
'Indicators must return (optionally a tuple of) numpy.arrays of same '
145-
f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}"'
145+
f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}" '
146146
f'shape: {getattr(value, "shape" , "")}, returned value: {value})')
147147

148148
if plot and overlay is None and np.issubdtype(value.dtype, np.number):
@@ -199,7 +199,8 @@ def buy(self, *,
199199
limit: Optional[float] = None,
200200
stop: Optional[float] = None,
201201
sl: Optional[float] = None,
202-
tp: Optional[float] = None):
202+
tp: Optional[float] = None,
203+
tag: object = None):
203204
"""
204205
Place a new long order. For explanation of parameters, see `Order` and its properties.
205206
@@ -209,14 +210,15 @@ def buy(self, *,
209210
"""
210211
assert 0 < size < 1 or round(size) == size, \
211212
"size must be a positive fraction of equity, or a positive whole number of units"
212-
return self._broker.new_order(size, limit, stop, sl, tp)
213+
return self._broker.new_order(size, limit, stop, sl, tp, tag)
213214

214215
def sell(self, *,
215216
size: float = _FULL_EQUITY,
216217
limit: Optional[float] = None,
217218
stop: Optional[float] = None,
218219
sl: Optional[float] = None,
219-
tp: Optional[float] = None):
220+
tp: Optional[float] = None,
221+
tag: object = None):
220222
"""
221223
Place a new short order. For explanation of parameters, see `Order` and its properties.
222224
@@ -228,7 +230,7 @@ def sell(self, *,
228230
"""
229231
assert 0 < size < 1 or round(size) == size, \
230232
"size must be a positive fraction of equity, or a positive whole number of units"
231-
return self._broker.new_order(-size, limit, stop, sl, tp)
233+
return self._broker.new_order(-size, limit, stop, sl, tp, tag)
232234

233235
@property
234236
def equity(self) -> float:
@@ -386,7 +388,8 @@ def __init__(self, broker: '_Broker',
386388
stop_price: Optional[float] = None,
387389
sl_price: Optional[float] = None,
388390
tp_price: Optional[float] = None,
389-
parent_trade: Optional['Trade'] = None):
391+
parent_trade: Optional['Trade'] = None,
392+
tag: object = None):
390393
self.__broker = broker
391394
assert size != 0
392395
self.__size = size
@@ -395,6 +398,7 @@ def __init__(self, broker: '_Broker',
395398
self.__sl_price = sl_price
396399
self.__tp_price = tp_price
397400
self.__parent_trade = parent_trade
401+
self.__tag = tag
398402

399403
def _replace(self, **kwargs):
400404
for k, v in kwargs.items():
@@ -410,6 +414,7 @@ def __repr__(self):
410414
('sl', self.__sl_price),
411415
('tp', self.__tp_price),
412416
('contingent', self.is_contingent),
417+
('tag', self.__tag),
413418
) if value is not None))
414419

415420
def cancel(self):
@@ -481,6 +486,14 @@ def tp(self) -> Optional[float]:
481486
def parent_trade(self):
482487
return self.__parent_trade
483488

489+
@property
490+
def tag(self):
491+
"""
492+
Arbitrary value (such as a string) which, if set, enables tracking
493+
of this order and the associated `Trade` (see `Trade.tag`).
494+
"""
495+
return self.__tag
496+
484497
__pdoc__['Order.parent_trade'] = False
485498

486499
# Extra properties
@@ -515,7 +528,7 @@ class Trade:
515528
When an `Order` is filled, it results in an active `Trade`.
516529
Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
517530
"""
518-
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
531+
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
519532
self.__broker = broker
520533
self.__size = size
521534
self.__entry_price = entry_price
@@ -524,10 +537,12 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
524537
self.__exit_bar: Optional[int] = None
525538
self.__sl_order: Optional[Order] = None
526539
self.__tp_order: Optional[Order] = None
540+
self.__tag = tag
527541

528542
def __repr__(self):
529543
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
530-
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}>'
544+
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
545+
f'{" tag="+str(self.__tag) if self.__tag is not None else ""}>'
531546

532547
def _replace(self, **kwargs):
533548
for k, v in kwargs.items():
@@ -541,7 +556,7 @@ def close(self, portion: float = 1.):
541556
"""Place new `Order` to close `portion` of the trade at next market price."""
542557
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
543558
size = copysign(max(1, round(abs(self.__size) * portion)), -self.__size)
544-
order = Order(self.__broker, size, parent_trade=self)
559+
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
545560
self.__broker.orders.insert(0, order)
546561

547562
# Fields getters
@@ -574,6 +589,19 @@ def exit_bar(self) -> Optional[int]:
574589
"""
575590
return self.__exit_bar
576591

592+
@property
593+
def tag(self):
594+
"""
595+
A tag value inherited from the `Order` that opened
596+
this trade.
597+
598+
This can be used to track trades and apply conditional
599+
logic / subgroup analysis.
600+
601+
See also `Order.tag`.
602+
"""
603+
return self.__tag
604+
577605
@property
578606
def _sl_order(self):
579607
return self.__sl_order
@@ -665,7 +693,7 @@ def __set_contingent(self, type, price):
665693
order.cancel()
666694
if price:
667695
kwargs = {'stop': price} if type == 'sl' else {'limit': price}
668-
order = self.__broker.new_order(-self.size, trade=self, **kwargs)
696+
order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
669697
setattr(self, attr, order)
670698

671699

@@ -700,6 +728,7 @@ def new_order(self,
700728
stop: Optional[float] = None,
701729
sl: Optional[float] = None,
702730
tp: Optional[float] = None,
731+
tag: object = None,
703732
*,
704733
trade: Optional[Trade] = None):
705734
"""
@@ -725,7 +754,7 @@ def new_order(self,
725754
"Short orders require: "
726755
f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
727756

728-
order = Order(self, size, limit, stop, sl, tp, trade)
757+
order = Order(self, size, limit, stop, sl, tp, trade, tag)
729758
# Put the new order in the order queue,
730759
# inserting SL/TP/trade-closing orders in-front
731760
if trade:
@@ -905,7 +934,12 @@ def _process_orders(self):
905934

906935
# Open a new trade
907936
if need_size:
908-
self._open_trade(adjusted_price, need_size, order.sl, order.tp, time_index)
937+
self._open_trade(adjusted_price,
938+
need_size,
939+
order.sl,
940+
order.tp,
941+
time_index,
942+
order.tag)
909943

910944
# We need to reprocess the SL/TP orders newly added to the queue.
911945
# This allows e.g. SL hitting in the same bar the order was open.
@@ -964,8 +998,8 @@ def _close_trade(self, trade: Trade, price: float, time_index: int):
964998
self._cash += trade.pl
965999

9661000
def _open_trade(self, price: float, size: int,
967-
sl: Optional[float], tp: Optional[float], time_index: int):
968-
trade = Trade(self, size, price, time_index)
1001+
sl: Optional[float], tp: Optional[float], time_index: int, tag):
1002+
trade = Trade(self, size, price, time_index, tag)
9691003
self.trades.append(trade)
9701004
# Create SL/TP (bracket) orders.
9711005
# Make sure SL order is created first so it gets adversarially processed before TP order
@@ -1143,6 +1177,13 @@ def run(self, **kwargs) -> pd.Series:
11431177
_equity_curve Eq...
11441178
_trades Size EntryB...
11451179
dtype: object
1180+
1181+
.. warning::
1182+
You may obtain different results for different strategy parameters.
1183+
E.g. if you use 50- and 200-bar SMA, the trading simulation will
1184+
begin on bar 201. The actual length of delay is equal to the lookback
1185+
period of the `Strategy.I` indicator which lags the most.
1186+
Obviously, this can affect results.
11461187
"""
11471188
data = _Data(self._data.copy(deep=False))
11481189
broker: _Broker = self._broker(data=data)
@@ -1518,7 +1559,7 @@ def _mp_task(backtest_uuid, batch_index):
15181559

15191560
def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
15201561
plot_equity=True, plot_return=False, plot_pl=True,
1521-
plot_volume=True, plot_drawdown=False,
1562+
plot_volume=True, plot_drawdown=False, plot_trades=True,
15221563
smooth_equity=False, relative_equity=True,
15231564
superimpose: Union[bool, str] = True,
15241565
resample=True, reverse_indicators=False,
@@ -1557,6 +1598,9 @@ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
15571598
If `plot_drawdown` is `True`, the resulting plot will contain
15581599
a separate drawdown graph section.
15591600
1601+
If `plot_trades` is `True`, the stretches between trade entries
1602+
and trade exits are marked by hash-marked tractor beams.
1603+
15601604
If `smooth_equity` is `True`, the equity graph will be
15611605
interpolated between fixed points at trade closing times,
15621606
unaffected by any interim asset volatility.
@@ -1615,6 +1659,7 @@ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
16151659
plot_pl=plot_pl,
16161660
plot_volume=plot_volume,
16171661
plot_drawdown=plot_drawdown,
1662+
plot_trades=plot_trades,
16181663
smooth_equity=smooth_equity,
16191664
relative_equity=relative_equity,
16201665
superimpose=superimpose,

backtesting/test/_test.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def test_compute_stats(self):
276276
'Return [%]': 414.2298999999996,
277277
'Volatility (Ann.) [%]': 36.49390889140787,
278278
'SQN': 1.0766187356697705,
279-
'Kelly Criterion': 0.7875234266909678,
279+
'Kelly Criterion': 0.1518705127029717,
280280
'Sharpe Ratio': 0.5803778344714113,
281281
'Sortino Ratio': 1.0847880675854096,
282282
'Start': pd.Timestamp('2004-08-19 00:00:00'),
@@ -304,7 +304,7 @@ def almost_equal(a, b):
304304
self.assertSequenceEqual(
305305
sorted(stats['_trades'].columns),
306306
sorted(['Size', 'EntryBar', 'ExitBar', 'EntryPrice', 'ExitPrice',
307-
'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration']))
307+
'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration', 'Tag']))
308308

309309
def test_compute_stats_bordercase(self):
310310

@@ -531,6 +531,18 @@ def coroutine(self):
531531
stats = self._Backtest(coroutine, close_all_at_end=True).run()
532532
self.assertEqual(len(stats._trades), 1)
533533

534+
def test_order_tag(self):
535+
def coroutine(self):
536+
yield self.buy(size=2, tag=1)
537+
yield self.sell(size=1, tag='s')
538+
yield self.sell(size=1)
539+
540+
yield self.buy(tag=2)
541+
yield self.position.close()
542+
543+
stats = self._Backtest(coroutine).run()
544+
self.assertEqual(list(stats._trades.Tag), [1, 1, 2])
545+
534546

535547
class TestOptimize(TestCase):
536548
def test_optimize(self):
@@ -663,6 +675,7 @@ def test_params(self):
663675
plot_return=True,
664676
plot_pl=False,
665677
plot_drawdown=True,
678+
plot_trades=False,
666679
superimpose=False,
667680
resample='1W',
668681
smooth_equity=False,

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@
4646
'test': [
4747
'seaborn',
4848
'matplotlib',
49-
'scikit-learn',
49+
'scikit-learn <= 1.1.3', # Pinned due to boken scikit-optimize
5050
'scikit-optimize',
5151
],
5252
'dev': [
53-
'ruff',
53+
'ruff==0.0.160',
5454
'coverage',
5555
'mypy',
5656
],

0 commit comments

Comments
 (0)