openbacktesting 0.2.0__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.
- openbacktesting/__init__.py +85 -0
- openbacktesting/aggregation.py +8 -0
- openbacktesting/broker/__init__.py +54 -0
- openbacktesting/broker/commission.py +57 -0
- openbacktesting/broker/core.py +1122 -0
- openbacktesting/broker/execution.py +62 -0
- openbacktesting/broker/instrument.py +106 -0
- openbacktesting/broker/ledger.py +60 -0
- openbacktesting/broker/models.py +236 -0
- openbacktesting/broker/observations.py +99 -0
- openbacktesting/broker/requests.py +192 -0
- openbacktesting/broker/sizing.py +76 -0
- openbacktesting/colors.py +175 -0
- openbacktesting/data/__init__.py +20 -0
- openbacktesting/data/aggregation.py +39 -0
- openbacktesting/data/context.py +30 -0
- openbacktesting/data/core.py +218 -0
- openbacktesting/data/generators.py +54 -0
- openbacktesting/data/strategy_data.py +103 -0
- openbacktesting/data/timeframe.py +69 -0
- openbacktesting/engine.py +34 -0
- openbacktesting/helpers.py +77 -0
- openbacktesting/indicator_api.py +10 -0
- openbacktesting/indicator_methods.py +123 -0
- openbacktesting/indicator_types.py +19 -0
- openbacktesting/indicators/__init__.py +7 -0
- openbacktesting/indicators/api.py +97 -0
- openbacktesting/indicators/calculus.py +75 -0
- openbacktesting/indicators/candles/__init__.py +0 -0
- openbacktesting/indicators/candles/cdl_doji.py +17 -0
- openbacktesting/indicators/candles/cdl_engulfing.py +14 -0
- openbacktesting/indicators/candles/cdl_eveningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_hammer.py +14 -0
- openbacktesting/indicators/candles/cdl_harami.py +14 -0
- openbacktesting/indicators/candles/cdl_inside.py +14 -0
- openbacktesting/indicators/candles/cdl_marubozu.py +14 -0
- openbacktesting/indicators/candles/cdl_morningstar.py +15 -0
- openbacktesting/indicators/candles/cdl_shootingstar.py +14 -0
- openbacktesting/indicators/candles/cdl_z.py +28 -0
- openbacktesting/indicators/candles/ha.py +19 -0
- openbacktesting/indicators/catalog.json +8348 -0
- openbacktesting/indicators/common.py +94 -0
- openbacktesting/indicators/cycles/__init__.py +0 -0
- openbacktesting/indicators/cycles/dsp.py +10 -0
- openbacktesting/indicators/cycles/ebsw.py +10 -0
- openbacktesting/indicators/cycles/ht_dcperiod.py +10 -0
- openbacktesting/indicators/cycles/ht_dcphase.py +10 -0
- openbacktesting/indicators/cycles/ht_phasor.py +17 -0
- openbacktesting/indicators/cycles/ht_sine.py +17 -0
- openbacktesting/indicators/cycles/ht_trendmode.py +10 -0
- openbacktesting/indicators/cycles/msw.py +19 -0
- openbacktesting/indicators/methods.py +5762 -0
- openbacktesting/indicators/momentum/__init__.py +0 -0
- openbacktesting/indicators/momentum/ac.py +9 -0
- openbacktesting/indicators/momentum/ao.py +10 -0
- openbacktesting/indicators/momentum/apo.py +10 -0
- openbacktesting/indicators/momentum/bias.py +10 -0
- openbacktesting/indicators/momentum/bop.py +10 -0
- openbacktesting/indicators/momentum/brar.py +26 -0
- openbacktesting/indicators/momentum/cci.py +10 -0
- openbacktesting/indicators/momentum/cfo.py +10 -0
- openbacktesting/indicators/momentum/cg.py +10 -0
- openbacktesting/indicators/momentum/cmo.py +10 -0
- openbacktesting/indicators/momentum/coppock.py +10 -0
- openbacktesting/indicators/momentum/crsi.py +28 -0
- openbacktesting/indicators/momentum/cti.py +10 -0
- openbacktesting/indicators/momentum/dm.py +17 -0
- openbacktesting/indicators/momentum/er.py +10 -0
- openbacktesting/indicators/momentum/eri.py +17 -0
- openbacktesting/indicators/momentum/fisher.py +17 -0
- openbacktesting/indicators/momentum/fosc.py +10 -0
- openbacktesting/indicators/momentum/gator.py +13 -0
- openbacktesting/indicators/momentum/ibs.py +7 -0
- openbacktesting/indicators/momentum/inertia.py +22 -0
- openbacktesting/indicators/momentum/kdj.py +18 -0
- openbacktesting/indicators/momentum/kst.py +30 -0
- openbacktesting/indicators/momentum/lrsi.py +10 -0
- openbacktesting/indicators/momentum/macd.py +27 -0
- openbacktesting/indicators/momentum/macdext.py +27 -0
- openbacktesting/indicators/momentum/macdfix.py +18 -0
- openbacktesting/indicators/momentum/mom.py +10 -0
- openbacktesting/indicators/momentum/pgo.py +10 -0
- openbacktesting/indicators/momentum/pmo.py +13 -0
- openbacktesting/indicators/momentum/po.py +10 -0
- openbacktesting/indicators/momentum/ppo.py +20 -0
- openbacktesting/indicators/momentum/psl.py +12 -0
- openbacktesting/indicators/momentum/pvo.py +20 -0
- openbacktesting/indicators/momentum/qqe.py +31 -0
- openbacktesting/indicators/momentum/rci.py +20 -0
- openbacktesting/indicators/momentum/rmi.py +7 -0
- openbacktesting/indicators/momentum/roc.py +10 -0
- openbacktesting/indicators/momentum/rocp.py +10 -0
- openbacktesting/indicators/momentum/rocr.py +10 -0
- openbacktesting/indicators/momentum/rocr100.py +10 -0
- openbacktesting/indicators/momentum/rsi.py +10 -0
- openbacktesting/indicators/momentum/rsx.py +10 -0
- openbacktesting/indicators/momentum/rvgi.py +26 -0
- openbacktesting/indicators/momentum/slope.py +17 -0
- openbacktesting/indicators/momentum/smc_sweep.py +18 -0
- openbacktesting/indicators/momentum/smi.py +21 -0
- openbacktesting/indicators/momentum/smi_stochastic.py +15 -0
- openbacktesting/indicators/momentum/squeeze.py +47 -0
- openbacktesting/indicators/momentum/squeeze_pro.py +53 -0
- openbacktesting/indicators/momentum/stc.py +20 -0
- openbacktesting/indicators/momentum/stoch.py +19 -0
- openbacktesting/indicators/momentum/stochf.py +19 -0
- openbacktesting/indicators/momentum/stochrsi.py +19 -0
- openbacktesting/indicators/momentum/td_seq.py +17 -0
- openbacktesting/indicators/momentum/trix.py +19 -0
- openbacktesting/indicators/momentum/trixh.py +20 -0
- openbacktesting/indicators/momentum/tsi.py +26 -0
- openbacktesting/indicators/momentum/uo.py +22 -0
- openbacktesting/indicators/momentum/vwmacd.py +20 -0
- openbacktesting/indicators/momentum/wavetrend.py +15 -0
- openbacktesting/indicators/momentum/willr.py +10 -0
- openbacktesting/indicators/overlap/__init__.py +0 -0
- openbacktesting/indicators/overlap/alligator.py +13 -0
- openbacktesting/indicators/overlap/alma.py +33 -0
- openbacktesting/indicators/overlap/anchored_vwap.py +50 -0
- openbacktesting/indicators/overlap/anchored_vwap_bands.py +13 -0
- openbacktesting/indicators/overlap/avgprice.py +10 -0
- openbacktesting/indicators/overlap/dema.py +10 -0
- openbacktesting/indicators/overlap/ema.py +12 -0
- openbacktesting/indicators/overlap/frama.py +29 -0
- openbacktesting/indicators/overlap/fwma.py +10 -0
- openbacktesting/indicators/overlap/hilo.py +26 -0
- openbacktesting/indicators/overlap/hl2.py +10 -0
- openbacktesting/indicators/overlap/hlc3.py +10 -0
- openbacktesting/indicators/overlap/hma.py +10 -0
- openbacktesting/indicators/overlap/ht_trendline.py +10 -0
- openbacktesting/indicators/overlap/hwma.py +10 -0
- openbacktesting/indicators/overlap/ichimoku.py +44 -0
- openbacktesting/indicators/overlap/jma.py +10 -0
- openbacktesting/indicators/overlap/kama.py +10 -0
- openbacktesting/indicators/overlap/linreg.py +15 -0
- openbacktesting/indicators/overlap/linregangle.py +10 -0
- openbacktesting/indicators/overlap/linregintercept.py +10 -0
- openbacktesting/indicators/overlap/linregslope.py +10 -0
- openbacktesting/indicators/overlap/ma.py +10 -0
- openbacktesting/indicators/overlap/ma_envelopes.py +16 -0
- openbacktesting/indicators/overlap/mama.py +17 -0
- openbacktesting/indicators/overlap/mavp.py +17 -0
- openbacktesting/indicators/overlap/mcgd.py +10 -0
- openbacktesting/indicators/overlap/medprice.py +10 -0
- openbacktesting/indicators/overlap/midpoint.py +10 -0
- openbacktesting/indicators/overlap/midprice.py +10 -0
- openbacktesting/indicators/overlap/mmar.py +21 -0
- openbacktesting/indicators/overlap/ohlc4.py +10 -0
- openbacktesting/indicators/overlap/pwma.py +10 -0
- openbacktesting/indicators/overlap/rainbow.py +25 -0
- openbacktesting/indicators/overlap/rma.py +10 -0
- openbacktesting/indicators/overlap/sinwma.py +10 -0
- openbacktesting/indicators/overlap/sma.py +14 -0
- openbacktesting/indicators/overlap/ssf.py +10 -0
- openbacktesting/indicators/overlap/supertrend.py +21 -0
- openbacktesting/indicators/overlap/swma.py +10 -0
- openbacktesting/indicators/overlap/t3.py +10 -0
- openbacktesting/indicators/overlap/tema.py +10 -0
- openbacktesting/indicators/overlap/trima.py +10 -0
- openbacktesting/indicators/overlap/tsf.py +10 -0
- openbacktesting/indicators/overlap/typprice.py +10 -0
- openbacktesting/indicators/overlap/vidya.py +10 -0
- openbacktesting/indicators/overlap/vwap.py +10 -0
- openbacktesting/indicators/overlap/vwma.py +10 -0
- openbacktesting/indicators/overlap/wcp.py +10 -0
- openbacktesting/indicators/overlap/wma.py +10 -0
- openbacktesting/indicators/overlap/zlma.py +10 -0
- openbacktesting/indicators/performance/__init__.py +0 -0
- openbacktesting/indicators/performance/drawdown.py +18 -0
- openbacktesting/indicators/performance/log_return.py +10 -0
- openbacktesting/indicators/performance/percent_return.py +10 -0
- openbacktesting/indicators/predicates.py +88 -0
- openbacktesting/indicators/registry.py +571 -0
- openbacktesting/indicators/runtime.py +290 -0
- openbacktesting/indicators/statistics/__init__.py +0 -0
- openbacktesting/indicators/statistics/beta.py +10 -0
- openbacktesting/indicators/statistics/correl.py +10 -0
- openbacktesting/indicators/statistics/entropy.py +10 -0
- openbacktesting/indicators/statistics/kurtosis.py +10 -0
- openbacktesting/indicators/statistics/mad.py +10 -0
- openbacktesting/indicators/statistics/md.py +10 -0
- openbacktesting/indicators/statistics/median.py +10 -0
- openbacktesting/indicators/statistics/mode.py +48 -0
- openbacktesting/indicators/statistics/quantile.py +10 -0
- openbacktesting/indicators/statistics/skew.py +10 -0
- openbacktesting/indicators/statistics/stderr.py +10 -0
- openbacktesting/indicators/statistics/stdev.py +10 -0
- openbacktesting/indicators/statistics/variance.py +10 -0
- openbacktesting/indicators/statistics/zscore.py +10 -0
- openbacktesting/indicators/trend/__init__.py +0 -0
- openbacktesting/indicators/trend/adx.py +28 -0
- openbacktesting/indicators/trend/adxr.py +28 -0
- openbacktesting/indicators/trend/amat.py +19 -0
- openbacktesting/indicators/trend/aroon.py +18 -0
- openbacktesting/indicators/trend/atr_stop.py +33 -0
- openbacktesting/indicators/trend/chop.py +20 -0
- openbacktesting/indicators/trend/cksp.py +17 -0
- openbacktesting/indicators/trend/cpr.py +57 -0
- openbacktesting/indicators/trend/decay.py +10 -0
- openbacktesting/indicators/trend/decreasing.py +18 -0
- openbacktesting/indicators/trend/dpo.py +12 -0
- openbacktesting/indicators/trend/dx.py +19 -0
- openbacktesting/indicators/trend/edecay.py +10 -0
- openbacktesting/indicators/trend/increasing.py +18 -0
- openbacktesting/indicators/trend/long_run.py +10 -0
- openbacktesting/indicators/trend/minus_dm.py +10 -0
- openbacktesting/indicators/trend/pivot_points.py +89 -0
- openbacktesting/indicators/trend/pivothigh.py +23 -0
- openbacktesting/indicators/trend/pivotlow.py +23 -0
- openbacktesting/indicators/trend/plus_dm.py +10 -0
- openbacktesting/indicators/trend/pmax.py +18 -0
- openbacktesting/indicators/trend/psar.py +19 -0
- openbacktesting/indicators/trend/qstick.py +10 -0
- openbacktesting/indicators/trend/sarext.py +35 -0
- openbacktesting/indicators/trend/short_run.py +10 -0
- openbacktesting/indicators/trend/tsignals.py +26 -0
- openbacktesting/indicators/trend/ttm_trend.py +16 -0
- openbacktesting/indicators/trend/vhf.py +10 -0
- openbacktesting/indicators/trend/volatility_stop.py +33 -0
- openbacktesting/indicators/trend/vortex.py +17 -0
- openbacktesting/indicators/trend/xsignals.py +29 -0
- openbacktesting/indicators/types.py +344 -0
- openbacktesting/indicators/utility/__init__.py +0 -0
- openbacktesting/indicators/utility/barssince.py +21 -0
- openbacktesting/indicators/utility/change.py +7 -0
- openbacktesting/indicators/utility/cross.py +12 -0
- openbacktesting/indicators/utility/crossover.py +11 -0
- openbacktesting/indicators/utility/crossunder.py +11 -0
- openbacktesting/indicators/utility/cum.py +7 -0
- openbacktesting/indicators/utility/falling.py +10 -0
- openbacktesting/indicators/utility/highest.py +7 -0
- openbacktesting/indicators/utility/highestbars.py +29 -0
- openbacktesting/indicators/utility/lowest.py +7 -0
- openbacktesting/indicators/utility/lowestbars.py +29 -0
- openbacktesting/indicators/utility/percentile_linear_interpolation.py +12 -0
- openbacktesting/indicators/utility/percentile_nearest_rank.py +16 -0
- openbacktesting/indicators/utility/percentrank.py +7 -0
- openbacktesting/indicators/utility/range.py +8 -0
- openbacktesting/indicators/utility/rising.py +10 -0
- openbacktesting/indicators/utility/rolling_count.py +12 -0
- openbacktesting/indicators/utility/rolling_sum.py +7 -0
- openbacktesting/indicators/utility/running_max.py +7 -0
- openbacktesting/indicators/utility/running_min.py +7 -0
- openbacktesting/indicators/utility/shift.py +7 -0
- openbacktesting/indicators/utility/valuewhen.py +25 -0
- openbacktesting/indicators/volatility/__init__.py +0 -0
- openbacktesting/indicators/volatility/aberration.py +21 -0
- openbacktesting/indicators/volatility/accbands.py +20 -0
- openbacktesting/indicators/volatility/adr.py +7 -0
- openbacktesting/indicators/volatility/atr.py +12 -0
- openbacktesting/indicators/volatility/avolume.py +10 -0
- openbacktesting/indicators/volatility/bbands.py +20 -0
- openbacktesting/indicators/volatility/ce.py +25 -0
- openbacktesting/indicators/volatility/cvi.py +10 -0
- openbacktesting/indicators/volatility/donchian.py +20 -0
- openbacktesting/indicators/volatility/hvol.py +10 -0
- openbacktesting/indicators/volatility/hwc.py +27 -0
- openbacktesting/indicators/volatility/kc.py +27 -0
- openbacktesting/indicators/volatility/massi.py +10 -0
- openbacktesting/indicators/volatility/natr.py +19 -0
- openbacktesting/indicators/volatility/pdist.py +10 -0
- openbacktesting/indicators/volatility/rvi.py +21 -0
- openbacktesting/indicators/volatility/starc.py +11 -0
- openbacktesting/indicators/volatility/thermo.py +28 -0
- openbacktesting/indicators/volatility/true_range.py +31 -0
- openbacktesting/indicators/volatility/ui.py +10 -0
- openbacktesting/indicators/volume/__init__.py +0 -0
- openbacktesting/indicators/volume/ad.py +10 -0
- openbacktesting/indicators/volume/adosc.py +19 -0
- openbacktesting/indicators/volume/aobv.py +31 -0
- openbacktesting/indicators/volume/cmf.py +12 -0
- openbacktesting/indicators/volume/efi.py +12 -0
- openbacktesting/indicators/volume/emv.py +10 -0
- openbacktesting/indicators/volume/eom.py +19 -0
- openbacktesting/indicators/volume/iii.py +9 -0
- openbacktesting/indicators/volume/kvo.py +28 -0
- openbacktesting/indicators/volume/marketfi.py +10 -0
- openbacktesting/indicators/volume/mfi.py +12 -0
- openbacktesting/indicators/volume/nvi.py +26 -0
- openbacktesting/indicators/volume/obv.py +10 -0
- openbacktesting/indicators/volume/pvi.py +26 -0
- openbacktesting/indicators/volume/pvol.py +10 -0
- openbacktesting/indicators/volume/pvr.py +10 -0
- openbacktesting/indicators/volume/pvt.py +10 -0
- openbacktesting/indicators/volume/rvol.py +8 -0
- openbacktesting/indicators/volume/vfi.py +18 -0
- openbacktesting/indicators/volume/vosc.py +10 -0
- openbacktesting/indicators/volume/wad.py +10 -0
- openbacktesting/indicators/volume/wvad.py +9 -0
- openbacktesting/inputs.py +51 -0
- openbacktesting/lib.py +31 -0
- openbacktesting/logs.py +129 -0
- openbacktesting/optimization.py +8 -0
- openbacktesting/optimizer/__init__.py +25 -0
- openbacktesting/optimizer/_semantics.py +155 -0
- openbacktesting/optimizer/identity.py +45 -0
- openbacktesting/optimizer/models.py +92 -0
- openbacktesting/optimizer/plan.py +129 -0
- openbacktesting/optimizer/portfolio-result.schema.json +4984 -0
- openbacktesting/optimizer/query.py +257 -0
- openbacktesting/optimizer/runner.py +98 -0
- openbacktesting/optimizer/storage.py +261 -0
- openbacktesting/optimizer/validation.py +64 -0
- openbacktesting/parallel.py +9 -0
- openbacktesting/plotting/__init__.py +23 -0
- openbacktesting/plotting/collector.py +509 -0
- openbacktesting/plotting/encoding.py +102 -0
- openbacktesting/plotting/types.py +92 -0
- openbacktesting/portfolio/__init__.py +15 -0
- openbacktesting/portfolio/analysis.py +245 -0
- openbacktesting/portfolio/builder.py +381 -0
- openbacktesting/portfolio/catalog.json +1404 -0
- openbacktesting/portfolio/helpers.py +92 -0
- openbacktesting/portfolio/metrics.py +186 -0
- openbacktesting/portfolio/models.py +63 -0
- openbacktesting/portfolio/periods.py +164 -0
- openbacktesting/portfolio/rates.py +14 -0
- openbacktesting/portfolio/snapshot.py +111 -0
- openbacktesting/portfolio/stats.py +272 -0
- openbacktesting/portfolio/trades.py +136 -0
- openbacktesting/portfolio/trading.py +215 -0
- openbacktesting/py.typed +0 -0
- openbacktesting/runtime.py +330 -0
- openbacktesting/simulation/__init__.py +17 -0
- openbacktesting/simulation/collection.py +13 -0
- openbacktesting/simulation/config.py +241 -0
- openbacktesting/simulation/core.py +528 -0
- openbacktesting/simulation/optimization.py +326 -0
- openbacktesting/simulation/parallel.py +145 -0
- openbacktesting/simulation/variants.py +107 -0
- openbacktesting/strategy/__init__.py +19 -0
- openbacktesting/strategy/bar_state.py +14 -0
- openbacktesting/strategy/base.py +810 -0
- openbacktesting/strategy/declaration.py +90 -0
- openbacktesting/strategy/helpers.py +198 -0
- openbacktesting/strategy/inputs.py +460 -0
- openbacktesting/strategy/sizing.py +80 -0
- openbacktesting/strategy/templates.py +160 -0
- openbacktesting/strategy_data.py +10 -0
- openbacktesting/test/BTCUSD.csv +157 -0
- openbacktesting/test/EURUSD.csv +5001 -0
- openbacktesting/test/GOOG.csv +2149 -0
- openbacktesting/test/__init__.py +29 -0
- openbacktesting/test/__main__.py +11 -0
- openbacktesting/test/_test.py +1312 -0
- openbacktesting/util.py +31 -0
- openbacktesting-0.2.0.dist-info/METADATA +151 -0
- openbacktesting-0.2.0.dist-info/RECORD +350 -0
- openbacktesting-0.2.0.dist-info/WHEEL +4 -0
- openbacktesting-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1312 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import multiprocessing as mp
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import unittest
|
|
8
|
+
from concurrent.futures.process import ProcessPoolExecutor
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from functools import partial
|
|
11
|
+
from glob import glob
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from tempfile import gettempdir
|
|
14
|
+
from unittest import TestCase
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from pandas.testing import assert_frame_equal
|
|
19
|
+
|
|
20
|
+
from openbacktesting import Backtest as _Backtest
|
|
21
|
+
from openbacktesting import Strategy
|
|
22
|
+
from openbacktesting._util import _Array, _as_str, _Indicator, patch, try_
|
|
23
|
+
from openbacktesting.lib import (
|
|
24
|
+
OHLCV_AGG,
|
|
25
|
+
FractionalBacktest,
|
|
26
|
+
MultiBacktest,
|
|
27
|
+
SignalStrategy,
|
|
28
|
+
TrailingStrategy,
|
|
29
|
+
barssince,
|
|
30
|
+
compute_stats,
|
|
31
|
+
quantile,
|
|
32
|
+
random_ohlc_data,
|
|
33
|
+
resample_apply,
|
|
34
|
+
)
|
|
35
|
+
from openbacktesting.portfolio.stats import compute_drawdown_duration_peaks
|
|
36
|
+
from openbacktesting.strategy.sizing import equity_fraction
|
|
37
|
+
from openbacktesting.test import BTCUSD, EURUSD, GOOG, SMA
|
|
38
|
+
|
|
39
|
+
SHORT_DATA = GOOG.iloc[:20] # Short data for fast tests with no indicator lag
|
|
40
|
+
|
|
41
|
+
# Avoid the 'Some trades remain open' warning in many tests
|
|
42
|
+
Backtest = partial(_Backtest, end_policy="close_at_last_close")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def chdir(path):
|
|
47
|
+
cwd = os.getcwd()
|
|
48
|
+
os.chdir(path)
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
os.chdir(cwd)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SmaCross(Strategy):
|
|
56
|
+
# NOTE: These values are also used on the website!
|
|
57
|
+
fast = 10
|
|
58
|
+
slow = 30
|
|
59
|
+
|
|
60
|
+
def init(self):
|
|
61
|
+
self.sma1 = self.ind(SMA, self.data.Close, self.fast)
|
|
62
|
+
self.sma2 = self.ind(SMA, self.data.Close, self.slow)
|
|
63
|
+
|
|
64
|
+
def next(self):
|
|
65
|
+
if self.ta.is_crossover(self.sma1, self.sma2):
|
|
66
|
+
self.position.close()
|
|
67
|
+
self.buy(
|
|
68
|
+
qty=equity_fraction(
|
|
69
|
+
0.95,
|
|
70
|
+
account=self.account,
|
|
71
|
+
reference_price=self.close[-1],
|
|
72
|
+
instrument=self.instrument,
|
|
73
|
+
).qty
|
|
74
|
+
)
|
|
75
|
+
elif self.ta.is_crossunder(self.sma1, self.sma2):
|
|
76
|
+
self.position.close()
|
|
77
|
+
self.sell(
|
|
78
|
+
qty=equity_fraction(
|
|
79
|
+
0.95,
|
|
80
|
+
account=self.account,
|
|
81
|
+
reference_price=self.close[-1],
|
|
82
|
+
instrument=self.instrument,
|
|
83
|
+
).qty
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _S(Strategy):
|
|
88
|
+
def init(self):
|
|
89
|
+
super().init()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestBacktest(TestCase):
|
|
93
|
+
def test_run(self):
|
|
94
|
+
bt = Backtest(EURUSD, SmaCross)
|
|
95
|
+
bt.run()
|
|
96
|
+
|
|
97
|
+
def test_run_invalid_param(self):
|
|
98
|
+
bt = Backtest(GOOG, SmaCross)
|
|
99
|
+
self.assertRaises(AttributeError, bt.run, foo=3)
|
|
100
|
+
|
|
101
|
+
def test_run_speed(self):
|
|
102
|
+
# Measure CPU performance thresholds with an uninstrumented interpreter; full regression coverage still captures functionality and branches.
|
|
103
|
+
process = subprocess.run(
|
|
104
|
+
[
|
|
105
|
+
sys.executable,
|
|
106
|
+
"-I",
|
|
107
|
+
"-c",
|
|
108
|
+
"""
|
|
109
|
+
import sys
|
|
110
|
+
import time
|
|
111
|
+
from openbacktesting.test._test import Backtest, GOOG, SmaCross
|
|
112
|
+
assert "coverage" not in sys.modules
|
|
113
|
+
bt = Backtest(GOOG, SmaCross)
|
|
114
|
+
start = time.process_time()
|
|
115
|
+
bt.run()
|
|
116
|
+
print(time.process_time() - start)
|
|
117
|
+
""",
|
|
118
|
+
],
|
|
119
|
+
env={
|
|
120
|
+
key: value for key, value in os.environ.items() if not key.startswith("COVERAGE_")
|
|
121
|
+
},
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
check=True,
|
|
125
|
+
timeout=30,
|
|
126
|
+
)
|
|
127
|
+
self.assertLess(float(process.stdout), 0.3)
|
|
128
|
+
|
|
129
|
+
def test_data_missing_columns(self):
|
|
130
|
+
df = GOOG.copy(deep=False)
|
|
131
|
+
del df["Open"]
|
|
132
|
+
with self.assertRaises(ValueError):
|
|
133
|
+
Backtest(df, SmaCross).run()
|
|
134
|
+
|
|
135
|
+
def test_data_nan_columns(self):
|
|
136
|
+
df = GOOG.copy()
|
|
137
|
+
df["Open"] = np.nan
|
|
138
|
+
with self.assertRaises(ValueError):
|
|
139
|
+
Backtest(df, SmaCross).run()
|
|
140
|
+
|
|
141
|
+
def test_data_extra_columns(self):
|
|
142
|
+
df = GOOG.copy(deep=False)
|
|
143
|
+
df["P/E"] = np.arange(len(df))
|
|
144
|
+
df["MCap"] = np.arange(len(df))
|
|
145
|
+
|
|
146
|
+
class S(Strategy):
|
|
147
|
+
def init(self):
|
|
148
|
+
assert len(self.data.MCap) == len(self.data.Close)
|
|
149
|
+
assert len(self.data["P/E"]) == len(self.data.Close)
|
|
150
|
+
|
|
151
|
+
def next(self):
|
|
152
|
+
assert len(self.data.MCap) == len(self.data.Close)
|
|
153
|
+
assert len(self.data["P/E"]) == len(self.data.Close)
|
|
154
|
+
|
|
155
|
+
Backtest(df, S).run()
|
|
156
|
+
|
|
157
|
+
def test_data_invalid(self):
|
|
158
|
+
with self.assertRaises(TypeError):
|
|
159
|
+
Backtest(GOOG.index, SmaCross).run()
|
|
160
|
+
with self.assertRaises(ValueError):
|
|
161
|
+
Backtest(GOOG.iloc[:0], SmaCross).run()
|
|
162
|
+
|
|
163
|
+
def test_assertions(self):
|
|
164
|
+
class Assertive(Strategy):
|
|
165
|
+
def init(self):
|
|
166
|
+
self.sma = self.ind(SMA, self.data.Close, 10)
|
|
167
|
+
self.remains_indicator = self.ind(
|
|
168
|
+
lambda: np.r_[2] * np.cumsum(self.sma * 5 + 1) * np.r_[2]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self.transpose_invalid = self.ind(
|
|
172
|
+
lambda: np.column_stack((self.data.Open, self.data.Close))
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
resampled = resample_apply("W", SMA, self.data.Close, 3)
|
|
176
|
+
resampled_ind = resample_apply("W", SMA, self.sma, 3)
|
|
177
|
+
assert np.unique(resampled[-5:]).size == 1
|
|
178
|
+
assert np.unique(resampled[-6:]).size == 2
|
|
179
|
+
assert any(item is resampled for item in self._indicators), (
|
|
180
|
+
"Strategy.ind not called"
|
|
181
|
+
)
|
|
182
|
+
assert any(item is resampled_ind for item in self._indicators), (
|
|
183
|
+
"Strategy.ind not called"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert 1 == try_(lambda: self.data.X, 1, AttributeError)
|
|
187
|
+
assert 1 == try_(lambda: self.data["X"], 1, KeyError)
|
|
188
|
+
|
|
189
|
+
assert self.data.pip == 0.01
|
|
190
|
+
|
|
191
|
+
assert float(self.data.Close) == self.data.Close[-1]
|
|
192
|
+
|
|
193
|
+
def next(self, _FEW_DAYS=pd.Timedelta("3 days")): # noqa: B008 — freeze defaults at initialization, preserving native behavior
|
|
194
|
+
assert self.equity >= 0
|
|
195
|
+
|
|
196
|
+
from openbacktesting.indicator_types import IndicatorHandle
|
|
197
|
+
|
|
198
|
+
assert isinstance(self.sma, IndicatorHandle)
|
|
199
|
+
assert isinstance(self.remains_indicator, IndicatorHandle)
|
|
200
|
+
assert self.remains_indicator.name
|
|
201
|
+
assert isinstance(self.remains_indicator._opts, dict)
|
|
202
|
+
|
|
203
|
+
assert not np.isnan(self.data.Open[-1])
|
|
204
|
+
assert not np.isnan(self.data.High[-1])
|
|
205
|
+
assert not np.isnan(self.data.Low[-1])
|
|
206
|
+
assert not np.isnan(self.data.Close[-1])
|
|
207
|
+
assert not np.isnan(self.data.Volume[-1])
|
|
208
|
+
assert bool(self.sma.valid[-1]) == bool(np.isfinite(self.sma[-1]))
|
|
209
|
+
assert self.data.index[-1]
|
|
210
|
+
|
|
211
|
+
_ = self.position
|
|
212
|
+
_ = self.position.size
|
|
213
|
+
_ = self.position.pl
|
|
214
|
+
_ = self.position.return_fraction
|
|
215
|
+
_ = self.position.is_long
|
|
216
|
+
|
|
217
|
+
if self.ta.is_crossover(self.sma, self.data.Close):
|
|
218
|
+
for order in self.orders:
|
|
219
|
+
if not order.is_contingent:
|
|
220
|
+
order.cancel()
|
|
221
|
+
price = self.data.Close[-1]
|
|
222
|
+
sl, tp = 1.05 * price, 0.9 * price
|
|
223
|
+
|
|
224
|
+
n_orders = len(self.orders)
|
|
225
|
+
self.sell(
|
|
226
|
+
qty=equity_fraction(
|
|
227
|
+
0.21,
|
|
228
|
+
account=self.account,
|
|
229
|
+
reference_price=self.close[-1],
|
|
230
|
+
instrument=self.instrument,
|
|
231
|
+
).qty,
|
|
232
|
+
limit=price,
|
|
233
|
+
stop=price,
|
|
234
|
+
sl=sl,
|
|
235
|
+
tp=tp,
|
|
236
|
+
)
|
|
237
|
+
assert len(self.orders) == n_orders + 1
|
|
238
|
+
|
|
239
|
+
order = self.orders[-1]
|
|
240
|
+
assert order.limit == price
|
|
241
|
+
assert order.stop == price
|
|
242
|
+
assert (
|
|
243
|
+
order.size
|
|
244
|
+
== -equity_fraction(0.21, account=self.account, reference_price=price).qty
|
|
245
|
+
)
|
|
246
|
+
assert order.sl == sl
|
|
247
|
+
assert order.tp == tp
|
|
248
|
+
assert not order.is_contingent
|
|
249
|
+
|
|
250
|
+
elif self.position:
|
|
251
|
+
assert not self.position.is_long
|
|
252
|
+
assert self.position.is_short
|
|
253
|
+
assert self.position.pl
|
|
254
|
+
assert self.position.return_fraction
|
|
255
|
+
assert self.position.size < 0
|
|
256
|
+
|
|
257
|
+
trade = self.trades[0]
|
|
258
|
+
if self.data.index[-1] - self.data.index[trade.entry_bar] > _FEW_DAYS:
|
|
259
|
+
assert not trade.is_long
|
|
260
|
+
assert trade.is_short
|
|
261
|
+
assert trade.size < 0
|
|
262
|
+
assert trade.entry_bar > 0
|
|
263
|
+
assert isinstance(trade.entry_time, pd.Timestamp)
|
|
264
|
+
assert trade.exit_bar is None
|
|
265
|
+
assert trade.exit_time is None
|
|
266
|
+
assert trade.entry_price > 0
|
|
267
|
+
assert trade.exit_price is None
|
|
268
|
+
assert trade.pl / 1
|
|
269
|
+
assert trade.return_fraction / 1
|
|
270
|
+
assert trade.value > 0
|
|
271
|
+
assert trade.sl
|
|
272
|
+
assert trade.tp
|
|
273
|
+
# Close multiple times
|
|
274
|
+
self.position.close(0.5 if abs(self.position.size) >= 2 else 1)
|
|
275
|
+
self.position.close(0.5 if abs(self.position.size) >= 2 else 1)
|
|
276
|
+
self.position.close(0.5 if abs(self.position.size) >= 2 else 1)
|
|
277
|
+
self.position.close()
|
|
278
|
+
self.position.close()
|
|
279
|
+
|
|
280
|
+
bt = Backtest(GOOG, Assertive)
|
|
281
|
+
stats = bt.run()
|
|
282
|
+
self.assertEqual(stats["# Trades"], 267) # Policy v2 baseline for explicit quantities and partial exits rounded toward zero
|
|
283
|
+
|
|
284
|
+
def test_broker_params(self):
|
|
285
|
+
bt = Backtest(
|
|
286
|
+
GOOG.iloc[:100],
|
|
287
|
+
SmaCross,
|
|
288
|
+
cash=1000,
|
|
289
|
+
spread=0.01,
|
|
290
|
+
margin=0.1,
|
|
291
|
+
execution_timing="same_close",
|
|
292
|
+
)
|
|
293
|
+
bt.run()
|
|
294
|
+
|
|
295
|
+
def test_absolute_size_order_warns_on_insufficient_margin(self):
|
|
296
|
+
class S(Strategy):
|
|
297
|
+
def init(self):
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
def next(self):
|
|
301
|
+
self.buy(qty=10_000)
|
|
302
|
+
|
|
303
|
+
bt = Backtest(GOOG.iloc[:3], S, cash=1000)
|
|
304
|
+
stats = bt.run()
|
|
305
|
+
self.assertIn("insufficient_funds", stats._orders.reason.dropna().tolist())
|
|
306
|
+
self.assertEqual(stats["# Trades"], 0)
|
|
307
|
+
|
|
308
|
+
def test_spread_commission(self):
|
|
309
|
+
class S(Strategy):
|
|
310
|
+
def init(self):
|
|
311
|
+
self.done = False
|
|
312
|
+
|
|
313
|
+
def next(self):
|
|
314
|
+
if not self.position:
|
|
315
|
+
self.buy(
|
|
316
|
+
qty=equity_fraction(
|
|
317
|
+
0.95,
|
|
318
|
+
account=self.account,
|
|
319
|
+
reference_price=self.close[-1],
|
|
320
|
+
instrument=self.instrument,
|
|
321
|
+
).qty
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
self.position.close()
|
|
325
|
+
self.next = lambda: None # Done
|
|
326
|
+
|
|
327
|
+
qty = int(10_000 * 0.95 / SHORT_DATA.Close.iloc[0])
|
|
328
|
+
for spread, commission in ((0.01, 0.01), (0, (100, 0.01)), (0, lambda q, p: q * p * 0.01)):
|
|
329
|
+
stats = Backtest(SHORT_DATA, S, spread=spread, commission=commission).run()
|
|
330
|
+
entry = min(SHORT_DATA.High.iloc[1], SHORT_DATA.Open.iloc[1] * (1 + spread))
|
|
331
|
+
exit_price = max(SHORT_DATA.Low.iloc[2], SHORT_DATA.Open.iloc[2] * (1 - spread))
|
|
332
|
+
|
|
333
|
+
def fee(q, price, commission=commission):
|
|
334
|
+
if callable(commission):
|
|
335
|
+
return commission(q, price)
|
|
336
|
+
fixed, relative = commission if isinstance(commission, tuple) else (0, commission)
|
|
337
|
+
return fixed + abs(q) * price * relative
|
|
338
|
+
|
|
339
|
+
expected = [
|
|
340
|
+
10_000 - fee(qty, entry) + qty * (SHORT_DATA.Close.iloc[1] - entry),
|
|
341
|
+
10_000 - fee(qty, entry) - fee(-qty, exit_price) + qty * (exit_price - entry),
|
|
342
|
+
]
|
|
343
|
+
np.testing.assert_allclose(stats._equity_curve.Equity.iloc[1:3], expected)
|
|
344
|
+
self.assertEqual(stats._trades.EntryPrice.iloc[0], entry)
|
|
345
|
+
self.assertEqual(stats._trades.ExitPrice.iloc[0], exit_price)
|
|
346
|
+
|
|
347
|
+
def test_commissions(self):
|
|
348
|
+
class S(_S):
|
|
349
|
+
def next(self):
|
|
350
|
+
if len(self.data) == 2:
|
|
351
|
+
self.buy(qty=SIZE, tp=3)
|
|
352
|
+
|
|
353
|
+
FIXED_COMMISSION, COMMISSION = 10, 0.01
|
|
354
|
+
CASH, SIZE, PRICE_ENTRY, PRICE_EXIT = 5000, 100, 1, 4
|
|
355
|
+
arr = np.r_[1, PRICE_ENTRY, 1, 2, PRICE_EXIT, 1, 2]
|
|
356
|
+
df = pd.DataFrame({"Open": arr, "High": arr, "Low": arr, "Close": arr})
|
|
357
|
+
with self.assertWarnsRegex(UserWarning, "index is not datetime"):
|
|
358
|
+
stats = Backtest(df, S, cash=CASH, commission=(FIXED_COMMISSION, COMMISSION)).run()
|
|
359
|
+
EXPECTED_PAID_COMMISSION = (
|
|
360
|
+
FIXED_COMMISSION
|
|
361
|
+
+ COMMISSION * SIZE * PRICE_ENTRY
|
|
362
|
+
+ FIXED_COMMISSION
|
|
363
|
+
+ COMMISSION * SIZE * PRICE_EXIT
|
|
364
|
+
)
|
|
365
|
+
self.assertEqual(stats["Commissions [$]"], EXPECTED_PAID_COMMISSION)
|
|
366
|
+
self.assertEqual(stats._trades["Commission"][0], EXPECTED_PAID_COMMISSION)
|
|
367
|
+
self.assertEqual(
|
|
368
|
+
stats["Equity Final [$]"],
|
|
369
|
+
CASH + (PRICE_EXIT - PRICE_ENTRY) * SIZE - EXPECTED_PAID_COMMISSION,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def test_dont_overwrite_data(self):
|
|
373
|
+
df = EURUSD.copy()
|
|
374
|
+
bt = Backtest(df, SmaCross)
|
|
375
|
+
bt.run()
|
|
376
|
+
bt.optimize(fast=4, slow=[6, 8])
|
|
377
|
+
self.assertTrue(df.equals(EURUSD))
|
|
378
|
+
|
|
379
|
+
def test_strategy_abstract(self):
|
|
380
|
+
class MyStrategy(Strategy):
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
self.assertRaises(TypeError, MyStrategy, None, None)
|
|
384
|
+
|
|
385
|
+
def test_strategy_str(self):
|
|
386
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross)
|
|
387
|
+
self.assertEqual(str(bt.run()._strategy), SmaCross.__name__)
|
|
388
|
+
self.assertEqual(str(bt.run(fast=11)._strategy), SmaCross.__name__ + "(fast=11)")
|
|
389
|
+
|
|
390
|
+
def test_compute_drawdown(self):
|
|
391
|
+
dd = pd.Series([0, 1, 7, 0, 4, 0, 0])
|
|
392
|
+
durations, peaks = compute_drawdown_duration_peaks(dd)
|
|
393
|
+
np.testing.assert_array_equal(durations, pd.Series([3, 2], index=[3, 5]).reindex(dd.index))
|
|
394
|
+
np.testing.assert_array_equal(peaks, pd.Series([7, 4], index=[3, 5]).reindex(dd.index))
|
|
395
|
+
|
|
396
|
+
def test_compute_stats(self):
|
|
397
|
+
stats = Backtest(GOOG, SmaCross, end_policy="close_at_last_close").run()
|
|
398
|
+
# Policy v2: Explicit 95% quantity budget and liquidation at the final Close; original historical baseline files are retained.
|
|
399
|
+
# Per-bar execution: Buy-and-hold starts at the first input bar; annualized risk includes the first zero-return daily bucket.
|
|
400
|
+
# Trades, equity, Beta, and drawdowns are unchanged; Alpha changes with the market benchmark.
|
|
401
|
+
expected = pd.Series(
|
|
402
|
+
{
|
|
403
|
+
"Start": pd.Timestamp("2004-08-19 00:00:00"),
|
|
404
|
+
"End": pd.Timestamp("2013-03-01 00:00:00"),
|
|
405
|
+
"Duration": pd.Timedelta("3116 days 00:00:00"),
|
|
406
|
+
"Exposure Time [%]": 96.74115456238361,
|
|
407
|
+
"Equity Final [$]": 49228.00999999996,
|
|
408
|
+
"Equity Peak [$]": 70110.02999999998,
|
|
409
|
+
"Return [%]": 392.28009999999955,
|
|
410
|
+
"Buy & Hold Return [%]": 703.4582419772772,
|
|
411
|
+
"Return (Ann.) [%]": 20.561671886086728,
|
|
412
|
+
"Volatility (Ann.) [%]": 34.57340358414538,
|
|
413
|
+
"CAGR [%]": 20.542294223341152,
|
|
414
|
+
"Sharpe Ratio": 0.5947251284081232,
|
|
415
|
+
"Sortino Ratio": 1.104726320298045,
|
|
416
|
+
"Calmar Ratio": 0.4462810975723509,
|
|
417
|
+
"Alpha [%]": 365.639613282582,
|
|
418
|
+
"Beta": 0.03787074360311223,
|
|
419
|
+
"Max. Drawdown [%]": -46.07336496646776,
|
|
420
|
+
"Avg. Drawdown [%]": -5.747302647122755,
|
|
421
|
+
"Max. Drawdown Duration": pd.Timedelta("584 days 00:00:00"),
|
|
422
|
+
"Avg. Drawdown Duration": pd.Timedelta("40 days 00:00:00"),
|
|
423
|
+
"# Trades": 66,
|
|
424
|
+
"Win Rate [%]": 46.96969696969697,
|
|
425
|
+
"Best Trade [%]": 53.59595229490424,
|
|
426
|
+
"Worst Trade [%]": -18.398873538354813,
|
|
427
|
+
"Avg. Trade [%]": 2.5479693282886906,
|
|
428
|
+
"Max. Trade Duration": pd.Timedelta("183 days 00:00:00"),
|
|
429
|
+
"Avg. Trade Duration": pd.Timedelta("46 days 00:00:00"),
|
|
430
|
+
"Profit Factor": 2.174469316409448,
|
|
431
|
+
"Expectancy [%]": 3.293098628562828,
|
|
432
|
+
"SQN": 1.1335447628114286,
|
|
433
|
+
"Kelly Criterion": 0.158222363299374,
|
|
434
|
+
"Commissions [$]": 0.0,
|
|
435
|
+
"# Closed Lots": 66,
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def almost_equal(a, b):
|
|
440
|
+
try:
|
|
441
|
+
return np.isclose(a, b, rtol=1.0e-8)
|
|
442
|
+
except TypeError:
|
|
443
|
+
return a == b
|
|
444
|
+
|
|
445
|
+
diff = {
|
|
446
|
+
key: print(key) or value
|
|
447
|
+
for key, value in stats.filter(regex="^[^_]").items()
|
|
448
|
+
if not almost_equal(value, expected[key])
|
|
449
|
+
}
|
|
450
|
+
self.assertDictEqual(diff, {})
|
|
451
|
+
|
|
452
|
+
self.assertSequenceEqual(
|
|
453
|
+
sorted(stats["_equity_curve"].columns),
|
|
454
|
+
sorted(["Equity", "DrawdownPct", "DrawdownDuration"]),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
self.assertEqual(len(stats["_trades"]), 66)
|
|
458
|
+
|
|
459
|
+
indicator_columns = [
|
|
460
|
+
f"{entry}_SMA(C,{n})"
|
|
461
|
+
for entry in ("Entry", "Exit")
|
|
462
|
+
for n in (SmaCross.fast, SmaCross.slow)
|
|
463
|
+
]
|
|
464
|
+
self.assertSequenceEqual(
|
|
465
|
+
sorted(stats["_trades"].columns),
|
|
466
|
+
sorted(
|
|
467
|
+
[
|
|
468
|
+
"Size",
|
|
469
|
+
"EntryBar",
|
|
470
|
+
"ExitBar",
|
|
471
|
+
"EntryPrice",
|
|
472
|
+
"ExitPrice",
|
|
473
|
+
"SL",
|
|
474
|
+
"TP",
|
|
475
|
+
"PnL",
|
|
476
|
+
"ReturnPct",
|
|
477
|
+
"EntryTime",
|
|
478
|
+
"ExitTime",
|
|
479
|
+
"Duration",
|
|
480
|
+
"Tag",
|
|
481
|
+
"Commission",
|
|
482
|
+
"trade_id",
|
|
483
|
+
"entry_fill_id",
|
|
484
|
+
"allocation_id",
|
|
485
|
+
"exit_fill_id",
|
|
486
|
+
"exit_sequence",
|
|
487
|
+
*indicator_columns,
|
|
488
|
+
]
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def test_compute_stats_bordercase(self):
|
|
493
|
+
|
|
494
|
+
class SingleTrade(Strategy):
|
|
495
|
+
def init(self):
|
|
496
|
+
self._done = False
|
|
497
|
+
|
|
498
|
+
def next(self):
|
|
499
|
+
if not self._done:
|
|
500
|
+
self.buy(
|
|
501
|
+
qty=equity_fraction(
|
|
502
|
+
0.95,
|
|
503
|
+
account=self.account,
|
|
504
|
+
reference_price=self.close[-1],
|
|
505
|
+
instrument=self.instrument,
|
|
506
|
+
).qty
|
|
507
|
+
)
|
|
508
|
+
self._done = True
|
|
509
|
+
if self.position:
|
|
510
|
+
self.position.close()
|
|
511
|
+
|
|
512
|
+
class SinglePosition(_S):
|
|
513
|
+
def next(self):
|
|
514
|
+
if not self.position:
|
|
515
|
+
self.buy(
|
|
516
|
+
qty=equity_fraction(
|
|
517
|
+
0.95,
|
|
518
|
+
account=self.account,
|
|
519
|
+
reference_price=self.close[-1],
|
|
520
|
+
instrument=self.instrument,
|
|
521
|
+
).qty
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
class NoTrade(_S):
|
|
525
|
+
def next(self):
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
for strategy in (SmaCross, SingleTrade, SinglePosition, NoTrade):
|
|
529
|
+
with self.subTest(strategy=strategy.__name__):
|
|
530
|
+
stats = Backtest(GOOG.iloc[:100], strategy).run()
|
|
531
|
+
|
|
532
|
+
self.assertFalse(np.isnan(stats["Equity Final [$]"]))
|
|
533
|
+
self.assertFalse(stats["_equity_curve"]["Equity"].isnull().any())
|
|
534
|
+
self.assertEqual(stats["_strategy"].__class__, strategy)
|
|
535
|
+
|
|
536
|
+
def test_trade_enter_hit_sl_on_same_day(self):
|
|
537
|
+
the_day = pd.Timestamp("2012-10-17 00:00:00")
|
|
538
|
+
|
|
539
|
+
class S(_S):
|
|
540
|
+
def next(self):
|
|
541
|
+
if self.data.index[-1] == the_day:
|
|
542
|
+
self.buy(sl=720, qty=1)
|
|
543
|
+
|
|
544
|
+
self.assertEqual(Backtest(GOOG, S).run()._trades.iloc[0].ExitPrice, 720)
|
|
545
|
+
|
|
546
|
+
class S(_S):
|
|
547
|
+
def next(self):
|
|
548
|
+
if self.data.index[-1] == the_day:
|
|
549
|
+
self.buy(stop=758, sl=720, qty=1)
|
|
550
|
+
|
|
551
|
+
result = Backtest(GOOG, S).run()
|
|
552
|
+
self.assertEqual(result._trades.iloc[0].ExitPrice, 705.58)
|
|
553
|
+
self.assertEqual(result.attrs["diagnostics"][0]["code"], "protection_next_bar")
|
|
554
|
+
|
|
555
|
+
def test_stop_price_between_sl_tp(self):
|
|
556
|
+
class S(_S):
|
|
557
|
+
def next(self):
|
|
558
|
+
if self.data.index[-1] == pd.Timestamp("2004-09-09 00:00:00"):
|
|
559
|
+
self.buy(stop=104, sl=103, tp=110, qty=1)
|
|
560
|
+
|
|
561
|
+
self.assertEqual(Backtest(GOOG, S).run()._trades.iloc[0].EntryPrice, 104)
|
|
562
|
+
|
|
563
|
+
def test_position_close_portion(self):
|
|
564
|
+
class SmaCross(Strategy):
|
|
565
|
+
def init(self):
|
|
566
|
+
self.sma1 = self.ind(SMA, self.data.Close, 10)
|
|
567
|
+
self.sma2 = self.ind(SMA, self.data.Close, 20)
|
|
568
|
+
|
|
569
|
+
def next(self):
|
|
570
|
+
if not self.position and self.ta.is_crossover(self.sma1, self.sma2):
|
|
571
|
+
self.buy(qty=10)
|
|
572
|
+
if self.position and self.ta.is_crossunder(self.sma1, self.sma2):
|
|
573
|
+
self.position.close(portion=0.5 if abs(self.position.size) >= 2 else 1)
|
|
574
|
+
|
|
575
|
+
bt = Backtest(GOOG, SmaCross, spread=0.002)
|
|
576
|
+
bt.run()
|
|
577
|
+
|
|
578
|
+
def test_close_orders_from_last_strategy_iteration(self):
|
|
579
|
+
class S(_S):
|
|
580
|
+
def next(self):
|
|
581
|
+
if not self.position:
|
|
582
|
+
self.buy(
|
|
583
|
+
qty=equity_fraction(
|
|
584
|
+
0.95,
|
|
585
|
+
account=self.account,
|
|
586
|
+
reference_price=self.close[-1],
|
|
587
|
+
instrument=self.instrument,
|
|
588
|
+
).qty
|
|
589
|
+
)
|
|
590
|
+
elif len(self.data) == len(SHORT_DATA):
|
|
591
|
+
self.position.close()
|
|
592
|
+
|
|
593
|
+
result = Backtest(SHORT_DATA, S, end_policy="mark_to_market").run()
|
|
594
|
+
self.assertTrue(result._trades.empty)
|
|
595
|
+
self.assertEqual(result._orders.iloc[-1].reason, "end_of_data")
|
|
596
|
+
self.assertFalse(
|
|
597
|
+
Backtest(SHORT_DATA, S, end_policy="close_at_last_close").run()._trades.empty
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def test_check_adjusted_price_when_placing_order(self):
|
|
601
|
+
class S(_S):
|
|
602
|
+
def next(self):
|
|
603
|
+
self.buy(tp=self.data.Close * 1.01, qty=1)
|
|
604
|
+
|
|
605
|
+
self.assertRaises(TypeError, Backtest(SHORT_DATA, S, spread=0.02).run)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class TestStrategy(TestCase):
|
|
609
|
+
@staticmethod
|
|
610
|
+
def _Backtest(strategy_coroutine, data=SHORT_DATA, **kwargs):
|
|
611
|
+
class S(Strategy):
|
|
612
|
+
def init(self):
|
|
613
|
+
self.step = strategy_coroutine(self)
|
|
614
|
+
|
|
615
|
+
def next(self):
|
|
616
|
+
try_(self.step.__next__, None, StopIteration)
|
|
617
|
+
|
|
618
|
+
return Backtest(data, S, **kwargs)
|
|
619
|
+
|
|
620
|
+
def test_position(self):
|
|
621
|
+
def coroutine(self):
|
|
622
|
+
yield self.buy(
|
|
623
|
+
qty=equity_fraction(
|
|
624
|
+
0.95,
|
|
625
|
+
account=self.account,
|
|
626
|
+
reference_price=self.close[-1],
|
|
627
|
+
instrument=self.instrument,
|
|
628
|
+
).qty
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
assert self.position
|
|
632
|
+
assert self.position.is_long
|
|
633
|
+
assert not self.position.is_short
|
|
634
|
+
assert self.position.size > 0
|
|
635
|
+
assert self.position.pl
|
|
636
|
+
assert self.position.return_fraction
|
|
637
|
+
|
|
638
|
+
yield self.position.close()
|
|
639
|
+
|
|
640
|
+
assert not self.position
|
|
641
|
+
assert not self.position.is_long
|
|
642
|
+
assert not self.position.is_short
|
|
643
|
+
assert not self.position.size
|
|
644
|
+
assert not self.position.pl
|
|
645
|
+
assert not self.position.return_fraction
|
|
646
|
+
|
|
647
|
+
self._Backtest(coroutine).run()
|
|
648
|
+
|
|
649
|
+
def test_broker_hedging(self):
|
|
650
|
+
def coroutine(self):
|
|
651
|
+
yield self.buy(qty=2)
|
|
652
|
+
|
|
653
|
+
assert len(self.trades) == 1
|
|
654
|
+
yield self.sell(qty=1)
|
|
655
|
+
|
|
656
|
+
assert len(self.trades) == 2
|
|
657
|
+
|
|
658
|
+
self._Backtest(coroutine, hedging=True).run()
|
|
659
|
+
|
|
660
|
+
def test_broker_exclusive_orders(self):
|
|
661
|
+
def coroutine(self):
|
|
662
|
+
yield self.buy(qty=2)
|
|
663
|
+
|
|
664
|
+
assert len(self.trades) == 1
|
|
665
|
+
yield self.sell(qty=3)
|
|
666
|
+
|
|
667
|
+
assert len(self.trades) == 1
|
|
668
|
+
assert self.trades[0].size == -3
|
|
669
|
+
|
|
670
|
+
self._Backtest(coroutine, exclusive_orders=True).run()
|
|
671
|
+
|
|
672
|
+
def test_trade_multiple_close(self):
|
|
673
|
+
def coroutine(self):
|
|
674
|
+
yield self.buy(
|
|
675
|
+
qty=equity_fraction(
|
|
676
|
+
0.95,
|
|
677
|
+
account=self.account,
|
|
678
|
+
reference_price=self.close[-1],
|
|
679
|
+
instrument=self.instrument,
|
|
680
|
+
).qty
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
assert self.trades
|
|
684
|
+
self.trades[-1].close(1)
|
|
685
|
+
self.trades[-1].close(0.1)
|
|
686
|
+
yield
|
|
687
|
+
|
|
688
|
+
self._Backtest(coroutine).run()
|
|
689
|
+
|
|
690
|
+
def test_close_trade_leaves_needsize_0(self):
|
|
691
|
+
def coroutine(self):
|
|
692
|
+
self.buy(qty=1)
|
|
693
|
+
self.buy(qty=1)
|
|
694
|
+
yield
|
|
695
|
+
if self.position:
|
|
696
|
+
self.sell(qty=1)
|
|
697
|
+
|
|
698
|
+
self._Backtest(coroutine).run()
|
|
699
|
+
|
|
700
|
+
def test_stop_limit_order_price_is_stop_price(self):
|
|
701
|
+
def coroutine(self):
|
|
702
|
+
yield # This price regression submits on the second bar, independently of the new first-bar callback.
|
|
703
|
+
self.buy(stop=112, limit=113, qty=1)
|
|
704
|
+
self.sell(stop=107, limit=105, qty=1)
|
|
705
|
+
yield
|
|
706
|
+
|
|
707
|
+
stats = self._Backtest(coroutine).run()
|
|
708
|
+
self.assertListEqual(stats._trades.filter(like="Price").stack().tolist(), [112, 107])
|
|
709
|
+
|
|
710
|
+
def test_autoclose_trades_on_finish(self):
|
|
711
|
+
def coroutine(self):
|
|
712
|
+
yield self.buy(
|
|
713
|
+
qty=equity_fraction(
|
|
714
|
+
0.95,
|
|
715
|
+
account=self.account,
|
|
716
|
+
reference_price=self.close[-1],
|
|
717
|
+
instrument=self.instrument,
|
|
718
|
+
).qty
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
stats = self._Backtest(coroutine, end_policy="close_at_last_close").run()
|
|
722
|
+
self.assertEqual(len(stats._trades), 1)
|
|
723
|
+
|
|
724
|
+
def test_order_tag(self):
|
|
725
|
+
def coroutine(self):
|
|
726
|
+
yield self.buy(qty=2, tag=1)
|
|
727
|
+
yield self.sell(qty=1, tag="s")
|
|
728
|
+
yield self.sell(qty=1)
|
|
729
|
+
|
|
730
|
+
yield self.buy(tag=2, qty=1)
|
|
731
|
+
yield self.position.close()
|
|
732
|
+
|
|
733
|
+
stats = self._Backtest(coroutine).run()
|
|
734
|
+
self.assertEqual(list(stats._trades.Tag), [1, 1, 2])
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class TestOptimize(TestCase):
|
|
738
|
+
def test_optimize(self):
|
|
739
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross)
|
|
740
|
+
OPT_PARAMS = {"fast": range(2, 5, 2), "slow": [2, 5, 7, 9]}
|
|
741
|
+
|
|
742
|
+
self.assertRaises(ValueError, bt.optimize)
|
|
743
|
+
self.assertRaises(ValueError, bt.optimize, maximize="missing key", **OPT_PARAMS)
|
|
744
|
+
self.assertRaises(ValueError, bt.optimize, maximize="missing key", **OPT_PARAMS)
|
|
745
|
+
self.assertRaises(TypeError, bt.optimize, maximize=15, **OPT_PARAMS)
|
|
746
|
+
self.assertRaises(TypeError, bt.optimize, constraint=15, **OPT_PARAMS)
|
|
747
|
+
self.assertRaises(ValueError, bt.optimize, constraint=lambda d: False, **OPT_PARAMS)
|
|
748
|
+
self.assertRaises(ValueError, bt.optimize, return_optimization=True, **OPT_PARAMS)
|
|
749
|
+
|
|
750
|
+
res = bt.optimize(**OPT_PARAMS)
|
|
751
|
+
self.assertIsInstance(res, pd.Series)
|
|
752
|
+
|
|
753
|
+
default_maximize = inspect.signature(bt.optimize).parameters["maximize"].default
|
|
754
|
+
res2 = bt.optimize(**OPT_PARAMS, maximize=lambda s: s[default_maximize])
|
|
755
|
+
self.assertDictEqual(
|
|
756
|
+
res.filter(regex="^[^_]").fillna(-1).to_dict(),
|
|
757
|
+
res2.filter(regex="^[^_]").fillna(-1).to_dict(),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
_res3, heatmap = bt.optimize(
|
|
761
|
+
**OPT_PARAMS, return_heatmap=True, constraint=lambda d: d.slow > 2 * d.fast
|
|
762
|
+
)
|
|
763
|
+
self.assertIsInstance(heatmap, pd.Series)
|
|
764
|
+
self.assertEqual(len(heatmap), 4)
|
|
765
|
+
self.assertEqual(heatmap.name, default_maximize)
|
|
766
|
+
|
|
767
|
+
def test_method_sambo(self):
|
|
768
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross, end_policy="close_at_last_close")
|
|
769
|
+
res, heatmap, sambo_results = bt.optimize(
|
|
770
|
+
fast=range(2, 20),
|
|
771
|
+
slow=np.arange(2, 20, dtype=object),
|
|
772
|
+
constraint=lambda p: p.fast < p.slow,
|
|
773
|
+
max_tries=30,
|
|
774
|
+
method="sambo",
|
|
775
|
+
return_optimization=True,
|
|
776
|
+
return_heatmap=True,
|
|
777
|
+
random_state=2,
|
|
778
|
+
)
|
|
779
|
+
self.assertIsInstance(res, pd.Series)
|
|
780
|
+
self.assertIsInstance(heatmap, pd.Series)
|
|
781
|
+
self.assertGreater(heatmap.max(), 1.1)
|
|
782
|
+
self.assertGreater(heatmap.min(), -2)
|
|
783
|
+
self.assertEqual(-sambo_results.fun, heatmap.max())
|
|
784
|
+
self.assertEqual(heatmap.index.tolist(), heatmap.dropna().index.unique().tolist())
|
|
785
|
+
|
|
786
|
+
def test_max_tries(self):
|
|
787
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross)
|
|
788
|
+
OPT_PARAMS = {"fast": range(2, 10, 2), "slow": [2, 5, 7, 9]}
|
|
789
|
+
for method, max_tries, random_state in (
|
|
790
|
+
("grid", 5, 0),
|
|
791
|
+
("grid", 0.3, 0),
|
|
792
|
+
("sambo", 6, 0),
|
|
793
|
+
("sambo", 0.42, 0),
|
|
794
|
+
):
|
|
795
|
+
with self.subTest(method=method, max_tries=max_tries, random_state=random_state):
|
|
796
|
+
_, heatmap = bt.optimize(
|
|
797
|
+
max_tries=max_tries,
|
|
798
|
+
method=method,
|
|
799
|
+
random_state=random_state,
|
|
800
|
+
return_heatmap=True,
|
|
801
|
+
**OPT_PARAMS,
|
|
802
|
+
)
|
|
803
|
+
if method == "grid":
|
|
804
|
+
self.assertEqual(len(heatmap), 6)
|
|
805
|
+
else:
|
|
806
|
+
# SAMBO may stop early when the objective converges; the budget is an upper bound.
|
|
807
|
+
self.assertTrue(0 < len(heatmap) <= 6)
|
|
808
|
+
self.assertTrue(heatmap.index.is_unique)
|
|
809
|
+
|
|
810
|
+
def test_optimize_invalid_param(self):
|
|
811
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross)
|
|
812
|
+
self.assertRaises(AttributeError, bt.optimize, foo=range(3))
|
|
813
|
+
self.assertRaises(ValueError, bt.optimize, fast=[])
|
|
814
|
+
|
|
815
|
+
def test_optimize_no_trades(self):
|
|
816
|
+
bt = Backtest(GOOG, SmaCross)
|
|
817
|
+
stats = bt.optimize(fast=[3], slow=[3])
|
|
818
|
+
self.assertTrue(stats.isnull().any())
|
|
819
|
+
|
|
820
|
+
def test_optimize_speed(self):
|
|
821
|
+
bt = Backtest(GOOG.iloc[:100], SmaCross)
|
|
822
|
+
start = time.process_time()
|
|
823
|
+
bt.optimize(fast=range(2, 20, 2), slow=range(10, 40, 2))
|
|
824
|
+
end = time.process_time()
|
|
825
|
+
print(end - start)
|
|
826
|
+
self.assertLess(end - start, 15.0)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
class TestCalculationRegressions(TestCase):
|
|
830
|
+
def test_wellknown(self):
|
|
831
|
+
class S(_S):
|
|
832
|
+
def next(self):
|
|
833
|
+
date = self.data.index[-1]
|
|
834
|
+
if date == pd.Timestamp("Thu 19 Oct 2006"):
|
|
835
|
+
self.buy(stop=484, limit=466, qty=100)
|
|
836
|
+
elif date == pd.Timestamp("Thu 30 Oct 2007"):
|
|
837
|
+
self.position.close()
|
|
838
|
+
elif date == pd.Timestamp("Tue 11 Nov 2008"):
|
|
839
|
+
self.sell(
|
|
840
|
+
stop=self.data.Low[-1],
|
|
841
|
+
limit=324.90, # High from 14 Nov
|
|
842
|
+
qty=200,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
bt = Backtest(GOOG, S, margin=0.1)
|
|
846
|
+
stats = bt.run()
|
|
847
|
+
trades = stats["_trades"]
|
|
848
|
+
|
|
849
|
+
self.assertAlmostEqual(stats["Equity Peak [$]"], 46961)
|
|
850
|
+
self.assertEqual(stats["Equity Final [$]"], -951)
|
|
851
|
+
self.assertEqual(len(trades), 2)
|
|
852
|
+
assert trades[["EntryTime", "ExitTime"]].equals(
|
|
853
|
+
pd.DataFrame(
|
|
854
|
+
{
|
|
855
|
+
"EntryTime": pd.to_datetime(["2006-11-01", "2008-11-14"]),
|
|
856
|
+
"ExitTime": pd.to_datetime(["2007-10-31", "2009-09-21"]),
|
|
857
|
+
}
|
|
858
|
+
)
|
|
859
|
+
)
|
|
860
|
+
assert trades["PnL"].round().equals(pd.Series([23469.0, -34420.0]))
|
|
861
|
+
|
|
862
|
+
def test_indicator_name(self):
|
|
863
|
+
test_self = self
|
|
864
|
+
|
|
865
|
+
class S(Strategy):
|
|
866
|
+
def init(self):
|
|
867
|
+
def _SMA():
|
|
868
|
+
return SMA(self.data.Close, 5), SMA(self.data.Close, 10)
|
|
869
|
+
|
|
870
|
+
test_self.assertRaises(TypeError, self.ind, _SMA, name=42)
|
|
871
|
+
test_self.assertRaises(ValueError, self.ind, _SMA, name=("SMA One",))
|
|
872
|
+
test_self.assertRaises(
|
|
873
|
+
ValueError, self.ind, _SMA, name=("SMA One", "SMA Two", "SMA Three")
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
self.ind(SMA, self.data.Close, 5)
|
|
877
|
+
self.ind(SMA, self.data.Close, 5, name="My SMA")
|
|
878
|
+
self.ind(SMA, self.data.Close, 5, name=("My SMA",))
|
|
879
|
+
self.ind(_SMA)
|
|
880
|
+
self.ind(_SMA, name="My SMA")
|
|
881
|
+
self.ind(_SMA, name=("SMA One", "SMA Two"))
|
|
882
|
+
|
|
883
|
+
def next(self):
|
|
884
|
+
pass
|
|
885
|
+
|
|
886
|
+
bt = Backtest(GOOG, S)
|
|
887
|
+
bt.run()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
class TestLib(TestCase):
|
|
891
|
+
def test_barssince(self):
|
|
892
|
+
self.assertEqual(barssince(np.r_[1, 0, 0]), 2)
|
|
893
|
+
self.assertEqual(barssince(np.r_[0, 0, 0]), np.inf)
|
|
894
|
+
self.assertEqual(barssince(np.r_[0, 0, 0], 0), 0)
|
|
895
|
+
|
|
896
|
+
def test_quantile(self):
|
|
897
|
+
self.assertEqual(quantile(np.r_[1, 3, 2], 0.5), 2)
|
|
898
|
+
self.assertEqual(quantile(np.r_[1, 3, 2]), 0.5)
|
|
899
|
+
|
|
900
|
+
def test_resample_apply(self):
|
|
901
|
+
res = resample_apply("D", SMA, EURUSD.Close, 10)
|
|
902
|
+
self.assertEqual(res.name, "C[D]")
|
|
903
|
+
self.assertEqual(res.count() / res.size, 0.9634)
|
|
904
|
+
np.testing.assert_almost_equal(
|
|
905
|
+
res.iloc[-48:].unique().tolist(), [1.242643, 1.242381, 1.242275], decimal=6
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
def resets_index(*args):
|
|
909
|
+
return pd.Series(SMA(*args).values)
|
|
910
|
+
|
|
911
|
+
res2 = resample_apply("D", resets_index, EURUSD.Close, 10)
|
|
912
|
+
self.assertTrue((res.dropna() == res2.dropna()).all())
|
|
913
|
+
self.assertTrue((res.index == res2.index).all())
|
|
914
|
+
|
|
915
|
+
res3 = resample_apply("D", None, EURUSD)
|
|
916
|
+
self.assertIn("Volume", res3)
|
|
917
|
+
|
|
918
|
+
res3 = resample_apply("D", lambda df: (df.Close, df.Close), EURUSD)
|
|
919
|
+
self.assertIsInstance(res3, pd.DataFrame)
|
|
920
|
+
|
|
921
|
+
def test_random_ohlc_data(self):
|
|
922
|
+
generator = random_ohlc_data(GOOG, frac=1)
|
|
923
|
+
new_data = next(generator)
|
|
924
|
+
self.assertEqual(list(new_data.index), list(GOOG.index))
|
|
925
|
+
self.assertEqual(new_data.shape, GOOG.shape)
|
|
926
|
+
self.assertEqual(list(new_data.columns), list(GOOG.columns))
|
|
927
|
+
|
|
928
|
+
def test_compute_stats(self):
|
|
929
|
+
stats = Backtest(GOOG, SmaCross).run()
|
|
930
|
+
only_long_trades = stats._trades[stats._trades.Size > 0]
|
|
931
|
+
long_stats = compute_stats(
|
|
932
|
+
stats=stats, trades=only_long_trades, data=GOOG, risk_free_rate=0.02
|
|
933
|
+
)
|
|
934
|
+
self.assertNotEqual(list(stats._equity_curve.Equity), list(long_stats._equity_curve.Equity))
|
|
935
|
+
self.assertNotEqual(stats["Sharpe Ratio"], long_stats["Sharpe Ratio"])
|
|
936
|
+
self.assertEqual(long_stats["# Trades"], len(only_long_trades))
|
|
937
|
+
self.assertEqual(stats._strategy, long_stats._strategy)
|
|
938
|
+
assert_frame_equal(long_stats._trades, only_long_trades)
|
|
939
|
+
|
|
940
|
+
def test_SignalStrategy(self):
|
|
941
|
+
class S(SignalStrategy):
|
|
942
|
+
def init(self):
|
|
943
|
+
sma = self.data.Close.s.rolling(10).mean()
|
|
944
|
+
self.set_signal(self.data.Close > sma, self.data.Close < sma)
|
|
945
|
+
|
|
946
|
+
stats = Backtest(GOOG, S).run()
|
|
947
|
+
self.assertIn("Rejected", stats._orders.status.tolist())
|
|
948
|
+
self.assertIn(stats["# Trades"], (1179, 1182)) # varies on different archs?
|
|
949
|
+
|
|
950
|
+
def test_TrailingStrategy(self):
|
|
951
|
+
class S(TrailingStrategy):
|
|
952
|
+
def init(self):
|
|
953
|
+
super().init()
|
|
954
|
+
self.set_atr_periods(40)
|
|
955
|
+
self.set_trailing_pct(0.1)
|
|
956
|
+
self.set_trailing_sl(3)
|
|
957
|
+
self.sma = self.ind(lambda: self.data.Close.s.rolling(10).mean())
|
|
958
|
+
|
|
959
|
+
def next(self):
|
|
960
|
+
super().next()
|
|
961
|
+
if not self.position and self.data.Close > self.sma:
|
|
962
|
+
self.buy(
|
|
963
|
+
qty=equity_fraction(
|
|
964
|
+
0.95,
|
|
965
|
+
account=self.account,
|
|
966
|
+
reference_price=self.close[-1],
|
|
967
|
+
instrument=self.instrument,
|
|
968
|
+
).qty
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
stats = Backtest(GOOG, S).run()
|
|
972
|
+
self.assertEqual(stats["# Trades"], 57)
|
|
973
|
+
|
|
974
|
+
def test_FractionalBacktest(self):
|
|
975
|
+
from openbacktesting.broker import InstrumentSpec
|
|
976
|
+
|
|
977
|
+
with self.assertRaisesRegex(TypeError, "InstrumentSpec"):
|
|
978
|
+
FractionalBacktest(BTCUSD, SmaCross)
|
|
979
|
+
ubtc_bt = Backtest(
|
|
980
|
+
BTCUSD["2015":],
|
|
981
|
+
SmaCross,
|
|
982
|
+
instrument=InstrumentSpec(quantity_step=1 / 1e6),
|
|
983
|
+
cash=100,
|
|
984
|
+
end_policy="close_at_last_close",
|
|
985
|
+
)
|
|
986
|
+
stats = ubtc_bt.run(fast=2, slow=3)
|
|
987
|
+
self.assertEqual(stats["# Trades"], 42)
|
|
988
|
+
trades = stats["_trades"]
|
|
989
|
+
self.assertEqual(len(trades), 42)
|
|
990
|
+
trade = trades.iloc[0]
|
|
991
|
+
self.assertAlmostEqual(trade["EntryPrice"], 236.69)
|
|
992
|
+
# Indicators retain internal computation units; explicit conversion still matches the original price baseline.
|
|
993
|
+
self.assertAlmostEqual(stats["_strategy"]._indicators[0][trade["EntryBar"]], 234.14)
|
|
994
|
+
|
|
995
|
+
def test_MultiBacktest(self):
|
|
996
|
+
import openbacktesting
|
|
997
|
+
|
|
998
|
+
assert callable(getattr(openbacktesting, "Pool", None)), openbacktesting.__dict__
|
|
999
|
+
for start_method in mp.get_all_start_methods():
|
|
1000
|
+
with (
|
|
1001
|
+
self.subTest(start_method=start_method),
|
|
1002
|
+
patch(openbacktesting, "Pool", mp.get_context(start_method).Pool),
|
|
1003
|
+
):
|
|
1004
|
+
start_time = time.monotonic()
|
|
1005
|
+
btm = MultiBacktest(
|
|
1006
|
+
[GOOG, EURUSD, BTCUSD], SmaCross, cash=100_000, end_policy="close_at_last_close"
|
|
1007
|
+
)
|
|
1008
|
+
res = btm.run(fast=2)
|
|
1009
|
+
self.assertIsInstance(res, pd.DataFrame)
|
|
1010
|
+
self.assertEqual(res.columns.tolist(), [0, 1, 2])
|
|
1011
|
+
heatmap = btm.optimize(fast=[2, 4], slow=[10, 20])
|
|
1012
|
+
self.assertIsInstance(heatmap, pd.DataFrame)
|
|
1013
|
+
self.assertEqual(heatmap.columns.tolist(), [0, 1, 2])
|
|
1014
|
+
print(start_method, time.monotonic() - start_time)
|
|
1015
|
+
|
|
1016
|
+
class SometimesNoTrade(Strategy):
|
|
1017
|
+
def init(self):
|
|
1018
|
+
self._will_trade = len(self.data) == 20
|
|
1019
|
+
|
|
1020
|
+
def next(self):
|
|
1021
|
+
if not self._will_trade:
|
|
1022
|
+
return
|
|
1023
|
+
if self.position:
|
|
1024
|
+
self.position.close()
|
|
1025
|
+
elif not self.closed_trades:
|
|
1026
|
+
self.buy(
|
|
1027
|
+
qty=equity_fraction(
|
|
1028
|
+
0.95,
|
|
1029
|
+
account=self.account,
|
|
1030
|
+
reference_price=self.close[-1],
|
|
1031
|
+
instrument=self.instrument,
|
|
1032
|
+
).qty
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
def test_MultiBacktest_handles_mixed_no_trade_results(self):
|
|
1036
|
+
btm = MultiBacktest(
|
|
1037
|
+
[GOOG.iloc[:20], GOOG.iloc[:21], GOOG.iloc[:22]], self.SometimesNoTrade, cash=100_000
|
|
1038
|
+
)
|
|
1039
|
+
res = btm.run()
|
|
1040
|
+
self.assertEqual(res.loc["# Trades"].tolist(), [1, 0, 0])
|
|
1041
|
+
self.assertNotIsInstance(res.iloc[0, 0], pd.Series)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class TestUtil(TestCase):
|
|
1045
|
+
def test_as_str(self):
|
|
1046
|
+
def func():
|
|
1047
|
+
pass
|
|
1048
|
+
|
|
1049
|
+
class Class:
|
|
1050
|
+
def __call__(self):
|
|
1051
|
+
pass
|
|
1052
|
+
|
|
1053
|
+
self.assertEqual(_as_str("4"), "4")
|
|
1054
|
+
self.assertEqual(_as_str(4), "4")
|
|
1055
|
+
self.assertEqual(_as_str(_Indicator([1, 2], name="x")), "x")
|
|
1056
|
+
self.assertEqual(_as_str(func), "func")
|
|
1057
|
+
self.assertEqual(_as_str(Class), "Class")
|
|
1058
|
+
self.assertEqual(_as_str(Class()), "Class")
|
|
1059
|
+
self.assertEqual(_as_str(pd.Series([1, 2], name="x")), "x")
|
|
1060
|
+
self.assertEqual(_as_str(pd.DataFrame()), "df")
|
|
1061
|
+
self.assertEqual(_as_str(lambda x: x), "λ")
|
|
1062
|
+
for s in ("Open", "High", "Low", "Close", "Volume"):
|
|
1063
|
+
self.assertEqual(_as_str(_Array([1], name=s)), s[0])
|
|
1064
|
+
|
|
1065
|
+
def test_patch(self):
|
|
1066
|
+
class Object:
|
|
1067
|
+
pass
|
|
1068
|
+
|
|
1069
|
+
o = Object()
|
|
1070
|
+
o.attr = False
|
|
1071
|
+
with patch(o, "attr", True):
|
|
1072
|
+
self.assertTrue(o.attr)
|
|
1073
|
+
self.assertFalse(o.attr)
|
|
1074
|
+
|
|
1075
|
+
def test_pandas_accessors(self):
|
|
1076
|
+
class S(Strategy):
|
|
1077
|
+
def init(self):
|
|
1078
|
+
close, index = self.data.Close, self.data.index
|
|
1079
|
+
assert close.s.equals(pd.Series(close, index=index))
|
|
1080
|
+
assert self.data.df["Close"].equals(pd.Series(close, index=index))
|
|
1081
|
+
self.data.df["new_key"] = 2 * close
|
|
1082
|
+
|
|
1083
|
+
def next(self):
|
|
1084
|
+
close, index = self.data.Close, self.data.index
|
|
1085
|
+
assert close.s.equals(pd.Series(close, index=index))
|
|
1086
|
+
assert self.data.df["Close"].equals(pd.Series(close, index=index))
|
|
1087
|
+
assert self.data.df["new_key"].equals(pd.Series(self.data.new_key, index=index))
|
|
1088
|
+
|
|
1089
|
+
Backtest(GOOG.iloc[:20], S).run()
|
|
1090
|
+
|
|
1091
|
+
def test_indicators_picklable(self):
|
|
1092
|
+
bt = Backtest(SHORT_DATA, SmaCross)
|
|
1093
|
+
with ProcessPoolExecutor() as executor:
|
|
1094
|
+
stats = executor.submit(_Backtest.run, bt).result()
|
|
1095
|
+
assert stats._strategy._indicators[0]._opts, "._opts and .name were not unpickled"
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
class TestDocs(TestCase):
|
|
1099
|
+
DOCS_DIR = str(Path(__file__).resolve().parents[3])
|
|
1100
|
+
|
|
1101
|
+
@unittest.skipUnless(os.path.isdir(DOCS_DIR), "docs dir doesn't exist")
|
|
1102
|
+
@unittest.skipUnless(
|
|
1103
|
+
sys.platform.startswith("linux"), "test_examples requires mp.start_method=fork"
|
|
1104
|
+
)
|
|
1105
|
+
def test_examples(self):
|
|
1106
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
1107
|
+
from runpy import run_path
|
|
1108
|
+
from unittest.mock import patch as mock_patch
|
|
1109
|
+
|
|
1110
|
+
import openbacktesting
|
|
1111
|
+
|
|
1112
|
+
examples = glob(os.path.join(self.DOCS_DIR, "examples", "**", "*.py"), recursive=True)
|
|
1113
|
+
self.assertGreaterEqual(len(examples), 4)
|
|
1114
|
+
with (
|
|
1115
|
+
chdir(gettempdir()),
|
|
1116
|
+
patch(openbacktesting, "Pool", mp.get_context("fork").Pool),
|
|
1117
|
+
):
|
|
1118
|
+
for file in examples:
|
|
1119
|
+
with self.subTest(example=os.path.basename(file)):
|
|
1120
|
+
# Preserve module identity until the run ends; fork task queues still need to pickle strategy classes.
|
|
1121
|
+
name = "_engine_example_" + Path(file).stem
|
|
1122
|
+
spec = spec_from_file_location(name, file)
|
|
1123
|
+
module = module_from_spec(spec)
|
|
1124
|
+
with mock_patch.dict(sys.modules, {name: module}):
|
|
1125
|
+
spec.loader.exec_module(module)
|
|
1126
|
+
if hasattr(module, "main"):
|
|
1127
|
+
result = module.main()
|
|
1128
|
+
else:
|
|
1129
|
+
host = run_path(
|
|
1130
|
+
str(Path(self.DOCS_DIR) / "examples/advanced/run_author.py")
|
|
1131
|
+
)
|
|
1132
|
+
result = openbacktesting.runtime.run(
|
|
1133
|
+
vars(module), **host["chart_for"](file)
|
|
1134
|
+
)
|
|
1135
|
+
self.assertIsNotNone(result)
|
|
1136
|
+
|
|
1137
|
+
def test_backtest_run_docstring_contains_stats_keys(self):
|
|
1138
|
+
stats = Backtest(SHORT_DATA, SmaCross).run()
|
|
1139
|
+
for key in stats.index:
|
|
1140
|
+
self.assertIn(key, _Backtest.run.__doc__)
|
|
1141
|
+
|
|
1142
|
+
def test_readme_backtest_example(self):
|
|
1143
|
+
import re
|
|
1144
|
+
from runpy import run_path
|
|
1145
|
+
from tempfile import TemporaryDirectory
|
|
1146
|
+
|
|
1147
|
+
readme = (Path(self.DOCS_DIR) / "README.md").read_text()
|
|
1148
|
+
examples = re.findall(r"```python\n(.*?)```", readme, re.DOTALL)
|
|
1149
|
+
self.assertTrue(examples, "README must include a runnable Python example")
|
|
1150
|
+
with TemporaryDirectory(prefix="readme-example-") as directory:
|
|
1151
|
+
script = Path(directory) / "quickstart.py"
|
|
1152
|
+
script.write_text("\n\n".join(examples))
|
|
1153
|
+
namespace = run_path(str(script))
|
|
1154
|
+
result = namespace["result"]
|
|
1155
|
+
self.assertIsInstance(result, pd.Series)
|
|
1156
|
+
self.assertGreater(result["# Trades"], 0)
|
|
1157
|
+
self.assertEqual(len(result["_equity_curve"]), len(GOOG))
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
class TestRegressions(TestCase):
|
|
1161
|
+
def test_gh_521(self):
|
|
1162
|
+
class S(_S):
|
|
1163
|
+
def next(self):
|
|
1164
|
+
if self.data.Close[-1] == 100:
|
|
1165
|
+
self.buy(qty=1, sl=90)
|
|
1166
|
+
|
|
1167
|
+
arr = np.r_[100, 100, 100, 50, 50]
|
|
1168
|
+
df = pd.DataFrame({"Open": arr, "High": arr, "Low": arr, "Close": arr})
|
|
1169
|
+
with self.assertWarnsRegex(UserWarning, "index is not datetime"):
|
|
1170
|
+
bt = Backtest(df, S, cash=100, execution_timing="same_close")
|
|
1171
|
+
result = bt.run()
|
|
1172
|
+
self.assertEqual(result._trades["ExitPrice"][0], 50)
|
|
1173
|
+
self.assertIn("insufficient_funds", result._orders.reason.dropna().tolist())
|
|
1174
|
+
|
|
1175
|
+
def test_stats_annualized(self):
|
|
1176
|
+
stats = Backtest(GOOG.resample("W").agg(OHLCV_AGG), SmaCross).run()
|
|
1177
|
+
self.assertFalse(np.isnan(stats["Return (Ann.) [%]"]))
|
|
1178
|
+
self.assertEqual(round(stats["Return (Ann.) [%]"]), -3)
|
|
1179
|
+
|
|
1180
|
+
def test_cancel_orders(self):
|
|
1181
|
+
class S(_S):
|
|
1182
|
+
def next(self):
|
|
1183
|
+
self.buy(sl=1, tp=1e3, qty=1)
|
|
1184
|
+
if self.position:
|
|
1185
|
+
self.position.close()
|
|
1186
|
+
for order in self.orders:
|
|
1187
|
+
order.cancel()
|
|
1188
|
+
|
|
1189
|
+
Backtest(SHORT_DATA, S).run()
|
|
1190
|
+
|
|
1191
|
+
def test_trade_on_close_closes_trades_on_close(self):
|
|
1192
|
+
def coro(strat):
|
|
1193
|
+
yield strat.buy(qty=1, sl=90) and strat.buy(qty=1, sl=80)
|
|
1194
|
+
assert len(strat.trades) == 2
|
|
1195
|
+
yield strat.trades[0].close()
|
|
1196
|
+
yield
|
|
1197
|
+
|
|
1198
|
+
arr = np.r_[100, 101, 102, 50, 51]
|
|
1199
|
+
df = pd.DataFrame({"Open": arr - 10, "Close": arr, "High": arr, "Low": arr - 10})
|
|
1200
|
+
with self.assertWarnsRegex(UserWarning, "index is not datetime"):
|
|
1201
|
+
trades = (
|
|
1202
|
+
TestStrategy._Backtest(coro, df, cash=250, execution_timing="same_close")
|
|
1203
|
+
.run()
|
|
1204
|
+
._trades
|
|
1205
|
+
)
|
|
1206
|
+
# trades = Backtest(df, S, cash=250, trade_on_close=True).run()._trades
|
|
1207
|
+
self.assertEqual(trades["EntryBar"][0], 0)
|
|
1208
|
+
self.assertEqual(trades["ExitBar"][0], 1)
|
|
1209
|
+
self.assertEqual(trades["EntryPrice"][0], 100)
|
|
1210
|
+
self.assertEqual(trades["ExitPrice"][0], 101)
|
|
1211
|
+
self.assertEqual(trades["EntryBar"][1], 0)
|
|
1212
|
+
self.assertEqual(trades["ExitBar"][1], 3)
|
|
1213
|
+
self.assertEqual(trades["EntryPrice"][1], 100)
|
|
1214
|
+
self.assertEqual(trades["ExitPrice"][1], 40)
|
|
1215
|
+
|
|
1216
|
+
with self.assertWarnsRegex(UserWarning, "index is not datetime"):
|
|
1217
|
+
trades = (
|
|
1218
|
+
TestStrategy._Backtest(coro, df, cash=250, execution_timing="next_open")
|
|
1219
|
+
.run()
|
|
1220
|
+
._trades
|
|
1221
|
+
)
|
|
1222
|
+
# trades = Backtest(df, S, cash=250, trade_on_close=False).run()._trades
|
|
1223
|
+
self.assertEqual(trades["EntryBar"][0], 1)
|
|
1224
|
+
self.assertEqual(trades["ExitBar"][0], 2)
|
|
1225
|
+
self.assertEqual(trades["EntryPrice"][0], 91)
|
|
1226
|
+
self.assertEqual(trades["ExitPrice"][0], 92)
|
|
1227
|
+
self.assertEqual(trades["EntryBar"][1], 1)
|
|
1228
|
+
self.assertEqual(trades["ExitBar"][1], 3)
|
|
1229
|
+
self.assertEqual(trades["EntryPrice"][1], 91)
|
|
1230
|
+
self.assertEqual(trades["ExitPrice"][1], 40)
|
|
1231
|
+
|
|
1232
|
+
def test_trades_dates_match_prices(self):
|
|
1233
|
+
bt = Backtest(EURUSD, SmaCross, execution_timing="same_close")
|
|
1234
|
+
trades = bt.run()._trades
|
|
1235
|
+
self.assertEqual(EURUSD.Close[trades["ExitTime"]].tolist(), trades["ExitPrice"].tolist())
|
|
1236
|
+
|
|
1237
|
+
def test_sl_always_before_tp(self):
|
|
1238
|
+
class S(_S):
|
|
1239
|
+
def next(self):
|
|
1240
|
+
i = len(self.data.index)
|
|
1241
|
+
if i == 4:
|
|
1242
|
+
self.buy(
|
|
1243
|
+
qty=equity_fraction(
|
|
1244
|
+
0.95,
|
|
1245
|
+
account=self.account,
|
|
1246
|
+
reference_price=self.close[-1],
|
|
1247
|
+
instrument=self.instrument,
|
|
1248
|
+
).qty
|
|
1249
|
+
)
|
|
1250
|
+
if i == 5:
|
|
1251
|
+
t = self.trades[0]
|
|
1252
|
+
t.sl = 105
|
|
1253
|
+
t.tp = 107.9
|
|
1254
|
+
|
|
1255
|
+
trades = Backtest(SHORT_DATA, S).run()._trades
|
|
1256
|
+
self.assertEqual(trades["ExitPrice"].iloc[0], 104.95)
|
|
1257
|
+
|
|
1258
|
+
def test_stop_entry_and_tp_in_same_bar(self):
|
|
1259
|
+
class S(_S):
|
|
1260
|
+
def next(self):
|
|
1261
|
+
i = len(self.data.index)
|
|
1262
|
+
if i == 3:
|
|
1263
|
+
self.sell(stop=108, tp=105, sl=113, qty=1)
|
|
1264
|
+
|
|
1265
|
+
trades = Backtest(SHORT_DATA, S).run()._trades
|
|
1266
|
+
self.assertEqual(trades["ExitBar"].iloc[0], 4)
|
|
1267
|
+
self.assertEqual(trades["ExitPrice"].iloc[0], 104.96) # Next-open price improvement
|
|
1268
|
+
|
|
1269
|
+
def test_optimize_datetime_index_with_timezone(self):
|
|
1270
|
+
data: pd.DataFrame = GOOG.iloc[:100]
|
|
1271
|
+
data.index = data.index.tz_localize("Asia/Kolkata")
|
|
1272
|
+
res = Backtest(data, SmaCross).optimize(fast=range(2, 3), slow=range(4, 5))
|
|
1273
|
+
self.assertGreater(res["# Trades"], 0)
|
|
1274
|
+
|
|
1275
|
+
def test_optimize_range_index(self):
|
|
1276
|
+
# Data is passed to the workers through shared memory sized by
|
|
1277
|
+
# `.nbytes`, which a lazy RangeIndex under-reports, previously raising
|
|
1278
|
+
# "TypeError: buffer is too small for requested array". See GH issue #1237.
|
|
1279
|
+
data: pd.DataFrame = GOOG.iloc[:100].reset_index(drop=True)
|
|
1280
|
+
with self.assertWarnsRegex(UserWarning, "index is not datetime"):
|
|
1281
|
+
bt = Backtest(data, SmaCross)
|
|
1282
|
+
res = bt.optimize(fast=range(2, 3), slow=range(4, 5))
|
|
1283
|
+
self.assertGreater(res["# Trades"], 0)
|
|
1284
|
+
|
|
1285
|
+
def test_sl_tp_values_in_trades_df(self):
|
|
1286
|
+
class S(_S):
|
|
1287
|
+
def next(self):
|
|
1288
|
+
self.next = lambda: None
|
|
1289
|
+
self.buy(qty=1, tp=111)
|
|
1290
|
+
self.buy(qty=1, sl=99)
|
|
1291
|
+
|
|
1292
|
+
trades = Backtest(SHORT_DATA, S).run()._trades
|
|
1293
|
+
self.assertEqual(trades["SL"].fillna(0).tolist(), [0, 99])
|
|
1294
|
+
self.assertEqual(trades["TP"].fillna(0).tolist(), [111, 0])
|
|
1295
|
+
|
|
1296
|
+
def test_sl_value_in_trades_df_when_gapped_through(self):
|
|
1297
|
+
# An SL that is gapped through (the bar opens beyond the stop, so the
|
|
1298
|
+
# fill price is worse than the stop price) must still be recorded in
|
|
1299
|
+
# stats._trades["SL"]. See GH issue #1340.
|
|
1300
|
+
class S(_S):
|
|
1301
|
+
def next(self):
|
|
1302
|
+
if len(self.data.index) == 9:
|
|
1303
|
+
self.buy(qty=1, sl=99.5)
|
|
1304
|
+
|
|
1305
|
+
trades = Backtest(SHORT_DATA, S).run()._trades
|
|
1306
|
+
self.assertEqual(len(trades), 1)
|
|
1307
|
+
trade = trades.iloc[0]
|
|
1308
|
+
# The long SL (99.5) is gapped through on the next bar's open, so the
|
|
1309
|
+
# trade exits at that worse market price rather than at the stop price.
|
|
1310
|
+
self.assertEqual(trade["ExitPrice"], 99.19)
|
|
1311
|
+
# ... yet the SL value must be preserved in the trades data frame.
|
|
1312
|
+
self.assertEqual(trade["SL"], 99.5)
|