Skip to content

Backtesting

Run your strategy against recorded market data.

Prerequisites

  • Completed Recording Data (or have a CSV / .floxlog file)
  • Build / install with backtest support — see Bindings for per-language details

The pipeline

data file → BacktestRunner → Strategy → SimulatedExecutor → BacktestResult

BacktestRunner runs your strategy through a SimulatedExecutor with a flat fee rate and nothing else. Useful for indicator sanity checks. Numbers it produces ignore funding, liquidation, queue position, rate limits, and venue outages — the forces that decide whether a perp strategy survives in production.

To run the same strategy against a venue's fill mechanics, hand the runner a VenueStack:

"""Run a BacktestRunner against a VenueStack's fill mechanics.

The runner feeds the stack's executor market data and harvests its fills,
so the run uses that venue's queue model and depth, iceberg refresh
latency, venue availability and rate limits.

Two things this does not change: fees still come from BacktestConfig
rather than the stack's FeeSchedule (fills carry is_maker, so per-side
rates work, but the schedule's 30-day volume tiering is not consulted),
and funding and liquidation are driven by explicit calls rather than the
replay loop. Read the result as "this venue's fill mechanics", not "this
venue's full economics".
"""
import pathlib
from collections import deque

import flox_py as flox

CSV = str(
    pathlib.Path(flox.__file__).resolve().parent
    / "templates/research/data/btcusdt_sample.csv"
)


class CrossAboveSMA(flox.Strategy):
    def __init__(self, symbols, period=20):
        super().__init__(symbols)
        self.window = deque(maxlen=period)

    def on_trade(self, ctx, trade):
        self.window.append(trade.price)
        if len(self.window) < self.window.maxlen:
            return
        sma = sum(self.window) / len(self.window)
        if trade.price > sma and ctx.is_flat():
            self.market_buy(1.0)
        elif trade.price < sma and ctx.is_long():
            self.close_position()


def main():
    registry = flox.SymbolRegistry()
    btc = registry.add_symbol("binance", "BTCUSDT", tick_size=0.01)

    bt = flox.BacktestRunner(registry, fee_rate=0.0004, initial_capital=10_000.0)
    bt.set_strategy(CrossAboveSMA([btc]))

    stack = flox.VenueStack.binance_um_futures(account_id=42, equity=10_000.0)
    bt.set_venue_stack(stack)  # pass None to revert to the built-in executor

    stats = bt.run_csv(CSV, "BTCUSDT")

    print(f"trades      : {stats['total_trades']}")
    print(f"return      : {stats['return_pct']:.2f}%")
    print(f"venue fills : {len(stack.executor().fills_list())}")


if __name__ == "__main__":
    main()

The runner then feeds that stack's executor market data and harvests its fills, so the run uses the venue's queue model and depth, iceberg refresh latency, venue availability and rate limits.

Two things it does not change, so read the result as "this venue's fill mechanics" and not "this venue's full economics":

  • Fees still come from BacktestConfig, not from the stack's FeeSchedule. Fills carry is_maker, so maker_fee_rate / taker_fee_rate charge the real spread between posting and taking; what the stack still knows and the result does not is 30-day volume tiering.
  • Funding and liquidation are driven by explicit calls (FundingSchedule settlement, LiquidationEngine.on_marks), not by the replay loop.

See Realistic backtest in one call and Cross-margin accounts.

Minimal example

A strategy that buys when price crosses above a 20-period SMA. Match the output against a known baseline; do not size positions off these numbers.

import flox_py as flox
from collections import deque

class CrossAboveSMA(flox.Strategy):
    def __init__(self, symbols, period=20):
        super().__init__(symbols)
        self.window = deque(maxlen=period)

    def on_trade(self, ctx, trade):
        self.window.append(trade.price)
        if len(self.window) < self.window.maxlen:
            return
        sma = sum(self.window) / len(self.window)
        if trade.price > sma and ctx.is_flat():
            self.market_buy(1.0)
        elif trade.price < sma and ctx.is_long():
            self.close_position()

reg = flox.SymbolRegistry()
btc = reg.add_symbol("binance", "BTCUSDT", tick_size=0.01)

strat = CrossAboveSMA([btc])
bt = flox.BacktestRunner(reg, fee_rate=0.0004, initial_capital=10_000)
bt.set_strategy(strat)

stats = bt.run_csv("/data/btcusdt_1m.csv", "BTCUSDT")
print(f"Return {stats['return_pct']:.2f}%  Sharpe {stats['sharpe_ratio']:.2f}  "
      f"DD {stats['max_drawdown_pct']:.2f}%  trades {stats['total_trades']}")
const flox = require('@flox-foundation/flox');

