Skip to content

Repository files navigation

FlashBackTesting

A small, fast backtesting framework built around one idea: signals are taken on a coarse timeframe, but every order is resolved on a fine one. Whether the take-profit or the stop-loss was reached first is decided by the actual price path inside the bar, not guessed from a single candle.

It works on one instrument at a time and is meant for optimising entry/exit rules driven by technical signals. It does not do stock picking, arbitrage, or multi-asset rebalancing.

Requires Python >= 3.10, pandas >= 1.5, numpy >= 1.23.

Table of Contents

Install

git clone https://github.com/almprmg/BackTest.git
cd BackTest
pip install -e .

Or just work inside the repository: from flashbacktesting import FlashBackTesting.

Quick start

import pandas as pd
from flashbacktesting import FlashBackTesting, Strategy

# Two timeframes of the same instrument, indexed by time.
hourly  = pd.read_csv('AGLDUSDT_1h.csv',  index_col=0, parse_dates=True)
minutes = pd.read_csv('AGLDUSDT_5m.csv', index_col=0, parse_dates=True)


def SMA(values, n):
    return pd.Series(values).rolling(n).mean().to_numpy()


class SmaCross(Strategy):
    n1 = 10
    n2 = 20

    def init(self):
        # init() sees the whole history: pre-compute here, once.
        close = self.data.Close
        self.sma1 = self.I(SMA, close, self.n1)
        self.sma2 = self.I(SMA, close, self.n2)

    def next(self):
        # next() only sees bars that have already closed.
        price = self.data.Close[-1]
        if self.sma1[-2] < self.sma2[-2] and self.sma1[-1] > self.sma2[-1]:
            self.buy(limit=price, tp=price * 1.20, sl=price * 0.90)
        elif self.sma1[-2] > self.sma2[-2] and self.sma1[-1] < self.sma2[-1]:
            self.sell(limit=price, tp=price * 0.80, sl=price * 1.10)


bt = FlashBackTesting(hourly, minutes, SmaCross, cash=1000, ratio_entry=20, fees=0.001)
stats = bt.run()
print(stats)
print(stats.trades)          # every trade, with entry/exit time, price and PnL
print(stats.equity_curve)    # equity, drawdown and open positions per bar

Parameters can be swept without rebuilding the object:

best, params = bt.optimize(maximize='Return [%]', n1=range(5, 30, 5), n2=range(20, 80, 10))

How a trade is simulated

  1. next() is called on bar i of the high timeframe, after that bar closed.
  2. An order placed there becomes active on bar i+1. Nothing that happened inside bar i can be used, so a strategy cannot trade on information it did not have.
  3. The order fills on the small timeframe the first time price actually reaches the limit (fill='limit'). A gap through the limit fills at the open, i.e. at a better price. fill='market' fills at the next open instead; fill='immediate' fills at the limit no matter what (pre-0.2 behaviour).
  4. From the fill onwards, the small timeframe is scanned for the take-profit and the stop-loss; the first one reached wins. A gap through a level fills at that bar's open, so a stop can slip. If both fall inside the same small bar the intrabar path is unknown and tie_breaker decides: 'sl' (default, pessimistic) or 'tp'.
  5. A fill away from the limit (a gap, or fill='market') can leave the entry on the far side of a level. Such a trade closes at its entry price: a stop never turns a profit and a target never turns a loss.
  6. Fees are charged on both sides, on the traded notional.
  7. If neither level is reached before the data ends, the trade is closed at the last available price and reported under # Forced Exits.

next() is not called while a registered indicator is still warming up: with SMA(20) the run starts on bar 20, so ind[-1] is never a NaN placeholder and ind[-2] never raises. bt.warmup_bars reports how many bars were skipped.

Deliberate simplifications, so the numbers are read for what they are: no funding, no borrow cost, no partial fills, no order-book impact, and a stop-loss is assumed to fill at its level (or at the open on a gap) rather than being swept.

One limitation is worth stating plainly: inside a single small bar, an OHLC row cannot say whether the high came before or after the fill, so a target reached in the very bar that fills the order is credited. A finer data_small shrinks the window where this matters, and tie_breaker covers the case where both levels sit in the same bar. Annualised figures (CAGR, Sharpe, ...) are reported as NaN rather than a number when the sample is too short to support them.

API

FlashBackTesting(data, data_small, strategy, ...)

argument default meaning
data -- high (signal) timeframe, OHLC(V)
data_small -- fine timeframe used to resolve orders
strategy -- a Strategy subclass
cash 1000 starting capital
ratio_entry 100 percent of the account per order, in (0, 100]
fees 0.001 fee rate per side (0.001 = 0.1%)
all_signal False allow concurrent orders
cp False compound: size off the current balance
max_orders 100 // ratio_entry cap on concurrent orders
fill 'limit' 'limit', 'market' or 'immediate'
fill_timeout None cancel an order that has not filled in time
tie_breaker 'sl' who wins inside a single small bar
validate True run the OHLC sanity checks

