Skip to main content

RaptorBT

PyPI Python Rust Downloads License Support

Blazing-fast backtesting for the modern quant.

RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It runs single-instrument, basket, pairs, options, spread, multi-strategy, and tick-level backtests over any OHLCV or tick arrays — from any broker, market, or asset class — and returns a full performance report in sub-millisecond time.

~13M bars/sec · Sweeps across every core · Bit-for-bit deterministic


Quick Install

pip install raptorbt

Upgrading from 0.6.x or 0.7.x? Public classes dropped their Py prefix in 0.7.0 — PyBacktestConfig is now BacktestConfig, PyTrade is Trade, and so on. The old names resolved with a DeprecationWarning through 0.7.x and are removed in 0.8.0: they now raise AttributeError. Rename them, or pin raptorbt<0.8.

Two other changes alter results. In 0.7.0, BarAggregator began honouring brick_size (Renko backtests through it were wrong) and tick backtests stopped truncating at 50 trades by default. In 0.8.0, each leg of a spread settles on its own expiry date, so calendar and diagonal spreads are measured correctly for the first time; same-expiry structures are unaffected. See the CHANGELOG.

30-Second Example

import numpy as np
import raptorbt

# Configure
config = raptorbt.BacktestConfig(initial_capital=100000, fees=0.001)

# Run backtest
result = raptorbt.run_single_backtest(
    timestamps=timestamps,
    open=open,
    high=high,
    low=low,
    close=close,
    volume=volume,
    entries=entries,
    exits=exits,
    direction=1,
    weight=1.0,
    symbol="AAPL",
    config=config,
)

# Results
print(f"Return: {result.metrics.total_return_pct:.2f}%")
print(f"Sharpe: {result.metrics.sharpe_ratio:.2f}")

RaptorBT is open source (MIT) and developed by the Alphabench team.


Table of Contents


Overview

RaptorBT compiles to a single native extension and runs entirely in Rust, so a full backtest with all 48 metrics finishes in under a millisecond at the bar counts most strategies use. Measured on an Apple M4 (raptorbt 0.13.2, Python 3.12):

Metric RaptorBT
Compiled engine size 1.75 MB
Backtest speed (1K bars) ~0.075 ms
Backtest speed (10K bars) ~0.77 ms
Backtest speed (50K bars) ~3.85 ms
Sustained throughput ~13M bars/sec
184-combo sweep, 10 cores 170 ms, 7.7x
Peak memory, 184-combo sweep 56 MB

See Performance for the full method and how to reproduce these numbers on your own hardware.

Key Features

  • 8 Strategy Types: Single instrument, basket/collective, pairs trading, options, spreads, multi-strategy, tick-level, and shared-capital portfolio
  • Two ways to write a strategy: precomputed signal arrays (the vectorized fast path), or a Strategy class with lifecycle hooks driven by bars, ticks, or a live feed
  • Asset- and broker-agnostic: Pass NumPy OHLCV or tick arrays from any source — equities, futures, FX, crypto, options — RaptorBT never assumes a market or data vendor
  • Tick-Level Simulation: Full tick resolution for intraday options momentum, scalping, and microstructure strategies
  • Live-feed ready: Push events as they arrive with TickStrategyStream, and seed a run with positions the account already holds via position adoption
  • Portfolio Construction: Ledoit-Wolf covariance, a constrained optimizer — long-only by default, long/short with gross and net exposure budgets (v0.6.3) — factor panels with rank-IC validation, risk contributions, and rebalance-cost simulation
  • Parallel Parameter Sweeps: batch_single_backtest runs many signal sets over one price series across every core — 7.7x on ten cores, bit-identical to a serial loop
  • Batch Spread Backtesting: Run multiple spread backtests in parallel via Rayon with GIL released
  • Monte Carlo Simulation: Correlated multi-asset forward projection via GBM + Cholesky decomposition
  • 48 Metrics: Sharpe, Sortino, Calmar, Omega, Ulcer Index, Time Under Water, SQN, Payoff Ratio, Recovery Factor, and more
  • Diagnostics that say why: return skew and excess kurtosis (is a high Sharpe really a short-vol payoff?), cost-to-gross-profit and breakeven cost multiple (do fees eat the edge, and how much room is left?), average MAE and MFE capture (where should the stop sit, and is the exit giving the move back?)
  • 20 Indicator & Tick Functions: 12 classic technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min/Max) plus 8 tick microstructure/feature functions
  • Stop/Target Management: Fixed, ATR-based, and trailing stops with risk-reward targets
  • Deterministic: Identical inputs produce bit-for-bit identical results across runs — no JIT compilation variance
  • Native Parallelism: Rayon-based parallel processing with explicit SIMD optimizations

Performance

Benchmark Results

Measured on an Apple M4 (10 cores, 24 GB, raptorbt 0.13.2, Python 3.12) with random-walk price data and an SMA-crossover strategy. Each figure is the fastest of several hundred run_single_backtest repetitions, so it reflects engine time rather than scheduler noise. Reproduce any row with uv run python benches/python/run_all.py — the harness is in the repo precisely so these are checkable.

Data size Time Throughput
1,000 bars 0.075 ms 13M bars/sec
5,000 bars 0.38 ms 13M bars/sec
10,000 bars 0.77 ms 13M bars/sec
50,000 bars 3.85 ms 13M bars/sec
93,750 bars 7.19 ms 13M bars/sec
1,875,000 bars 158 ms 12M bars/sec
25,000,000 bars 1.92 s 13M bars/sec

The 1,875,000-bar row is the one worth dwelling on: that is roughly twenty years of Indian one-minute intraday data, backtested in about a sixth of a second. Throughput holds between 11M and 13M bars/sec across the whole range, from a thousand bars to twenty-five million — a spread of about 15%, against a 25,000x change in input size. Scaling is linear: the engine does not fall off a cliff when the data stops fitting in cache.

Every row runs the full metric set — all 48 fields, including the return-shape and cost diagnostics added in 0.13.2. Against the published 0.13.1 wheel on this same harness, small runs are unchanged (0.072 → 0.075 ms at 1,000 bars) and large ones are faster: 1.875M bars 165 → 158 ms, and 25M bars 2.43 s → 1.92 s, a 21% improvement. Trade counts are identical at every size. The large-run gain is the binding no longer duplicating its input — see Memory below.

Other paths, measured the same way:

Path Result
Tick engine 107–221M ticks/sec, every tick traversed to the end of the array
500 option spreads in parallel 40,817/sec, 7.6x faster than serial, bit-for-bit identical to it
184-combo sweep, one Python loop 1.51 s wall, 56 MB peak RSS
184-combo sweep, batch_single_backtest 170 ms, 7.7x faster, bit-identical to the serial loop
25M-bar run, peak memory over a 1.25 GB input 1.13 GB (was 2.16 GB before the input copy was removed)
Determinism 20 runs → one SHA-256
Compiled engine 1.75 MB
Metrics per backtest 48 attributes (31 in to_dict())

The tick row is a range because tick throughput, unlike bar throughput, does not stay flat: ~197M ticks/sec at 10,000 ticks and ~221M at 100,000, falling to ~164M at a million and 100–130M at ten million as the arrays outgrow cache. The ten-million row is also the least repeatable — it is the one measurement here that varies by more than a few percent between runs. Every figure is verified to traverse to the last tick before it is published: a truncated run would time a prefix of the array and report an inflated ticks/sec, so the harness asserts the final trade's exit index lands at the end of the input.

Compare within one harness only. These figures come from benches/python/run_all.py at 0.13.2. Numbers published against 0.6.4 and earlier came from a one-off script that was not kept and are not comparable to them — that is why the harness now ships with the code. The spreads and sweep rows are measured standalone (run_all.py spreads, run_all.py sweep): the first competes with Rayon for the same cores, and the second reads a whole-process peak-RSS high-water mark that anything running before it would inflate.

Timings will vary with your CPU, data, and signal density.

Memory

The engine reads NumPy's buffers directly rather than copying them, so a run's peak memory is roughly its input plus the curves it produces, not twice its input. On a 25-million-bar backtest over a 1.25 GB input, peak RSS is 1.13 GB above baseline, down from 2.16 GB — close to exactly the duplicate that is no longer made. The same change is most of why that run got 21% faster.

Two consequences worth knowing:

  • Arrays must be C-contiguous, which is what ordinary NumPy code produces. A non-contiguous view (a strided slice, a transpose) is rejected rather than silently copied — np.ascontiguousarray fixes it.
  • The arrays must stay alive and unmodified for the duration of the call. That is automatic in normal use, since the call holds the GIL.

The tick path and the spread path's premium arrays still copy; converting them is the same fix and has not been done yet.

Determinism

RaptorBT is fully deterministic: the same inputs produce bit-for-bit identical results across runs (no JIT warmup, no nondeterministic reductions). Running the Verification Test five times in a row on this machine produced the same total return every time, to the last decimal:

Total return:           -30.6192%  (seed=42, 500 bars, periodic entries/exits)
Max difference across 5 runs: 0.0000000000%

The harness makes the stronger version of this claim: it hashes the full equity curve and every trade's entry index, exit index and P&L on a 50,000-bar run, and 20 repetitions collapse to a single SHA-256 digest. Not just a stable summary number — a stable curve, trade for trade.

(The exact return depends on your data and signals — the point is that it does not change between runs.)


Class-Based Strategies

New in 0.5.0: strategies can be written as event-driven classes instead of precomputed signal arrays. Subclass raptorbt.Strategy, override lifecycle hooks, and emit order intents; the engine simulates fills and routes events back into your hooks. Both paths share one execution core, so identical decisions produce identical results — the class contract is the recommended way to write new strategies, while the array runners remain the fast path for vectorized workloads.

import numpy as np
import raptorbt


class SmaCross(raptorbt.Strategy):
    def on_start(self, ctx):
        # Full OHLCV arrays are available for indicator precomputation.
        self.fast = raptorbt.sma(ctx.close, 10)
        self.slow = raptorbt.sma(ctx.close, 30)

    def on_bar(self, ctx):
        i = ctx.idx
        if i == 0 or np.isnan(self.slow[i]) or np.isnan(self.slow[i - 1]):
            return
        crossed_up = self.fast[i] > self.slow[i] and self.fast[i - 1] <= self.slow[i - 1]
        crossed_dn = self.fast[i] < self.slow[i] and self.fast[i - 1] >= self.slow[i - 1]
        if crossed_up and ctx.position is None:
            self.enter()                      # optional: size_frac=, stop_price=, target_price=
        elif crossed_dn and ctx.position is not None:
            self.close_position()

    def on_position_closed(self, ctx, event):
        self.log.info("closed: pnl=%.2f", event.trade.pnl)


result = raptorbt.run_strategy_backtest(
    SmaCross(), timestamps, open_, high, low, close, volume,
    symbol="EXAMPLE", config=raptorbt.BacktestConfig(fees=0.001),
)
print(result.metrics.total_return_pct, len(result.trades()))

Hooks: on_start, on_bar, on_stop, on_order_filled, on_order_rejected, on_position_opened, on_position_closed. Inside on_bar, ctx provides the current bar, position snapshot, equity, cash, history(n), and set_stop_price() / set_target_price() for programmatic exits. Decision logic must only read array values at ctx.idx or earlier — indexing past the current bar reads the future.

