Most traders start backtesting with a spreadsheet or a few lines of pandas: compute an indicator over the whole price history, shift the signal by one bar, multiply by returns and read off the profit. It is quick, and it hides almost everything that decides whether a strategy survives real trading: when exactly the order is sent, what price it fills at, what happens when the market gaps, what the broker charges, and whether the code quietly used a price from the future.
Nautilus Trader is an open-source trading platform built to answer those questions. It replays the market one event at a time and puts your strategy through the same order life cycle it would face in live trading. This series introduces it to traders who have never used an event-driven backtester. In Part 1 we cover:
- What event-driven backtesting is, and why it matters
- The architecture of Nautilus Trader, piece by piece
- How the backtest decides where your market, limit and stop orders fill
- How OpenAlgo supplies the historical data, straight from your broker (
source="api") or from Historify (source="db") - A complete, verified example: a Keltner Channel breakout on SBIN hourly bars over 10 years
- What else Nautilus Trader can do: single-leg and multi-leg options, portfolios and multiple strategies
Vectorized vs event-driven backtesting
A vectorized backtest works on the whole price series at once. It is fast and perfect for screening ideas, but orders do not really exist in it: a column of signals stands in for them, and the fill price is whatever you assume. An event-driven backtest feeds your strategy one piece of data at a time, in time order, exactly as a live market would. The strategy sends real orders, and a simulated exchange decides whether and where they fill.
| Vectorized (pandas style) | Event-driven (Nautilus Trader) | |
|---|---|---|
| How it runs | The whole price series at once | One event at a time, in time order |
| Orders | Implied by a signal column | Real order objects: market, limit, stop and more |
| Fill price | Usually assumed: the close or the next open | Decided by a simulated exchange, with slippage and fees |
| Lookahead risk | Easy to leak future data by accident | The strategy only sees data that has already arrived |
| Speed | Very fast | Slower, but still quick: 10 years of hourly bars ran in about a second |
| Path to live trading | Rewrite the logic for execution | The same strategy class runs live |
A good workflow uses both. Screen ideas with a vectorized test, then take the survivors to an event-driven engine to find out whether they still hold up when every order has to be placed, filled and paid for.
What is Nautilus Trader?
NautilusTrader is an open-source, high-performance algorithmic trading platform developed by Nautech Systems. The core is written in Rust for speed and reliability, and you write your strategies in Python. Two design ideas matter most to a trader:
- Event-driven everywhere. Backtesting and live trading run on the same engine and the same message bus. The only difference is where the data comes from and where the orders go.
- Research and live parity. The strategy class that passes the backtest is the same class you deploy, so there is no second implementation that can drift out of sync with the one you tested.
It is also asset-class agnostic. Equities, futures, options, spreads, currencies, crypto and betting markets all share one model of instruments, orders, positions and accounts, which is what makes the options and portfolio work later in this series possible.
The architecture of Nautilus Trader

