Skip to content

Plotted data and printed stats don't match #362

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Pilipets opened this issue May 25, 2021 · 6 comments
Closed

Plotted data and printed stats don't match #362

Pilipets opened this issue May 25, 2021 · 6 comments
Labels
question Not a bug, but a FAQ entry

Comments

@Pilipets
Copy link

Pilipets commented May 25, 2021

Max drawdown duration is 273 days from the plot, but 248 days from the stats.

2
3

@Pilipets Pilipets changed the title Plotted data and stats doesn't match. Plotted data and printed stats doesn't match. May 25, 2021
@kernc
Copy link
Owner

kernc commented May 25, 2021

The issue is not observed in the simple examples that accompany the distribution.

Are you, perchance, using more than:

_MAX_CANDLES = 10_000

bars of input data, with some downsampling (bt.plot(resample=)) taking place?

There are indeed two different code paths: stats:

s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())

using:
@staticmethod
def _compute_drawdown_duration_peaks(dd: pd.Series):
iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1])
iloc = pd.Series(iloc, index=dd.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 (dd.replace(0, np.nan),) * 2
df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__)
df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1)
df = df.reindex(dd.index)
return df['duration'], df['peak_dd']
def _compute_stats(self, broker: _Broker, strategy: Strategy) -> pd.Series:
data = self._data
index = data.index
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
dd = 1 - equity / np.maximum.accumulate(equity)
dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=index))

And plot:
dd_end = equity_data['DrawdownDuration'].idxmax()
if np.isnan(dd_end):
dd_start = dd_end = equity.index[0]
else:
dd_start = equity[:dd_end].idxmax()
# If DD not extending into the future, get exact point of intersection with equity
if dd_end != equity.index[-1]:
dd_end = np.interp(equity[dd_start],
(equity[dd_end - 1], equity[dd_end]),
(dd_end - 1, dd_end))

dd_timedelta_label = df['datetime'].iloc[int(round(dd_end))] - df['datetime'].iloc[dd_start]
fig.line([dd_start, dd_end], equity.iloc[dd_start],
line_color='red', line_width=2,
legend_label=f'Max Dd Dur. ({dd_timedelta_label})'
.replace(' 00:00:00', '')
.replace('(0 days ', '('))

I'd consider stats value the more reliable one.

Appreciate any further investigation you can do.

What version is this anyway? Something similar had been fixed in #162.

@Pilipets
Copy link
Author

Pilipets commented May 25, 2021

Here is the version Backtesting==0.3.1
I was using a CSV file with 1 hour ohlc data - around 10k rows.

Thanks for your quick reply - I understand that the issue needs to be investigated on my side, but I created it in case anyone else will experience such.

@kernc
Copy link
Owner

kernc commented May 25, 2021

It's most likely that the issue is a rounding error that stems from plot resampling (Backtest.plot(resample=)). Plotting resamples very early in its pipeline:

# Limit data to max_candles
if is_datetime_index:
df, indicators, equity_data, trades = _maybe_resample_data(
resample, df, indicators, equity_data, trades)

@Pilipets
Copy link
Author

Pilipets commented May 25, 2021

It's most likely that the issue is a rounding error that stems from plot resampling (Backtest.plot(resample=)). Plotting resamples very early in its pipeline:

# Limit data to max_candles
if is_datetime_index:
df, indicators, equity_data, trades = _maybe_resample_data(
resample, df, indicators, equity_data, trades)

backtest = Backtest(df, *, cash=*.cash, commission=.002, hedging=True)
print(backtest.run())
backtest.plot(resample=True, filename="temp2.html")

I'm using resampling as above, where len(df) == 7825.

Alright, as far as I understood from your response, this is expected behavior due to the resampling taking place. Let me know if I should close the issue.

@Pilipets
Copy link
Author

Pilipets commented May 26, 2021

But why trades amount is different as well - 68 vs 175?
This is a larger dataset, but is it because of the resampling as well?

image
image

@kernc
Copy link
Owner

kernc commented May 27, 2021

I'm fairly confident it's due to brefore-plot resampling. See how trades are resampled and weighted aggregated on the bottom here:

_MAX_CANDLES = 10_000
def _maybe_resample_data(resample_rule, df, indicators, equity_data, trades):
if isinstance(resample_rule, str):
freq = resample_rule
else:
if resample_rule is False or len(df) <= _MAX_CANDLES:
return df, indicators, equity_data, trades
from_index = dict(day=-2, hour=-6, minute=1, second=0, millisecond=0,
microsecond=0, nanosecond=0)[df.index.resolution]
FREQS = ('1T', '5T', '10T', '15T', '30T', '1H', '2H', '4H', '8H', '1D', '1W', '1M')
freq = next((f for f in FREQS[from_index:]
if len(df.resample(f)) <= _MAX_CANDLES), FREQS[-1])
warnings.warn(f"Data contains too many candlesticks to plot; downsampling to {freq!r}. "
"See `Backtest.plot(resample=...)`")
from .lib import OHLCV_AGG, TRADES_AGG, _EQUITY_AGG
df = df.resample(freq, label='right').agg(OHLCV_AGG).dropna()
indicators = [_Indicator(i.df.resample(freq, label='right').mean()
.dropna().reindex(df.index).values.T,
**dict(i._opts, name=i.name,
# Replace saved index with the resampled one
index=df.index))
for i in indicators]
assert not indicators or indicators[0].df.index.equals(df.index)
equity_data = equity_data.resample(freq, label='right').agg(_EQUITY_AGG).dropna(how='all')
assert equity_data.index.equals(df.index)
def _weighted_returns(s, trades=trades):
df = trades.loc[s.index]
return ((df['Size'].abs() * df['ReturnPct']) / df['Size'].abs().sum()).sum()
def _group_trades(column):
def f(s, new_index=df.index.astype(np.int64), bars=trades[column]):
if s.size:
# Via int64 because on pandas recently broken datetime
mean_time = int(bars.loc[s.index].view('i8').mean())
new_bar_idx = new_index.get_loc(mean_time, method='nearest')
return new_bar_idx
return f
if len(trades): # Avoid pandas "resampling on Int64 index" error
trades = trades.assign(count=1).resample(freq, on='ExitTime', label='right').agg(dict(
TRADES_AGG,
ReturnPct=_weighted_returns,
count='sum',
EntryBar=_group_trades('EntryTime'),
ExitBar=_group_trades('ExitTime'),
)).dropna()
return df, indicators, equity_data, trades

Plot trade count is then inferred simply as length of the data frame:

fig_ohlc.multi_line(xs='position_lines_xs', ys='position_lines_ys',
source=trade_source, line_color=trades_cmap,
legend_label=f'Trades ({len(trades)})',
line_width=8, line_alpha=1, line_dash='dotted')

Improvements are always welcome! 😁

If you avoid resampling by running:

backtest.plot(resample=False, filename="temp2.html")

or:

backtest.plot(resample=1_000_000, filename="temp2.html")  # Some large number

you should see values more aligned with those expected.

@kernc kernc added the question Not a bug, but a FAQ entry label May 27, 2021
@kernc kernc changed the title Plotted data and printed stats doesn't match. Plotted data and printed stats don't match May 27, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Not a bug, but a FAQ entry
Projects
None yet
Development

No branches or pull requests

2 participants