Engine-level stop/target/sizing configuration (BacktestConfig, InstrumentConfig) applies to both paths. run_strategy_backtest returns the same BacktestResult as run_single_backtest. For advanced drivers (live feeds, custom loops), KernelSession exposes the per-bar engine step directly.

Note: one Python hook call per bar makes the class path slower than the array path — fine for typical bar counts, but prefer arrays for large parameter sweeps.

Instrument Definitions

New in 0.5.0: InstrumentSpec describes the market being traded — tick size, lot size, contract multiplier, expiry — separately from the per-run allocation knobs in InstrumentConfig. Attach one to a class-based run via run_strategy_backtest(..., instrument=...) (or directly on KernelSession):

import raptorbt

# NIFTY monthly future: 50-unit lots, expiry settlement at the contract's
# expiration timestamp, entries refused before activation / after expiry.
fut = raptorbt.InstrumentSpec.futures_contract(
    "NIFTY24AUGFUT",
    expiration_ns=1724839200_000_000_000,
    lot_size=50.0,
    price_increment=0.05,
    underlying="NIFTY",
)

result = raptorbt.run_strategy_backtest(
    MyStrategy, ts, o, h, l, c, v, instrument=fut,
)

Constructors: equity, futures_contract, perpetual, option (vanilla and binary; settles to intrinsic value when an underlying price is known), currency_pair, and index (non-tradable reference). With a spec attached the engine:

  • scales notional by the contract multiplier — sizing, cash, PnL, and value-based fees charge on price * size * multiplier, while per-share/per-contract fee models keep charging per contract;
  • floors sizes to lot_size / size_increment (an explicit InstrumentConfig.lot_size still wins — it is the per-run override);
  • rounds engine-derived stop/target prices onto the price_increment grid, conservatively (never in the strategy's favor);
  • force-settles open positions at expiry (Settlement exit reason) and rejects entries outside the activation/expiration window.

Without a spec, behavior is unchanged — existing results reproduce bit-for-bit. margin_init/margin_maint feed the margin account layer (see Margin accounts below); maker_fee/taker_fee are carried for fee models that distinguish liquidity roles.

Since 0.12.0 an option spec can also model the deposit an exchange blocks against a sold option: InstrumentSpec.option(..., span_pct=0.0975, exposure_pct=0.02) reserves (span_pct + exposure_pct) × strike × multiplier per contract instead of the premium, so a book too small to carry the deposit books no trade and reports InsufficientMargin. Both default to 0.0 (premium-funded, as before); bought options always stay funded at their premium. Since 0.12.1, sold legs that share an underlying and expiry are re-priced as one position group once they are open together — a straddle pays its scenario deposit once, a vertical or condor pays exposure plus its width — so a hedged book keeps the capital a real account would. A new sold leg still sizes on its naked deposit; the group benefit lands after the leg is on.

Choosing a side

New in 0.6.0. enter() opens in the session's configured direction, as it always has. To decide the side in code, call enter_long() / enter_short() (or enter(side="buy"/"sell")) — they take the same arguments and ignore the configured direction, so one run can hold long and short legs and a leg can flip side once it is flat:

class CrossSectional(raptorbt.Strategy):
    def on_bar(self, ctx):
        if ctx.position is not None:
            self.close_position()      # flat before flipping
            return
        if ctx.symbol in winners:
            self.enter_long(size_frac=0.1)
        elif ctx.symbol in losers:
            self.enter_short(size_frac=0.1)

Under the default netting policy an order's side is authoritative for opening: with no position it opens in that side, while an order opposing an open position closes it (so bracket legs and take-profits behave as before). Mark an order reduce_only to guarantee it can only ever close.

Typed Orders

New in 0.5.0: alongside the enter()/close_position() sugar, strategies can submit typed orders that rest across bars and report a full lifecycle:

from raptorbt.strategy import orders

class Breakout(raptorbt.Strategy):
    def on_bar(self, ctx):
        if ctx.idx == 20 and ctx.position is None:
            # Buy stop above the market, protective stop attached.
            self.oid = self.submit_order(orders.StopMarket(
                side="buy",
                trigger=float(ctx.high[:20].max()),
                size_frac=0.5,
                stop_price=float(ctx.close[ctx.idx] * 0.97),
                tif="day",
            ))
        if ctx.position is not None:
            self.submit_order(orders.Limit(side="sell", price=ctx.position.entry_price * 1.1))
  • Kinds: orders.Market, orders.Limit (with post_only=), orders.StopMarket, orders.StopLimit (trigger fires, then rests as a limit from the next bar), orders.MarketIfTouched / orders.LimitIfTouched (favorable-touch triggers — a buy fires when price falls to the trigger), orders.MarketToLimit (fills at the next bar's open), and orders.TrailingStopMarket / orders.TrailingStopLimit (trigger trails the running favorable extreme; offset_kind is "price", "bps", or "ticks" — ticks need an instrument price_increment).
  • Time-in-force: gtc (default), day (UTC-date rollover), gtd (with expire_ns), ioc, fok, plus at_open / at_close for market orders queued to a bar phase.
  • Flags: post_only (limit rejects if marketable at its first resting open), reduce_only (a fill may never increase exposure).
  • Brackets: self.submit_bracket(entry, stop_trigger=…, target_price=…, stop_limit_price=None) — the protective legs are held until the entry fills (one-triggers-other), then linked one-cancels-other: the first leg to fill cancels its sibling, and both die if the entry never fills. Generic linkage: submit_order(order, parent=other_id) and self.link_oco(id_a, id_b, …). One-updates-other reduces to one-cancels-other while fills are all-or-nothing (partial fills arrive with book depth). Netting policy only — under hedging every order opens, so protect positions with per-position stop_price/target_price attachments instead.
  • Sizing: units= (explicit contracts; refused if it exceeds available capital) or size_frac= (fraction of capital, resolved at fill time); omit both on a closing-side order to close the full position.
  • Semantics: market orders fill on the submission bar at the configured fill-price model — the same contract as enter(). Resting orders begin matching on the next bar (an order cannot rest into a bar that had already closed), with gap-throughs filling at the open.
  • Lifecycle hooks: on_order_accepted, on_order_triggered, on_order_filled, on_order_canceled, on_order_expired, on_order_rejected, plus catch-all on_order_event. Events carry client_order_id (deterministic "{order_id_tag}-{seq}").
  • Management: self.cancel_order(client_id), self.cancel_all_orders(), self.modify_order(client_id, limit_price=…, trigger_price=…, units=…).
  • Order-driven exits report exit_reason == "Order" on the trade record. One position at a time: an opening order while a position is open rejects with "position_open" (independent concurrent positions arrive in a later 0.5.x release).

The signal-array runners do not interact with the order book and are unaffected.

Bar Aggregation and Multi-Timeframe Strategies

New in 0.5.0. Streaming and batch aggregation of bars (and raw ticks) into coarser bars — time ("ms"/"s"/"m"/"h"/"d"/"w"), "tick", "volume", and "value" units. Time bars use left-open epoch-aligned windows and are stamped with the window-end timestamp, so a bar labeled t contains only data strictly before t — no look-ahead by construction. Beyond time, tick, volume and value windows, two families sample on something other than the clock:

Renko ("renko") emits a brick per full brick-height price move and ignores time and volume entirely — a quiet hour produces nothing, a fast move produces several bricks at once. Set the height with brick_size; without it, step reads as whole price units. Because one record can complete several bricks, push returns only the first and the rest must be drained:

agg = raptorbt.BarAggregator(1, "renko", brick_size=0.05)
bar = agg.push_trade(ts, price, size)
while bar is not None:
    handle(bar)
    bar = agg.next_pending()      # drain, or bricks are silently lost

Bricks carry no wicks, and a partial brick is discarded at end of data rather than flushed — an incomplete brick is not a brick.

Signed-flow bars ("{tick,volume,value}_imbalance" and "{tick,volume,value}_runs") sample by order-flow direction. Imbalance closes on net signed flow, so balanced two-sided trading never closes a bar however heavy it is; runs closes on the larger one-sided accumulation, so the same tape does close bars. step is the threshold — fixed, rather than the adaptive estimate in the literature, so runs stay reproducible.

Direction comes from the buy/sell quantity deltas when you supply them (bars_from_ticks), and otherwise from the tick rule, which is what lets these units work over plain OHLC bars.

# Batch: 1-minute bars -> 5-minute bars (or ticks -> bars).
ts5, o5, h5, l5, c5, v5 = raptorbt.aggregate_bars(ts, o, h, l, c, v, 5, "m")
bts, bo, bh, bl, bc, bv = raptorbt.bars_from_ticks(ts, ltp, buys, sells, 1000, "volume")
# Signed flow: close a bar every 10,000 shares of net buying or selling.
its = raptorbt.bars_from_ticks(ts, ltp, buys, sells, 10_000, "volume_imbalance")

# In a strategy: a 5-minute trend filter gating 1-minute entries.
class TrendGated(raptorbt.Strategy):
    def on_start(self, ctx):
        self.h5 = self.subscribe_bars(5, "m")
        self.trend_up = False

    def on_composite_bar(self, ctx, bar):   # fires when a 5m bar completes
        self.trend_up = bar.close > bar.open

    def on_bar(self, ctx):                  # every 1m bar
        if self.trend_up and ctx.position is None:
            self.enter()

on_composite_bar dispatches before the on_bar of the primary bar that completed the window — the composite closed strictly earlier. A partial final window is not dispatched to strategies (it never closed); the batch helpers do include it, flushed at end of data.

In a portfolio run one subscribe_bars declaration yields one aggregated stream per symbol, each built only from that symbol's bars. The symbol that completed a bar arrives as bar.symbol (and ctx.symbol); the dispatch-before-on_bar guarantee holds per symbol, while ordering across symbols follows the merged schedule.

Calendar "month"/"year" units aggregate on civil UTC dates. Passing tz_offset_ns (e.g. raptorbt.IST_OFFSET_NS) aligns day/week/month/year windows to that timezone's civil dates — an NSE day bar covers one IST trading date (a 23:30 IST print stays on its trading date instead of rolling into the next UTC day).

Streaming Indicators, Clock, and Cache

New in 0.5.0:

  • Streaming indicators — raptorbt.Indicator.sma(14), .ema, .wilder_ma, .wma, .roc, .stddev, .rsi, .atr, .donchian (value (upper, lower)), .bollinger ((middle, upper, lower)), .macd ((macd, signal, histogram)). Rust incremental cores, O(1)-ish per bar, producing values identical to the batch array functions (equivalence-tested). Register for auto-update:

    class Cross(raptorbt.Strategy):
        def on_start(self, ctx):
            self.fast = self.register_indicator(raptorbt.Indicator.ema(10))
            self.slow = self.register_indicator(raptorbt.Indicator.ema(30))
            # Or feed a subscribed higher timeframe instead:
            h5 = self.subscribe_bars(5, "m")
            self.trend = self.register_indicator(raptorbt.Indicator.sma(20), stream_id=h5)
    
        def on_bar(self, ctx):
            if not self.indicators_initialized():
                return
            if self.fast.value > self.slow.value and ctx.position is None:
                self.enter()
    

    In portfolio runs an indicator tracks one symbol, so register one per symbol with symbol= — an unrouted registration is fed every symbol's bars interleaved (and warns):

    class Cross(raptorbt.Strategy):
        def on_start(self, ctx):
            self.fast = self.register_indicators(
                lambda: raptorbt.Indicator.ema(10), ctx.symbols
            )
            # Equivalently, explicit per symbol:
            self.slow = {
                s: self.register_indicator(raptorbt.Indicator.ema(30), symbol=s)
                for s in ctx.symbols
            }
    
        def on_bar(self, ctx):
            fast, slow = self.fast[ctx.symbol], self.slow[ctx.symbol]
            if self.indicators_initialized() and fast.value > slow.value:
                self.enter()
    

    Registered indicators update before handlers see the bar.

  • Clock — self.clock.set_time_alert(name, at_ns) (one-shot) and set_timer(name, interval_ns, start_ns=None, stop_ns=None) (recurring; one firing per bar, gaps collapse); due events reach on_time_event before the bar's data handlers. Bar-granular by design: events carry ts_scheduled and ts_fired. On a TickStrategyStream, a pushed row the session refuses (no price, no two-sided book) still advances that symbol's clock, so a schedule due by its timestamp fires inside that push.

  • Cache — self.cache, an event-sourced mirror (no per-query engine calls): order(client_id) / orders_open() / is_order_open(), closed_trades(), realized_pnl(symbol=None).

  • Portfolio view — ctx.net_position / is_net_long / is_net_short / is_flat (signed across hedged positions) — properties on every context; per-symbol lookups via position_for(symbol) / positions(symbol) / net_position_for(symbol) on the portfolio context.

Multi-Instrument Strategies

New in 0.5.0: one class-based strategy trading N instruments against a single shared cash pool. Bars from all instruments merge into one deterministic schedule (by timestamp, then registration order); on_bar fires once per event with ctx.symbol naming the instrument whose bar closed. Capital committed to one symbol is unavailable to the rest.

class Rotation(raptorbt.Strategy):
    def on_bar(self, ctx):
        if ctx.idx == 0:
            self.enter(size_frac=0.4)          # routes to ctx.symbol
        if ctx.symbol == "INFY" and ctx.idx == 50:
            # Orders and closes can route across symbols explicitly.
            self.submit_order(orders.Limit(side="buy", price=2400.0,
                                           units=10.0), symbol="TCS")
            if ctx.position_for("RELIANCE") is not None:
                self.close_position(symbol="RELIANCE")

result = raptorbt.run_portfolio_strategy(
    Rotation,
    data={sym: dict(timestamps=..., open=..., high=..., low=..., close=...,
                    volume=...) for sym in symbols},
    instruments={...},        # optional per-symbol InstrumentSpec
    oms_type="netting",       # or "hedging", per instrument
    account_type="cash",      # or "margin", shared across all instruments
    leverage=1.0,             # portfolio-wide under account_type="margin"
)
result.result.equity_curve()  # portfolio curve, sampled per merged event
result.per_instrument         # per-symbol trades / pnl / rejections

ctx in portfolio runs is a PortfolioContext: ctx.bar / ctx.symbol / ctx.idx (local to the symbol), ctx.series(symbol) for full arrays, ctx.position / ctx.is_flat (properties for the current symbol, same as the single-instrument context), ctx.position_for(symbol) / ctx.positions(symbol), and portfolio-level ctx.equity / ctx.cash. Composite-bar subscriptions are single-instrument for now, and one-cancels-other links cannot span symbols.

Risk limits on the config are portfolio-wide, matching run_portfolio_backtest: max_positions counts open positions across all symbols (including entries from resting orders), and max_drawdown_pct trips on portfolio equity and halts entries on every symbol. Capital allocation is the strategy's own — each entry is offered the full free balance, so size it with size_frac; there is no EqualWeight budget on this path yet.

With account_type="margin" the instruments share one account: leverage applies portfolio-wide, sizing draws on the portfolio's free capital (balance less every instrument's locked margin), and equity marks the balance plus direction-aware unrealized PnL so winning shorts price upward. The maintenance requirement is the sum of each instrument's own requirement, so per-symbol margin_maint rates apply rather than one blended rate. A breach fires on_margin_call once and halts new entries on every instrument — subsequent entries are rejected with MarginCall, including on symbols that never traded. Results carry halted and halted_at; in portfolio runs halted_at is a schedule-event ordinal (the session interleaves N streams), not the bar index the array runners report. result.rejected_entries is the sum across instruments, whereas the array portfolio runner reports its single shared risk gate's counter.

Transaction Costs

By default the engine charges a flat fraction of traded value on each side:

config = raptorbt.BacktestConfig(fees=0.001)   # 0.1% per side

That is fine for equities, but it cannot express a flat per-order charge — and where brokerage is a fixed amount per order rather than a percentage, a purely proportional rate understates cost badly on cheap instruments. Set fee_segment to charge a real regulatory schedule instead:

config.fee_segment = "NFO-OPT"   # options: flat brokerage per order,
                                 # transaction tax on the sell leg,
                                 # stamp duty on the buy leg, GST

Accepted segments are NSE / BSE (add -INTRADAY or -DELIVERY), NFO / BFO, MCX and CDS, each optionally suffixed -OPT or -FUT. An unparseable value falls back to the flat fees rate rather than raising, so fees should still hold a usable composite number.

Each side of each leg is charged separately, because the charges are not symmetric: transaction tax lands on the sell and stamp duty on the buy, so a short leg owes the tax when it opens and a long leg when it closes. A multi-leg structure pays the per-order charge once per leg per side — a four-leg spread round trip is eight orders.

trade.fee_breakdown reports the components (brokerage, stt, exchange_txn, sebi_fee, stamp_duty, gst, total) when a segment is set, and None when it is not.

Execution Realism Knobs

Four opt-in settings, all off by default:

config.limit_slippage = 0.0005          # adverse adjustment on limit fills
config.liquidate_on_margin_call = True  # broker closes you out, vs only halting
spec.settlement_fee = 0.001             # charged on the settled notional at expiry

limit_slippage models adverse selection on a resting order — you tend to be filled when the market is about to move through you. It is suppressed when queue_fill_model granted the fill, since volume observed trading ahead of your order is evidence you genuinely held that price.

liquidate_on_margin_call turns a margin call from a latching halt into a forced close. Unlike expiry settlement or end-of-data finalization, which close free, a liquidation prices through the fill model and pays exit costs, reporting exit_reason == "Liquidation".

Options settle at their own last close unless you supply an underlying — an option's bars carry the option's price, so intrinsic value has to come from somewhere else:

class Hold(raptorbt.Strategy):
    def on_bar(self, ctx):
        # From whatever index series the strategy already tracks.
        ctx.set_underlying_price(spot_close[ctx.idx])

Without it a 100-strike call whose own price decayed to 0.50 settles at 0.50, even with spot at 112 — where intrinsic is 12.00.

Execution Algorithms

orders.Twap slices an order into equal parts released at a fixed interval, each an ordinary order with its own fill:

class Accumulate(raptorbt.Strategy):
    def on_bar(self, ctx):
        if ctx.idx == 0:
            self.submit_order(orders.Twap(
                side="buy", units=1_000, slices=10,
                every=60_000_000_000,        # one slice per minute
            ))

    def on_order_filled(self, ctx, event):
        # client ids are "<parent>#0", "<parent>#1", ...
        ...

The interval is a duration rather than a bar count, because a bar index means different things in bar and tick sessions — "every 1 bar" would silently become "every 1 print" on a tick feed. Use every_bars=N, bar_ns=... if you prefer to think in bars.

A schedule is not an order: cancelling it (cancel_twap) stops the remaining slices but does not unwind the ones that already traded, and on_algo_completed means fully released, not fully filled. Only explicit units can be sliced — size_frac resolves against equity at fill time, so each slice would size against a different account.

Position Policies, Margin Accounts, and Fill Realism

New in 0.5.0, all default-off (defaults reproduce prior results bit-for-bit; a committed golden-fixture suite enforces this):

  • Hedging — run_strategy_backtest(..., oms_type="hedging"): every typed order opens an independent position in its own direction (buy → long, sell → short), so longs and shorts coexist, each with its own protective stop/target and trailing state. Inspect them via ctx.positions (each has a position_id) and close one with self.close_position(position_id). The default "netting" keeps the one-position-at-a-time behavior.
  • Margin accounts — account_type="margin", leverage=N: entries lock initial margin (a sold option's span_pct/exposure_pct deposit when modelled, else the instrument's margin_init, else 1/leverage) instead of full notional, equity marks balance plus direction-aware unrealized PnL (shorts price correctly), and an equity breach of the maintenance requirement (margin_maint, else half initial) fires on_margin_call and halts new entries — no forced liquidation. ctx.free_capital reports unlocked cash.
  • Stochastic fills — BacktestConfig(fill_prob_limit=0.9, fill_prob_slippage=0.1, fill_seed=42): a marketable resting limit may be passed over (it stays working and retries), and stop/market fills may slip one tick against the trader (needs an instrument price_increment). Seeded and fully deterministic: same seed, same fills.
  • Adaptive bar path — BacktestConfig(bar_path_adaptive=True): when a stop and target are both touched inside one bar, infer the traversal from candle geometry (up-candle: open→low→high→close) instead of the conservative stop-first default.

Strategy Types

All strategy entrypoints take NumPy arrays directly. Signals (entries / exits) are boolean arrays you compute however you like — pandas, the built-in indicators, or your own model. The engine is asset- and broker-agnostic: timestamps are int64 (nanoseconds for tick data; any monotonic int for bars), prices are float64.

1. Single Instrument

Long or short on one instrument. This is the canonical example — the other strategy types follow the same shape.

import numpy as np
import pandas as pd
import raptorbt

df = pd.read_csv("your_data.csv", index_col=0, parse_dates=True)

# Signals (SMA crossover) — any boolean arrays work here
sma_fast = df["close"].rolling(10).mean()
sma_slow = df["close"].rolling(20).mean()
entries = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1))
exits = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1))

