From f552467b66adc81a59bd8d4256d67dcfca2daccb Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Sun, 12 Jan 2025 10:46:02 -0500 Subject: [PATCH 1/3] Initial stats setup --- .coveragerc | 3 + pytradebacktest/stats.py | 213 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_stats.py | 2 + 3 files changed, 218 insertions(+) create mode 100644 .coveragerc create mode 100644 pytradebacktest/stats.py create mode 100644 tests/unit/test_stats.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4958ee9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + pytradebacktest/stats.py \ No newline at end of file diff --git a/pytradebacktest/stats.py b/pytradebacktest/stats.py new file mode 100644 index 0000000..bd65fdb --- /dev/null +++ b/pytradebacktest/stats.py @@ -0,0 +1,213 @@ +from typing import Any + +import numpy as np +import pandas as pd +from pytrade.models.trade import Trade +from pytrade.strategy import FxStrategy + +from pytradebacktest.data import MarketData + + +class Stats: + + def __init__( + self, + trades: list[Trade], + equity: np.ndarray, + market_data: MarketData, + strategy: FxStrategy, + ): + self._trades = trades + self._equity = equity + self._market_data = market_data + self._strategy = strategy + self._trades_df = pd.DataFrame( + { + "Size": [t.size for t in trades], + "EntryBar": [t.entry_bar for t in trades], + "ExitBar": [t.exit_bar for t in trades], + "EntryPrice": [t.entry_price for t in trades], + "ExitPrice": [t.exit_price for t in trades], + "PnL": [t.pl for t in trades], + "ReturnPct": [t.pl_pct for t in trades], + "EntryTime": [t.entry_time for t in trades], + "ExitTime": [t.exit_time for t in trades], + "Tag": [t.tag for t in trades], + "TakeProfit": [t.tp for t in trades], + "StopLoss": [t.sl for t in trades], + } + ) + self._trades_df["Duration"] = ( + self._trades_df["ExitTime"] - self._trades_df["EntryTime"] + ) + self._drawdown: np.ndarray[np.floating[Any], Any] + + @property + def drawdown(self) -> np.ndarray[np.floating[Any], Any]: + if not self._drawdown: + self._drawdown = 1 - self._equity / np.maximum.accumulate(self._equity) + return self._drawdown + + @property + def drawdown_duration(self): + iloc = np.unique( + np.r_[(self.drawdown == 0).values.nonzero()[0], len(self.drawdown) - 1] + ) + iloc = pd.Series(iloc, index=self.drawdown.index[iloc]) + df = iloc.to_frame("iloc").assign(prev=iloc.shift()) + df = df[df["iloc"] > df["prev"] + 1].astype(int) + + # If no drawdown since no trade, avoid below for pandas sake and return nan series + if not len(df): + return (self.drawdown.replace(0, np.nan),) * 2 + + # df = df.reindex(self.drawdown.index) + return df["iloc"].map(self.drawdown.index.__getitem__) - df["prev"].map( + self.drawdown.index.__getitem__ + ) + + @property + def drawdown_peaks(self): + iloc = np.unique( + np.r_[(self.drawdown == 0).values.nonzero()[0], len(self.drawdown) - 1] + ) + iloc = pd.Series(iloc, index=self.drawdown.index[iloc]) + df = iloc.to_frame("iloc").assign(prev=iloc.shift()) + df = df[df["iloc"] > df["prev"] + 1].astype(int) + + # If no drawdown since no trade, avoid below for pandas sake and return nan series + if not len(df): + return (self.drawdown.replace(0, np.nan),) * 2 + + # df = df.reindex(self.drawdown.index) + return df.apply( + lambda row: self.drawdown.iloc[row["prev"] : row["iloc"] + 1].max(), axis=1 + ) + + @property + def profit_and_loss(self): + return self._trades_df["PnL"] + + @property + def returns(self): + return self._trades_df["ReturnPct"] + + @property + def durations(self): + return self._trades_df["Duration"] + + @property + def start(self): + return self._market_data._start_index + + @property + def end(self): + return self._market_data._end_index + + @property + def duration(self): + return self.start - self.end + + @property + def positions(self): + pass + + @property + def exposure_time(self): + pass + + @property + def equity_final(self): + pass + + @property + def equity_peak(self): + pass + + @property + def return_pct(self): + pass + + @property + def buy_and_hold_return(self): + pass + + @property + def annualized_return(self): + pass + + @property + def annualized_volatility(self): + pass + + @property + def sharpe_ratio(self): + pass + + @property + def sortino_ratio(self): + pass + + @property + def calmar_ratio(self): + pass + + @property + def max_drawndown(self): + pass + + @property + def avg_drawdown(self): + pass + + @property + def max_drawdown_duration(self): + pass + + @property + def avg_drawdown_duration(self): + pass + + @property + def number_of_trades(self): + pass + + @property + def win_rate(self): + pass + + @property + def best_trade_return(self): + pass + + @property + def worst_trade_return(self): + pass + + @property + def avg_trade_return(self): + pass + + @property + def max_trade_duration(self): + pass + + @property + def avg_trade_duration(self): + pass + + @property + def profit_factor(self): + pass + + @property + def expectancy(self): + pass + + @property + def SQN(self): + pass + + @property + def kelly_criterion(self): + pass diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py new file mode 100644 index 0000000..cdbcab2 --- /dev/null +++ b/tests/unit/test_stats.py @@ -0,0 +1,2 @@ +def test_stats(): + pass \ No newline at end of file From 7b4d71caa3c79dbdccefd97a515d794db89f071e Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 29 Jan 2025 00:14:40 -0500 Subject: [PATCH 2/3] Adding initial stats implementation --- lib/PyTrade | 2 +- pytradebacktest/backtest.py | 12 +- pytradebacktest/broker.py | 101 +++++++---- pytradebacktest/data.py | 4 +- pytradebacktest/order.py | 40 ++++- pytradebacktest/stats.py | 268 +++++++++++++++++++++-------- tests/unit/resources/indicators.py | 17 +- tests/unit/test_backtest_broker.py | 20 +-- tests/unit/test_stats.py | 40 ++++- 9 files changed, 363 insertions(+), 141 deletions(-) diff --git a/lib/PyTrade b/lib/PyTrade index 12786ae..419e1e8 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit 12786aebc4a00df44737130af270d0e1d2013db8 +Subproject commit 419e1e8b56926b76a78f0cbac61615e054920f36 diff --git a/pytradebacktest/backtest.py b/pytradebacktest/backtest.py index 1726f31..e1c9303 100644 --- a/pytradebacktest/backtest.py +++ b/pytradebacktest/backtest.py @@ -1,10 +1,12 @@ from typing import Type +import pandas as pd from pytrade.indicator import Indicator from pytrade.strategy import FxStrategy from pytradebacktest.broker import BacktestBroker from pytradebacktest.data import MarketData +from pytradebacktest.stats import Stats class Backtest: @@ -46,10 +48,14 @@ def increment_indicator(self): broker.next() strategy.next() - # Increment data points - # Update indicators - # Update trades + # Close any open trades: + broker.close_trades() + # Call broker one last time to clean up any outstanding orders from strategy + broker.next() + # Claculate results/stats + equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values + return Stats(broker.closed_trades, equity, self.data, strategy) def plot(self): pass diff --git a/pytradebacktest/broker.py b/pytradebacktest/broker.py index 39cc24e..6a8845a 100644 --- a/pytradebacktest/broker.py +++ b/pytradebacktest/broker.py @@ -1,4 +1,3 @@ -from math import copysign from typing import Optional import numpy as np @@ -48,6 +47,10 @@ def margin_available(self) -> float: margin_used = sum(trade.value / self._leverage for trade in self.trades) return max(0, self.equity - margin_used) + @property + def leverage(self) -> float: + return self._leverage + def order(self, order: Order): # If exclusive orders (each new order auto-closes previous orders/position), @@ -69,6 +72,13 @@ def subscribe( def get_position(self, instrument: Instrument) -> Position: return Position(instrument, self.trades) + def close_position(self, instrument: Instrument): + position = self.get_position(instrument) + for trade in position.trades: + self.orders.insert( + 0, Order(trade.instrument, -trade.size, parent_trade=trade) + ) + def next(self): self._process_orders() self._update_equity() @@ -77,6 +87,7 @@ def _update_equity(self): # Update equity equity = self.equity i = self._data.i + self._equity[i] = equity # If equity is negative, set all to 0 and stop the simulation if equity <= 0: @@ -95,32 +106,37 @@ def _process_orders(self): for order in list(self.orders): _data = self._get_instrument_data(order.instrument) - with OrderContext(order, _data, self._trade_on_close) as ctx: - # Related SL/TP order already removed - if order not in self.orders: - continue + ctx = OrderContext( + order, + _data, + self._trade_on_close, + self._commission, + self._leverage, + self.margin_available, + ) + # Related SL/TP order already removed + if order not in self.orders: + continue - if order.stop: - stop_hit = self._evaluate_stop_order(ctx) - if not stop_hit: - continue + if order.stop: + stop_hit = self._evaluate_stop_order(ctx) + if not stop_hit: + continue - if order.limit: - limit_hit, limit_hit_before_stop = self._evaluate_limit_order(ctx) - if not limit_hit or limit_hit_before_stop: - continue + if order.limit: + limit_hit, limit_hit_before_stop = self._evaluate_limit_order(ctx) + if not limit_hit or limit_hit_before_stop: + continue - if order.is_contingent: - self._process_contingent_order(ctx) - else: - new_trade = self._process_market_order(ctx) + if order.is_contingent: + self._process_contingent_order(ctx) + else: + new_trade = self._process_market_order(ctx) - if new_trade and ( - order.stop_loss_on_fill or order.take_profit_on_fill - ): - # Need to reprocess since we created a contingent order that could - # hit within the same bar - reprocess_orders = True + if new_trade and (order.stop_loss_on_fill or order.take_profit_on_fill): + # Need to reprocess since we created a contingent order that could + # hit within the same bar + reprocess_orders = True if reprocess_orders: self._process_orders() @@ -147,40 +163,48 @@ def _evaluate_limit_order(self, ctx: OrderContext): def _process_contingent_order(self, ctx: OrderContext): order = ctx.order trade = ctx.order.parent_trade - size = int(copysign(min(abs(trade.size), abs(order.size)), order.size)) + _order_size = ctx.adjusted_size if trade in self.trades: - closed = self._reduce_trade(trade, size, ctx.entry_price, ctx.entry_time) + closed = self._reduce_trade( + trade, _order_size, ctx.entry_price, ctx.entry_time + ) if closed: self.orders.remove(order) def _process_market_order(self, ctx: OrderContext): order = ctx.order - _need_size = ctx.order.size + _order_size = ctx.adjusted_size new_trade = False if not self._hedging: - _need_size = self._update_position(ctx) + _order_size = self._update_position(ctx) + order.resize(_order_size) # IF we have the margin to cover the order _insufficent_funds = ( - abs(_need_size) * ctx.entry_price > self.margin_available * self._leverage + abs(_order_size) * ctx.entry_price > self.margin_available * self._leverage ) - if _need_size and not _insufficent_funds: + if _order_size and not _insufficent_funds: self._open_trade(ctx) new_trade = True + if _insufficent_funds: + pass + self.orders.remove(order) return new_trade def _update_position(self, ctx: OrderContext): order = ctx.order - _need_size = ctx.order.size + _need_size = ctx.adjusted_size # Fill position by FIFO closing/reducing existing opposite-facing trades. # Existing trades are closed at unadjusted price, because the adjustment # was already made when buying. for trade in list(self.trades): - if trade.is_long and order.is_long: + opposing = trade.is_long != order.is_long + same_instrument = trade.instrument == order.instrument + if not opposing or not same_instrument: continue # Order is equal or larger so close trade @@ -198,8 +222,9 @@ def _update_position(self, ctx: OrderContext): def _open_trade(self, ctx: OrderContext, tag: Optional[str] = None): order = ctx.order + size = ctx.adjusted_size trade = Trade( - order.instrument, order.size, ctx.entry_price, ctx.entry_time, tag + order.instrument, size, ctx.entry_price, ctx.entry_time, ctx.data, tag ) self.trades.append(trade) @@ -235,18 +260,24 @@ def _reduce_trade( else: trade.reduce(size_left) if trade.sl: - trade.sl.reduce(-size_left) + trade.sl.resize(-size_left) if trade.tp: - trade.tp.reduce(-size_left) + trade.tp.resize(-size_left) close_trade = Trade( - trade.instrument, size, trade.entry_price, trade.entry_time + trade.instrument, size, trade.entry_price, trade.entry_time, trade.data ) self.trades.append(close_trade) self._close_trade(close_trade, price, timestamp) return closed + def close_trades(self): + for trade in self.trades: + self.orders.insert( + 0, Order(trade.instrument, -trade.size, parent_trade=trade) + ) + def _close_trade(self, trade: Trade, price: float, timestamp: Timestamp): self.trades.remove(trade) if trade.sl: diff --git a/pytradebacktest/data.py b/pytradebacktest/data.py index a59eb04..8f460ea 100644 --- a/pytradebacktest/data.py +++ b/pytradebacktest/data.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd -from pandas import Index, Timestamp +from pandas import DatetimeIndex, Timestamp from pytrade.events.event import Event from pytrade.instruments import Granularity, Instrument from pytrade.interfaces.data import IDataContext, IInstrumentData @@ -140,7 +140,7 @@ def __len__(self): return len(self._market_index) def _init_index(self): - _market_index: Index[Timestamp] = pd.Index([]) + _market_index: DatetimeIndex = DatetimeIndex([]) for source in self._sources: _market_index = _market_index.union(source.df.index) diff --git a/pytradebacktest/order.py b/pytradebacktest/order.py index 84b89d5..dca93a9 100644 --- a/pytradebacktest/order.py +++ b/pytradebacktest/order.py @@ -1,3 +1,5 @@ +from math import copysign + import numpy as np from pytrade.broker import Order @@ -6,16 +8,21 @@ class OrderContext: - def __init__(self, order: Order, data: InstrumentData, trade_on_close: bool): + def __init__( + self, + order: Order, + data: InstrumentData, + trade_on_close: bool, + commission: float, + leverage: float, + margin_available: float, + ): self._order = order self._data = data self._trade_on_close = trade_on_close - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass + self._commission = commission + self._leverage = leverage + self._margin_availalbe = margin_available @property def order(self): @@ -72,3 +79,22 @@ def entry_price(self): stop_default = -np.inf if self.order.is_long else np.inf price = func(price, self.order.stop or stop_default) return price + + @property + def adjusted_entry_price(self): + return self.entry_price * (1 + copysign(self._commission, self.order.size)) + + @property + def adjusted_size(self): + order = self.order + _size = order.size + if -1 < _size < 1: + _size = copysign( + int( + (self._margin_availalbe * self._leverage * abs(order.size)) + // self.adjusted_entry_price + ), + order.size, + ) + + return _size diff --git a/pytradebacktest/stats.py b/pytradebacktest/stats.py index bd65fdb..bb1a0b5 100644 --- a/pytradebacktest/stats.py +++ b/pytradebacktest/stats.py @@ -1,8 +1,10 @@ -from typing import Any +from numbers import Number +from typing import Union import numpy as np import pandas as pd -from pytrade.models.trade import Trade +from pandas import DatetimeIndex +from pytrade.models import Trade from pytrade.strategy import FxStrategy from pytradebacktest.data import MarketData @@ -20,6 +22,7 @@ def __init__( self._trades = trades self._equity = equity self._market_data = market_data + self._index: DatetimeIndex = market_data._market_index self._strategy = strategy self._trades_df = pd.DataFrame( { @@ -40,49 +43,65 @@ def __init__( self._trades_df["Duration"] = ( self._trades_df["ExitTime"] - self._trades_df["EntryTime"] ) - self._drawdown: np.ndarray[np.floating[Any], Any] - @property - def drawdown(self) -> np.ndarray[np.floating[Any], Any]: - if not self._drawdown: - self._drawdown = 1 - self._equity / np.maximum.accumulate(self._equity) + self._compute_drawdown_stats() + + self._equity_df = pd.DataFrame( + { + "Equity": equity, + "DrawdownPct": self.drawdown, + "DrawdownDuration": self.drawdown_duration, + }, + index=self._market_data._market_index, + ) + self._risk_free_rate = 0 + + def __repr__(self): + return f""" +# Trades: {self.number_of_trades}, +Avg. Drawdown Duration: {self.avg_drawdown_duration}, +Avg. Drawdown [%]: {self.avg_drawdown}, +Avg. Trade Duration: {self.avg_trade_duration}, +Avg. Trade [%]: {self.avg_trade_return}, +Best Trade [%]: {self.best_trade_return}, +Calmar Ratio: {self.calmar_ratio}, +Duration: {self.duration}, +End: {self.end}, +Equity Final [$]: {self.equity_final}, +Equity Peak [$]: {self.equity_peak}, +Expectancy [%]: {self.expectancy}, +Exposure Time [%]: {self.exposure_time}, +Max. Drawdown Duration: {self.max_drawdown_duration}, +Max. Drawdown [%]: {self.max_drawdown_pct}, +Max. Trade Duration: {self.max_trade_duration}, +Profit Factor: {self.profit_factor}, +Return (Ann.) [%]: {self.annualized_return_pct}, +Return [%]: {self.return_pct}, +Volatility (Ann.) [%]: {self.annualized_volatility}, +SQN: {self.SQN}, +Kelly Criterion: {self.kelly_criterion}, +Sharpe Ratio: {self.sharpe_ratio}, +Sortino Ratio: {self.sortino_ratio}, +Start: {self.start}, +Win Rate [%]: {self.win_rate}, +Worst Trade [%]: {self.worst_trade_return}, +""" + + @property + def total_trades(self): + return len(self._trades_df) + + @property + def drawdown(self) -> pd.Series: return self._drawdown @property def drawdown_duration(self): - iloc = np.unique( - np.r_[(self.drawdown == 0).values.nonzero()[0], len(self.drawdown) - 1] - ) - iloc = pd.Series(iloc, index=self.drawdown.index[iloc]) - df = iloc.to_frame("iloc").assign(prev=iloc.shift()) - df = df[df["iloc"] > df["prev"] + 1].astype(int) - - # If no drawdown since no trade, avoid below for pandas sake and return nan series - if not len(df): - return (self.drawdown.replace(0, np.nan),) * 2 - - # df = df.reindex(self.drawdown.index) - return df["iloc"].map(self.drawdown.index.__getitem__) - df["prev"].map( - self.drawdown.index.__getitem__ - ) + return self._drawdown_duration @property def drawdown_peaks(self): - iloc = np.unique( - np.r_[(self.drawdown == 0).values.nonzero()[0], len(self.drawdown) - 1] - ) - iloc = pd.Series(iloc, index=self.drawdown.index[iloc]) - df = iloc.to_frame("iloc").assign(prev=iloc.shift()) - df = df[df["iloc"] > df["prev"] + 1].astype(int) - - # If no drawdown since no trade, avoid below for pandas sake and return nan series - if not len(df): - return (self.drawdown.replace(0, np.nan),) * 2 - - # df = df.reindex(self.drawdown.index) - return df.apply( - lambda row: self.drawdown.iloc[row["prev"] : row["iloc"] + 1].max(), axis=1 - ) + return self._drawdown_peaks @property def profit_and_loss(self): @@ -98,15 +117,15 @@ def durations(self): @property def start(self): - return self._market_data._start_index - + return self._index[0] + @property def end(self): - return self._market_data._end_index - + return self._index[-1] + @property def duration(self): - return self.start - self.end + return self.end - self.start @property def positions(self): @@ -114,100 +133,211 @@ def positions(self): @property def exposure_time(self): - pass + have_position = np.repeat(0, len(self._index)) + for t in self._trades_df.itertuples(index=False): + have_position[t.EntryBar : t.ExitBar + 1] = 1 + + return have_position.mean() * 100 @property def equity_final(self): - pass + return self._equity[-1] @property def equity_peak(self): - pass + return self._equity.max() @property def return_pct(self): - pass + equity = self._equity + return (equity[-1] - equity[0]) / equity[0] * 100 @property - def buy_and_hold_return(self): - pass + def annualized_return(self): + day_returns = ( + self._equity_df["Equity"].resample("D").last().dropna().pct_change() + ) + gmean_day_return = self._geometric_mean(day_returns) + annual_trading_days = float( + 365 + if self._index.dayofweek.to_series().between(5, 6).mean() > 2 / 7 * 0.6 + else 252 + ) + return (1 + gmean_day_return) ** annual_trading_days - 1 @property - def annualized_return(self): - pass + def annualized_return_pct(self): + return self.annualized_return * 100 @property def annualized_volatility(self): - pass + day_returns = ( + self._equity_df["Equity"].resample("D").last().dropna().pct_change() + ) + gmean_day_return = self._geometric_mean(day_returns) + annual_trading_days = float( + 365 + if self._index.dayofweek.to_series().between(5, 6).mean() > 2 / 7 * 0.6 + else 252 + ) + return ( + 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 + ) @property def sharpe_ratio(self): - pass + return (self.annualized_return_pct - self._risk_free_rate * 100) / ( + self.annualized_volatility or np.nan + ) @property def sortino_ratio(self): - pass + day_returns = ( + self._equity_df["Equity"].resample("D").last().dropna().pct_change() + ) + annualized_return = self.annualized_return + annual_trading_days = float( + 365 + if self._index.dayofweek.to_series().between(5, 6).mean() > 2 / 7 * 0.6 + else 252 + ) + return (annualized_return - self._risk_free_rate) / ( + np.sqrt(np.mean(day_returns.clip(-np.inf, 0) ** 2)) + * np.sqrt(annual_trading_days) + ) # noqa: E501 @property def calmar_ratio(self): - pass + return self.annualized_return / (-self.max_drawndown or np.nan) @property def max_drawndown(self): - pass + return -np.nan_to_num(self.drawdown.max()) + + @property + def max_drawdown_pct(self): + return self.max_drawndown * 100 @property def avg_drawdown(self): - pass + return -self.drawdown_peaks.mean() * 100 @property def max_drawdown_duration(self): - pass + return self._round_timedelta(self.drawdown_duration.max()) @property def avg_drawdown_duration(self): - pass + return self._round_timedelta(self.drawdown_duration.mean()) @property def number_of_trades(self): - pass + return len(self._trades_df) @property def win_rate(self): - pass + return ( + np.nan if not self.number_of_trades else (self.profit_and_loss > 0).mean() + ) * 100 @property def best_trade_return(self): - pass + return self.returns.max() * 100 @property def worst_trade_return(self): - pass + return self.returns.min() * 100 @property def avg_trade_return(self): - pass + return self._geometric_mean(self.returns) * 100 @property def max_trade_duration(self): - pass + return self._round_timedelta(self.durations.max()) @property def avg_trade_duration(self): - pass + return self._round_timedelta(self.durations.mean()) @property def profit_factor(self): - pass + returns = self.returns + return returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) @property def expectancy(self): - pass + return self.returns.mean() * 100 @property def SQN(self): - pass + n_trades = self.number_of_trades + pl = self.profit_and_loss + return np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) @property def kelly_criterion(self): - pass + pl = self.profit_and_loss + win_rate = np.nan if not len(self._trades_df) else (pl > 0).mean() + return win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean()) + + def _compute_drawdown_stats(self): + _drawdown = 1 - self._equity / np.maximum.accumulate(self._equity) + self._drawdown = pd.Series(_drawdown, index=self._index) + + iloc = np.unique( + np.r_[(self._drawdown == 0).values.nonzero()[0], len(self._drawdown) - 1] + ) + iloc = pd.Series(iloc, index=self._drawdown.index[iloc]) + df = iloc.to_frame("iloc").assign(prev=iloc.shift()) + df = df[df["iloc"] > df["prev"] + 1].astype(int) + + # If no drawdown since no trade, avoid below for pandas sake and return nan series + if not len(df): + self._drawdown_duration, self._drawdown_peaks = ( + self._drawdown.replace(0, np.nan), + ) * 2 + else: + df["duration"] = df["iloc"].map(self._drawdown.index.__getitem__) - df[ + "prev" + ].map(self.drawdown.index.__getitem__) + + df["peak_dd"] = df.apply( + lambda row: self._drawdown.iloc[row["prev"] : row["iloc"] + 1].max(), + axis=1, + ) + + df = df.reindex(self.drawdown.index) + + self._drawdown_duration, self._drawdown_peaks = ( + df["duration"], + df["peak_dd"], + ) + + def _data_period(self, index) -> Union[pd.Timedelta, Number]: + """Return data index period as pd.Timedelta""" + values = pd.Series(index[-100:]) + return values.diff().dropna().median() + + def _round_timedelta(self, value): + + if not isinstance(value, pd.Timedelta): + return value + + _period = self._data_period(self._index) + resolution = getattr(_period, "resolution_string", None) or _period.resolution + return value.ceil(resolution) + + def _geometric_mean(self, returns: pd.Series) -> float: + returns = returns.fillna(0) + 1 + if np.any(returns <= 0): + return 0 + return np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1 diff --git a/tests/unit/resources/indicators.py b/tests/unit/resources/indicators.py index 962f17e..c047a2c 100644 --- a/tests/unit/resources/indicators.py +++ b/tests/unit/resources/indicators.py @@ -1,3 +1,4 @@ +import sys from numbers import Number from typing import Sequence @@ -95,14 +96,16 @@ def _init(self) -> None: """ Create indicators to be used for signals in the `_next` method. """ - data = self.get_data("GOOG", Granularity.D1) - self.sma1 = Sma(data, self.fast) - self.sma2 = Sma(data, self.slow) + self.data = self.get_data("GOOG", Granularity.D1) + self.sma1 = Sma(self.data, self.fast) + self.sma2 = Sma(self.data, self.slow) def _next(self): if crossover(self.sma1._values, self.sma2._values): - # self.position.close() - self.buy("GOOG", 10) + self.broker.close_position("GOOG") + rel_size = 1 - sys.float_info.epsilon + self.buy("GOOG", rel_size) elif crossover(self.sma2._values, self.sma1._values): - # self.position.close() - self.sell("GOOG", 10) + self.broker.close_position("GOOG") + rel_size = 1 - sys.float_info.epsilon + self.sell("GOOG", rel_size) diff --git a/tests/unit/test_backtest_broker.py b/tests/unit/test_backtest_broker.py index 40d90b0..942bcbc 100644 --- a/tests/unit/test_backtest_broker.py +++ b/tests/unit/test_backtest_broker.py @@ -119,7 +119,9 @@ def test_stop_loss_order(index, buy, test_stock_universe: MarketData): ) stop_idx = goog_data.index.get_loc(stop_timestmap) stop_price = ( - goog_data.Low.iloc[stop_idx] + 0.01 if buy else goog_data.High.iloc[stop_idx] - 0.01 + goog_data.Low.iloc[stop_idx] + 0.01 + if buy + else goog_data.High.iloc[stop_idx] - 0.01 ) size = 100 if buy else -100 entry_price = goog_data.Open.iloc[index] @@ -215,7 +217,7 @@ def test_negative_equity(test_stock_universe: MarketData): assert len(broker.orders) == 1 assert len(broker.trades) == 1 - for _ in range(1, 300): + for _ in range(1, 215): test_stock_universe.next() broker.next() @@ -241,8 +243,6 @@ def test_change_position(test_stock_universe: MarketData): assert goog_position.is_long is True assert goog_position.size == 100 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 assert len(goog_position.trades) == 1 broker.order(Order("GOOG", -200)) @@ -250,9 +250,7 @@ def test_change_position(test_stock_universe: MarketData): broker.next() assert goog_position.is_long is False - assert goog_position.size == -200 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 + assert goog_position.size == -100 assert len(goog_position.trades) == 1 @@ -269,8 +267,6 @@ def test_reduce_position(test_stock_universe: MarketData): assert goog_position.is_long is True assert goog_position.size == 100 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 assert len(goog_position.trades) == 1 broker.order(Order("GOOG", -50)) @@ -279,8 +275,6 @@ def test_reduce_position(test_stock_universe: MarketData): assert goog_position.is_long is True assert goog_position.size == 50 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 def test_close_position(test_stock_universe: MarketData): @@ -296,8 +290,6 @@ def test_close_position(test_stock_universe: MarketData): assert goog_position.is_long is True assert goog_position.size == 100 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 assert len(goog_position.trades) == 1 broker.order(Order("GOOG", -100)) @@ -307,8 +299,6 @@ def test_close_position(test_stock_universe: MarketData): assert goog_position.is_long is False assert goog_position.is_short is False assert goog_position.size == 0 - assert goog_position.pl == 0 - assert goog_position.pl_pct == 0 def test_exclusive_orders(test_stock_universe: MarketData): diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py index cdbcab2..31fa04c 100644 --- a/tests/unit/test_stats.py +++ b/tests/unit/test_stats.py @@ -1,2 +1,38 @@ -def test_stats(): - pass \ No newline at end of file +import pandas as pd +import pytest + +from pytradebacktest.backtest import Backtest +from tests.unit.resources.indicators import SmaCross + + +@pytest.mark.asyncio +async def test_stats(test_stock_universe): + test = Backtest(test_stock_universe, SmaCross, 10000) + stats = await test.run() + + assert stats.total_trades == 66 + assert stats.avg_drawdown_duration == pd.Timedelta("41 days 00:00:00") + assert stats.avg_drawdown == -5.925851581948801 + assert stats.avg_trade_duration == pd.Timedelta("46 days 00:00:00") + assert stats.avg_trade_return == 2.531715975158555 + assert stats.best_trade_return == 53.59595229490424 + assert stats.calmar_ratio == 0.4414380935608377 + assert stats.duration == pd.Timedelta("3116 days 00:00:00") + assert stats.end == pd.Timestamp("2013-03-01 00:00:00") + assert stats.equity_final == 51422.98999999996 + assert stats.equity_peak == 75787.44 + assert stats.expectancy == 3.274807806674883 + assert stats.exposure_time == 96.74115456238361 + assert stats.max_drawdown_duration == pd.Timedelta("584 days 00:00:00") + assert stats.max_drawdown_pct == -47.98012705007589 + assert stats.max_trade_duration == pd.Timedelta("183 days 00:00:00") + assert stats.profit_factor == 2.167945974262033 + assert stats.annualized_return_pct == 21.180255813792282 + assert stats.return_pct == 414.2298999999996 + assert stats.annualized_volatility == 36.49390889140787 + assert stats.SQN == 1.07661873566977 + assert stats.kelly_criterion == 0.1518705127029717 + assert stats.sharpe_ratio == 0.5803778344714113 + assert stats.start == pd.Timestamp("2004-08-19 00:00:00") + assert stats.win_rate == 46.96969696969697 + assert stats.worst_trade_return == -18.39887353835481 From 7bbfed208db60b7e9d18635d8f4690f3b724a307 Mon Sep 17 00:00:00 2001 From: Kyle Widmann Date: Wed, 29 Jan 2025 17:37:00 -0500 Subject: [PATCH 3/3] Bump stats --- lib/PyTrade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/PyTrade b/lib/PyTrade index 419e1e8..ed1b101 160000 --- a/lib/PyTrade +++ b/lib/PyTrade @@ -1 +1 @@ -Subproject commit 419e1e8b56926b76a78f0cbac61615e054920f36 +Subproject commit ed1b1018c69f0e05aec33e046e724f8804d37d74