The diagram reads from top to bottom: your code, the kernel that runs it, and the layer that is either a simulated exchange (backtest) or a real venue (live).
The message bus: how everything talks
Every piece of market data, every command such as “submit this order”, and every event such as “order filled” travels on a single message bus, in strict time order. Components never call each other directly; they publish and subscribe. This is why the same strategy runs unchanged in a backtest and live: it only ever talks to the bus.
The engines around the bus
- Data engine: receives bars, trade ticks, quote ticks and order book updates, and routes them to whoever subscribed.
- Risk engine: checks every order before it leaves: quantity, price precision, notional limits and order rate limits.
- Execution engine: routes orders to the right venue and turns the venue’s replies into order and position events.
- Portfolio: tracks positions, realised and unrealised P&L and exposure, and has an analyzer for performance statistics.
- Cache: the in-memory record of instruments, orders, positions, accounts and the latest prices. Your strategy reads from it.
- Clock: simulated time in a backtest, the real clock when live. Timers and time alerts use the same API in both.
The trader: your code
- Strategies receive data through handlers such as
on_bar,on_quote_tickandon_order_filled, and they submit, modify and cancel orders. - Actors receive the same data and events but do not trade. They suit signal generators, monitors and alerting.
- Execution algorithms take a parent order and work it in pieces, for example a TWAP that slices a large order over time.
Backtest or live: the bottom layer
In a backtest, a simulated exchange stands in for the venue. It holds an order matching engine plus pluggable models for fills (slippage and queue position), fees, latency and the account type (cash or margin), and optional simulation modules such as option exercise at expiry. You drive it with BacktestEngine, a low-level API where you add venues, instruments, data and strategies in code (used in this post), or with BacktestNode, a configuration-driven runner that streams large datasets from a Parquet data catalog.
Live, a TradingNode connects the same kernel to real venues through adapters, each a data client plus an execution client. Official adapters exist for venues such as Interactive Brokers, Binance, Bybit, OKX and Databento. There is no official adapter for Indian brokers, so in this series Nautilus is the research engine and OpenAlgo is the source of Indian market data.
The life of one bar in a backtest
Here is what happens every time the backtest moves forward by one hourly bar:
- The engine takes the next piece of data, strictly in timestamp order.
- The simulated exchange replays that bar through its matching engine, so any resting orders can trigger or fill.
- The data engine publishes the bar on the message bus and the cache stores it.
- Your strategy’s
on_barruns. It can read indicators, its position and the account balance, and submit orders. - Each order passes the risk engine, then the execution engine sends it to the simulated exchange.
- The exchange accepts, fills, rejects or cancels it, and events such as
OrderAcceptedandOrderFilledcome back. - The portfolio updates positions and P&L, and your strategy’s
on_order_filledruns.
Because the strategy only sees what has already been published, it cannot peek at the next bar. That protection only holds if every bar carries the right timestamp, which is the first thing we fix when we load the data.
How your orders get filled: the execution mechanism
This is the part most beginners never look at, and it moves results more than most parameter choices. With bar data there are no ticks inside a bar, so the simulated exchange replays each bar as four prices: open, high, low, close. With bar_adaptive_high_low_ordering=True it visits the high first when the high is nearer the open and the low first otherwise, the same assumption TradingView makes.

Market orders
A market order fills immediately at the current simulated price. Sent from on_bar, that is the close of the bar you just received. Intraday, one bar’s close is almost the next bar’s open, so this is a fair model of acting the moment a bar completes. FillModel(prob_slippage=1.0) makes every market fill one tick worse, a sensible default for a liquid NSE stock.
Limit orders
A limit order rests until a replayed price reaches your limit, then fills at the limit price. One that is already marketable when you send it fills at the market price. When the price only touches your limit, prob_fill_on_limit decides whether you were filled, as a simple model of your place in the queue. If the market gaps through a resting limit, Nautilus still fills it at the limit, which is conservative: you never get a better price than you asked for.
Stop orders
A stop-market order triggers when a replayed price trades at or through the stop, then fills at the stop price. There is one trap. On bar data, Nautilus fills a stop at its level even when the bar opened beyond it. We tested it: a sell stop at 99 on a bar that opened at 95 filled at 99, a price that never traded. On a stock that gaps overnight, that silently flatters every stop exit. The example below fixes it by sending an opening-price event before each bar, so a stop the market gapped past fills at the open, just as it would at a broker.
Charges and latency
Fees are a FeeModel you can subclass. The example charges the full Indian delivery schedule on every fill: STT, stamp duty, exchange and SEBI fees, GST and DP charges. A LatencyModel can also delay orders between your strategy and the exchange, which matters for intraday strategies on tick data.
Where OpenAlgo fits: data management
OpenAlgo is a self-hosted, open-source bridge between your broker and your code, with one symbol format across Indian brokers. In this series it has exactly one job: data management. It never places an order during a backtest.

