Backtesting a signal: from indicator to a costed equity curve#

BacktestPriceTarget turns a position signal and a price into a mark-to-market equity curve with transaction costs, and backtest_report reads off the statistics. This example builds a trend signal on real Deribit BTC-perpetual bars and backtests it, with and without cost.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from screamer import BacktestPriceTarget, RollingMean, backtest_report, Resample

trades = pd.read_csv("data/deribit_btc_perp_6h.csv")
ts = trades["timestamp"].to_numpy(np.int64)
price = trades["price"].to_numpy(np.float64)
close, idx = Resample(freq=60_000, agg="last")(price, ts)   # one-minute close
t = pd.to_datetime(idx, unit="ms")
print(f"{len(close)} one-minute bars")
319 one-minute bars

A trend signal#

A moving-average crossover: hold long when the fast average is above the slow one, short when it is below. np.sign maps that to a position of +1 or -1; the warmup region is flat (0).

fast = RollingMean(5)(close)
slow = RollingMean(30)(close)
signal = np.sign(np.nan_to_num(fast - slow))   # +1 long, -1 short, 0 while warming up

Backtest it, with and without cost#

The frictionless run is the raw edge of the signal; the costed run charges a 5 bps spread and a 2 bps fee on every trade. The gap between them is what trading costs eat.

free = BacktestPriceTarget()(signal, close)
costed = BacktestPriceTarget(spread=0.0005, fee=0.0002)(signal, close)
running, summary = backtest_report(costed)      # running columns + summary (no pandas)

eq_f, eq_c = free[:, 0], costed[:, 0]
dd = running["drawdown"]                          # dollar drawdown, from the BacktestReport node

fig, (a0, a1) = plt.subplots(2, 1, sharex=True, figsize=(9, 5),
                             gridspec_kw={"height_ratios": [2, 1]})
a0.plot(t, eq_f, color="0.6", ls="--", label="frictionless")
a0.plot(t, eq_c, color="steelblue", label="with cost")
a0.axhline(0, color="k", lw=0.5); a0.set_ylabel("equity ($)")
a0.set_title("Strategy equity"); a0.legend()
a1.fill_between(t, dd, 0, color="crimson", alpha=0.5); a1.set_ylabel("drawdown ($)")
plt.tight_layout()
../_images/a1f8230a5913538d00e65d3fefc25f24e142d34baa3717aff039b6dbe7fd3af1.png

The statistics#

backtest_report aggregates the engine's [equity, pnl, position, cost] output into the summary every backtest wants.

for name, value in summary.items():             # summary is a plain dict of floats
    print(f"{name:13s} {value:10.4f}")
total_pnl      -346.5605
max_drawdown   -422.5605
total_cost      354.5605
turnover         27.0000
num_trades       15.0000
sharpe           -0.1481

Statistics are running series#

Each statistic produced by backtest_report is a causal time series, so you can watch the cost and the trade count accumulate through the session, not just read the final number.

fig, (a0, a1) = plt.subplots(2, 1, sharex=True, figsize=(9, 4))
a0.plot(t, running["cum_cost"], color="crimson"); a0.set_ylabel("cumulative cost ($)")
a1.plot(t, running["trades"], color="steelblue"); a1.set_ylabel("number of trades")
plt.tight_layout()
../_images/0d2a0ff52beb72b855a210ccb7be08098a17b4ed1dd56839b560f455c6cedbb1.png