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.
git clone https://github.com/almprmg/BackTest.git
cd BackTest
pip install -e .Or just work inside the repository: from flashbacktesting import FlashBackTesting.
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 barParameters can be swept without rebuilding the object:
best, params = bt.optimize(maximize='Return [%]', n1=range(5, 30, 5), n2=range(20, 80, 10))next()is called on bar i of the high timeframe, after that bar closed.- 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.
- 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). - 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_breakerdecides:'sl'(default, pessimistic) or'tp'. - 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. - Fees are charged on both sides, on the traded notional.
- 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.
| 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.
| 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 |
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.
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.
pip install pytest
python -m pytest155 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.
Fork, branch, keep python -m pytest green, and open a pull request describing
the change.
Hareth AL-Maqtari