config = raptorbt.BacktestConfig(initial_capital=100000, fees=0.001, slippage=0.0005)
config.set_fixed_stop(0.02)    # optional 2% stop-loss
config.set_fixed_target(0.04)  # optional 4% take-profit

result = raptorbt.run_single_backtest(
    timestamps=df.index.astype("int64").values,
    open=df["open"].values,
    high=df["high"].values,
    low=df["low"].values,
    close=df["close"].values,
    volume=df["volume"].values,
    entries=entries.values,
    exits=exits.values,
    direction=1,   # 1 = long, -1 = short
    weight=1.0,
    symbol="AAPL",
    config=config,
    instrument_config=raptorbt.InstrumentConfig(lot_size=1.0),  # optional: lot rounding, capital cap
)

print(f"Return {result.metrics.total_return_pct:.2f}%  "
      f"Sharpe {result.metrics.sharpe_ratio:.2f}  "
      f"MaxDD {result.metrics.max_drawdown_pct:.2f}%  "
      f"Trades {result.metrics.total_trades}")

equity = result.equity_curve()  # np.ndarray
trades = result.trades()        # list[Trade]

2. Basket/Collective

Trade multiple instruments with synchronized signals.

instruments = [
    (timestamps, open1, high1, low1, close1, volume1, entries1, exits1, 1, 0.33, "AAPL"),
    (timestamps, open2, high2, low2, close2, volume2, entries2, exits2, 1, 0.33, "GOOGL"),
    (timestamps, open3, high3, low3, close3, volume3, entries3, exits3, 1, 0.34, "MSFT"),
]