The OpenAlgo Python SDK’s history() call returns a pandas DataFrame of OHLCV bars, and the source argument picks where they come from:
from openalgo import api
client = api(api_key="your_openalgo_api_key", host="http://127.0.0.1:5000")
# Straight from your broker, through OpenAlgo
df = client.history(symbol="SBIN", exchange="NSE", interval="1h",
start_date="2025-01-01", end_date="2025-12-31",
source="api")
# From Historify, OpenAlgo's local DuckDB store of data you already downloaded
df = client.history(symbol="SBIN", exchange="NSE", interval="1h",
start_date="2025-01-01", end_date="2025-12-31",
source="db")
source="api": your broker’s own history, with no setup. Broker limits still apply (Zerodha, for example, serves 60 days of intraday data per request); OpenAlgo splits a long range into chunks for you and paces the requests.source="db": reads Historify, OpenAlgo’s local DuckDB store. Download once, and every later backtest reads the same stored bars, fast and offline, even after your broker session has expired for the day.
Both return the same DataFrame, indexed by the bar’s open time in IST. The source argument needs a recent SDK, so run pip install -U openalgo. The same package also ships openalgo.ta, a library of over 100 technical indicators, which we use for the Keltner Channel.
Hands-on: a Keltner Channel breakout on SBIN
Our test strategy is the classic TradingView “Keltner Channels Strategy”, made long only and run on SBIN hourly bars from September 2016 to September 2026:
- Channel: middle line = EMA(20) of the close; bands = middle line plus and minus 2 x ATR(10).
- Entry: when a bar closes above the upper band, place a buy stop one tick above that bar’s high. Cancel it if a later bar closes below the middle line.
- Exit: when a bar closes below the lower band while long, place a sell stop one tick below that bar’s low. Cancel it if a later bar closes above the middle line.
- Position: long only, delivery (CNC), one position at a time, 99% of available cash, Rs 10 lakh starting capital.
The original script also goes short on the lower-band signal; here that signal only closes the long. Put the blocks below into one file, in order, and run it. It was tested with nautilus_trader 1.220.0 on Python 3.13. Nautilus changes quickly between releases, so pin the version you build on.
pip install -U nautilus_trader openalgo pyarrow
Step 1: imports
import os
from pathlib import Path
import numpy as np
import pandas as pd
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.backtest.models import FeeModel, FillModel
from nautilus_trader.config import LoggingConfig, RiskEngineConfig, StrategyConfig
from nautilus_trader.model.currencies import INR
from nautilus_trader.model.data import Bar, BarType
from nautilus_trader.model.enums import AccountType, OmsType, OrderSide
from nautilus_trader.model.identifiers import InstrumentId, Symbol, Venue
from nautilus_trader.model.instruments import Equity
from nautilus_trader.model.objects import Money, Price, Quantity
from nautilus_trader.persistence.wranglers import BarDataWrangler
from nautilus_trader.trading.strategy import Strategy
from openalgo import api, ta
Step 2: load the data and fix the timestamps
The first run downloads 10 years of hourly bars, one calendar year per request, and caches them to a Parquet file, so later runs never touch the broker. Then comes the most important line in the file. OpenAlgo stamps each bar with its open time (09:15, 10:15 …), while Nautilus treats a bar’s timestamp as the moment it is complete. Hand the bars over unchanged and your strategy acts on the 10:15 close at 09:15, an hour before it existed. So every bar is restamped at its close, and the short last bar of the session (15:15 to 15:30) closes at 15:30.
def load_bars(symbol="SBIN", exchange="NSE", years=10, source="api"):
"""Hourly bars from OpenAlgo, cached to Parquet after the first download.
source="api" asks your broker through OpenAlgo; source="db" reads what
you have already stored in Historify.
"""
cache = Path(f"{symbol}_{exchange}_1h.parquet")
if cache.exists():
df = pd.read_parquet(cache)
else:
client = api(api_key=os.environ["OPENALGO_API_KEY"], host="http://127.0.0.1:5000")
end = pd.Timestamp.today().normalize()
start = end - pd.DateOffset(years=years)
frames = []
for year in range(start.year, end.year + 1): # one year per request
s = max(start, pd.Timestamp(year=year, month=1, day=1))
e = min(end, pd.Timestamp(year=year, month=12, day=31))
part = client.history(
symbol=symbol,
exchange=exchange,
interval="1h",
start_date=s.strftime("%Y-%m-%d"),
end_date=e.strftime("%Y-%m-%d"),
source=source,
)
if isinstance(part, pd.DataFrame):
frames.append(part)
df = pd.concat(frames)
df = df[~df.index.duplicated()].sort_index()
df = df[["open", "high", "low", "close", "volume"]].astype(float)
df.to_parquet(cache)
# OpenAlgo stamps a bar with its OPEN time. Nautilus must see it at its
# CLOSE time, or the strategy acts on a close before it happened.
# The last bar of the day (15:15) closes at 15:30, not 16:15.
close_time = df.index + pd.Timedelta(hours=1)
session_end = df.index.normalize() + pd.Timedelta(hours=15, minutes=30)
capped = (df.index < session_end) & (close_time > session_end)
df["bar_close"] = close_time.where(~capped, session_end)
return df[df["bar_close"] <= pd.Timestamp.now(tz="Asia/Kolkata")]
Step 3: indicators with openalgo.ta
The channel and its crossings are computed once, over the whole history, with ta.keltner, ta.crossover and ta.crossunder. That is safe only because an EMA and an ATR at any bar use that bar and earlier ones. We checked it: recomputing on the first 9,000 bars alone gives identical values, so no future data leaks in. The results are keyed by bar close time, so the strategy looks up the value for exactly the bar it has just received.
def keltner_signals(df, length=20, mult=2.0, atr_length=10):
high, low, close = (df[c].to_numpy(dtype=float) for c in ("high", "low", "close"))
upper, middle, lower = ta.keltner(high, low, close, length, atr_length, mult)
ts = pd.DatetimeIndex(df["bar_close"]).tz_convert("UTC").as_unit("ns").asi8
cross_up = ta.crossover(close, upper)
cross_dn = ta.crossunder(close, lower)
# bar close time (ns) -> (middle line, crossed above upper, crossed below lower)
return dict(zip(ts, zip(middle, cross_up, cross_dn, strict=True), strict=True))
Step 4: Indian delivery charges
class ZerodhaDeliveryFees(FeeModel):
"""Equity delivery charges: STT, stamp duty, exchange, SEBI, GST, DP."""
def get_commission(self, order, fill_qty, fill_px, instrument):
value = float(fill_qty) * float(fill_px)
exchange, sebi = value * 0.0000297, value * 0.000001
fee = value * 0.001 + exchange + sebi + (exchange + sebi) * 0.18
fee += value * 0.00015 if order.side == OrderSide.BUY else 15.34
return Money(fee, INR)
BUY_COST = 0.001 + 0.00015 + (0.0000297 + 0.000001) * 1.18 # to size entries
Step 5: the strategy
The strategy works in two moments per bar. At the close (on_close) it withdraws any stop that did not fill, records equity, and applies the Keltner rules to set or clear a pending entry or exit level. At the open of the next bar (on_open) it sends that level to the market: as a real stop order if the open is still on the right side of it, or as a market order if the price has already gapped past it. That second case is the gap fix from the execution section.
class KeltnerConfig(StrategyConfig, frozen=True):
instrument_id: InstrumentId
bar_type: BarType # the hourly bars
open_bar_type: BarType # one opening-price event before each bar
allocation: float = 0.99
class KeltnerLongOnly(Strategy):
def __init__(self, config, signals):
super().__init__(config)
self.signals = signals
self.entry_level = None # buy stop waiting to fill
self.exit_level = None # sell stop waiting to fill
self.working = None # stop order resting in the current bar
self.equity = [] # (time, marked-to-market equity)
def on_start(self):
self.instrument = self.cache.instrument(self.config.instrument_id)
self.tick = self.instrument.price_increment.as_double()
self.subscribe_bars(self.config.bar_type)
self.subscribe_bars(self.config.open_bar_type)
def on_bar(self, bar: Bar):
if bar.bar_type == self.config.open_bar_type:
self.on_open(bar)
else:
self.on_close(bar)
def is_long(self):
return self.portfolio.is_net_long(self.config.instrument_id)
def on_open(self, bar):
"""At the open: send the pending stop, or a market order if price gapped past it."""
open_px = bar.open.as_double()
if not self.is_long() and self.entry_level is not None:
level = self.entry_level
cash = self.portfolio.account(bar.bar_type.instrument_id.venue).balance_free(INR)
price = (max(open_px, level) + self.tick) * (1 + BUY_COST)
qty = int(cash.as_double() * self.config.allocation // price)
if qty > 0:
self.send(OrderSide.BUY, qty, None if open_px >= level else level)
elif self.is_long() and self.exit_level is not None:
level = self.exit_level
qty = float(self.portfolio.net_position(self.config.instrument_id))
self.send(OrderSide.SELL, qty, None if open_px <= level else level)
def on_close(self, bar):
"""At the close: withdraw an unfilled stop, then apply the Keltner rules."""
if self.working is not None and self.working.is_open:
self.cancel_order(self.working)
self.working = None
account = self.portfolio.account(bar.bar_type.instrument_id.venue)
shares = float(self.portfolio.net_position(self.config.instrument_id))
cash = account.balance_total(INR).as_double()
self.equity.append((bar.ts_event, cash + shares * bar.close.as_double()))
middle, cross_up, cross_dn = self.signals.get(bar.ts_event, (np.nan, False, False))
if np.isnan(middle):
return # indicator still warming up
close, long = bar.close.as_double(), self.is_long()
if self.entry_level is not None and (long or close < middle):
self.entry_level = None # filled, or closed below the middle line
if self.exit_level is not None and (not long or close > middle):
self.exit_level = None # filled, or closed above the middle line
if cross_up and not long:
self.entry_level = bar.high.as_double() + self.tick
if cross_dn and long:
self.exit_level = bar.low.as_double() - self.tick
def send(self, side, qty, stop_level):
quantity = self.instrument.make_qty(qty)
reduce_only = side == OrderSide.SELL
if stop_level is None: # the open is already past the level
order = self.order_factory.market(
self.config.instrument_id, side, quantity, reduce_only=reduce_only
)
else:
order = self.order_factory.stop_market(
self.config.instrument_id,
side,
quantity,
trigger_price=Price(stop_level, self.instrument.price_precision),
reduce_only=reduce_only,
)
self.working = order
self.submit_order(order)
Step 6: build the engine and run
This is where the venue is described: NSE, SBIN with a Rs 0.05 tick, a cash account in rupees, the delivery charges, one tick of slippage and TradingView’s high/low ordering. The opening-price events are the same bars with only the open price, stamped a nanosecond after each bar opens.
def main():
df = load_bars()
signals = keltner_signals(df)
nse = Venue("NSE")
sbin = Equity(
instrument_id=InstrumentId(Symbol("SBIN"), nse),
raw_symbol=Symbol("SBIN"),
currency=INR,
price_precision=2,
price_increment=Price.from_str("0.05"),
lot_size=Quantity.from_int(1),
ts_event=0,
ts_init=0,
)
hourly = BarType.from_str("SBIN.NSE-1-HOUR-LAST-EXTERNAL")
# Same length as 1-HOUR on purpose: Nautilus executes only against the
# shortest bar type it sees, and both must reach the matching engine.
opening = BarType.from_str("SBIN.NSE-60-MINUTE-LAST-EXTERNAL")
bars = df.set_index("bar_close")[["open", "high", "low", "close", "volume"]]
bars.index = bars.index.tz_convert("UTC")
opens = pd.DataFrame(
{c: df["open"].to_numpy() for c in ("open", "high", "low", "close")}
| {"volume": df["volume"].to_numpy()},
index=df.index.tz_convert("UTC") + pd.Timedelta(1, "ns"),
)
engine = BacktestEngine(
BacktestEngineConfig(
logging=LoggingConfig(log_level="ERROR"),
risk_engine=RiskEngineConfig(bypass=True), # see the pitfalls section
)
)
engine.add_venue(
venue=nse,
oms_type=OmsType.NETTING,
account_type=AccountType.CASH,
base_currency=INR,
starting_balances=[Money(1_000_000, INR)],
fee_model=ZerodhaDeliveryFees(),
fill_model=FillModel(prob_slippage=1.0, random_seed=42), # 1 tick against you
bar_adaptive_high_low_ordering=True,
)
engine.add_instrument(sbin)
engine.add_data(BarDataWrangler(hourly, sbin).process(bars))
engine.add_data(BarDataWrangler(opening, sbin).process(opens))
config = KeltnerConfig(instrument_id=sbin.id, bar_type=hourly, open_bar_type=opening)
strategy = KeltnerLongOnly(config, signals)
engine.add_strategy(strategy)
engine.run()
# ---- Results
positions = engine.trader.generate_positions_report()
pnl = positions["realized_pnl"].astype(str).str.replace(" INR", "").astype(float)
equity = pd.Series(
[e for _, e in strategy.equity],
index=pd.to_datetime([t for t, _ in strategy.equity], unit="ns", utc=True),
).tz_convert("Asia/Kolkata")
daily = equity.groupby(equity.index.date).last()
years = (pd.Timestamp(daily.index[-1]) - pd.Timestamp(daily.index[0])).days / 365.25
cagr = (daily.iloc[-1] / 1_000_000) ** (1 / years) - 1
max_dd = (daily / daily.cummax().clip(lower=1_000_000) - 1).min()
print(f"Trades {len(positions)}")
print(f"Win rate {(pnl > 0).mean():.1%}")
print(f"Final equity Rs {daily.iloc[-1]:,.0f}")
print(f"CAGR {cagr:.2%}")
print(f"Max drawdown {max_dd:.1%}")
engine.dispose()
if __name__ == "__main__":
main()
Output:
Trades 102
Win rate 42.2%
Final equity Rs 3,478,430
CAGR 13.29%
Max drawdown -31.7%
Reading the results

| Keltner breakout | Buy and hold SBIN | |
|---|---|---|
| Final equity (from Rs 10 lakh) | Rs 34.78 lakh | Rs 38.47 lakh |
| CAGR | 13.29% | 14.43% |
| Maximum drawdown | -31.6% | -59.1% |
| Sharpe ratio (risk-free rate 0%) | 0.67 | 0.59 |
| Time in the market | 53.4% | 100% |
| Trades | 102 (win rate 42.2%, profit factor 1.70) | 1 |
| Charges paid | Rs 4.72 lakh | one buy |
The breakout did not beat buy and hold on return: 13.29% a year against 14.43%. It got there with roughly half the drawdown (31.6% against 59.1%, most of it the 2020 crash), holding the stock only 53% of the time. Charges took Rs 4.72 lakh, 47% of the starting capital, mostly STT at 0.1% on each side of every delivery trade. That is worth knowing before trading it: the edge here is risk reduction, not extra return. The maximum drawdown in the table is -31.65%, which the script rounds to -31.7%.
The results include one tick of slippage per fill and full delivery charges. They do not include dividends, as the price data is not dividend adjusted. Past performance in a backtest is not a guarantee of future returns, and none of this is investment advice.
How we checked the numbers
A backtest is only useful if you can trust it, so this one was checked three ways:
- The equity curve was rebuilt from the list of fills and matched Nautilus’s own account balance to the paisa.
- A separate, plain pandas replay of the same rules, using TradingView’s fill rule (a buy stop fills at the higher of the open and the stop), produced all 204 fills with the same bar, side and price as Nautilus.
ta.keltnermatched the Pine Script formulas to within 0.000000000001, and gave identical values on a truncated history, which rules out lookahead.
Four pitfalls we hit, so you do not have to
- Bars stamped at the open. Restamp every bar at its close before handing it to Nautilus. Left at the open, every decision is stamped an hour before the price it used existed, and the opening-price events land after the bar they belong to, which quietly switches the gap handling off. Part 2 measures the effect.
- Stops filling at their level on a gap. On bar data a stop fills at its trigger price even when the market opened beyond it. An opening-price event before each bar fixes it: 5 of the 102 entries and 5 of the exits gapped past their stop.
- Only the shortest bar type executes. Nautilus executes orders against the shortest bar type it has seen for an instrument. Our first attempt labelled the opening events as 1-minute bars, and Nautilus silently stopped using the hourly bars for execution: every stop could only fill at an open, and the CAGR read 7.70% instead of 13.29%. Give both bar types the same length, as
1-HOURand60-MINUTEdo. - The risk engine and cash accounts. On a cash account, the pre-trade risk engine denied the protective sell stops because it treated closing a long as if it needed free cash for the full order value. The example bypasses it with
RiskEngineConfig(bypass=True)and sizes every entry from free cash itself. The simulated exchange still refuses a short sale.
What else can Nautilus Trader do?
One stock and one strategy only scratches the surface. These are the capabilities that matter most to Indian traders, and the ones this series will build on:
Single-leg options backtesting
Nautilus has an OptionContract instrument with strike, expiry, option type and underlying, so a call or a put is a first-class instrument with its own prices, orders and positions. A GreeksCalculator computes delta, gamma, vega and theta for instruments and for the whole portfolio, and an option exercise module handles what happens at expiry in a backtest. You need historical option prices for this. Whether expired contracts are available depends on your broker; Historify stores whatever you download.
Multi-leg options backtesting
Straddles, strangles, iron condors and calendar spreads can be built as several OptionContract legs managed by one strategy, with portfolio greeks aggregated across the legs. Where a venue quotes the spread itself, it can be modelled as a single OptionSpread instrument. Contingent orders (OCO, OTO) tie a stop loss and a target together, so a leg exits cleanly when one side fills.
Portfolio backtesting
Any number of instruments can trade in one engine against one shared account, so position sizing, cash and margin interact as they do in a real account. That is what you need for a momentum rotation across the Nifty 50, or a pairs trade between two banks.
Multi-strategy backtesting
Several strategies can run in the same engine, each with its own ID and orders, all sharing the portfolio. You can test a trend follower and a mean-reversion system together and see whether they actually diversify each other, rather than adding up two separate equity curves.
And more
- Order types: market, limit, stop-market, stop-limit, market-if-touched, limit-if-touched and trailing stops, with time in force GTC, IOC, FOK, GTD, DAY, at the open and at the close.
- Richer data: trade ticks, quote ticks and order book depth, for fill and slippage modelling closer to reality when you have the data.
- Several venues and asset classes in one run, for example NSE equity alongside MCX futures.
- Scale: a Parquet data catalog and
BacktestNodefor large datasets and many parameter runs. - Live trading on a
TradingNodewith the same strategy class, where an adapter exists for your venue.
Tips if you are new to event-driven backtesting
- Always know what a bar’s timestamp means, and make it the bar’s close.
- Precompute only indicators whose value at a bar uses that bar and earlier ones, and prove it with a truncation test.
- Model charges from the first run. In Indian delivery trading, STT alone can decide whether a strategy is worth trading.
- Check where your orders filled, not just the final profit. Compare a few fills against the chart by hand.
- Pin the Nautilus version you build on, and read the release notes before upgrading.
Coming up in this series
With the architecture and the data pipeline in place, Part 2 covers position sizing, order types, backtest settings and tearsheets with openstatz. Later parts move on to options backtesting with OpenAlgo data, portfolios of several stocks, and running several strategies together. For the OpenAlgo side, see the OpenAlgo documentation; for Nautilus, the NautilusTrader documentation.