Both frames are validated: required columns (any capitalisation), no NaNs, a sorted and unique index, and Low <= min(Open, Close) <= max(Open, Close) <= High on every bar. Malformed bars raise; duplicated timestamps are dropped with a warning. Pass validate=False to downgrade the bar check to a warning.

Strategy

member description
init() called once, sees the whole history; pre-compute here
next() called per bar, sees only closed bars
self.I(func, *args) register an indicator; it is truncated to "now" automatically
self.data history up to now; self.data.Close[-1] is the latest value
self.buy(limit, tp, sl) long order, needs sl < limit < tp
self.sell(limit, tp, sl) short order, needs tp < limit < sl
self.position True while an order is open
self.trades / self.closed_trades open orders / finished trades
self.equity realised balance so far

Results

run() returns a pd.Series with, among others:

Start / End / Duration / Exposure Time [%]
Equity Start [$] / Equity Final [$] / Equity Peak [$]
Return [%] / Buy & Hold Return [%] / CAGR [%] / Return (Ann.) [%]
Volatility (Ann.) [%] / Sharpe Ratio / Sortino Ratio / Calmar Ratio
Max. Drawdown [%] / Avg. Drawdown [%] / Max. & Avg. Drawdown Duration
# Trades / # Unfilled Orders / # Open at End / # Forced Exits
Win Rate [%] / Best / Worst / Avg. Trade [%] / Avg. Win [%] / Avg. Loss [%]
Max. & Avg. Trade Duration / Profit Factor / Expectancy [%] / Expectancy [$]
SQN / Fees Paid [$] / Max. Consecutive Wins / Max. Consecutive Losses
_trades / _equity_curve / _rejected

The equity curve is marked to market on the high timeframe: closed trades are realised on their exit bar and open trades contribute their unrealised PnL, so drawdown and Sharpe describe the account as it was actually lived.

What changed in 0.2.0

The engine was rebuilt. The bugs below all changed reported PnL, so results produced with an earlier version are not comparable.

# bug effect
1 check_empty() returned the last matching timestamp instead of the first a trade whose target was touched once, with the stop never touched, was booked as a stop-loss at a price that never traded
2 fees were subtracted once instead of per trade fees were understated by roughly n_trades - 1 times
3 Return [%] and drawdown were measured from the equity after the first trade the first trade never showed up in the return or the drawdown
4 operator precedence in the order gate (A and B if C else D) the concurrency cap was ignored: 19 concurrent orders on a 5-order account, equity driven below zero
5 the exit search window started inside the signal bar look-ahead: a trade could be resolved on data the strategy had not seen
6 Strategy.init() was never called pre-computation silently did nothing
7 trades were removed from the list being iterated closes were skipped
8 trades still open at the end were dropped trades disappeared from the results
9 cash was multiplied by 1000 via string concatenation any fractional starting capital crashed
10 the default ratio_entry=1000 was rejected by the validator the documented defaults raised ValueError
11 orders were always filled at the limit, even if price never got there trades that could not have happened
12 no equity curve drawdown was sampled only at trade closes
13 zero-trade runs crashed --
14 no input validation duplicated timestamps and malformed bars went straight into the results

New in this version: intrabar gap slippage, unfilled/expired limit orders, fill and tie_breaker policies, a mark-to-market equity curve, Sharpe / Sortino / Calmar / CAGR / exposure, Strategy.I() look-ahead-free indicators, automatic indicator warm-up, optimize(), and a test suite. Orders are dropped rather than trusted when they would produce a nonsensical trade: an unreachable limit, a non-positive fill price, or a level already passed at entry.

Measured on the bundled sample (data11.csv, hourly, 5%/3% targets, 0.1% fees, 20% per order), the old engine reported -12.3% where the correct figure is -22.7%: the missing fees and the look-ahead flattered the result by ten points.

The old flat modules still import (from flashBackTesting import FlashBackTesting, Strategy), they now re-export the package.

Tests

pip install pytest
python -m pytest

155 tests cover intrabar resolution, fees and sizing, statistics, risk limits, validation, timezones, backwards compatibility, end-to-end runs on the bundled data, and a randomised sweep asserting the invariants that must hold for any strategy (the account equals the sum of its trades, every fill price is one the market traded at, a stop never profits, the concurrency cap holds). The examples in this file and every code cell of the quick start notebook are checked against the real signatures, so the documentation cannot drift from the code.

Contributing

Fork, branch, keep python -m pytest green, and open a pull request describing the change.

License

AGPL-3.0

Contact

Hareth AL-Maqtari