# Optional: Per-instrument configs for lot_size and capital allocation
instrument_configs = {
    "AAPL": raptorbt.InstrumentConfig(lot_size=1.0, alloted_capital=33000),
    "GOOGL": raptorbt.InstrumentConfig(lot_size=1.0, alloted_capital=33000),
    "MSFT": raptorbt.InstrumentConfig(lot_size=1.0, alloted_capital=34000),
}

result = raptorbt.run_basket_backtest(
    instruments=instruments,
    config=config,
    sync_mode="all",  # "all", "any", "majority", "master"
    instrument_configs=instrument_configs,  # Optional
)

Sync Modes:

  • all: Enter only when ALL instruments signal
  • any: Enter when ANY instrument signals
  • majority: Enter when >50% of instruments signal
  • master: Follow the first instrument's signals

3. Pairs Trading

Long one instrument, short another with optional hedge ratio.

result = raptorbt.run_pairs_backtest(
    # Long leg
    leg1_timestamps=timestamps,
    leg1_open=long_open,
    leg1_high=long_high,
    leg1_low=long_low,
    leg1_close=long_close,
    leg1_volume=long_volume,
    # Short leg
    leg2_timestamps=timestamps,
    leg2_open=short_open,
    leg2_high=short_high,
    leg2_low=short_low,
    leg2_close=short_close,
    leg2_volume=short_volume,
    # Signals
    entries=entries,
    exits=exits,
    direction=1,
    symbol="TCS_INFY",
    config=config,
    hedge_ratio=1.5,      # Short 1.5x the long position
    dynamic_hedge=False,  # Use rolling hedge ratio
)

4. Options

Backtest options strategies with strike selection.

result = raptorbt.run_options_backtest(
    timestamps=timestamps,
    open=underlying_open,
    high=underlying_high,
    low=underlying_low,
    close=underlying_close,
    volume=volume,
    option_prices=option_prices,  # Option premium series
    entries=entries,
    exits=exits,
    direction=1,
    symbol="NIFTY_CE",
    config=config,
    option_type="call",           # "call" or "put"
    strike_selection="atm",       # "atm", "otm1", "otm2", "itm1", "itm2"
    size_type="percent",          # "percent", "contracts", "notional", "risk"
    size_value=0.1,               # 10% of capital
    lot_size=50,                  # Options lot size
    strike_interval=50.0,         # Strike interval (e.g., 50 for NIFTY)
)

5. Multi-Strategy

Combine multiple strategies on the same instrument.

strategies = [
    (entries_sma, exits_sma, 1, 0.4, "SMA_Crossover"),    # 40% weight
    (entries_rsi, exits_rsi, 1, 0.35, "RSI_MeanRev"),     # 35% weight
    (entries_bb, exits_bb, 1, 0.25, "BB_Breakout"),       # 25% weight
]

result = raptorbt.run_multi_backtest(
    timestamps=timestamps,
    open=open_prices,
    high=high_prices,
    low=low_prices,
    close=close_prices,
    volume=volume,
    strategies=strategies,
    config=config,
    combine_mode="any",  # "any", "all", "majority", "weighted", "independent"
)

Combine Modes:

  • any: Enter when any strategy signals
  • all: Enter only when all strategies signal
  • majority: Enter when >50% of strategies signal
  • weighted: Weight signals by strategy weight
  • independent: Run strategies independently (aggregate PnL)

6. Batch Spread Backtest

Run multiple spread backtests in parallel. Shared data (timestamps, underlying close) is converted once, then each item is backtested on its own Rayon thread with the GIL released for maximum throughput.

import numpy as np
import raptorbt

config = raptorbt.BacktestConfig(initial_capital=100000, fees=0.001)

# Create batch items — one per strategy variation
items = [
    raptorbt.BatchSpreadItem(
        strategy_id="straddle_24000",
        legs_premiums=[call_24000_premiums, put_24000_premiums],
        leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)],
        entries=entries,
        exits=exits,
        spread_type="straddle",
        max_loss=5000.0,
        target_profit=3000.0,
    ),
    raptorbt.BatchSpreadItem(
        strategy_id="strangle_23500_24500",
        legs_premiums=[call_24500_premiums, put_23500_premiums],
        leg_configs=[("CE", 24500.0, -1, 50), ("PE", 23500.0, -1, 50)],
        entries=entries,
        exits=exits,
        spread_type="strangle",
    ),
]

# Run all in parallel — returns list of (strategy_id, result) tuples
results = raptorbt.batch_spread_backtest(
    timestamps=timestamps,
    underlying_close=underlying_close,
    items=items,
    config=config,
)

for strategy_id, result in results:
    print(f"{strategy_id}: {result.metrics.total_return_pct:.2f}%")

7. Tick-Level Backtest

Simulate intraday strategies at full tick resolution — no bar resampling, no intra-bar path approximation. Designed for options momentum, scalping, and any setup where the exact fill tick matters.

import numpy as np
import raptorbt

# Raw tick arrays (one element per tick, same length N)
# buy_qty_delta / sell_qty_delta must be per-tick deltas, NOT Zerodha cumulative sums
result = raptorbt.run_tick_backtest(
    timestamps=timestamps_ns,       # int64 nanoseconds-since-epoch
    ltp=ltp_arr,                    # last traded price
    bid=bid_arr,
    ask=ask_arr,
    buy_qty_delta=buy_delta,        # pre-converted from cumulative: np.diff(buy_cum).clip(0)
    sell_qty_delta=sell_delta,
    oi=oi_arr,
    entries=entry_signals,          # bool array — True where entry is allowed
    exits=exit_signals,             # bool array — True where position should exit
    symbol="NIFTY26APR24600PE",
    initial_capital=100_000.0,
    fees=0.001,
    slippage=0.0005,
    stop_loss_pct=5.0,
    take_profit_pct=10.0,
    max_hold_seconds=1800,          # 30-minute maximum hold
    entry_cooldown_ticks=10,        # minimum ticks between entries
)

print(f"trades: {result.metrics.total_trades}")
print(f"profit_factor: {result.metrics.profit_factor:.2f}")
print(f"win_rate: {result.metrics.win_rate_pct:.1f}%")

max_trades is a hard early exit, not a filter. Set it and the run stops after that many trades, reporting as if the tape ended there — so the metrics describe a prefix of your data, not your data. It is unlimited by default from 0.7.0; through 0.6.4 it defaulted to 50, which on a million-tick input meant a backtest that silently covered 0.8% of the ticks and understated max drawdown by more than a hundredfold. Pass it only when you actually want a truncated run.

Class-Contract Tick Strategies

run_tick_backtest above is the array runner: precomputed signal arrays, one long-only position at a time. For the full class contract — typed orders, multiple positions, margin accounts, portfolio risk gates — drive the event session from ticks instead:

class Scalper(raptorbt.Strategy):
    def on_quote(self, ctx, quote):
        # Quotes are observation only: nothing fills here.
        self.wide = quote.spread > 0.05

    def on_trade_tick(self, ctx, tick):
        # ctx.best_bid / ctx.best_ask are the book observed BEFORE this
        # print — the quote from the same feed row arrives next.
        if not self.wide and ctx.is_flat and ctx.best_bid is not None:
            self.submit_order(orders.Limit(side="buy", price=ctx.best_bid, units=10))

    def on_bar(self, ctx):
        # Only fires when primary_bars is set. A view, not a venue.
        ...

result = raptorbt.run_tick_strategy(
    Scalper,
    ticks={"NIFTY24600PE": dict(timestamps=ts, ltp=ltp, bid=bid, ask=ask)},
    primary_bars=(1, "m"),     # aggregate prints into 1m bars for on_bar
    account_type="margin", leverage=5.0,
)

Three semantics to know:

  • Quotes do not fill orders, move trailing stops, or mark equity. Filling against a quote asserts a counterparty the engine has no evidence for; the print that follows is that evidence. Orders submitted from on_quote rest and match on the next print. Quotes also do not sample the equity curve, so metrics do not shift with how chatty the feed is.
  • primary_bars builds bars from prints as a view. They fire on_bar and feed indicators registered without a stream_id; subscribe_bars composites work too. Order matching still happens against ticks only.
  • AT_OPEN/AT_CLOSE market orders keep resting on a print — a print has no bar phase to queue against. Trailing stops ratchet off every print, so they resolve at tick resolution; a tick run and a bar run over the same data legitimately differ there, since a bar can trigger a stop against a low that preceded the high which set the watermark.

Live Feeds: TickStrategyStream

run_tick_strategy replays a finite array. For an open-ended feed — a real broker socket, a replayer, anything where the next event has not happened yet — use TickStrategyStream. You construct it once, then push events as they arrive; every strategy hook a push triggers fires before that push returns.

stream = raptorbt.TickStrategyStream(
    Scalper(),
    symbols=["RELIANCE", "INFY"],
    config=raptorbt.BacktestConfig(initial_capital=100_000.0, fees=0.001),
    warmup_bars={"RELIANCE": dict(timestamps=ts, open=o, high=h,
                                  low=l, close=c, volume=v)},
    primary_bars=(1, "m"),
)

# Feed it as the market moves. Hooks fire synchronously inside the push.
stream.push_tick("RELIANCE", timestamp_ns, price)
stream.push_bar("RELIANCE", timestamp_ns, o, h, l, c, v)
stream.push_depth("RELIANCE", timestamp_ns, bid_prices, bid_sizes,
                  ask_prices, ask_sizes)

result = stream.finish()      # closes out and computes metrics

A pushed row with ltp == 0 and no two-sided book yields no event — push_tick returns 0 — but it still names an instant the market reached, and alerts or timers due by then fire inside that push rather than waiting for the next accepted print (for a quiet index, minutes later). Orders such a timer submits are routed to their instrument at once and match from its next print; enter() / close_position() intents, which name no symbol, are held for that symbol's next print so another symbol's print cannot pick them up. The batch runner never sees such rows and cannot fire on them.

warmup_bars is replayed during construction, so indicators are primed before the first live push. Those bars execute — they match orders and mark equity — unlike bars aggregated from prints via primary_bars, which remain a view. Hand the stream a strategy that stays passive on history if that is not what you want.

Position Adoption — Starting on Shares You Already Own

New in 0.6.2. A strategy attached to a stock the user already holds must start out knowing it holds those shares, at the price actually paid. The naive workaround — submitting a fake buy at the average price — is wrong in three ways: it charges brokerage that was never paid, it writes a trade into the log that never happened, and it emits an entry event that any position-diffing consumer reads as a fresh signal.

initial_positions adopts the holding instead: no order, no fill, no fees, no trade record, and no Entered event. Cash is reduced by the cost basis, so equity reads as initial + unrealized, exactly like an account that bought earlier.

