@@ -29,7 +29,8 @@ def _tqdm(seq, **_):
2929 return seq
3030
3131from ._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_
3334
3435__pdoc__ = {
3536 'Strategy.__init__' : False ,
@@ -1089,7 +1090,7 @@ def __init__(self,
10891090 exclusive_orders = exclusive_orders , index = data .index ,
10901091 )
10911092 self ._strategy = strategy
1092- self ._results = None
1093+ self ._results : Optional [ pd . Series ] = None
10931094
10941095 def run (self , ** kwargs ) -> pd .Series :
10951096 """
@@ -1180,7 +1181,15 @@ def run(self, **kwargs) -> pd.Series:
11801181 # for future `indicator._opts['data'].index` calls to work
11811182 data ._set_length (len (self ._data ))
11821183
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+
11841193 return self ._results
11851194
11861195 def optimize (self , * ,
@@ -1491,134 +1500,6 @@ def _mp_task(backtest_uuid, batch_index):
14911500
14921501 _mp_backtests : Dict [float , Tuple ['Backtest' , List , Callable ]] = {}
14931502
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-
16221503 def plot (self , * , results : pd .Series = None , filename = None , plot_width = None ,
16231504 plot_equity = True , plot_return = False , plot_pl = True ,
16241505 plot_volume = True , plot_drawdown = False ,
0 commit comments