BackcastPro 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of BackcastPro might be problematic. Click here for more details.

@@ -0,0 +1,1763 @@
1
+ """
2
+ Core framework data structures.
3
+ Objects from this module can also be imported from the top-level
4
+ module directly, e.g.
5
+
6
+ from BackcastPro import Backtest, Strategy
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import warnings
13
+ from abc import ABCMeta, abstractmethod
14
+ from copy import copy
15
+ from functools import lru_cache, partial
16
+ from itertools import chain, product, repeat
17
+ from math import copysign
18
+ from numbers import Number
19
+ from typing import Callable, List, Optional, Sequence, Tuple, Type, Union
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ from numpy.random import default_rng
24
+
25
+ from ._plotting import plot # noqa: I001
26
+ from ._stats import compute_stats, dummy_stats
27
+ from ._util import (
28
+ SharedMemoryManager, _as_str, _Indicator, _Data, _batch, _indicator_warmup_nbars,
29
+ _strategy_indicators, patch, try_, _tqdm,
30
+ )
31
+
32
+ __pdoc__ = {
33
+ 'Strategy.__init__': False,
34
+ 'Order.__init__': False,
35
+ 'Position.__init__': False,
36
+ 'Trade.__init__': False,
37
+ }
38
+
39
+
40
+ class Strategy(metaclass=ABCMeta):
41
+ """
42
+ A trading strategy base class. Extend this class and
43
+ override methods
44
+ `backtesting.backtesting.Strategy.init` and
45
+ `backtesting.backtesting.Strategy.next` to define
46
+ your own strategy.
47
+ """
48
+ def __init__(self, broker, data, params):
49
+ self._indicators = []
50
+ self._broker: _Broker = broker
51
+ self._data: _Data = data
52
+ self._params = self._check_params(params)
53
+
54
+ def __repr__(self):
55
+ return '<Strategy ' + str(self) + '>'
56
+
57
+ def __str__(self):
58
+ params = ','.join(f'{i[0]}={i[1]}' for i in zip(self._params.keys(),
59
+ map(_as_str, self._params.values())))
60
+ if params:
61
+ params = '(' + params + ')'
62
+ return f'{self.__class__.__name__}{params}'
63
+
64
+ def _check_params(self, params):
65
+ for k, v in params.items():
66
+ if not hasattr(self, k):
67
+ raise AttributeError(
68
+ f"Strategy '{self.__class__.__name__}' is missing parameter '{k}'."
69
+ "Strategy class should define parameters as class variables before they "
70
+ "can be optimized or run with.")
71
+ setattr(self, k, v)
72
+ return params
73
+
74
+ def I(self, # noqa: E743
75
+ func: Callable, *args,
76
+ name=None, plot=True, overlay=None, color=None, scatter=False,
77
+ **kwargs) -> np.ndarray:
78
+ """
79
+ Declare an indicator. An indicator is just an array of values
80
+ (or a tuple of such arrays in case of, e.g., MACD indicator),
81
+ but one that is revealed gradually in
82
+ `backtesting.backtesting.Strategy.next` much like
83
+ `backtesting.backtesting.Strategy.data` is.
84
+ Returns `np.ndarray` of indicator values.
85
+
86
+ `func` is a function that returns the indicator array(s) of
87
+ same length as `backtesting.backtesting.Strategy.data`.
88
+
89
+ In the plot legend, the indicator is labeled with
90
+ function name, unless `name` overrides it. If `func` returns
91
+ a tuple of arrays, `name` can be a sequence of strings, and
92
+ its size must agree with the number of arrays returned.
93
+
94
+ If `plot` is `True`, the indicator is plotted on the resulting
95
+ `backtesting.backtesting.Backtest.plot`.
96
+
97
+ If `overlay` is `True`, the indicator is plotted overlaying the
98
+ price candlestick chart (suitable e.g. for moving averages).
99
+ If `False`, the indicator is plotted standalone below the
100
+ candlestick chart. By default, a heuristic is used which decides
101
+ correctly most of the time.
102
+
103
+ `color` can be string hex RGB triplet or X11 color name.
104
+ By default, the next available color is assigned.
105
+
106
+ If `scatter` is `True`, the plotted indicator marker will be a
107
+ circle instead of a connected line segment (default).
108
+
109
+ Additional `*args` and `**kwargs` are passed to `func` and can
110
+ be used for parameters.
111
+
112
+ For example, using simple moving average function from TA-Lib:
113
+
114
+ def init():
115
+ self.sma = self.I(ta.SMA, self.data.Close, self.n_sma)
116
+
117
+ .. warning::
118
+ Rolling indicators may front-pad warm-up values with NaNs.
119
+ In this case, the **backtest will only begin on the first bar when
120
+ all declared indicators have non-NaN values** (e.g. bar 201 for a
121
+ strategy that uses a 200-bar MA).
122
+ This can affect results.
123
+ """
124
+ def _format_name(name: str) -> str:
125
+ return name.format(*map(_as_str, args),
126
+ **dict(zip(kwargs.keys(), map(_as_str, kwargs.values()))))
127
+
128
+ if name is None:
129
+ params = ','.join(filter(None, map(_as_str, chain(args, kwargs.values()))))
130
+ func_name = _as_str(func)
131
+ name = (f'{func_name}({params})' if params else f'{func_name}')
132
+ elif isinstance(name, str):
133
+ name = _format_name(name)
134
+ elif try_(lambda: all(isinstance(item, str) for item in name), False):
135
+ name = [_format_name(item) for item in name]
136
+ else:
137
+ raise TypeError(f'Unexpected `name=` type {type(name)}; expected `str` or '
138
+ '`Sequence[str]`')
139
+
140
+ try:
141
+ value = func(*args, **kwargs)
142
+ except Exception as e:
143
+ raise RuntimeError(f'Indicator "{name}" error. See traceback above.') from e
144
+
145
+ if isinstance(value, pd.DataFrame):
146
+ value = value.values.T
147
+
148
+ if value is not None:
149
+ value = try_(lambda: np.asarray(value, order='C'), None)
150
+ is_arraylike = bool(value is not None and value.shape)
151
+
152
+ # Optionally flip the array if the user returned e.g. `df.values`
153
+ if is_arraylike and np.argmax(value.shape) == 0:
154
+ value = value.T
155
+
156
+ if isinstance(name, list) and (np.atleast_2d(value).shape[0] != len(name)):
157
+ raise ValueError(
158
+ f'Length of `name=` ({len(name)}) must agree with the number '
159
+ f'of arrays the indicator returns ({value.shape[0]}).')
160
+
161
+ if not is_arraylike or not 1 <= value.ndim <= 2 or value.shape[-1] != len(self._data.Close):
162
+ raise ValueError(
163
+ 'Indicators must return (optionally a tuple of) numpy.arrays of same '
164
+ f'length as `data` (data shape: {self._data.Close.shape}; indicator "{name}" '
165
+ f'shape: {getattr(value, "shape", "")}, returned value: {value})')
166
+
167
+ if overlay is None and np.issubdtype(value.dtype, np.number):
168
+ x = value / self._data.Close
169
+ # By default, overlay if strong majority of indicator values
170
+ # is within 30% of Close
171
+ with np.errstate(invalid='ignore'):
172
+ overlay = ((x < 1.4) & (x > .6)).mean() > .6
173
+
174
+ value = _Indicator(value, name=name, plot=plot, overlay=overlay,
175
+ color=color, scatter=scatter,
176
+ # _Indicator.s Series accessor uses this:
177
+ index=self.data.index)
178
+ self._indicators.append(value)
179
+ return value
180
+
181
+ @abstractmethod
182
+ def init(self):
183
+ """
184
+ Initialize the strategy.
185
+ Override this method.
186
+ Declare indicators (with `backtesting.backtesting.Strategy.I`).
187
+ Precompute what needs to be precomputed or can be precomputed
188
+ in a vectorized fashion before the strategy starts.
189
+
190
+ If you extend composable strategies from `backtesting.lib`,
191
+ make sure to call:
192
+
193
+ super().init()
194
+ """
195
+
196
+ @abstractmethod
197
+ def next(self):
198
+ """
199
+ Main strategy runtime method, called as each new
200
+ `backtesting.backtesting.Strategy.data`
201
+ instance (row; full candlestick bar) becomes available.
202
+ This is the main method where strategy decisions
203
+ upon data precomputed in `backtesting.backtesting.Strategy.init`
204
+ take place.
205
+
206
+ If you extend composable strategies from `backtesting.lib`,
207
+ make sure to call:
208
+
209
+ super().next()
210
+ """
211
+
212
+ class __FULL_EQUITY(float): # noqa: N801
213
+ def __repr__(self): return '.9999' # noqa: E704
214
+ _FULL_EQUITY = __FULL_EQUITY(1 - sys.float_info.epsilon)
215
+
216
+ def buy(self, *,
217
+ size: float = _FULL_EQUITY,
218
+ limit: Optional[float] = None,
219
+ stop: Optional[float] = None,
220
+ sl: Optional[float] = None,
221
+ tp: Optional[float] = None,
222
+ tag: object = None) -> 'Order':
223
+ """
224
+ Place a new long order and return it. For explanation of parameters, see `Order`
225
+ and its properties.
226
+ Unless you're running `Backtest(..., trade_on_close=True)`,
227
+ market orders are filled on next bar's open,
228
+ whereas other order types (limit, stop-limit, stop-market) are filled when
229
+ the respective conditions are met.
230
+
231
+ See `Position.close()` and `Trade.close()` for closing existing positions.
232
+
233
+ See also `Strategy.sell()`.
234
+ """
235
+ assert 0 < size < 1 or round(size) == size >= 1, \
236
+ "size must be a positive fraction of equity, or a positive whole number of units"
237
+ return self._broker.new_order(size, limit, stop, sl, tp, tag)
238
+
239
+ def sell(self, *,
240
+ size: float = _FULL_EQUITY,
241
+ limit: Optional[float] = None,
242
+ stop: Optional[float] = None,
243
+ sl: Optional[float] = None,
244
+ tp: Optional[float] = None,
245
+ tag: object = None) -> 'Order':
246
+ """
247
+ Place a new short order and return it. For explanation of parameters, see `Order`
248
+ and its properties.
249
+
250
+ .. caution::
251
+ Keep in mind that `self.sell(size=.1)` doesn't close existing `self.buy(size=.1)`
252
+ trade unless:
253
+
254
+ * the backtest was run with `exclusive_orders=True`,
255
+ * the underlying asset price is equal in both cases and
256
+ the backtest was run with `spread = commission = 0`.
257
+
258
+ Use `Trade.close()` or `Position.close()` to explicitly exit trades.
259
+
260
+ See also `Strategy.buy()`.
261
+
262
+ .. note::
263
+ If you merely want to close an existing long position,
264
+ use `Position.close()` or `Trade.close()`.
265
+ """
266
+ assert 0 < size < 1 or round(size) == size >= 1, \
267
+ "size must be a positive fraction of equity, or a positive whole number of units"
268
+ return self._broker.new_order(-size, limit, stop, sl, tp, tag)
269
+
270
+ @property
271
+ def equity(self) -> float:
272
+ """Current account equity (cash plus assets)."""
273
+ return self._broker.equity
274
+
275
+ @property
276
+ def data(self) -> _Data:
277
+ """
278
+ Price data, roughly as passed into
279
+ `backtesting.backtesting.Backtest.__init__`,
280
+ but with two significant exceptions:
281
+
282
+ * `data` is _not_ a DataFrame, but a custom structure
283
+ that serves customized numpy arrays for reasons of performance
284
+ and convenience. Besides OHLCV columns, `.index` and length,
285
+ it offers `.pip` property, the smallest price unit of change.
286
+ * Within `backtesting.backtesting.Strategy.init`, `data` arrays
287
+ are available in full length, as passed into
288
+ `backtesting.backtesting.Backtest.__init__`
289
+ (for precomputing indicators and such). However, within
290
+ `backtesting.backtesting.Strategy.next`, `data` arrays are
291
+ only as long as the current iteration, simulating gradual
292
+ price point revelation. In each call of
293
+ `backtesting.backtesting.Strategy.next` (iteratively called by
294
+ `backtesting.backtesting.Backtest` internally),
295
+ the last array value (e.g. `data.Close[-1]`)
296
+ is always the _most recent_ value.
297
+ * If you need data arrays (e.g. `data.Close`) to be indexed
298
+ **Pandas series**, you can call their `.s` accessor
299
+ (e.g. `data.Close.s`). If you need the whole of data
300
+ as a **DataFrame**, use `.df` accessor (i.e. `data.df`).
301
+ """
302
+ return self._data
303
+
304
+ @property
305
+ def position(self) -> 'Position':
306
+ """Instance of `backtesting.backtesting.Position`."""
307
+ return self._broker.position
308
+
309
+ @property
310
+ def orders(self) -> 'Tuple[Order, ...]':
311
+ """List of orders (see `Order`) waiting for execution."""
312
+ return _Orders(self._broker.orders)
313
+
314
+ @property
315
+ def trades(self) -> 'Tuple[Trade, ...]':
316
+ """List of active trades (see `Trade`)."""
317
+ return tuple(self._broker.trades)
318
+
319
+ @property
320
+ def closed_trades(self) -> 'Tuple[Trade, ...]':
321
+ """List of settled trades (see `Trade`)."""
322
+ return tuple(self._broker.closed_trades)
323
+
324
+
325
+ class _Orders(tuple):
326
+ """
327
+ TODO: remove this class. Only for deprecation.
328
+ """
329
+ def cancel(self):
330
+ """Cancel all non-contingent (i.e. SL/TP) orders."""
331
+ for order in self:
332
+ if not order.is_contingent:
333
+ order.cancel()
334
+
335
+ def __getattr__(self, item):
336
+ # TODO: Warn on deprecations from the previous version. Remove in the next.
337
+ removed_attrs = ('entry', 'set_entry', 'is_long', 'is_short',
338
+ 'sl', 'tp', 'set_sl', 'set_tp')
339
+ if item in removed_attrs:
340
+ raise AttributeError(f'Strategy.orders.{"/.".join(removed_attrs)} were removed in'
341
+ 'Backtesting 0.2.0. '
342
+ 'Use `Order` API instead. See docs.')
343
+ raise AttributeError(f"'tuple' object has no attribute {item!r}")
344
+
345
+
346
+ class Position:
347
+ """
348
+ Currently held asset position, available as
349
+ `backtesting.backtesting.Strategy.position` within
350
+ `backtesting.backtesting.Strategy.next`.
351
+ Can be used in boolean contexts, e.g.
352
+
353
+ if self.position:
354
+ ... # we have a position, either long or short
355
+ """
356
+ def __init__(self, broker: '_Broker'):
357
+ self.__broker = broker
358
+
359
+ def __bool__(self):
360
+ return self.size != 0
361
+
362
+ @property
363
+ def size(self) -> float:
364
+ """Position size in units of asset. Negative if position is short."""
365
+ return sum(trade.size for trade in self.__broker.trades)
366
+
367
+ @property
368
+ def pl(self) -> float:
369
+ """Profit (positive) or loss (negative) of the current position in cash units."""
370
+ return sum(trade.pl for trade in self.__broker.trades)
371
+
372
+ @property
373
+ def pl_pct(self) -> float:
374
+ """Profit (positive) or loss (negative) of the current position in percent."""
375
+ total_invested = sum(trade.entry_price * abs(trade.size) for trade in self.__broker.trades)
376
+ return (self.pl / total_invested) * 100 if total_invested else 0
377
+
378
+ @property
379
+ def is_long(self) -> bool:
380
+ """True if the position is long (position size is positive)."""
381
+ return self.size > 0
382
+
383
+ @property
384
+ def is_short(self) -> bool:
385
+ """True if the position is short (position size is negative)."""
386
+ return self.size < 0
387
+
388
+ def close(self, portion: float = 1.):
389
+ """
390
+ Close portion of position by closing `portion` of each active trade. See `Trade.close`.
391
+ """
392
+ for trade in self.__broker.trades:
393
+ trade.close(portion)
394
+
395
+ def __repr__(self):
396
+ return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
397
+
398
+
399
+ class _OutOfMoneyError(Exception):
400
+ pass
401
+
402
+
403
+ class Order:
404
+ """
405
+ Place new orders through `Strategy.buy()` and `Strategy.sell()`.
406
+ Query existing orders through `Strategy.orders`.
407
+
408
+ When an order is executed or [filled], it results in a `Trade`.
409
+
410
+ If you wish to modify aspects of a placed but not yet filled order,
411
+ cancel it and place a new one instead.
412
+
413
+ All placed orders are [Good 'Til Canceled].
414
+
415
+ [filled]: https://www.investopedia.com/terms/f/fill.asp
416
+ [Good 'Til Canceled]: https://www.investopedia.com/terms/g/gtc.asp
417
+ """
418
+ def __init__(self, broker: '_Broker',
419
+ size: float,
420
+ limit_price: Optional[float] = None,
421
+ stop_price: Optional[float] = None,
422
+ sl_price: Optional[float] = None,
423
+ tp_price: Optional[float] = None,
424
+ parent_trade: Optional['Trade'] = None,
425
+ tag: object = None):
426
+ self.__broker = broker
427
+ assert size != 0
428
+ self.__size = size
429
+ self.__limit_price = limit_price
430
+ self.__stop_price = stop_price
431
+ self.__sl_price = sl_price
432
+ self.__tp_price = tp_price
433
+ self.__parent_trade = parent_trade
434
+ self.__tag = tag
435
+
436
+ def _replace(self, **kwargs):
437
+ for k, v in kwargs.items():
438
+ setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
439
+ return self
440
+
441
+ def __repr__(self):
442
+ return '<Order {}>'.format(', '.join(f'{param}={try_(lambda: round(value, 5), value)!r}'
443
+ for param, value in (
444
+ ('size', self.__size),
445
+ ('limit', self.__limit_price),
446
+ ('stop', self.__stop_price),
447
+ ('sl', self.__sl_price),
448
+ ('tp', self.__tp_price),
449
+ ('contingent', self.is_contingent),
450
+ ('tag', self.__tag),
451
+ ) if value is not None)) # noqa: E126
452
+
453
+ def cancel(self):
454
+ """Cancel the order."""
455
+ self.__broker.orders.remove(self)
456
+ trade = self.__parent_trade
457
+ if trade:
458
+ if self is trade._sl_order:
459
+ trade._replace(sl_order=None)
460
+ elif self is trade._tp_order:
461
+ trade._replace(tp_order=None)
462
+ else:
463
+ pass # Order placed by Trade.close()
464
+
465
+ # Fields getters
466
+
467
+ @property
468
+ def size(self) -> float:
469
+ """
470
+ Order size (negative for short orders).
471
+
472
+ If size is a value between 0 and 1, it is interpreted as a fraction of current
473
+ available liquidity (cash plus `Position.pl` minus used margin).
474
+ A value greater than or equal to 1 indicates an absolute number of units.
475
+ """
476
+ return self.__size
477
+
478
+ @property
479
+ def limit(self) -> Optional[float]:
480
+ """
481
+ Order limit price for [limit orders], or None for [market orders],
482
+ which are filled at next available price.
483
+
484
+ [limit orders]: https://www.investopedia.com/terms/l/limitorder.asp
485
+ [market orders]: https://www.investopedia.com/terms/m/marketorder.asp
486
+ """
487
+ return self.__limit_price
488
+
489
+ @property
490
+ def stop(self) -> Optional[float]:
491
+ """
492
+ Order stop price for [stop-limit/stop-market][_] order,
493
+ otherwise None if no stop was set, or the stop price has already been hit.
494
+
495
+ [_]: https://www.investopedia.com/terms/s/stoporder.asp
496
+ """
497
+ return self.__stop_price
498
+
499
+ @property
500
+ def sl(self) -> Optional[float]:
501
+ """
502
+ A stop-loss price at which, if set, a new contingent stop-market order
503
+ will be placed upon the `Trade` following this order's execution.
504
+ See also `Trade.sl`.
505
+ """
506
+ return self.__sl_price
507
+
508
+ @property
509
+ def tp(self) -> Optional[float]:
510
+ """
511
+ A take-profit price at which, if set, a new contingent limit order
512
+ will be placed upon the `Trade` following this order's execution.
513
+ See also `Trade.tp`.
514
+ """
515
+ return self.__tp_price
516
+
517
+ @property
518
+ def parent_trade(self):
519
+ return self.__parent_trade
520
+
521
+ @property
522
+ def tag(self):
523
+ """
524
+ Arbitrary value (such as a string) which, if set, enables tracking
525
+ of this order and the associated `Trade` (see `Trade.tag`).
526
+ """
527
+ return self.__tag
528
+
529
+ __pdoc__['Order.parent_trade'] = False
530
+
531
+ # Extra properties
532
+
533
+ @property
534
+ def is_long(self):
535
+ """True if the order is long (order size is positive)."""
536
+ return self.__size > 0
537
+
538
+ @property
539
+ def is_short(self):
540
+ """True if the order is short (order size is negative)."""
541
+ return self.__size < 0
542
+
543
+ @property
544
+ def is_contingent(self):
545
+ """
546
+ True for [contingent] orders, i.e. [OCO] stop-loss and take-profit bracket orders
547
+ placed upon an active trade. Remaining contingent orders are canceled when
548
+ their parent `Trade` is closed.
549
+
550
+ You can modify contingent orders through `Trade.sl` and `Trade.tp`.
551
+
552
+ [contingent]: https://www.investopedia.com/terms/c/contingentorder.asp
553
+ [OCO]: https://www.investopedia.com/terms/o/oco.asp
554
+ """
555
+ return bool((parent := self.__parent_trade) and
556
+ (self is parent._sl_order or
557
+ self is parent._tp_order))
558
+
559
+
560
+ class Trade:
561
+ """
562
+ When an `Order` is filled, it results in an active `Trade`.
563
+ Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
564
+ """
565
+ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
566
+ self.__broker = broker
567
+ self.__size = size
568
+ self.__entry_price = entry_price
569
+ self.__exit_price: Optional[float] = None
570
+ self.__entry_bar: int = entry_bar
571
+ self.__exit_bar: Optional[int] = None
572
+ self.__sl_order: Optional[Order] = None
573
+ self.__tp_order: Optional[Order] = None
574
+ self.__tag = tag
575
+ self._commissions = 0
576
+
577
+ def __repr__(self):
578
+ return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
579
+ f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
580
+ f'{" tag=" + str(self.__tag) if self.__tag is not None else ""}>'
581
+
582
+ def _replace(self, **kwargs):
583
+ for k, v in kwargs.items():
584
+ setattr(self, f'_{self.__class__.__qualname__}__{k}', v)
585
+ return self
586
+
587
+ def _copy(self, **kwargs):
588
+ return copy(self)._replace(**kwargs)
589
+
590
+ def close(self, portion: float = 1.):
591
+ """Place new `Order` to close `portion` of the trade at next market price."""
592
+ assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
593
+ # Ensure size is an int to avoid rounding errors on 32-bit OS
594
+ size = copysign(max(1, int(round(abs(self.__size) * portion))), -self.__size)
595
+ order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
596
+ self.__broker.orders.insert(0, order)
597
+
598
+ # Fields getters
599
+
600
+ @property
601
+ def size(self):
602
+ """Trade size (volume; negative for short trades)."""
603
+ return self.__size
604
+
605
+ @property
606
+ def entry_price(self) -> float:
607
+ """Trade entry price."""
608
+ return self.__entry_price
609
+
610
+ @property
611
+ def exit_price(self) -> Optional[float]:
612
+ """Trade exit price (or None if the trade is still active)."""
613
+ return self.__exit_price
614
+
615
+ @property
616
+ def entry_bar(self) -> int:
617
+ """Candlestick bar index of when the trade was entered."""
618
+ return self.__entry_bar
619
+
620
+ @property
621
+ def exit_bar(self) -> Optional[int]:
622
+ """
623
+ Candlestick bar index of when the trade was exited
624
+ (or None if the trade is still active).
625
+ """
626
+ return self.__exit_bar
627
+
628
+ @property
629
+ def tag(self):
630
+ """
631
+ A tag value inherited from the `Order` that opened
632
+ this trade.
633
+
634
+ This can be used to track trades and apply conditional
635
+ logic / subgroup analysis.
636
+
637
+ See also `Order.tag`.
638
+ """
639
+ return self.__tag
640
+
641
+ @property
642
+ def _sl_order(self):
643
+ return self.__sl_order
644
+
645
+ @property
646
+ def _tp_order(self):
647
+ return self.__tp_order
648
+
649
+ # Extra properties
650
+
651
+ @property
652
+ def entry_time(self) -> Union[pd.Timestamp, int]:
653
+ """Datetime of when the trade was entered."""
654
+ return self.__broker._data.index[self.__entry_bar]
655
+
656
+ @property
657
+ def exit_time(self) -> Optional[Union[pd.Timestamp, int]]:
658
+ """Datetime of when the trade was exited."""
659
+ if self.__exit_bar is None:
660
+ return None
661
+ return self.__broker._data.index[self.__exit_bar]
662
+
663
+ @property
664
+ def is_long(self):
665
+ """True if the trade is long (trade size is positive)."""
666
+ return self.__size > 0
667
+
668
+ @property
669
+ def is_short(self):
670
+ """True if the trade is short (trade size is negative)."""
671
+ return not self.is_long
672
+
673
+ @property
674
+ def pl(self):
675
+ """
676
+ Trade profit (positive) or loss (negative) in cash units.
677
+ Commissions are reflected only after the Trade is closed.
678
+ """
679
+ price = self.__exit_price or self.__broker.last_price
680
+ return (self.__size * (price - self.__entry_price)) - self._commissions
681
+
682
+ @property
683
+ def pl_pct(self):
684
+ """Trade profit (positive) or loss (negative) in percent."""
685
+ price = self.__exit_price or self.__broker.last_price
686
+ gross_pl_pct = copysign(1, self.__size) * (price / self.__entry_price - 1)
687
+
688
+ # Total commission across the entire trade size to individual units
689
+ commission_pct = self._commissions / (abs(self.__size) * self.__entry_price)
690
+ return gross_pl_pct - commission_pct
691
+
692
+ @property
693
+ def value(self):
694
+ """Trade total value in cash (volume × price)."""
695
+ price = self.__exit_price or self.__broker.last_price
696
+ return abs(self.__size) * price
697
+
698
+ # SL/TP management API
699
+
700
+ @property
701
+ def sl(self):
702
+ """
703
+ Stop-loss price at which to close the trade.
704
+
705
+ This variable is writable. By assigning it a new price value,
706
+ you create or modify the existing SL order.
707
+ By assigning it `None`, you cancel it.
708
+ """
709
+ return self.__sl_order and self.__sl_order.stop
710
+
711
+ @sl.setter
712
+ def sl(self, price: float):
713
+ self.__set_contingent('sl', price)
714
+
715
+ @property
716
+ def tp(self):
717
+ """
718
+ Take-profit price at which to close the trade.
719
+
720
+ This property is writable. By assigning it a new price value,
721
+ you create or modify the existing TP order.
722
+ By assigning it `None`, you cancel it.
723
+ """
724
+ return self.__tp_order and self.__tp_order.limit
725
+
726
+ @tp.setter
727
+ def tp(self, price: float):
728
+ self.__set_contingent('tp', price)
729
+
730
+ def __set_contingent(self, type, price):
731
+ assert type in ('sl', 'tp')
732
+ assert price is None or 0 < price < np.inf, f'Make sure 0 < price < inf! price: {price}'
733
+ attr = f'_{self.__class__.__qualname__}__{type}_order'
734
+ order: Order = getattr(self, attr)
735
+ if order:
736
+ order.cancel()
737
+ if price:
738
+ kwargs = {'stop': price} if type == 'sl' else {'limit': price}
739
+ order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
740
+ setattr(self, attr, order)
741
+
742
+
743
+ class _Broker:
744
+ def __init__(self, *, data, cash, spread, commission, margin,
745
+ trade_on_close, hedging, exclusive_orders, index):
746
+ assert cash > 0, f"cash should be > 0, is {cash}"
747
+ assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
748
+ self._data: _Data = data
749
+ self._cash = cash
750
+
751
+ if callable(commission):
752
+ self._commission = commission
753
+ else:
754
+ try:
755
+ self._commission_fixed, self._commission_relative = commission
756
+ except TypeError:
757
+ self._commission_fixed, self._commission_relative = 0, commission
758
+ assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0'
759
+ assert -.1 <= self._commission_relative < .1, \
760
+ ("commission should be between -10% "
761
+ f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}")
762
+ self._commission = self._commission_func
763
+
764
+ self._spread = spread
765
+ self._leverage = 1 / margin
766
+ self._trade_on_close = trade_on_close
767
+ self._hedging = hedging
768
+ self._exclusive_orders = exclusive_orders
769
+
770
+ self._equity = np.tile(np.nan, len(index))
771
+ self.orders: List[Order] = []
772
+ self.trades: List[Trade] = []
773
+ self.position = Position(self)
774
+ self.closed_trades: List[Trade] = []
775
+
776
+ def _commission_func(self, order_size, price):
777
+ return self._commission_fixed + abs(order_size) * price * self._commission_relative
778
+
779
+ def __repr__(self):
780
+ return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'
781
+
782
+ def new_order(self,
783
+ size: float,
784
+ limit: Optional[float] = None,
785
+ stop: Optional[float] = None,
786
+ sl: Optional[float] = None,
787
+ tp: Optional[float] = None,
788
+ tag: object = None,
789
+ *,
790
+ trade: Optional[Trade] = None) -> Order:
791
+ """
792
+ Argument size indicates whether the order is long or short
793
+ """
794
+ size = float(size)
795
+ stop = stop and float(stop)
796
+ limit = limit and float(limit)
797
+ sl = sl and float(sl)
798
+ tp = tp and float(tp)
799
+
800
+ is_long = size > 0
801
+ assert size != 0, size
802
+ adjusted_price = self._adjusted_price(size)
803
+
804
+ if is_long:
805
+ if not (sl or -np.inf) < (limit or stop or adjusted_price) < (tp or np.inf):
806
+ raise ValueError(
807
+ "Long orders require: "
808
+ f"SL ({sl}) < LIMIT ({limit or stop or adjusted_price}) < TP ({tp})")
809
+ else:
810
+ if not (tp or -np.inf) < (limit or stop or adjusted_price) < (sl or np.inf):
811
+ raise ValueError(
812
+ "Short orders require: "
813
+ f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
814
+
815
+ order = Order(self, size, limit, stop, sl, tp, trade, tag)
816
+
817
+ if not trade:
818
+ # If exclusive orders (each new order auto-closes previous orders/position),
819
+ # cancel all non-contingent orders and close all open trades beforehand
820
+ if self._exclusive_orders:
821
+ for o in self.orders:
822
+ if not o.is_contingent:
823
+ o.cancel()
824
+ for t in self.trades:
825
+ t.close()
826
+
827
+ # Put the new order in the order queue, Ensure SL orders are processed first
828
+ self.orders.insert(0 if trade and stop else len(self.orders), order)
829
+
830
+ return order
831
+
832
+ @property
833
+ def last_price(self) -> float:
834
+ """ Price at the last (current) close. """
835
+ return self._data.Close[-1]
836
+
837
+ def _adjusted_price(self, size=None, price=None) -> float:
838
+ """
839
+ Long/short `price`, adjusted for spread.
840
+ In long positions, the adjusted price is a fraction higher, and vice versa.
841
+ """
842
+ return (price or self.last_price) * (1 + copysign(self._spread, size))
843
+
844
+ @property
845
+ def equity(self) -> float:
846
+ return self._cash + sum(trade.pl for trade in self.trades)
847
+
848
+ @property
849
+ def margin_available(self) -> float:
850
+ # From https://github.com/QuantConnect/Lean/pull/3768
851
+ margin_used = sum(trade.value / self._leverage for trade in self.trades)
852
+ return max(0, self.equity - margin_used)
853
+
854
+ def next(self):
855
+ i = self._i = len(self._data) - 1
856
+ self._process_orders()
857
+
858
+ # Log account equity for the equity curve
859
+ equity = self.equity
860
+ self._equity[i] = equity
861
+
862
+ # If equity is negative, set all to 0 and stop the simulation
863
+ if equity <= 0:
864
+ assert self.margin_available <= 0
865
+ for trade in self.trades:
866
+ self._close_trade(trade, self._data.Close[-1], i)
867
+ self._cash = 0
868
+ self._equity[i:] = 0
869
+ raise _OutOfMoneyError
870
+
871
+ def _process_orders(self):
872
+ data = self._data
873
+ open, high, low = data.Open[-1], data.High[-1], data.Low[-1]
874
+ reprocess_orders = False
875
+
876
+ # Process orders
877
+ for order in list(self.orders): # type: Order
878
+
879
+ # Related SL/TP order was already removed
880
+ if order not in self.orders:
881
+ continue
882
+
883
+ # Check if stop condition was hit
884
+ stop_price = order.stop
885
+ if stop_price:
886
+ is_stop_hit = ((high >= stop_price) if order.is_long else (low <= stop_price))
887
+ if not is_stop_hit:
888
+ continue
889
+
890
+ # > When the stop price is reached, a stop order becomes a market/limit order.
891
+ # https://www.sec.gov/fast-answers/answersstopordhtm.html
892
+ order._replace(stop_price=None)
893
+
894
+ # Determine purchase price.
895
+ # Check if limit order can be filled.
896
+ if order.limit:
897
+ is_limit_hit = low <= order.limit if order.is_long else high >= order.limit
898
+ # When stop and limit are hit within the same bar, we pessimistically
899
+ # assume limit was hit before the stop (i.e. "before it counts")
900
+ is_limit_hit_before_stop = (is_limit_hit and
901
+ (order.limit <= (stop_price or -np.inf)
902
+ if order.is_long
903
+ else order.limit >= (stop_price or np.inf)))
904
+ if not is_limit_hit or is_limit_hit_before_stop:
905
+ continue
906
+
907
+ # stop_price, if set, was hit within this bar
908
+ price = (min(stop_price or open, order.limit)
909
+ if order.is_long else
910
+ max(stop_price or open, order.limit))
911
+ else:
912
+ # Market-if-touched / market order
913
+ # Contingent orders always on next open
914
+ prev_close = data.Close[-2]
915
+ price = prev_close if self._trade_on_close and not order.is_contingent else open
916
+ if stop_price:
917
+ price = max(price, stop_price) if order.is_long else min(price, stop_price)
918
+
919
+ # Determine entry/exit bar index
920
+ is_market_order = not order.limit and not stop_price
921
+ time_index = (
922
+ (self._i - 1)
923
+ if is_market_order and self._trade_on_close and not order.is_contingent else
924
+ self._i)
925
+
926
+ # If order is a SL/TP order, it should close an existing trade it was contingent upon
927
+ if order.parent_trade:
928
+ trade = order.parent_trade
929
+ _prev_size = trade.size
930
+ # If order.size is "greater" than trade.size, this order is a trade.close()
931
+ # order and part of the trade was already closed beforehand
932
+ size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
933
+ # If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
934
+ if trade in self.trades:
935
+ self._reduce_trade(trade, price, size, time_index)
936
+ assert order.size != -_prev_size or trade not in self.trades
937
+ if price == stop_price:
938
+ # Set SL back on the order for stats._trades["SL"]
939
+ trade._sl_order._replace(stop_price=stop_price)
940
+ if order in (trade._sl_order,
941
+ trade._tp_order):
942
+ assert order.size == -trade.size
943
+ assert order not in self.orders # Removed when trade was closed
944
+ else:
945
+ # It's a trade.close() order, now done
946
+ assert abs(_prev_size) >= abs(size) >= 1
947
+ self.orders.remove(order)
948
+ continue
949
+
950
+ # Else this is a stand-alone trade
951
+
952
+ # Adjust price to include commission (or bid-ask spread).
953
+ # In long positions, the adjusted price is a fraction higher, and vice versa.
954
+ adjusted_price = self._adjusted_price(order.size, price)
955
+ adjusted_price_plus_commission = \
956
+ adjusted_price + self._commission(order.size, price) / abs(order.size)
957
+
958
+ # If order size was specified proportionally,
959
+ # precompute true size in units, accounting for margin and spread/commissions
960
+ size = order.size
961
+ if -1 < size < 1:
962
+ size = copysign(int((self.margin_available * self._leverage * abs(size))
963
+ // adjusted_price_plus_commission), size)
964
+ # Not enough cash/margin even for a single unit
965
+ if not size:
966
+ warnings.warn(
967
+ f'time={self._i}: Broker canceled the relative-sized '
968
+ f'order due to insufficient margin.', category=UserWarning)
969
+ # XXX: The order is canceled by the broker?
970
+ self.orders.remove(order)
971
+ continue
972
+ assert size == round(size)
973
+ need_size = int(size)
974
+
975
+ if not self._hedging:
976
+ # Fill position by FIFO closing/reducing existing opposite-facing trades.
977
+ # Existing trades are closed at unadjusted price, because the adjustment
978
+ # was already made when buying.
979
+ for trade in list(self.trades):
980
+ if trade.is_long == order.is_long:
981
+ continue
982
+ assert trade.size * order.size < 0
983
+
984
+ # Order size greater than this opposite-directed existing trade,
985
+ # so it will be closed completely
986
+ if abs(need_size) >= abs(trade.size):
987
+ self._close_trade(trade, price, time_index)
988
+ need_size += trade.size
989
+ else:
990
+ # The existing trade is larger than the new order,
991
+ # so it will only be closed partially
992
+ self._reduce_trade(trade, price, need_size, time_index)
993
+ need_size = 0
994
+
995
+ if not need_size:
996
+ break
997
+
998
+ # If we don't have enough liquidity to cover for the order, the broker CANCELS it
999
+ if abs(need_size) * adjusted_price_plus_commission > \
1000
+ self.margin_available * self._leverage:
1001
+ self.orders.remove(order)
1002
+ continue
1003
+
1004
+ # Open a new trade
1005
+ if need_size:
1006
+ self._open_trade(adjusted_price,
1007
+ need_size,
1008
+ order.sl,
1009
+ order.tp,
1010
+ time_index,
1011
+ order.tag)
1012
+
1013
+ # We need to reprocess the SL/TP orders newly added to the queue.
1014
+ # This allows e.g. SL hitting in the same bar the order was open.
1015
+ # See https://github.com/kernc/backtesting.py/issues/119
1016
+ if order.sl or order.tp:
1017
+ if is_market_order:
1018
+ reprocess_orders = True
1019
+ # Order.stop and TP hit within the same bar, but SL wasn't. This case
1020
+ # is not ambiguous, because stop and TP go in the same price direction.
1021
+ elif stop_price and not order.limit and order.tp and (
1022
+ (order.is_long and order.tp <= high and (order.sl or -np.inf) < low) or
1023
+ (order.is_short and order.tp >= low and (order.sl or np.inf) > high)):
1024
+ reprocess_orders = True
1025
+ elif (low <= (order.sl or -np.inf) <= high or
1026
+ low <= (order.tp or -np.inf) <= high):
1027
+ warnings.warn(
1028
+ f"({data.index[-1]}) A contingent SL/TP order would execute in the "
1029
+ "same bar its parent stop/limit order was turned into a trade. "
1030
+ "Since we can't assert the precise intra-candle "
1031
+ "price movement, the affected SL/TP order will instead be executed on "
1032
+ "the next (matching) price/bar, making the result (of this trade) "
1033
+ "somewhat dubious. "
1034
+ "See https://github.com/kernc/backtesting.py/issues/119",
1035
+ UserWarning)
1036
+
1037
+ # Order processed
1038
+ self.orders.remove(order)
1039
+
1040
+ if reprocess_orders:
1041
+ self._process_orders()
1042
+
1043
+ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
1044
+ assert trade.size * size < 0
1045
+ assert abs(trade.size) >= abs(size)
1046
+
1047
+ size_left = trade.size + size
1048
+ assert size_left * trade.size >= 0
1049
+ if not size_left:
1050
+ close_trade = trade
1051
+ else:
1052
+ # Reduce existing trade ...
1053
+ trade._replace(size=size_left)
1054
+ if trade._sl_order:
1055
+ trade._sl_order._replace(size=-trade.size)
1056
+ if trade._tp_order:
1057
+ trade._tp_order._replace(size=-trade.size)
1058
+
1059
+ # ... by closing a reduced copy of it
1060
+ close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
1061
+ self.trades.append(close_trade)
1062
+
1063
+ self._close_trade(close_trade, price, time_index)
1064
+
1065
+ def _close_trade(self, trade: Trade, price: float, time_index: int):
1066
+ self.trades.remove(trade)
1067
+ if trade._sl_order:
1068
+ self.orders.remove(trade._sl_order)
1069
+ if trade._tp_order:
1070
+ self.orders.remove(trade._tp_order)
1071
+
1072
+ closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
1073
+ self.closed_trades.append(closed_trade)
1074
+ # Apply commission one more time at trade exit
1075
+ commission = self._commission(trade.size, price)
1076
+ self._cash += trade.pl - commission
1077
+ # Save commissions on Trade instance for stats
1078
+ trade_open_commission = self._commission(closed_trade.size, closed_trade.entry_price)
1079
+ # applied here instead of on Trade open because size could have changed
1080
+ # by way of _reduce_trade()
1081
+ closed_trade._commissions = commission + trade_open_commission
1082
+
1083
+ def _open_trade(self, price: float, size: int,
1084
+ sl: Optional[float], tp: Optional[float], time_index: int, tag):
1085
+ trade = Trade(self, size, price, time_index, tag)
1086
+ self.trades.append(trade)
1087
+ # Apply broker commission at trade open
1088
+ self._cash -= self._commission(size, price)
1089
+ # Create SL/TP (bracket) orders.
1090
+ if tp:
1091
+ trade.tp = tp
1092
+ if sl:
1093
+ trade.sl = sl
1094
+
1095
+
1096
+ class Backtest:
1097
+ """
1098
+ Backtest a particular (parameterized) strategy
1099
+ on particular data.
1100
+
1101
+ Initialize a backtest. Requires data and a strategy to test.
1102
+ After initialization, you can call method
1103
+ `backtesting.backtesting.Backtest.run` to run a backtest
1104
+ instance, or `backtesting.backtesting.Backtest.optimize` to
1105
+ optimize it.
1106
+
1107
+ `data` is a `pd.DataFrame` with columns:
1108
+ `Open`, `High`, `Low`, `Close`, and (optionally) `Volume`.
1109
+ If any columns are missing, set them to what you have available,
1110
+ e.g.
1111
+
1112
+ df['Open'] = df['High'] = df['Low'] = df['Close']
1113
+
1114
+ The passed data frame can contain additional columns that
1115
+ can be used by the strategy (e.g. sentiment info).
1116
+ DataFrame index can be either a datetime index (timestamps)
1117
+ or a monotonic range index (i.e. a sequence of periods).
1118
+
1119
+ `strategy` is a `backtesting.backtesting.Strategy`
1120
+ _subclass_ (not an instance).
1121
+
1122
+ `cash` is the initial cash to start with.
1123
+
1124
+ `spread` is the the constant bid-ask spread rate (relative to the price).
1125
+ E.g. set it to `0.0002` for commission-less forex
1126
+ trading where the average spread is roughly 0.2‰ of the asking price.
1127
+
1128
+ `commission` is the commission rate. E.g. if your broker's commission
1129
+ is 1% of order value, set commission to `0.01`.
1130
+ The commission is applied twice: at trade entry and at trade exit.
1131
+ Besides one single floating value, `commission` can also be a tuple of floating
1132
+ values `(fixed, relative)`. E.g. set it to `(100, .01)`
1133
+ if your broker charges minimum $100 + 1%.
1134
+ Additionally, `commission` can be a callable
1135
+ `func(order_size: int, price: float) -> float`
1136
+ (note, order size is negative for short orders),
1137
+ which can be used to model more complex commission structures.
1138
+ Negative commission values are interpreted as market-maker's rebates.
1139
+
1140
+ .. note::
1141
+ Before v0.4.0, the commission was only applied once, like `spread` is now.
1142
+ If you want to keep the old behavior, simply set `spread` instead.
1143
+
1144
+ .. note::
1145
+ With nonzero `commission`, long and short orders will be placed
1146
+ at an adjusted price that is slightly higher or lower (respectively)
1147
+ than the current price. See e.g.
1148
+ [#153](https://github.com/kernc/backtesting.py/issues/153),
1149
+ [#538](https://github.com/kernc/backtesting.py/issues/538),
1150
+ [#633](https://github.com/kernc/backtesting.py/issues/633).
1151
+
1152
+ `margin` is the required margin (ratio) of a leveraged account.
1153
+ No difference is made between initial and maintenance margins.
1154
+ To run the backtest using e.g. 50:1 leverge that your broker allows,
1155
+ set margin to `0.02` (1 / leverage).
1156
+
1157
+ If `trade_on_close` is `True`, market orders will be filled
1158
+ with respect to the current bar's closing price instead of the
1159
+ next bar's open.
1160
+
1161
+ If `hedging` is `True`, allow trades in both directions simultaneously.
1162
+ If `False`, the opposite-facing orders first close existing trades in
1163
+ a [FIFO] manner.
1164
+
1165
+ If `exclusive_orders` is `True`, each new order auto-closes the previous
1166
+ trade/position, making at most a single trade (long or short) in effect
1167
+ at each time.
1168
+
1169
+ If `finalize_trades` is `True`, the trades that are still
1170
+ [active and ongoing] at the end of the backtest will be closed on
1171
+ the last bar and will contribute to the computed backtest statistics.
1172
+
1173
+ .. tip:: Fractional trading
1174
+ See also `backtesting.lib.FractionalBacktest` if you want to trade
1175
+ fractional units (of e.g. bitcoin).
1176
+
1177
+ [FIFO]: https://www.investopedia.com/terms/n/nfa-compliance-rule-2-43b.asp
1178
+ [active and ongoing]: https://kernc.github.io/backtesting.py/doc/backtesting/backtesting.html#backtesting.backtesting.Strategy.trades
1179
+ """ # noqa: E501
1180
+ def __init__(self,
1181
+ data: pd.DataFrame,
1182
+ strategy: Type[Strategy],
1183
+ *,
1184
+ cash: float = 10_000,
1185
+ spread: float = .0,
1186
+ commission: Union[float, Tuple[float, float]] = .0,
1187
+ margin: float = 1.,
1188
+ trade_on_close=False,
1189
+ hedging=False,
1190
+ exclusive_orders=False,
1191
+ finalize_trades=False,
1192
+ ):
1193
+ if not (isinstance(strategy, type) and issubclass(strategy, Strategy)):
1194
+ raise TypeError('`strategy` must be a Strategy sub-type')
1195
+ if not isinstance(data, pd.DataFrame):
1196
+ raise TypeError("`data` must be a pandas.DataFrame with columns")
1197
+ if not isinstance(spread, Number):
1198
+ raise TypeError('`spread` must be a float value, percent of '
1199
+ 'entry order price')
1200
+ if not isinstance(commission, (Number, tuple)) and not callable(commission):
1201
+ raise TypeError('`commission` must be a float percent of order value, '
1202
+ 'a tuple of `(fixed, relative)` commission, '
1203
+ 'or a function that takes `(order_size, price)`'
1204
+ 'and returns commission dollar value')
1205
+
1206
+ data = data.copy(deep=False)
1207
+
1208
+ # Convert index to datetime index
1209
+ if (not isinstance(data.index, pd.DatetimeIndex) and
1210
+ not isinstance(data.index, pd.RangeIndex) and
1211
+ # Numeric index with most large numbers
1212
+ (data.index.is_numeric() and
1213
+ (data.index > pd.Timestamp('1975').timestamp()).mean() > .8)):
1214
+ try:
1215
+ data.index = pd.to_datetime(data.index, infer_datetime_format=True)
1216
+ except ValueError:
1217
+ pass
1218
+
1219
+ if 'Volume' not in data:
1220
+ data['Volume'] = np.nan
1221
+
1222
+ if len(data) == 0:
1223
+ raise ValueError('OHLC `data` is empty')
1224
+ if len(data.columns.intersection({'Open', 'High', 'Low', 'Close', 'Volume'})) != 5:
1225
+ raise ValueError("`data` must be a pandas.DataFrame with columns "
1226
+ "'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'")
1227
+ if data[['Open', 'High', 'Low', 'Close']].isnull().values.any():
1228
+ raise ValueError('Some OHLC values are missing (NaN). '
1229
+ 'Please strip those lines with `df.dropna()` or '
1230
+ 'fill them in with `df.interpolate()` or whatever.')
1231
+ if np.any(data['Close'] > cash):
1232
+ warnings.warn('Some prices are larger than initial cash value. Note that fractional '
1233
+ 'trading is not supported by this class. If you want to trade Bitcoin, '
1234
+ 'increase initial cash, or trade μBTC or satoshis instead (see e.g. class '
1235
+ '`backtesting.lib.FractionalBacktest`.',
1236
+ stacklevel=2)
1237
+ if not data.index.is_monotonic_increasing:
1238
+ warnings.warn('Data index is not sorted in ascending order. Sorting.',
1239
+ stacklevel=2)
1240
+ data = data.sort_index()
1241
+ if not isinstance(data.index, pd.DatetimeIndex):
1242
+ warnings.warn('Data index is not datetime. Assuming simple periods, '
1243
+ 'but `pd.DateTimeIndex` is advised.',
1244
+ stacklevel=2)
1245
+
1246
+ self._data: pd.DataFrame = data
1247
+ self._broker = partial(
1248
+ _Broker, cash=cash, spread=spread, commission=commission, margin=margin,
1249
+ trade_on_close=trade_on_close, hedging=hedging,
1250
+ exclusive_orders=exclusive_orders, index=data.index,
1251
+ )
1252
+ self._strategy = strategy
1253
+ self._results: Optional[pd.Series] = None
1254
+ self._finalize_trades = bool(finalize_trades)
1255
+
1256
+ def run(self, **kwargs) -> pd.Series:
1257
+ """
1258
+ Run the backtest. Returns `pd.Series` with results and statistics.
1259
+
1260
+ Keyword arguments are interpreted as strategy parameters.
1261
+
1262
+ >>> Backtest(GOOG, SmaCross).run()
1263
+ Start 2004-08-19 00:00:00
1264
+ End 2013-03-01 00:00:00
1265
+ Duration 3116 days 00:00:00
1266
+ Exposure Time [%] 96.74115
1267
+ Equity Final [$] 51422.99
1268
+ Equity Peak [$] 75787.44
1269
+ Return [%] 414.2299
1270
+ Buy & Hold Return [%] 703.45824
1271
+ Return (Ann.) [%] 21.18026
1272
+ Volatility (Ann.) [%] 36.49391
1273
+ CAGR [%] 14.15984
1274
+ Sharpe Ratio 0.58038
1275
+ Sortino Ratio 1.08479
1276
+ Calmar Ratio 0.44144
1277
+ Alpha [%] 394.37391
1278
+ Beta 0.03803
1279
+ Max. Drawdown [%] -47.98013
1280
+ Avg. Drawdown [%] -5.92585
1281
+ Max. Drawdown Duration 584 days 00:00:00
1282
+ Avg. Drawdown Duration 41 days 00:00:00
1283
+ # Trades 66
1284
+ Win Rate [%] 46.9697
1285
+ Best Trade [%] 53.59595
1286
+ Worst Trade [%] -18.39887
1287
+ Avg. Trade [%] 2.53172
1288
+ Max. Trade Duration 183 days 00:00:00
1289
+ Avg. Trade Duration 46 days 00:00:00
1290
+ Profit Factor 2.16795
1291
+ Expectancy [%] 3.27481
1292
+ SQN 1.07662
1293
+ Kelly Criterion 0.15187
1294
+ _strategy SmaCross
1295
+ _equity_curve Eq...
1296
+ _trades Size EntryB...
1297
+ dtype: object
1298
+
1299
+ .. warning::
1300
+ You may obtain different results for different strategy parameters.
1301
+ E.g. if you use 50- and 200-bar SMA, the trading simulation will
1302
+ begin on bar 201. The actual length of delay is equal to the lookback
1303
+ period of the `Strategy.I` indicator which lags the most.
1304
+ Obviously, this can affect results.
1305
+ """
1306
+ data = _Data(self._data.copy(deep=False))
1307
+ broker: _Broker = self._broker(data=data)
1308
+ strategy: Strategy = self._strategy(broker, data, kwargs)
1309
+
1310
+ strategy.init()
1311
+ data._update() # Strategy.init might have changed/added to data.df
1312
+
1313
+ # Indicators used in Strategy.next()
1314
+ indicator_attrs = _strategy_indicators(strategy)
1315
+
1316
+ # Skip first few candles where indicators are still "warming up"
1317
+ # +1 to have at least two entries available
1318
+ start = 1 + _indicator_warmup_nbars(strategy)
1319
+
1320
+ # Disable "invalid value encountered in ..." warnings. Comparison
1321
+ # np.nan >= 3 is not invalid; it's False.
1322
+ with np.errstate(invalid='ignore'):
1323
+
1324
+ for i in _tqdm(range(start, len(self._data)), desc=self.run.__qualname__,
1325
+ unit='bar', mininterval=2, miniters=100):
1326
+ # Prepare data and indicators for `next` call
1327
+ data._set_length(i + 1)
1328
+ for attr, indicator in indicator_attrs:
1329
+ # Slice indicator on the last dimension (case of 2d indicator)
1330
+ setattr(strategy, attr, indicator[..., :i + 1])
1331
+
1332
+ # Handle orders processing and broker stuff
1333
+ try:
1334
+ broker.next()
1335
+ except _OutOfMoneyError:
1336
+ break
1337
+
1338
+ # Next tick, a moment before bar close
1339
+ strategy.next()
1340
+ else:
1341
+ if self._finalize_trades is True:
1342
+ # Close any remaining open trades so they produce some stats
1343
+ for trade in reversed(broker.trades):
1344
+ trade.close()
1345
+
1346
+ # HACK: Re-run broker one last time to handle close orders placed in the last
1347
+ # strategy iteration. Use the same OHLC values as in the last broker iteration.
1348
+ if start < len(self._data):
1349
+ try_(broker.next, exception=_OutOfMoneyError)
1350
+ elif len(broker.trades):
1351
+ warnings.warn(
1352
+ 'Some trades remain open at the end of backtest. Use '
1353
+ '`Backtest(..., finalize_trades=True)` to close them and '
1354
+ 'include them in stats.', stacklevel=2)
1355
+
1356
+ # Set data back to full length
1357
+ # for future `indicator._opts['data'].index` calls to work
1358
+ data._set_length(len(self._data))
1359
+
1360
+ equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
1361
+ self._results = compute_stats(
1362
+ trades=broker.closed_trades,
1363
+ equity=equity,
1364
+ ohlc_data=self._data,
1365
+ risk_free_rate=0.0,
1366
+ strategy_instance=strategy,
1367
+ )
1368
+
1369
+ return self._results
1370
+
1371
+ def optimize(self, *,
1372
+ maximize: Union[str, Callable[[pd.Series], float]] = 'SQN',
1373
+ method: str = 'grid',
1374
+ max_tries: Optional[Union[int, float]] = None,
1375
+ constraint: Optional[Callable[[dict], bool]] = None,
1376
+ return_heatmap: bool = False,
1377
+ return_optimization: bool = False,
1378
+ random_state: Optional[int] = None,
1379
+ **kwargs) -> Union[pd.Series,
1380
+ Tuple[pd.Series, pd.Series],
1381
+ Tuple[pd.Series, pd.Series, dict]]:
1382
+ """
1383
+ Optimize strategy parameters to an optimal combination.
1384
+ Returns result `pd.Series` of the best run.
1385
+
1386
+ `maximize` is a string key from the
1387
+ `backtesting.backtesting.Backtest.run`-returned results series,
1388
+ or a function that accepts this series object and returns a number;
1389
+ the higher the better. By default, the method maximizes
1390
+ Van Tharp's [System Quality Number](https://google.com/search?q=System+Quality+Number).
1391
+
1392
+ `method` is the optimization method. Currently two methods are supported:
1393
+
1394
+ * `"grid"` which does an exhaustive (or randomized) search over the
1395
+ cartesian product of parameter combinations, and
1396
+ * `"sambo"` which finds close-to-optimal strategy parameters using
1397
+ [model-based optimization], making at most `max_tries` evaluations.
1398
+
1399
+ [model-based optimization]: https://sambo-optimization.github.io
1400
+
1401
+ `max_tries` is the maximal number of strategy runs to perform.
1402
+ If `method="grid"`, this results in randomized grid search.
1403
+ If `max_tries` is a floating value between (0, 1], this sets the
1404
+ number of runs to approximately that fraction of full grid space.
1405
+ Alternatively, if integer, it denotes the absolute maximum number
1406
+ of evaluations. If unspecified (default), grid search is exhaustive,
1407
+ whereas for `method="sambo"`, `max_tries` is set to 200.
1408
+
1409
+ `constraint` is a function that accepts a dict-like object of
1410
+ parameters (with values) and returns `True` when the combination
1411
+ is admissible to test with. By default, any parameters combination
1412
+ is considered admissible.
1413
+
1414
+ If `return_heatmap` is `True`, besides returning the result
1415
+ series, an additional `pd.Series` is returned with a multiindex
1416
+ of all admissible parameter combinations, which can be further
1417
+ inspected or projected onto 2D to plot a heatmap
1418
+ (see `backtesting.lib.plot_heatmaps()`).
1419
+
1420
+ If `return_optimization` is True and `method = 'sambo'`,
1421
+ in addition to result series (and maybe heatmap), return raw
1422
+ [`scipy.optimize.OptimizeResult`][OptimizeResult] for further
1423
+ inspection, e.g. with [SAMBO]'s [plotting tools].
1424
+
1425
+ [OptimizeResult]: https://sambo-optimization.github.io/doc/sambo/#sambo.OptimizeResult
1426
+ [SAMBO]: https://sambo-optimization.github.io
1427
+ [plotting tools]: https://sambo-optimization.github.io/doc/sambo/plot.html
1428
+
1429
+ If you want reproducible optimization results, set `random_state`
1430
+ to a fixed integer random seed.
1431
+
1432
+ Additional keyword arguments represent strategy arguments with
1433
+ list-like collections of possible values. For example, the following
1434
+ code finds and returns the "best" of the 7 admissible (of the
1435
+ 9 possible) parameter combinations:
1436
+
1437
+ best_stats = backtest.optimize(sma1=[5, 10, 15], sma2=[10, 20, 40],
1438
+ constraint=lambda p: p.sma1 < p.sma2)
1439
+ """
1440
+ if not kwargs:
1441
+ raise ValueError('Need some strategy parameters to optimize')
1442
+
1443
+ maximize_key = None
1444
+ if isinstance(maximize, str):
1445
+ maximize_key = str(maximize)
1446
+ if maximize not in dummy_stats().index:
1447
+ raise ValueError('`maximize`, if str, must match a key in pd.Series '
1448
+ 'result of backtest.run()')
1449
+
1450
+ def maximize(stats: pd.Series, _key=maximize):
1451
+ return stats[_key]
1452
+
1453
+ elif not callable(maximize):
1454
+ raise TypeError('`maximize` must be str (a field of backtest.run() result '
1455
+ 'Series) or a function that accepts result Series '
1456
+ 'and returns a number; the higher the better')
1457
+ assert callable(maximize), maximize
1458
+
1459
+ have_constraint = bool(constraint)
1460
+ if constraint is None:
1461
+
1462
+ def constraint(_):
1463
+ return True
1464
+
1465
+ elif not callable(constraint):
1466
+ raise TypeError("`constraint` must be a function that accepts a dict "
1467
+ "of strategy parameters and returns a bool whether "
1468
+ "the combination of parameters is admissible or not")
1469
+ assert callable(constraint), constraint
1470
+
1471
+ if method == 'skopt':
1472
+ method = 'sambo'
1473
+ warnings.warn('`Backtest.optimize(method="skopt")` is deprecated. Use `method="sambo"`.',
1474
+ DeprecationWarning, stacklevel=2)
1475
+ if return_optimization and method != 'sambo':
1476
+ raise ValueError("return_optimization=True only valid if method='sambo'")
1477
+
1478
+ def _tuple(x):
1479
+ return x if isinstance(x, Sequence) and not isinstance(x, str) else (x,)
1480
+
1481
+ for k, v in kwargs.items():
1482
+ if len(_tuple(v)) == 0:
1483
+ raise ValueError(f"Optimization variable '{k}' is passed no "
1484
+ f"optimization values: {k}={v}")
1485
+
1486
+ class AttrDict(dict):
1487
+ def __getattr__(self, item):
1488
+ return self[item]
1489
+
1490
+ def _grid_size():
1491
+ size = int(np.prod([len(_tuple(v)) for v in kwargs.values()]))
1492
+ if size < 10_000 and have_constraint:
1493
+ size = sum(1 for p in product(*(zip(repeat(k), _tuple(v))
1494
+ for k, v in kwargs.items()))
1495
+ if constraint(AttrDict(p)))
1496
+ return size
1497
+
1498
+ def _optimize_grid() -> Union[pd.Series, Tuple[pd.Series, pd.Series]]:
1499
+ rand = default_rng(random_state).random
1500
+ grid_frac = (1 if max_tries is None else
1501
+ max_tries if 0 < max_tries <= 1 else
1502
+ max_tries / _grid_size())
1503
+ param_combos = [dict(params) # back to dict so it pickles
1504
+ for params in (AttrDict(params)
1505
+ for params in product(*(zip(repeat(k), _tuple(v))
1506
+ for k, v in kwargs.items())))
1507
+ if constraint(params)
1508
+ and rand() <= grid_frac]
1509
+ if not param_combos:
1510
+ raise ValueError('No admissible parameter combinations to test')
1511
+
1512
+ if len(param_combos) > 300:
1513
+ warnings.warn(f'Searching for best of {len(param_combos)} configurations.',
1514
+ stacklevel=2)
1515
+
1516
+ heatmap = pd.Series(np.nan,
1517
+ name=maximize_key,
1518
+ index=pd.MultiIndex.from_tuples(
1519
+ [p.values() for p in param_combos],
1520
+ names=next(iter(param_combos)).keys()))
1521
+
1522
+ from . import Pool
1523
+ with Pool() as pool, \
1524
+ SharedMemoryManager() as smm:
1525
+ with patch(self, '_data', None):
1526
+ bt = copy(self) # bt._data will be reassigned in _mp_task worker
1527
+ results = _tqdm(
1528
+ pool.imap(Backtest._mp_task,
1529
+ ((bt, smm.df2shm(self._data), params_batch)
1530
+ for params_batch in _batch(param_combos))),
1531
+ total=len(param_combos),
1532
+ desc='Backtest.optimize'
1533
+ )
1534
+ for param_batch, result in zip(_batch(param_combos), results):
1535
+ for params, stats in zip(param_batch, result):
1536
+ if stats is not None:
1537
+ heatmap[tuple(params.values())] = maximize(stats)
1538
+
1539
+ if pd.isnull(heatmap).all():
1540
+ # No trade was made in any of the runs. Just make a random
1541
+ # run so we get some, if empty, results
1542
+ stats = self.run(**param_combos[0])
1543
+ else:
1544
+ best_params = heatmap.idxmax(skipna=True)
1545
+ stats = self.run(**dict(zip(heatmap.index.names, best_params)))
1546
+
1547
+ if return_heatmap:
1548
+ return stats, heatmap
1549
+ return stats
1550
+
1551
+ def _optimize_sambo() -> Union[pd.Series,
1552
+ Tuple[pd.Series, pd.Series],
1553
+ Tuple[pd.Series, pd.Series, dict]]:
1554
+ try:
1555
+ import sambo
1556
+ except ImportError:
1557
+ raise ImportError("Need package 'sambo' for method='sambo'. pip install sambo") from None
1558
+
1559
+ nonlocal max_tries
1560
+ max_tries = (200 if max_tries is None else
1561
+ max(1, int(max_tries * _grid_size())) if 0 < max_tries <= 1 else
1562
+ max_tries)
1563
+
1564
+ dimensions = []
1565
+ for key, values in kwargs.items():
1566
+ values = np.asarray(values)
1567
+ if values.dtype.kind in 'mM': # timedelta, datetime64
1568
+ # these dtypes are unsupported in SAMBO, so convert to raw int
1569
+ # TODO: save dtype and convert back later
1570
+ values = values.astype(np.int64)
1571
+
1572
+ if values.dtype.kind in 'iumM':
1573
+ dimensions.append((values.min(), values.max() + 1))
1574
+ elif values.dtype.kind == 'f':
1575
+ dimensions.append((values.min(), values.max()))
1576
+ else:
1577
+ dimensions.append(values.tolist())
1578
+
1579
+ # Avoid recomputing re-evaluations
1580
+ @lru_cache()
1581
+ def memoized_run(tup):
1582
+ nonlocal maximize, self
1583
+ stats = self.run(**dict(tup))
1584
+ return -maximize(stats)
1585
+
1586
+ progress = iter(_tqdm(repeat(None), total=max_tries, leave=False,
1587
+ desc=self.optimize.__qualname__, mininterval=2))
1588
+ _names = tuple(kwargs.keys())
1589
+
1590
+ def objective_function(x):
1591
+ nonlocal progress, memoized_run, constraint, _names
1592
+ next(progress)
1593
+ value = memoized_run(tuple(zip(_names, x)))
1594
+ return 0 if np.isnan(value) else value
1595
+
1596
+ def cons(x):
1597
+ nonlocal constraint, _names
1598
+ return constraint(AttrDict(zip(_names, x)))
1599
+
1600
+ res = sambo.minimize(
1601
+ fun=objective_function,
1602
+ bounds=dimensions,
1603
+ constraints=cons,
1604
+ max_iter=max_tries,
1605
+ method='sceua',
1606
+ rng=random_state)
1607
+
1608
+ stats = self.run(**dict(zip(kwargs.keys(), res.x)))
1609
+ output = [stats]
1610
+
1611
+ if return_heatmap:
1612
+ heatmap = pd.Series(dict(zip(map(tuple, res.xv), -res.funv)),
1613
+ name=maximize_key)
1614
+ heatmap.index.names = kwargs.keys()
1615
+ heatmap.sort_index(inplace=True)
1616
+ output.append(heatmap)
1617
+
1618
+ if return_optimization:
1619
+ output.append(res)
1620
+
1621
+ return stats if len(output) == 1 else tuple(output)
1622
+
1623
+ if method == 'grid':
1624
+ output = _optimize_grid()
1625
+ elif method in ('sambo', 'skopt'):
1626
+ output = _optimize_sambo()
1627
+ else:
1628
+ raise ValueError(f"Method should be 'grid' or 'sambo', not {method!r}")
1629
+ return output
1630
+
1631
+ @staticmethod
1632
+ def _mp_task(arg):
1633
+ bt, data_shm, params_batch = arg
1634
+ bt._data, shm = SharedMemoryManager.shm2df(data_shm)
1635
+ try:
1636
+ return [stats.filter(regex='^[^_]') if stats['# Trades'] else None
1637
+ for stats in (bt.run(**params)
1638
+ for params in params_batch)]
1639
+ finally:
1640
+ for shmem in shm:
1641
+ shmem.close()
1642
+
1643
+ def plot(self, *, results: pd.Series = None, filename=None, plot_width=None,
1644
+ plot_equity=True, plot_return=False, plot_pl=True,
1645
+ plot_volume=True, plot_drawdown=False, plot_trades=True,
1646
+ smooth_equity=False, relative_equity=True,
1647
+ superimpose: Union[bool, str] = True,
1648
+ resample=True, reverse_indicators=False,
1649
+ show_legend=True, open_browser=True):
1650
+ """
1651
+ Plot the progression of the last backtest run.
1652
+
1653
+ If `results` is provided, it should be a particular result
1654
+ `pd.Series` such as returned by
1655
+ `backtesting.backtesting.Backtest.run` or
1656
+ `backtesting.backtesting.Backtest.optimize`, otherwise the last
1657
+ run's results are used.
1658
+
1659
+ `filename` is the path to save the interactive HTML plot to.
1660
+ By default, a strategy/parameter-dependent file is created in the
1661
+ current working directory.
1662
+
1663
+ `plot_width` is the width of the plot in pixels. If None (default),
1664
+ the plot is made to span 100% of browser width. The height is
1665
+ currently non-adjustable.
1666
+
1667
+ If `plot_equity` is `True`, the resulting plot will contain
1668
+ an equity (initial cash plus assets) graph section. This is the same
1669
+ as `plot_return` plus initial 100%.
1670
+
1671
+ If `plot_return` is `True`, the resulting plot will contain
1672
+ a cumulative return graph section. This is the same
1673
+ as `plot_equity` minus initial 100%.
1674
+
1675
+ If `plot_pl` is `True`, the resulting plot will contain
1676
+ a profit/loss (P/L) indicator section.
1677
+
1678
+ If `plot_volume` is `True`, the resulting plot will contain
1679
+ a trade volume section.
1680
+
1681
+ If `plot_drawdown` is `True`, the resulting plot will contain
1682
+ a separate drawdown graph section.
1683
+
1684
+ If `plot_trades` is `True`, the stretches between trade entries
1685
+ and trade exits are marked by hash-marked tractor beams.
1686
+
1687
+ If `smooth_equity` is `True`, the equity graph will be
1688
+ interpolated between fixed points at trade closing times,
1689
+ unaffected by any interim asset volatility.
1690
+
1691
+ If `relative_equity` is `True`, scale and label equity graph axis
1692
+ with return percent, not absolute cash-equivalent values.
1693
+
1694
+ If `superimpose` is `True`, superimpose larger-timeframe candlesticks
1695
+ over the original candlestick chart. Default downsampling rule is:
1696
+ monthly for daily data, daily for hourly data, hourly for minute data,
1697
+ and minute for (sub-)second data.
1698
+ `superimpose` can also be a valid [Pandas offset string],
1699
+ such as `'5T'` or `'5min'`, in which case this frequency will be
1700
+ used to superimpose.
1701
+ Note, this only works for data with a datetime index.
1702
+
1703
+ If `resample` is `True`, the OHLC data is resampled in a way that
1704
+ makes the upper number of candles for Bokeh to plot limited to 10_000.
1705
+ This may, in situations of overabundant data,
1706
+ improve plot's interactive performance and avoid browser's
1707
+ `Javascript Error: Maximum call stack size exceeded` or similar.
1708
+ Equity & dropdown curves and individual trades data is,
1709
+ likewise, [reasonably _aggregated_][TRADES_AGG].
1710
+ `resample` can also be a [Pandas offset string],
1711
+ such as `'5T'` or `'5min'`, in which case this frequency will be
1712
+ used to resample, overriding above numeric limitation.
1713
+ Note, all this only works for data with a datetime index.
1714
+
1715
+ If `reverse_indicators` is `True`, the indicators below the OHLC chart
1716
+ are plotted in reverse order of declaration.
1717
+
1718
+ [Pandas offset string]: \
1719
+ https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#dateoffset-objects
1720
+
1721
+ [TRADES_AGG]: lib.html#backtesting.lib.TRADES_AGG
1722
+
1723
+ If `show_legend` is `True`, the resulting plot graphs will contain
1724
+ labeled legends.
1725
+
1726
+ If `open_browser` is `True`, the resulting `filename` will be
1727
+ opened in the default web browser.
1728
+ """
1729
+ if results is None:
1730
+ if self._results is None:
1731
+ raise RuntimeError('First issue `backtest.run()` to obtain results.')
1732
+ results = self._results
1733
+
1734
+ return plot(
1735
+ results=results,
1736
+ df=self._data,
1737
+ indicators=results._strategy._indicators,
1738
+ filename=filename,
1739
+ plot_width=plot_width,
1740
+ plot_equity=plot_equity,
1741
+ plot_return=plot_return,
1742
+ plot_pl=plot_pl,
1743
+ plot_volume=plot_volume,
1744
+ plot_drawdown=plot_drawdown,
1745
+ plot_trades=plot_trades,
1746
+ smooth_equity=smooth_equity,
1747
+ relative_equity=relative_equity,
1748
+ superimpose=superimpose,
1749
+ resample=resample,
1750
+ reverse_indicators=reverse_indicators,
1751
+ show_legend=show_legend,
1752
+ open_browser=open_browser)
1753
+
1754
+
1755
+ # NOTE: Don't put anything public below this __all__ list
1756
+
1757
+ __all__ = [getattr(v, '__name__', k)
1758
+ for k, v in globals().items() # export
1759
+ if ((callable(v) and getattr(v, '__module__', None) == __name__ or # callables from this module; getattr for Python 3.9; # noqa: E501
1760
+ k.isupper()) and # or CONSTANTS
1761
+ not getattr(v, '__name__', k).startswith('_'))] # neither marked internal
1762
+
1763
+ # NOTE: Don't put anything public below here. See above.