stream = raptorbt.TickStrategyStream(
    MyStrategy(),
    symbols=["RELIANCE"],
    config=raptorbt.BacktestConfig(initial_capital=100_000.0, fees=0.001),
    initial_positions={
        # The broker says: 100 shares, average cost 90.00.
        "RELIANCE": {"quantity": 100, "avg_price": 90.0},
        # "timestamp_ns" is optional and defaults to 0.
    },
)

pos = stream.ctx.position_for("RELIANCE")
pos.size, pos.entry_price, pos.direction   # 100.0, 90.0, 1
stream.ctx.equity                          # 91_000.0  = 100_000 - (90 x 100)

Adoption happens before warmup replay and before the first push, so the position is present in every before-snapshot. Code that diffs positions() around a push can never mistake it for a new entry — which is the whole point, since that diff is how a live deployment turns engine state into broker orders.

The resulting metrics carry it as an open trade with no cost: total_fees_paid is 0.0, total_open_trades is 1, total_closed_trades is 0, and open_trade_pnl marks against the current price.

Things it deliberately refuses rather than guesses:

{"quantity": 0,  "avg_price": 90.0}   # ValueError: needs positive quantity and avg_price
{"quantity": 10, "avg_price": 0}      # ValueError: same
{"UNKNOWN": {...}}                    # ValueError: names unknown symbol 'UNKNOWN'
account_type="margin", leverage=2.0   # ValueError: requires a cash or fully funded account

Cash and fully funded margin books are both supported. In cash mode the cost basis is debited from the balance; under margin it is locked as initial margin instead, which is what that mode's equity formula requires. Fully funded matters in practice: a strategy holding a short must run under a margin account for the short to transact at all, and such a book still needs seeding.

Above leverage 1.0 it is refused, because the margin a broker has already posted against a position it holds is not derivable from quantity and average price — inventing a number there would misstate free capital, which gates every later entry.

Adoption is long-only (quantity is a positive share count); an existing short cannot be seeded this way.

Adoption must also happen before the first equity sample, and this is enforced. Adopting mid-run leaves the equity curve flat for the pre-adoption stretch, which holds the running peak down and makes the later decline measure against the wrong high-water mark — a real 0.495% max drawdown reporting as 0.199%. The curve is written as the run proceeds, so it cannot be corrected afterwards. Quotes and depth snapshots sample no equity, so adopting after one is still allowed.

For a session you drive yourself rather than through TickStrategyStream, the same primitive is on the portfolio session, taking positional arguments and returning the new position id:

from raptorbt import PortfolioSession

session = PortfolioSession(config=config, account_type="cash")
i = session.add_instrument("RELIANCE", direction=1)
session.set_bars(i, timestamps, o, h, l, c, v)
session.seal()

position_id = session.adopt_position(i, timestamp_ns, 90.0, 100.0)
session.cash()      # 91_000.0

Call it after seal() and before the first apply_current().

Order Book and Queue-Position Fills

Pass depth= to run_tick_strategy for five-level book snapshots, which arrive via on_order_book and persist on ctx.book:

depth = {"NIFTY24600PE": dict(
    timestamps=ts,                      # int64 ns, one row per snapshot
    bid_prices=bp, bid_sizes=bs,        # (n_snapshots, levels), best first
    ask_prices=ap, ask_sizes=asz,
)}

class Maker(raptorbt.Strategy):
    def on_order_book(self, ctx, book):
        if book.imbalance and book.imbalance > 0.7:      # bid-heavy
            self.submit_order(orders.Limit(side="buy", price=book.best_bid, units=10))

config.queue_fill_model = True          # opt-in
result = raptorbt.run_tick_strategy(Maker, ticks, config=config, depth=depth)

queue_fill_model replaces fill_prob_limit's coin flip with the tape. The size queued ahead is estimated once when the order rests, then consumed by print volume at that price; a print through the level fills unconditionally. Progress is monotone, so an order passed over repeatedly genuinely advances — the probability model has no such memory.

It does not claim a real queue rank. Market-by-price data cannot tell you where you stand in line, nor separate size that executed ahead of you from size that was cancelled, so the model falls back to fill_prob_limit rather than guessing: on bar events (a bar's volume is not volume at the limit price) and on a quote whose size is unknown. A level outside the visible five reads as unknown, never as empty.

Depth snapshots are not required for the model to work. A feed that publishes the size at the best bid and ask makes a top-of-book quote as queue-capable as a snapshot — pass bid_qty/ask_qty beside bid/ask (as arrays to run_tick_strategy, or keywords to push_tick) and an order resting at the touch joins behind the displayed size. Two more optional arrays ride the same path: ltq, the exchange's last traded quantity, which becomes tick.size when present (the buy/sell flow deltas are only a proxy for it), and oi, the open interest published with the print (tick.oi). A size the feed did not carry is unknown (nan), never zero.

Books, like quotes, are observation only — they never fill an order, move a trailing stop, or mark equity. Displayed size is intent, not a trade.

Tick Signal & Feature Helpers

Precompute entry/exit signal arrays and tick microstructure features before calling run_tick_backtest:

# Signal arrays
entries = raptorbt.compute_tick_entry_signals(
    spread_pct=raptorbt.tick_spread_pct(bid, ask),
    bsi_delta=raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum),  # pass raw cumulative
    return_1m=raptorbt.return_window(timestamps_ns, ltp, window_seconds=60.0),
    spread_pct_max=3.0,
    bsi_min=0.55,           # minimum buy-side delta fraction
    return_1m_min_abs=0.3,  # minimum 1-min return % (abs)
    return_direction=1,     # +1 long, -1 short
    cooldown_ticks=10,
)
exits = raptorbt.compute_tick_exit_signals(
    timestamps_ns=timestamps_ns,
    eod_exit_time_ns=eod_ns,   # force exit at/after this timestamp; 0 = disabled
)

# Feature arrays (all return Vec<f64> of same length as input)
spread   = raptorbt.tick_spread_pct(bid, ask)               # (ask-bid)/mid * 100
bsi      = raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum)  # delta BSI per tick
ret_1m   = raptorbt.return_window(ts_ns, ltp, 60.0)         # 1-min lookback return %
vol      = raptorbt.realized_vol_rolling(ts_ns, ltp, 300.0)  # 5-min realized vol %
oi_pos   = raptorbt.oi_position_pct(oi, oi_day_high, oi_day_low)  # [0, 100]
velocity = raptorbt.tick_velocity(ts_ns, 60.0)              # ticks/min over last 60s

Important for Zerodha data: total_buy_qty and total_sell_qty from KiteTicker are cumulative session running sums, not per-tick values. Pass them as-is to buy_sell_imbalance_delta (it computes deltas internally). For run_tick_backtest, convert first: buy_delta = np.diff(buy_cum, prepend=0).clip(min=0).


Metrics

Every backtest returns a BacktestMetrics object exposing 48 metric fields (listed in full under BacktestMetrics). metrics.to_dict() returns a subset of 31 of them under human-readable labels (e.g. "Sharpe Ratio", "Total Return [%]") for quick display; read fields directly off the object to access all 48. The most useful are grouped below.

Core Performance

Metric Description
total_return_pct Total return as percentage
sharpe_ratio Risk-adjusted return (annualized)
sortino_ratio Downside risk-adjusted return
calmar_ratio Return / Max Drawdown (not annualized)
omega_ratio Probability-weighted gains/losses

Drawdown

Metric Description
max_drawdown_pct Maximum peak-to-trough decline
max_drawdown_duration Longest drawdown period (bars)
max_drawdown_duration_secs The same stretch in seconds; None without timestamps
ulcer_index RMS of the drawdown curve — depth weighted by duration
time_under_water_pct Share of samples below the running high-water mark

Depth is not the whole story. max_drawdown_pct is one order statistic: a -8% pit lasting five bars and a -8% pit lasting two hundred report the same number, and the second is the one people abandon. ulcer_index weights each shortfall by how long it persisted (same percentage points, never larger than max_drawdown_pct); time_under_water_pct isolates persistence by discarding depth entirely. All three fold the same drawdown curve that drawdown_curve() returns.

Bars are not days. A bar is one day on daily data and one tick on a tick run, so max_drawdown_duration cannot be rendered as a duration on its own — a 6-day tick backtest reports ~93,510 bars. Read the _secs field for anything shown to a person, and fall back to the bar count only when it is None.

Trade Statistics

Metric Description
total_trades Total number of trades
total_closed_trades Number of closed trades
total_open_trades Number of open positions
winning_trades Number of profitable trades
losing_trades Number of losing trades
win_rate_pct Percentage of winning trades

Trade Performance

Metric Description
profit_factor Gross profit / Gross loss; None when nothing lost
expectancy Average expected profit per trade
sqn System Quality Number
avg_trade_return_pct Average trade return
avg_win_pct Average winning trade return; None without winners
avg_loss_pct Average losing trade return; None without losers
best_trade_pct Best single trade return
worst_trade_pct Worst single trade return

Duration

Metric Description
avg_holding_period Average trade duration (bars)
avg_holding_period_secs The same average in seconds; None without timestamps
avg_winning_duration Average winning trade duration; None without winners
avg_losing_duration Average losing trade duration; None without losers

Streaks

Metric Description
max_consecutive_wins Longest winning streak
max_consecutive_losses Longest losing streak

Other

Metric Description
start_value Initial portfolio value
end_value Final portfolio value
total_fees_paid Total transaction costs
open_trade_pnl Unrealized PnL from open positions
exposure_pct Percentage of time in market, capped at 100%

Diagnostics — why a strategy behaves the way it does

New in 0.13.2. Every field is float | None, and None means not measured on that path, never a measured zero — 0.0 is a legitimate value for most of them.

Metric Description
return_skew Skewness of per-bar returns (Fisher-Pearson, bias-corrected)
return_kurtosis Excess kurtosis — Gaussian is 0.0, not 3.0
tail_ratio 95th percentile over 5th, in absolute value — above 1.0 the right tail outruns the left
cost_to_gross_profit_pct Costs as a share of gross profit; larger is worse
breakeven_cost_multiple How many times current costs the run could absorb before net P&L hits 0
return_consistency_pct Share of moving bars that moved up (the curve's batting average)
avg_drawdown_pct Mean drawdown across underwater samples only
mae_mfe_coverage_pct Share of closed trades whose excursions were measured — read first
avg_mae_pnl Mean maximum adverse excursion, in money; never positive
mfe_capture_ratio Share of the favourable move winners kept

What they are for:

  • Is the Sharpe real? Sharpe assumes symmetric returns. A high Sharpe with strongly negative return_skew is the signature of a strategy collecting small gains in front of a rare large loss. A high return_kurtosis means the observed max_drawdown_pct understates what the strategy can lose.
  • Do costs eat it? cost_to_gross_profit_pct is the overfit tell — an edge handing most of its gross profit to brokerage is one slippage assumption from unprofitable. breakeven_cost_multiple of 3.4 says costs could triple before the edge dies, directly comparable against backtest-vs-live slippage.
  • One bad week, or chronic? max_drawdown_pct 18% with avg_drawdown_pct 2% is one bad stretch; 18% with 11% is a strategy that lives underwater. Same headline risk, opposite sizing decision.
  • Where should the stop go? avg_mae_pnl is how far the average trade goes against you before it works. A stop tighter than that closes trades that would have won.
  • Entry or exit? mfe_capture_ratio of 0.35 means the exits give back two thirds of every favourable move — the exit rule is the bottleneck, not the signal.

Conventions are pinned deliberately, because a silent disagreement with another library is the failure mode: kurtosis is excess; skew and kurtosis are sample bias-corrected (scipy.stats with bias=False); tail_ratio uses linear-interpolation percentiles (NumPy's default); avg_drawdown_pct is conditional on being underwater.

mfe_capture_ratio is a ratio of sums over winning trades, gross of costs. Each restriction matters: a mean of per-trade ratios lets one trade with a near-zero excursion dominate, and including losers puts negative P&L over a positive excursion, which produces a negative "capture" that means nothing. A value above 1.0 is not clamped — it says the bar-resolution excursion missed an intra-bar extreme, which is worth seeing.

Excursions are None per trade on paths that synthesize a trade rather than closing a tracked position (spread, basket and pairs legs), so those trades are excluded from the aggregates and mae_mfe_coverage_pct reports how much of the trade list the two figures actually describe.


Indicators

RaptorBT exports 12 classic technical indicators, computed in native Rust and operating on (and returning) NumPy arrays:

import raptorbt

# Trend indicators
sma = raptorbt.sma(close, period=20)
ema = raptorbt.ema(close, period=20)
supertrend, direction = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0)

# Momentum indicators
rsi = raptorbt.rsi(close, period=14)
macd_line, signal_line, histogram = raptorbt.macd(close, 12, 26, 9)  # fast, slow, signal (positional)
stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3)

