@@ -29,7 +29,8 @@ def _tqdm(seq, **_):
29
29
return seq
30
30
31
31
from ._plotting import plot
32
- from ._util import _as_str , _Indicator , _Data , _data_period , try_
32
+ from ._stats import compute_stats
33
+ from ._util import _as_str , _Indicator , _Data , try_
33
34
34
35
__pdoc__ = {
35
36
'Strategy.__init__' : False ,
@@ -1089,7 +1090,7 @@ def __init__(self,
1089
1090
exclusive_orders = exclusive_orders , index = data .index ,
1090
1091
)
1091
1092
self ._strategy = strategy
1092
- self ._results = None
1093
+ self ._results : Optional [ pd . Series ] = None
1093
1094
1094
1095
def run (self , ** kwargs ) -> pd .Series :
1095
1096
"""
@@ -1180,7 +1181,15 @@ def run(self, **kwargs) -> pd.Series:
1180
1181
# for future `indicator._opts['data'].index` calls to work
1181
1182
data ._set_length (len (self ._data ))
1182
1183
1183
- self ._results = self ._compute_stats (broker , strategy )
1184
+ equity = pd .Series (broker ._equity ).bfill ().fillna (broker ._cash ).values
1185
+ self ._results = compute_stats (
1186
+ trades = broker .closed_trades ,
1187
+ equity = equity ,
1188
+ ohlc_data = self ._data ,
1189
+ risk_free_rate = 0.0 ,
1190
+ strategy_instance = strategy ,
1191
+ )
1192
+
1184
1193
return self ._results
1185
1194
1186
1195
def optimize (self , * ,
@@ -1491,134 +1500,6 @@ def _mp_task(backtest_uuid, batch_index):
1491
1500
1492
1501
_mp_backtests : Dict [float , Tuple ['Backtest' , List , Callable ]] = {}
1493
1502
1494
- @staticmethod
1495
- def _compute_drawdown_duration_peaks (dd : pd .Series ):
1496
- iloc = np .unique (np .r_ [(dd == 0 ).values .nonzero ()[0 ], len (dd ) - 1 ])
1497
- iloc = pd .Series (iloc , index = dd .index [iloc ])
1498
- df = iloc .to_frame ('iloc' ).assign (prev = iloc .shift ())
1499
- df = df [df ['iloc' ] > df ['prev' ] + 1 ].astype (int )
1500
- # If no drawdown since no trade, avoid below for pandas sake and return nan series
1501
- if not len (df ):
1502
- return (dd .replace (0 , np .nan ),) * 2
1503
- df ['duration' ] = df ['iloc' ].map (dd .index .__getitem__ ) - df ['prev' ].map (dd .index .__getitem__ )
1504
- df ['peak_dd' ] = df .apply (lambda row : dd .iloc [row ['prev' ]:row ['iloc' ] + 1 ].max (), axis = 1 )
1505
- df = df .reindex (dd .index )
1506
- return df ['duration' ], df ['peak_dd' ]
1507
-
1508
- def _compute_stats (self , broker : _Broker , strategy : Strategy ) -> pd .Series :
1509
- data = self ._data
1510
- index = data .index
1511
-
1512
- equity = pd .Series (broker ._equity ).bfill ().fillna (broker ._cash ).values
1513
- dd = 1 - equity / np .maximum .accumulate (equity )
1514
- dd_dur , dd_peaks = self ._compute_drawdown_duration_peaks (pd .Series (dd , index = index ))
1515
-
1516
- equity_df = pd .DataFrame ({
1517
- 'Equity' : equity ,
1518
- 'DrawdownPct' : dd ,
1519
- 'DrawdownDuration' : dd_dur },
1520
- index = index )
1521
-
1522
- trades = broker .closed_trades
1523
- trades_df = pd .DataFrame ({
1524
- 'Size' : [t .size for t in trades ],
1525
- 'EntryBar' : [t .entry_bar for t in trades ],
1526
- 'ExitBar' : [t .exit_bar for t in trades ],
1527
- 'EntryPrice' : [t .entry_price for t in trades ],
1528
- 'ExitPrice' : [t .exit_price for t in trades ],
1529
- 'PnL' : [t .pl for t in trades ],
1530
- 'ReturnPct' : [t .pl_pct for t in trades ],
1531
- 'EntryTime' : [t .entry_time for t in trades ],
1532
- 'ExitTime' : [t .exit_time for t in trades ],
1533
- })
1534
- trades_df ['Duration' ] = trades_df ['ExitTime' ] - trades_df ['EntryTime' ]
1535
-
1536
- pl = trades_df ['PnL' ]
1537
- returns = trades_df ['ReturnPct' ]
1538
- durations = trades_df ['Duration' ]
1539
-
1540
- def _round_timedelta (value , _period = _data_period (index )):
1541
- if not isinstance (value , pd .Timedelta ):
1542
- return value
1543
- resolution = getattr (_period , 'resolution_string' , None ) or _period .resolution
1544
- return value .ceil (resolution )
1545
-
1546
- s = pd .Series (dtype = object )
1547
- s .loc ['Start' ] = index [0 ]
1548
- s .loc ['End' ] = index [- 1 ]
1549
- s .loc ['Duration' ] = s .End - s .Start
1550
-
1551
- have_position = np .repeat (0 , len (index ))
1552
- for t in trades :
1553
- have_position [t .entry_bar :t .exit_bar + 1 ] = 1 # type: ignore
1554
-
1555
- s .loc ['Exposure Time [%]' ] = have_position .mean () * 100 # In "n bars" time, not index time
1556
- s .loc ['Equity Final [$]' ] = equity [- 1 ]
1557
- s .loc ['Equity Peak [$]' ] = equity .max ()
1558
- s .loc ['Return [%]' ] = (equity [- 1 ] - equity [0 ]) / equity [0 ] * 100
1559
- c = data .Close .values
1560
- s .loc ['Buy & Hold Return [%]' ] = (c [- 1 ] - c [0 ]) / c [0 ] * 100 # long-only return
1561
-
1562
- def geometric_mean (returns ):
1563
- returns = returns .fillna (0 ) + 1
1564
- return (0 if np .any (returns <= 0 ) else
1565
- np .exp (np .log (returns ).sum () / (len (returns ) or np .nan )) - 1 )
1566
-
1567
- day_returns = gmean_day_return = np .array (np .nan )
1568
- annual_trading_days = np .nan
1569
- if isinstance (index , pd .DatetimeIndex ):
1570
- day_returns = equity_df ['Equity' ].resample ('D' ).last ().dropna ().pct_change ()
1571
- gmean_day_return = geometric_mean (day_returns )
1572
- annual_trading_days = float (
1573
- 365 if index .dayofweek .to_series ().between (5 , 6 ).mean () > 2 / 7 * .6 else
1574
- 252 )
1575
-
1576
- # Annualized return and risk metrics are computed based on the (mostly correct)
1577
- # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517
1578
- # Our annualized return matches `empyrical.annual_return(day_returns)` whereas
1579
- # our risk doesn't; they use the simpler approach below.
1580
- annualized_return = (1 + gmean_day_return )** annual_trading_days - 1
1581
- s .loc ['Return (Ann.) [%]' ] = annualized_return * 100
1582
- s .loc ['Volatility (Ann.) [%]' ] = np .sqrt ((day_returns .var (ddof = int (bool (day_returns .shape ))) + (1 + gmean_day_return )** 2 )** annual_trading_days - (1 + gmean_day_return )** (2 * annual_trading_days )) * 100 # noqa: E501
1583
- # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
1584
- # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
1585
-
1586
- # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
1587
- # and simple standard deviation
1588
- s .loc ['Sharpe Ratio' ] = np .clip (s .loc ['Return (Ann.) [%]' ] / (s .loc ['Volatility (Ann.) [%]' ] or np .nan ), 0 , np .inf ) # noqa: E501
1589
- # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
1590
- s .loc ['Sortino Ratio' ] = np .clip (annualized_return / (np .sqrt (np .mean (day_returns .clip (- np .inf , 0 )** 2 )) * np .sqrt (annual_trading_days )), 0 , np .inf ) # noqa: E501
1591
- max_dd = - np .nan_to_num (dd .max ())
1592
- s .loc ['Calmar Ratio' ] = np .clip (annualized_return / (- max_dd or np .nan ), 0 , np .inf )
1593
- s .loc ['Max. Drawdown [%]' ] = max_dd * 100
1594
- s .loc ['Avg. Drawdown [%]' ] = - dd_peaks .mean () * 100
1595
- s .loc ['Max. Drawdown Duration' ] = _round_timedelta (dd_dur .max ())
1596
- s .loc ['Avg. Drawdown Duration' ] = _round_timedelta (dd_dur .mean ())
1597
- s .loc ['# Trades' ] = n_trades = len (trades )
1598
- s .loc ['Win Rate [%]' ] = np .nan if not n_trades else (pl > 0 ).sum () / n_trades * 100 # noqa: E501
1599
- s .loc ['Best Trade [%]' ] = returns .max () * 100
1600
- s .loc ['Worst Trade [%]' ] = returns .min () * 100
1601
- mean_return = geometric_mean (returns )
1602
- s .loc ['Avg. Trade [%]' ] = mean_return * 100
1603
- s .loc ['Max. Trade Duration' ] = _round_timedelta (durations .max ())
1604
- s .loc ['Avg. Trade Duration' ] = _round_timedelta (durations .mean ())
1605
- s .loc ['Profit Factor' ] = returns [returns > 0 ].sum () / (abs (returns [returns < 0 ].sum ()) or np .nan ) # noqa: E501
1606
- s .loc ['Expectancy [%]' ] = returns .mean () * 100
1607
- s .loc ['SQN' ] = np .sqrt (n_trades ) * pl .mean () / (pl .std () or np .nan )
1608
-
1609
- s .loc ['_strategy' ] = strategy
1610
- s .loc ['_equity_curve' ] = equity_df
1611
- s .loc ['_trades' ] = trades_df
1612
-
1613
- s = Backtest ._Stats (s )
1614
- return s
1615
-
1616
- class _Stats (pd .Series ):
1617
- def __repr__ (self ):
1618
- # Prevent expansion due to _equity and _trades dfs
1619
- with pd .option_context ('max_colwidth' , 20 ):
1620
- return super ().__repr__ ()
1621
-
1622
1503
def plot (self , * , results : pd .Series = None , filename = None , plot_width = None ,
1623
1504
plot_equity = True , plot_return = False , plot_pl = True ,
1624
1505
plot_volume = True , plot_drawdown = False ,
0 commit comments