class CrossAboveSMA {
  constructor(symbols, period = 20) {
    this.symbols = symbols;
    this.period = period;
    this.window = [];
  }
  onTrade(ctx, trade, emit) {
    this.window.push(trade.price);
    if (this.window.length > this.period) this.window.shift();
    if (this.window.length < this.period) return;
    const sma = this.window.reduce((a,b)=>a+b, 0) / this.period;
    if (trade.price > sma && ctx.position === 0) emit.marketBuy(1.0);
    else if (trade.price < sma && ctx.position > 0) emit.closePosition();
  }
}

const reg = new flox.SymbolRegistry();
const btc = reg.addSymbol("binance", "BTCUSDT", 0.01);
const bt  = new flox.BacktestRunner(reg, 0.0004, 10_000);
bt.setStrategy(new CrossAboveSMA([btc]));

const stats = bt.runCsv("/data/btcusdt_1m.csv", "BTCUSDT");
console.log(`Return ${stats.returnPct.toFixed(2)}%  Sharpe ${stats.sharpeRatio.toFixed(2)}`);
from flox.runner import BacktestRunner
from flox.strategy import Strategy
from flox.context import SymbolContext
from flox.types import TradeData
from flox.indicators import SMA

class CrossAboveSMA(Strategy):
    sma: SMA
    def __init__(self, symbols: List[int], period: int = 20):
        super().__init__(symbols)
        self.sma = SMA(period)

    def on_trade(self, ctx: SymbolContext, trade: TradeData):
        v = self.sma.update(trade.price.to_double())
        if not self.sma.ready:
            return
        if trade.price.to_double() > v and self.position() == 0.0:
            self.market_buy(1.0)
        elif trade.price.to_double() < v and self.position() > 0.0:
            self.close_position()

bt = BacktestRunner(reg, fee_rate=0.0004, initial_capital=10_000.0)
bt.set_strategy(CrossAboveSMA([btc]))
stats = bt.run_csv("/data/btcusdt_1m.csv", "BTCUSDT")
#include "flox/backtest/backtest_runner.h"
#include "flox/replay/abstract_event_reader.h"

int main() {
  replay::ReaderFilter filter;
  filter.symbols = {1};
  auto reader = replay::createMultiSegmentReader("/data/btcusdt", filter);

  BacktestConfig config{ .initialCapital = 10000.0, .feeRate = 0.0004 };
  BacktestRunner runner(config);

  SymbolRegistry registry;
  SymbolInfo info{ .exchange = "binance", .symbol = "BTCUSDT",
                   .tickSize = Price::fromDouble(0.01) };
  auto symId = registry.registerSymbol(info);

  MyStrategy strat(symId, registry);
  runner.setStrategy(&strat);

  auto result = runner.run(*reader);
  auto stats  = result.computeStats();
  std::cout << "Return " << stats.returnPct << "%\n";
}

What's in stats

Python and Codon use snake_case, Node.js camelCase. C++ reads the same fields off BacktestStats.

Python / Codon Node.js Description
total_trades totalTrades Number of closed trades
final_capital finalCapital Ending capital
return_pct returnPct Total return %
sharpe_ratio sharpeRatio Annualised Sharpe
sortino_ratio sortinoRatio Annualised Sortino
calmar_ratio calmarRatio Calmar ratio
max_drawdown_pct maxDrawdownPct Worst drawdown
win_rate winRate Win rate (0-1)
profit_factor profitFactor Gross profit / gross loss

Python BacktestRunner.run_* returns a plain dict; Codon returns a BacktestStats object with attribute access; Node.js returns an object literal. Every key follows the snake_case to camelCase rule with no exceptions, and every producer emits all three risk ratios -- the C++ BacktestStats field name is the source both are derived from.

Time-range filtering

Use pandas to slice your CSV before passing in, or pass arrays to run_bars(start_time_ns, end_time_ns, ...) filtered to the window you want.

replay::ReaderFilter filter;
filter.from_ns = 1704067200000000000LL;   // 2024-01-01
filter.to_ns   = 1704153600000000000LL;   // 2024-01-02
filter.symbols = {1};
auto reader = replay::createMultiSegmentReader("/data", filter);

Performance tips

  1. Release buildcmake -DCMAKE_BUILD_TYPE=Release (pip install flox-py already gives you Release)
  2. Filter symbols — only load what you need
  3. Pre-aggregate for repeated parameter sweeps; see Bar aggregation
  4. Avoid logging in callbacks — measure first; logging in the inner loop dominates

Next

BacktestRunner is a flat fee rate, no funding, no liquidation, no rate limits. Good enough for a sanity check; not enough before live. flox.VenueStack.binance_um_futures(account_id=42, equity=10_000) gives you an executor, account, liquidation engine, fee schedule and funding schedule with venue defaults, driven directly. See the links below.