# Volatility indicators
atr = raptorbt.atr(high, low, close, period=14)
upper, middle, lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0)

# Strength indicators
adx = raptorbt.adx(high, low, close, period=14)

# Volume indicators
vwap = raptorbt.vwap(high, low, close, volume)

# Rolling indicators (LLV / HHV)
rolling_low = raptorbt.rolling_min(low, period=20)    # Lowest Low Value
rolling_high = raptorbt.rolling_max(high, period=20)  # Highest High Value

In addition, 8 tick microstructure / feature functions are available for tick-level work (tick_spread_pct, buy_sell_imbalance_delta, return_window, realized_vol_rolling, oi_position_pct, tick_velocity, compute_tick_entry_signals, compute_tick_exit_signals) — see Tick-Level Backtest.


Stop-Loss & Take-Profit

Fixed Percentage

config = raptorbt.BacktestConfig(initial_capital=100000, fees=0.001)
config.set_fixed_stop(0.02)    # 2% stop-loss
config.set_fixed_target(0.04)  # 4% take-profit

ATR-Based

config.set_atr_stop(multiplier=2.0, period=14)    # 2x ATR stop
config.set_atr_target(multiplier=3.0, period=14)  # 3x ATR target

Trailing Stop

config.set_trailing_stop(0.02)  # 2% trailing stop

Risk-Reward Target

config.set_risk_reward_target(ratio=2.0)  # 2:1 risk-reward ratio

Monte Carlo Portfolio Simulation

RaptorBT includes a high-performance Monte Carlo forward simulation engine for portfolio risk analysis. It uses Geometric Brownian Motion (GBM) with Cholesky decomposition for correlated multi-asset simulation, parallelized via Rayon.

import numpy as np
import raptorbt

# Historical daily returns per strategy/asset (numpy arrays)
returns = [
    np.array([0.001, -0.002, 0.003, ...]),  # Strategy 1 returns
    np.array([0.002, 0.001, -0.001, ...]),   # Strategy 2 returns
]

# Portfolio weights (must sum to 1.0)
weights = np.array([0.6, 0.4])

# Correlation matrix (N x N)
correlation_matrix = [
    np.array([1.0, 0.3]),
    np.array([0.3, 1.0]),
]

# Run simulation
result = raptorbt.simulate_portfolio_mc(
    returns=returns,
    weights=weights,
    correlation_matrix=correlation_matrix,
    initial_value=100000.0,
    n_simulations=10000,   # Number of Monte Carlo paths (default: 10,000)
    horizon_days=252,      # Forward projection horizon (default: 252)
    seed=42,               # Random seed for reproducibility (default: 42)
)

# Results
print(f"Expected Return: {result['expected_return']:.2f}%")
print(f"Probability of Loss: {result['probability_of_loss']:.2%}")
print(f"VaR (95%): {result['var_95']:.2f}%")
print(f"CVaR (95%): {result['cvar_95']:.2f}%")

# Percentile paths: list of (percentile, path_values)
# Percentiles: 5th, 25th, 50th, 75th, 95th
for pct, path in result['percentile_paths']:
    print(f"  P{pct:.0f} final value: {path[-1]:.2f}")

# Final values: numpy array of terminal values for all simulations
final_values = result['final_values']  # numpy array, length = n_simulations

Result Fields

Field Type Description
expected_return float Expected return as percentage over the horizon
probability_of_loss float Probability that final value < initial value (0.0 to 1.0)
var_95 float Value at Risk at 95% confidence (percentage)
cvar_95 float Conditional VaR at 95% confidence (percentage)
percentile_paths List[Tuple[float, List]] Portfolio paths at 5th, 25th, 50th, 75th, 95th percentiles
final_values numpy.ndarray Terminal portfolio values for all simulations

API Reference

BacktestConfig

config = raptorbt.BacktestConfig(
    initial_capital: float = 100000.0,
    fees: float = 0.001,
    slippage: float = 0.0,
    upon_bar_close: bool = True,   # deprecated — use fill_timing
    fill_timing: str | None = None,
)

fill_timing names the execution-timing policy — when a decision made from a bar's data is allowed to trade:

Value Meaning
"same_bar_close" Decide at bar i's close, fill at bar i's close (zero latency).
"next_bar_open" Decide at bar i's close, fill at bar i+1's open — the standard bar contract. A decision on the final bar never trades.
"same_bar_open_lookahead" Pre-0.11 behavior: fills a bar-i decision at bar i's own open, a price from before the decision's information existed. Not causally valid; exists only to reproduce pre-0.11 results.

upon_bar_close is deprecated and maps onto the policy (True → "same_bar_close", False → "next_bar_open"); an explicit fill_timing wins. Runners priced off premium-only series (options, spreads) have no open to fill at by default, so "next_bar_open" there fills at the bar after the decision, at that bar's premium — unless the caller supplies the premium series' own opening prices (option_open_prices on run_options_backtest, legs_open_premiums on run_spread_backtest and BatchSpreadItem), in which case a signal fill prices at the fill bar's open premium. Nothing is ever synthesized: without real open data, the next bar's value is the fill.

# Stop methods
config.set_fixed_stop(percent: float)
config.set_atr_stop(multiplier: float, period: int)
config.set_trailing_stop(percent: float)

# Target methods
config.set_fixed_target(percent: float)
config.set_atr_target(multiplier: float, period: int)
config.set_risk_reward_target(ratio: float)

InstrumentConfig

Per-instrument configuration for position sizing and risk management.

inst_config = raptorbt.InstrumentConfig(
    lot_size=1.0,              # Min tradeable quantity (1 for equity, 50 for NIFTY F&O)
    alloted_capital=50000.0,   # Capital allocated to this instrument (optional)
    existing_qty=None,         # Existing position quantity (future use)
    avg_price=None,            # Existing position avg price (future use)
)

# Optional: per-instrument stop/target overrides
inst_config.set_fixed_stop(0.02)
inst_config.set_trailing_stop(0.03)
inst_config.set_fixed_target(0.05)

Fields:

  • lot_size - Minimum tradeable quantity. Position sizes are rounded down to nearest lot_size multiple. Use 1.0 for equities, 50.0 for NIFTY F&O, 0.01 for forex.
  • alloted_capital - Per-instrument capital cap (capped at available cash).
  • existing_qty / avg_price - Reserved for future live-to-backtest transitions.

BatchSpreadItem

item = raptorbt.BatchSpreadItem(
    strategy_id: str,                    # Unique identifier for this backtest
    legs_premiums: List[np.ndarray],     # Premium series per leg
    leg_configs: List[Tuple[str, float, int, int]],  # (option_type, strike, quantity, lot_size)
    entries: np.ndarray,                 # bool entry signals
    exits: np.ndarray,                   # bool exit signals
    spread_type: str = "custom",         # Spread type string
    max_loss: float = None,              # Optional max loss exit
    target_profit: float = None,         # Optional target profit exit
)

batch_spread_backtest

results = raptorbt.batch_spread_backtest(
    timestamps: np.ndarray,              # int64 nanosecond timestamps (shared)
    underlying_close: np.ndarray,        # Underlying close prices (shared)
    items: List[BatchSpreadItem],      # List of spread backtest items
    config: BacktestConfig = None,     # Optional shared config
) -> List[Tuple[str, BacktestResult]]  # (strategy_id, result) pairs

Runs all spread backtests in parallel via Rayon. Timestamps and underlying close are shared across all items and converted once. The GIL is released during execution for maximum Python concurrency.

BatchSingleItem

item = raptorbt.BatchSingleItem(
    item_id: str,                        # Label; returned alongside this item's result
    entries: np.ndarray,                 # bool entry signals
    exits: np.ndarray,                   # bool exit signals
    direction: int = 1,                  # 1 long, -1 short
    weight: float = 1.0,
    symbol: str = "UNKNOWN",
    config: BacktestConfig = None,       # Per-item override; None uses the batch config
    position_sizes: np.ndarray = None,
    instrument_config: InstrumentConfig = None,
)

batch_single_backtest

results = raptorbt.batch_single_backtest(
    timestamps: np.ndarray,              # int64 nanoseconds (shared)
    open: np.ndarray,                    # OHLCV, shared across every item
    high: np.ndarray,
    low: np.ndarray,
    close: np.ndarray,
    volume: np.ndarray,
    items: List[BatchSingleItem],        # One per parameter set
    config: BacktestConfig = None,       # Default config for items without one
) -> List[Tuple[str, BacktestResult]]    # (item_id, result), in input order

The parameter-sweep entry point: one price series, many signal sets, every core. The OHLCV arrays are converted once and shared by reference; each item runs on its own Rayon thread with the GIL released. 184 combinations over 93,750 bars: 1.31 s down a Python loop, 170 ms batched — 7.7x on ten cores.

Results are bit-identical to calling run_single_backtest in a loop, which is the point rather than a bonus: a sweep whose numbers moved with the thread count would make comparing two parameter sets meaningless. Results come back in input order, so they can be zipped against the parameters that produced them.

An item whose signals do not match the shared series length, or whose direction is neither 1 nor -1, raises ValueError naming that item — before any worker starts.

import numpy as np, raptorbt

combos = [(f, s) for f in range(5, 55, 5) for s in range(20, 220, 10) if f < s]
items = [
    raptorbt.BatchSingleItem(f"{fast}_{slow}", *signals_for(close, fast, slow))
    for fast, slow in combos
]
for item_id, result in raptorbt.batch_single_backtest(
    ts, o, h, l, c, v, items, config
):
    print(item_id, result.metrics.sharpe_ratio, result.metrics.mfe_capture_ratio)

simulate_portfolio_mc

result = raptorbt.simulate_portfolio_mc(
    returns: List[np.ndarray],               # Per-asset daily returns (N arrays)
    weights: np.ndarray,                     # Portfolio weights (length N, sum to 1)
    correlation_matrix: List[np.ndarray],    # N x N correlation matrix
    initial_value: float,                    # Starting portfolio value
    n_simulations: int = 10000,              # Number of Monte Carlo paths
    horizon_days: int = 252,                 # Forward projection horizon in days
    seed: int = 42,                          # Random seed for reproducibility
) -> dict

Returns a dictionary with keys: expected_return, probability_of_loss, var_95, cvar_95, percentile_paths, final_values.

BacktestResult

result = raptorbt.run_single_backtest(...)

# Attributes
result.metrics        # BacktestMetrics object

# Methods
result.equity_curve()    # numpy.ndarray
result.drawdown_curve()  # numpy.ndarray
result.returns()         # numpy.ndarray
result.trades()          # List[Trade]

BacktestMetrics

38 read-only fields — see the Metrics section for the full table with descriptions. metrics.to_dict() returns 28 of them under human-readable labels (e.g. "Sharpe Ratio") for quick display; read fields off the object directly for the complete set.

m = result.metrics
m.total_return_pct, m.sharpe_ratio, m.max_drawdown_pct   # etc. — 38 fields total
stats = m.to_dict()

Trade

for trade in result.trades():
    print(trade.id)           # Trade ID
    print(trade.symbol)       # Symbol
    print(trade.entry_idx)    # Entry bar index
    print(trade.exit_idx)     # Exit bar index
    print(trade.entry_price)  # Entry price
    print(trade.exit_price)   # Exit price
    print(trade.size)         # Position size
    print(trade.direction)    # 1=Long, -1=Short
    print(trade.pnl)          # Profit/Loss
    print(trade.return_pct)   # Return percentage
    print(trade.fees)         # Total costs: entry_fees + exit_fees
    print(trade.entry_fees)   # Charged when the position opened
    print(trade.exit_fees)    # Charged when it closed; 0 if left to expire
    print(trade.fee_breakdown)  # Itemized components, or None on a flat rate
    print(trade.exit_reason)  # "Signal", "StopLoss", "TakeProfit", "TrailingStop", "EndOfData", "Settlement", "TimeExit"
    print(trade.mae_pnl)      # Worst unrealized loss while open (<= 0), or None
    print(trade.mfe_pnl)      # Best unrealized profit while open (>= 0), or None
    print(trade.mae_price)    # Price at that worst point, or None
    print(trade.mfe_price)    # Price at that best point, or None

fees always equals entry_fees + exit_fees, and when an itemized schedule is configured fee_breakdown["total"] equals fees — the reported costs and the equity curve are the same money. An option left to expire carries exit_fees == 0.0: it is never traded out, so it owes no exit-side charge.

The order log

In plain words: what happened to every order the strategy placed, including the ones that never filled. A result reports the trades that happened; this reports the ones that were attempted. An entry refused for margin, a limit that rested all day and expired, a stop refused because a position was already open — none of those produce a trade, so without the log a strategy that never got into the market is indistinguishable from one whose idea was wrong.

for order in result.orders():
    if order.reject_reason:
        ...                          # the engine refused it, and says why
    elif order.filled_qty == 0.0:
        ...                          # it rested and never filled
    elif order.fill_slices > 1:
        ...                          # it filled across several prints

Each record carries id, client_id, symbol, side, kind, tif and the final status; requested_qty and filled_qty; avg_fill_price (size weighted) with last_fill_idx and fill_slices; limit_price and trigger_price; parent_id and oco_group; and reject_reason.

Three conventions:

  • The log is reconciled against the order book, not assembled from events, so an order that expired without ever filling or being refused still appears.
  • requested_qty is None when the order named a capital fraction rather than a number — the size is not known until the engine prices it, and 0.0 would read as "asked for nothing". filled_qty is 0.0 for an unfilled order, which is a measurement, and avg_fill_price is None, because no price exists.
  • reject_reason is the stable snake_case identifier ("insufficient_margin", "max_positions", "zero_size", …), the same vocabulary every refusal event uses.

result.orders() is empty for a run that placed no typed orders.

Maximum adverse and favourable excursion

In plain words: how far a trade went against you before it worked, and how far in your favour before it failed. A realized loss alone cannot tell a bad entry apart from a good one that was stopped too early — both report the same number. The excursions do.

for trade in result.trades():
    if trade.mae_pnl is None:
        continue                      # this path did not track extremes
    if trade.pnl > 0 and trade.mae_pnl < 0:
        ...                           # a winner that first dipped: a tighter
                                      # stop would have cut it
    if trade.pnl <= 0 and trade.mfe_pnl > 0:
        ...                           # a loser that first led: a target would
                                      # have banked it

Three properties hold for every measured trade:

  • mae_pnl <= 0 <= mfe_pnl, in the same money and on the same contract multiplier as pnl, before costs.
  • mae_pnl <= (pnl + fees) <= mfe_pnl — gross P&L is realized somewhere inside the band the trade actually travelled.
  • They are measured bar by bar while the position was open, not reconstructed afterwards from an OHLC window. The resolution is therefore the bar: a run on 5-minute bars knows the worst 5-minute extreme, not the worst tick inside it, and the engine makes no finer claim.

None means the trade came from a path that does not track intra-trade extremes — a synthesized spread, basket or pairs leg. Read it as "not measured", never as a zero excursion.


Building from Source

Most users should pip install raptorbt. To build the engine yourself you need Rust 1.70+, Python 3.10+, and maturin:

cd raptorbt
maturin develop --release   # editable install into the active venv
cargo test                  # run the Rust test suite

Verification Test

A seeded smoke test — run it twice and the result is identical to the last decimal (the determinism guarantee):

import numpy as np
import raptorbt

np.random.seed(42)
n = 500
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
entries = np.zeros(n, dtype=bool); entries[::20] = True
exits = np.zeros(n, dtype=bool);  exits[10::20] = True

config = raptorbt.BacktestConfig(initial_capital=100000, fees=0.001)
result = raptorbt.run_single_backtest(
    # Timestamps are int64 nanoseconds, and annualization is derived from the
    # elapsed wall-clock span they describe. A bare np.arange(n) is 500
    # *nanoseconds* of history, which inflates Sharpe by orders of magnitude —
    # space the bars a real interval apart.
    timestamps=np.arange(n, dtype=np.int64) * 86_400_000_000_000,  # daily bars
    open=close,
    high=close,
    low=close,
    close=close,
    volume=np.ones(n),
    entries=entries,
    exits=exits,
    direction=1,
    weight=1.0,
    symbol="TEST",
    config=config,
)
print(f"Total Return: {result.metrics.total_return_pct:.4f}%")  # -30.6192%
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.4f}")       # -0.9086

Update Notice

raptorbt writes a single INFO log line when the installed version is behind the newest release on PyPI:

raptorbt 0.6.2 is behind the latest release 0.6.3. Install the latest version:
pip install -U raptorbt (set RAPTORBT_NO_VERSION_CHECK=1 to silence this).

The check runs on a daemon thread with a 2s timeout and is cached on disk for 24 hours, so it never delays import raptorbt.

It fails silently. An unreachable PyPI, a proxy, a read-only cache directory, or a malformed response all produce no output whatsoever — no traceback, no stderr, nothing on the log. It cannot raise, and it cannot print. The notice itself is INFO, which keeps it below logging.lastResort's WARNING threshold, so it is invisible unless you have asked for INFO logs.

Variable Effect
RAPTORBT_NO_VERSION_CHECK=1 Disable the check entirely; no request is made.

Continuous-integration environments (CI, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL, BUILDKITE) are skipped automatically.

Support

RaptorBT is free and MIT licensed, and always will be. If it saved you time and you want to put something back, you can send a one-time amount of your choosing.

It buys no features, no support commitment, and no priority — it just helps keep the work going.


License

MIT License - see LICENSE for details.


Changelog

Full release notes, including the 0.5.0 migration guide, live in CHANGELOG.md. Recent releases in brief:

v0.6.3

  • The portfolio optimizer learns long/short — by explicit configuration only. OptimizerConfig gains short_cap (per-name short bound, default 0 = long-only, byte-identical to the historical problem), gross_max (total size of all bets, sum |w|), and net_min/net_max (directional tilt, sum(w); both 0 pins a dollar-neutral book). Sector caps become GROSS in long/short mode — concentration, not direction. OptimizationResult gains gross_exposure/net_exposure. Short position adoption remains refused, pinned by test.
  • Fixed: adopting a position mid-run understated max drawdown. The equity curve is written as the run proceeds, so a position adopted after it started left the curve flat beforehand, holding the running peak down — a real 0.495% drawdown reported as 0.199%, with total return and open PnL unchanged. Adoption is now refused once any equity sample has been taken. Quotes and depth snapshots sample no equity, so adopting after one still works.
  • Fixed: a seeded long/short strategy could not be deployed at all. Such a book must run under a margin account for its short to transact, and adoption refused margin outright. Fully funded margin books (leverage 1.0) now adopt by locking the cost basis as initial margin rather than debiting cash, which is what that mode's equity formula requires. Leveraged books stay refused. The error message changed from "adopt_position supports cash accounts only".

v0.6.2

  • Position adoption — seed a run with a position the account already holds, at the real average cost, with no order, no fill, no fees and no trade record. TickStrategyStream(initial_positions=...) and PortfolioSession.adopt_position(...). Cash accounts only, long-only; margin adoption is refused rather than guessed. See Position Adoption.

v0.6.1

  • Overlap-deflated rank-IC t-statistic. rank_ic reported only the naive t-stat, which counts overlapping forward windows as independent evidence and inflates significance by ~sqrt(horizon). Adds t_stat_deflated (the number to decide on), n_independent, and overlap_days; the naive t_stat is kept so the inflation stays auditable.

v0.6.0

  • An order's side now opens the position, so one run can hold long and short legs and a leg can flip once flat. Adds enter_long() / enter_short() and enter(side=...); enter() without side is unchanged.
  • Portfolio construction maths: estimate_covariance (Ledoit-Wolf shrinkage), optimize_portfolio / batch_optimize_portfolios (long-only QP with turnover penalty and caps), factor panels (winsorize_panel, zscore_panel, rank_panel, momentum_panel, composite_scores), rank_ic, compute_risk_contributions, and simulate_rebalance_policy with the Indian cost schedule.
  • Every refused order now counts against rejected_entries; an order-path open honors its ATR stop/target config instead of a hardcoded zero ATR.

v0.5.0

  • Class-based strategy contract (Strategy + run_strategy_backtest), tick-driven (run_tick_strategy) and live streaming (TickStrategyStream) variants, and run_portfolio_backtest — N instruments against one shared cash pool.
  • Correctness fixes that change reported numbers: configured slippage was silently ignored; Sharpe/Sortino were computed from different quantities per runner; Calmar was meaningless on intraday data; undefined ratios crossed to Python as inf and are now Optional[float].
  • Order book with queue-position fills, TWAP schedules, Renko and signed-flow bars, shared margin accounts, and itemized Indian transaction costs.

Upgrading from 0.4.x: set apply_slippage=False, legacy_annualization=True to reproduce old results bit-identically. See CHANGELOG.md.

v0.4.1

  • Release chore; no behavior change.

v0.4.0

Tick-level backtesting — full tick resolution, no bar resampling.

  • Add TickData struct — parallel arrays of timestamps, ltp, bid, ask, buy_qty_delta, sell_qty_delta, oi (one element per tick). Callers must pre-convert Zerodha cumulative session totals to per-tick deltas before passing.
  • Add ExitReason::TimeExit — max hold-time exceeded exit for tick strategies.
  • Add run_tick_backtest — tick-native simulation engine. Entry fills at ask+slippage; stop/target checked against ltp on every tick (not OHLC approximation); max-hold-seconds time exit; configurable cooldown between entries. Returns the same BacktestResult / BacktestMetrics (33 fields) as all other strategy types.
  • Add compute_tick_entry_signals — compute momentum entry bool array from precomputed feature arrays (spread gate, delta BSI gate, 1-min return gate, cooldown enforcement). O(N) single pass.
  • Add compute_tick_exit_signals — time-based (EOD) exit bool array from tick timestamps.
  • Add tick_spread_pct — per-tick bid/ask spread as percentage of mid price.
  • Add buy_sell_imbalance_delta — per-tick delta BSI from Zerodha cumulative running sums. Fixes the raw-cumulative BSI artefact (~0.95 all day regardless of order flow).
  • Add return_window — per-tick lookback return over a configurable time window using binary search (O(N log N)). Returns NaN where history is insufficient — correctly gates the entry filter rather than silently passing.
  • Add realized_vol_rolling — rolling realized volatility proxy (stddev of log-returns) over a time window.
  • Add oi_position_pct — OI position within the day's high/low range, per tick: [0, 100].
  • Add tick_velocity — rolling tick count per minute over a configurable time window.
  • Expose compute_backtest_metrics as a public free function in portfolio::engine — non-OHLCV strategy types can produce identical metrics without duplicating the calculation logic.

v0.3.4

  • Add single-leg option spread types: LongCall, LongPut, NakedCall, NakedPut to SpreadType enum
  • Add ExitReason::Settlement for option expiry settlement exits
  • Add leg_expiry_timestamps parameter to run_spread_backtest for per-leg expiry tracking
  • Positions are force-closed at settlement when any leg expires, with premiums replaced by intrinsic value
  • Prevent re-entry after all legs have expired

v0.3.3

  • Add batch_spread_backtest function for running multiple spread backtests in parallel via Rayon
  • Add BatchSpreadItem class for defining individual items in a batch spread backtest
  • Shared data (timestamps, underlying close) is converted once and reused across all items
  • GIL released during parallel execution for maximum Python concurrency
  • Each item carries its own strategy_id, leg configs, signals, spread type, and optional max loss / target profit
  • Returns a list of (strategy_id, BacktestResult) tuples preserving result-to-input mapping

v0.3.2

  • Add payoff_ratio metric to BacktestMetrics — average winning trade return divided by average losing trade return (absolute), measures risk/reward per trade
  • Add recovery_factor metric to BacktestMetrics — net profit divided by maximum drawdown in absolute terms, measures how many times over the strategy recovered from its worst drawdown
  • Both metrics computed in StreamingMetrics::finalize() (single-instrument backtest) and PortfolioEngine (multi-strategy aggregation)
  • Both metrics exposed via PyO3 as #[pyo3(get)] attributes on BacktestMetrics
  • Handles edge cases: returns f64::INFINITY when denominator is zero with positive numerator, 0.0 otherwise

v0.3.1

  • Add Monte Carlo portfolio simulation (simulate_portfolio_mc) for forward risk projection
  • Geometric Brownian Motion (GBM) with Cholesky decomposition for correlated multi-asset simulation
  • Rayon-parallelized simulation paths with deterministic seeding (xoshiro256**)
  • Returns percentile paths (P5/P25/P50/P75/P95), VaR, CVaR, expected return, and probability of loss
  • GIL released during simulation for maximum Python concurrency

v0.3.0

  • Per-instrument configuration via InstrumentConfig (lot_size, alloted_capital, stop/target overrides)
  • Position sizes now correctly rounded to lot_size multiples
  • Support for per-instrument capital allocation in basket backtests
  • Future-ready fields: existing_qty, avg_price for live-to-backtest transitions

v0.2.2

  • Export run_spread_backtest Python binding for multi-leg options spread strategies
  • Export rolling_min and rolling_max indicator functions to Python

v0.2.1

  • Add rolling_min and rolling_max indicators for LLV (Lowest Low Value) and HHV (Highest High Value) support
  • NaN handling for warmup period

v0.2.0

  • Add multi-leg spread backtesting (run_spread_backtest) supporting straddles, strangles, vertical spreads, iron condors, iron butterflies, butterfly spreads, calendar spreads, and diagonal spreads
  • Coordinated entry/exit across all legs with net premium P&L calculation
  • Max loss and target profit exit thresholds for spreads
  • Add SessionTracker for intraday session management: market hours detection, squareoff time enforcement, session high/low/open tracking
  • Pre-built session configs for NSE equity (9:15-15:30), MCX commodity (9:00-23:30), and CDS currency (9:00-17:00)
  • Extend StreamingMetrics with equity/drawdown tracking, trade recording, and finalize() method

v0.1.0

  • Initial release
  • 5 strategy types: single, basket, pairs, options, multi
  • 30+ performance metrics: Sharpe, Sortino, Calmar, Omega, SQN, profit factor, drawdown duration, and more
  • 10 technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend)
  • Stop-loss management: fixed, ATR-based, and trailing stops
  • Take-profit management: fixed, ATR-based, and risk-reward targets
  • PyO3 Python bindings for seamless Python integration

Release files for raptorbt 0.13.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for raptorbt 0.13.4
File Size Uploaded
raptorbt-0.13.4.tar.gz 776.0 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for raptorbt 0.13.4
File
raptorbt-0.13.4-cp312-cp312-win_amd64.whl CPython 3.12 CPython 3.12 Windows x86-64 Details
raptorbt-0.13.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.12 CPython 3.12 Linux glibc 2.17+ x86-64 Details
raptorbt-0.13.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.12 CPython 3.12 Linux glibc 2.17+ ARM64 Details
raptorbt-0.13.4-cp312-cp312-macosx_11_0_arm64.whl CPython 3.12 CPython 3.12 macOS 11.0+ ARM64 Details
raptorbt-0.13.4-cp312-cp312-macosx_10_12_x86_64.whl CPython 3.12 CPython 3.12 macOS 10.12+ x86-64 Details
raptorbt-0.13.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.11 CPython 3.11 Linux glibc 2.17+ x86-64 Details
raptorbt-0.13.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.11 CPython 3.11 Linux glibc 2.17+ ARM64 Details
raptorbt-0.13.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.10 CPython 3.10 Linux glibc 2.17+ x86-64 Details
raptorbt-0.13.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.10 CPython 3.10 Linux glibc 2.17+ ARM64 Details

Total release size: 9.7 MB

Release files / raptorbt-0.13.4.tar.gz

Download URL raptorbt-0.13.4.tar.gz
Size 776.0 kB
Tags Source
SHA-256 checksum
How to use checksums
5d17786f182de22f2e2d922523dc83eb6e4ecc648b5cb67311704bc67b1f18e3
BLAKE2b-256 checksum
How to use checksums
d430676a57ff611f04e730661e14ea36c34b2498a2a177f0ac1667b15780e18b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp312-cp312-win_amd64.whl

Download URL raptorbt-0.13.4-cp312-cp312-win_amd64.whl
Size 1.0 MB
Tags CPython 3.12 Windows x86-64
SHA-256 checksum
How to use checksums
25d2673b7e8dc5b011334d12c529b0bc808ff14a56d609af01ceb825ace738da
BLAKE2b-256 checksum
How to use checksums
adf31be0ce3213651168d29303e76fa6b5e153115ebe2dec1fe6a627e20c0a8f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL raptorbt-0.13.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 1.0 MB
Tags CPython 3.12 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
55e42a00da5b92f9a1199541c6c122b1214000e60e7b4e149eea18d2da8edcd0
BLAKE2b-256 checksum
How to use checksums
fd7897b541046f2cd67720271d8f54818da748e01c1c2f1b0442a2c70ff2f06a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL raptorbt-0.13.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 957.0 kB
Tags CPython 3.12 Linux glibc 2.17+ ARM64
SHA-256 checksum
How to use checksums
a397713fcc1f1dba5e7be4ef34657d108464d10b47b4649f3150dac967be24b9
BLAKE2b-256 checksum
How to use checksums
d01d5a9a6ac4d413582d8dee80766ded7b9a57fbed81ce9ba07e703873ac3b46
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp312-cp312-macosx_11_0_arm64.whl

Download URL raptorbt-0.13.4-cp312-cp312-macosx_11_0_arm64.whl
Size 941.7 kB
Tags CPython 3.12 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
003b3520f8923661f4825acde2a61f13f43d1a2fb3d41cf2168a689f0cf2eb98
BLAKE2b-256 checksum
How to use checksums
92a80e12855c5765d34bd19e90b05e287fbc59681ff66d7f34573fbc205bea96
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp312-cp312-macosx_10_12_x86_64.whl

Download URL raptorbt-0.13.4-cp312-cp312-macosx_10_12_x86_64.whl
Size 990.1 kB
Tags CPython 3.12 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
cd10911e408873dff5aafe78f67de303ca44208c80f3ba50ded680f5ef4ebcc3
BLAKE2b-256 checksum
How to use checksums
8f82804592be400b3cce9c1e1acd28688d1e5101c9a07a1873a65e54164e6354
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL raptorbt-0.13.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 1.0 MB
Tags CPython 3.11 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
334229ed7c6ea0e6132993f7266cc7b711dbcd68b24d370e24e9ab1ad5f88309
BLAKE2b-256 checksum
How to use checksums
2857c7620accd00f6c3d9f0d5099230ae1851467cc7e4c576c763ef004539383
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL raptorbt-0.13.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 958.6 kB
Tags CPython 3.11 Linux glibc 2.17+ ARM64
SHA-256 checksum
How to use checksums
0edeb839032867f49abb0ce6135a3947c8b0353ac0891f4653ada14c6613e53d
BLAKE2b-256 checksum
How to use checksums
5008f9d87c49e200b9074dbdbf688eb638c4a29b75643c76f2b98106bb091b71
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL raptorbt-0.13.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 1.0 MB
Tags CPython 3.10 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
20cdf69876e410b1c71ae63e35b5d3cf355cfc9dbb3a625b34f981dae2de85a0
BLAKE2b-256 checksum
How to use checksums
5675cd6969957dc127e1ec1cfb89e96b04f50a42b9bdf3ff1fc0f450de7e172d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log

Release files / raptorbt-0.13.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL raptorbt-0.13.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 958.6 kB
Tags CPython 3.10 Linux glibc 2.17+ ARM64
SHA-256 checksum
How to use checksums
455eeda5afbb9f147f6538ca90ebf9a2acadc1cf5cee23480622797f03a4a0ef
BLAKE2b-256 checksum
How to use checksums
d9dc5b3a5fb2c1be4a2327393a22442cc3b698f04a8b9cbac0f1d3104129c291
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page