Rajandran R Creator of OpenAlgo - OpenSource Algo Trading framework for Indian Traders. Building GenAI Applications. Telecom Engineer turned Full-time Derivative Trader. Mostly Trading Nifty, Banknifty, High Liquid Stock Derivatives. Trading the Markets Since 2006 onwards. Using Market Profile and Orderflow for more than a decade. Designed and published 100+ open source trading systems on various trading tools. Strongly believe that market understanding and robust trading frameworks are the key to the trading success. Building Algo Platforms, Writing about Markets, Trading System Design, Market Sentiment, Trading Softwares & Trading Nuances since 2007 onwards. Author of Marketcalls.in

Introduction to Nautilus Trader Part 2: Position Sizing, Order Types, Backtest Settings and Tearsheets

15 min read

Map of Nautilus Trader order types: buy stop, stop-limit, sell limit and market-if-touched above the market, buy limit, if-touched, sell stop and trailing stop below it, plus bracket orders and time in force

In Part 1 we met Nautilus Trader, walked through its event-driven architecture, and backtested a Keltner Channel breakout on ten years of SBIN hourly bars, with OpenAlgo supplying the data. The result was 13.29% a year with a 31.6% maximum drawdown, against 14.43% and 59.1% for buy and hold.

That result rested on four choices we made quietly: how many shares to buy, what kind of order to send, how the simulated exchange behaves, and how the results were measured. Part 2 takes each of those choices in turn. It uses the same strategy, the same data and the same rupee of starting capital, so every difference you see comes from the choice itself:

  • Position sizing: fixed quantity, fixed value, percent of cash, and risk-based sizing with Nautilus’s FixedRiskSizer
  • Order types and execution: market, limit, stop, stop-limit, if-touched, trailing stop and bracket orders, and exactly how each one fills on bar data
  • Backtest settings: where every setting lives, and how much each one moves the result
  • Tearsheets: Nautilus’s own statistics, your own custom statistics, and a full interactive tearsheet with openstatz, benchmarked against the Nifty 50 through OpenAlgo

Setup

Save the complete code from Part 1 as nautilus_part1.py; Part 2 imports its data loader and Zerodha delivery charges from it. Then install the four packages. openstatz is the tearsheet library, a modern rebuild of QuantStats maintained by OpenAlgo and marketcalls.

pip install -U nautilus_trader openalgo openstatz pyarrow

Everything below was run in a clean environment with nautilus_trader 1.220.0, openalgo 2.0.5 and openstatz 0.4.1 on Python 3.13. Put the code blocks into one file, nautilus_part2.py, in the order they appear. First the imports:

import os
from decimal import Decimal

import numpy as np
import openstatz
import pandas as pd
from nautilus_part1 import BUY_COST, ZerodhaDeliveryFees, load_bars
from nautilus_trader.analysis.statistic import PortfolioStatistic
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.risk.sizing import FixedRiskSizer
from nautilus_trader.trading.strategy import Strategy
from openalgo import ta
from openstatz.providers import OpenAlgoProvider

The signal function is Part 1’s, with one addition: it also returns the ATR, which risk-based sizing needs.

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)
    atr = ta.atr(high, low, close, atr_length)
    ts = pd.DatetimeIndex(df["bar_close"]).tz_convert("UTC").as_unit("ns").asi8
    rows = zip(middle, ta.crossover(close, upper), ta.crossunder(close, lower), atr, strict=True)
    # bar close time (ns) -> (middle line, crossed above, crossed below, ATR)
    return dict(zip(ts, rows, strict=True))

1. Position sizing: same signals, very different results

Position sizing decides how many shares each signal buys. It never changes which trades you take, only how much each one is worth, and that alone can turn the same strategy into a quiet 4% a year or an aggressive 13% with twice the drawdown. We compare four rules:

  • Fixed quantity: always 1,000 shares. Simple, but the rupee exposure drifts with the share price, and profits never compound.
  • Fixed value: always Rs 5 lakh per trade. Constant exposure in rupees, still no compounding.
  • Percent of cash: 99% of the account on every trade, which is what Part 1 did. Profits compound, and so do losses.
  • Risk-based (fixed fractional): risk a fixed share of equity, such as 1%, if the trade goes against you by a set distance. We use 2 x ATR(10) as that distance, so the position shrinks when SBIN is volatile and grows when it is calm.

Risk-based sizing with FixedRiskSizer

Nautilus ships the risk-based rule as FixedRiskSizer. Give it the entry price, the stop price, your equity and the risk as a fraction, and it returns the quantity: equity x risk / (entry – stop), adjusted for commission. Two things to know before you use it:

  • risk is a plain fraction: Decimal("0.01") means 1%. With Rs 10 lakh of equity and a Rs 20 stop distance it returns 500 shares.
  • It crashes with a TypeError on an Equity created without max_quantity, because it compares against that limit. Part 2’s instrument sets one.

On a cash account you also cannot buy more than your cash covers, so every rule is capped at what is affordable. The sizing method sits inside the strategy class, which starts like this:

class Part2Config(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    open_bar_type: BarType
    sizing: str = "percent"  # fixed_qty | fixed_value | percent | risk
    size: float = 0.99  # shares | rupees | share of cash | share of equity at risk
    atr_stop: float = 2.0  # stop distance in ATRs, used by risk sizing
    entry: str = "stop"  # stop | market | limit
    gap_aware: bool = True  # False: send stops at the close, as most first attempts do


class KeltnerPart2(Strategy):
    def __init__(self, config, signals):
        super().__init__(config)
        self.signals = signals
        self.entry_level = None  # price of the pending entry order
        self.exit_level = None  # price of the pending exit stop
        self.working = None  # order resting in the current bar
        self.atr = np.nan
        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.sizer = FixedRiskSizer(self.instrument)
        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:
            if self.config.gap_aware:
                self.place_pending(bar.open.as_double())
        else:
            self.on_close(bar)

    def is_long(self):
        return self.portfolio.is_net_long(self.config.instrument_id)

The quantity() method implements all four rules:

    # ---- position sizing: how many shares for a buy at `price`
    def quantity(self, price):
        account = self.portfolio.account(self.config.instrument_id.venue)
        # Entries are only sized while flat, so the total balance is all cash. The
        # free balance can still be held by the order being replaced.
        cash = account.balance_total(INR).as_double()
        per_share = (price + self.tick) * (1 + BUY_COST)
        affordable = int(cash // per_share)  # a cash account cannot buy more than this
        mode, size = self.config.sizing, self.config.size
        if mode == "fixed_qty":
            qty = int(size)
        elif mode == "fixed_value":
            qty = int(size // per_share)
        elif mode == "percent":
            qty = int(cash * size // per_share)
        elif mode == "risk":
            stop = price - self.config.atr_stop * self.atr
            qty = self.sizer.calculate(
                entry=Price(price, 2),
                stop_loss=Price(stop, 2),
                equity=account.balance_total(INR),  # flat at entry, so this is equity
                risk=Decimal(str(size)),
                commission_rate=Decimal(str(BUY_COST)),
            ).as_double()
        else:
            raise ValueError(f"unknown sizing {mode}")
        return max(0, min(int(qty), affordable))

Note the comment about the total balance. Our first version sized entries from the free balance, and when an order was cancelled and replaced in the same step, the cash still reserved for the old order made the new one tiny: positions as small as 30 shares. Since entries are only sized while flat, the total balance is the right number.

The results

Equity curves of the same SBIN Keltner breakout signals under four position sizing rules: 99 percent of cash, risk 1 percent per 2 ATR, fixed value Rs 5 lakh and fixed quantity 1000 shares
Same signals, four position sizing rules: equity from Rs 10 lakh, September 2016 to September 2026. Click to open the full-size image.
Sizing ruleFinal equityCAGRMax drawdownSharpeCapital deployedCharges
Fixed quantity: 1,000 sharesRs 15.17 lakh4.26%-14.0%0.5638%Rs 1.15 lakh
Fixed value: Rs 5 lakh per tradeRs 17.57 lakh5.80%-15.7%0.6138%Rs 1.16 lakh
Percent of cash: 99%Rs 34.78 lakh13.29%-31.6%0.6799%Rs 4.72 lakh
Risk 0.25% of equity per 2 x ATRRs 12.58 lakh2.32%-4.9%0.6416%Rs 0.43 lakh
Risk 0.5% of equity per 2 x ATRRs 15.66 lakh4.59%-9.5%0.6533%Rs 0.94 lakh
Risk 1% of equity per 2 x ATRRs 21.49 lakh7.96%-18.0%0.6264%Rs 2.20 lakh

Three things stand out. First, the Sharpe ratio barely moves, from 0.56 to 0.67: sizing scales return and drawdown together, so the real question is how deep a drawdown you can sit through. Second, fixed quantity and fixed value never compound, which is why they finish far behind despite taking the same 102 trades. Third, risk-based sizing behaves exactly as the arithmetic says it should. The median hourly ATR of SBIN is 0.78% of the price, so risking 1% against a 2 x ATR stop should put about 1% / 1.56%, or 64%, of equity into each trade. The backtest deployed 63.9%.

Risk-based sizing at 1% gave 7.96% a year with an 18% drawdown, and at 0.5% it gave 4.59% with under 10%. It adapts to volatility on its own: at 0.25% the position ranged from 112 to 1,007 shares depending on how wild the market was.

2. Order types and how they fill

Part 1 introduced market, limit and stop orders. Nautilus supports nine order types in all, plus time-in-force rules and linked orders. The map below shows where each one waits relative to the market price.

Map of Nautilus Trader order types: buy stop, stop-limit, sell limit and market-if-touched above the market, buy limit, if-touched, sell stop and trailing stop below it, plus bracket orders and time in force
Where each order type waits relative to the market price. Click to open the full-size image.

Every one of them comes from the strategy’s order_factory. Inside a strategy method, with Price from nautilus_trader.model.objects, Decimal from decimal, and TrailingOffsetType and TriggerType from nautilus_trader.model.enums:

f, iid = self.order_factory, self.config.instrument_id
qty = self.instrument.make_qty(100)

f.market(iid, OrderSide.BUY, qty)  # fills now
f.limit(iid, OrderSide.BUY, qty, price=Price.from_str("800.00"))  # at 800 or better
f.stop_market(iid, OrderSide.BUY, qty, trigger_price=Price.from_str("820.00"))  # breakout
f.stop_limit(iid, OrderSide.BUY, qty, price=Price.from_str("821.00"), trigger_price=Price.from_str("820.00"))
f.market_if_touched(iid, OrderSide.BUY, qty, trigger_price=Price.from_str("790.00"))  # buy the dip
f.limit_if_touched(iid, OrderSide.BUY, qty, price=Price.from_str("789.00"), trigger_price=Price.from_str("790.00"))
f.trailing_stop_market(
    iid,
    OrderSide.SELL,
    qty,
    trailing_offset=Decimal("10"),
    trailing_offset_type=TrailingOffsetType.PRICE,
    trigger_type=TriggerType.LAST_PRICE,
    reduce_only=True,
)
bracket = f.bracket(
    iid, OrderSide.BUY, qty, sl_trigger_price=Price.from_str("780.00"), tp_price=Price.from_str("860.00")
)
self.submit_order_list(bracket)  # single orders go through self.submit_order(order)

Each call returns an order object; nothing reaches the exchange until you submit it. A bracket is three orders: a market entry, then a stop loss and a take-profit linked so that one exit cancels the other.

How each order type fills on bar data

Part 1 showed that a bar is replayed as four prices. We placed each order type on hand-made bars and recorded the fill, so there is no guesswork in this table:

OrderBars it metFill
Buy if touched (MIT), trigger 98Bar trades down from 100 to 9798.00, the trigger
Buy limit-if-touched, trigger 98, limit 97.50Same bar97.50, the limit
Trailing stop sell, Rs 2 below the highPrice rises to 110, then falls to 105108.00: the stop trailed the high
Bracket: target 104, stop 97A later bar reaches 104.5Target at 104.00, stop cancelled
Buy stop-limit, trigger 105, limit 105.50One bar rises from 101 to 108No fill in that bar; filled at 105.00 (the trigger) on the next
Bracket: target 104, stop 97ONE bar: open 100, high 105, low 96Stop at 97.00 with adaptive ordering; target at 104.00 with plain O-H-L-C

The last two rows are the ones to remember:

  • Stop-limit orders are unreliable on bars. The replay jumps from one of the four prices to the next, so a stop-limit can trigger at a price well past its limit and then miss the fill that a real market passing through 105 would have given. On bar data, prefer a stop-market order and model the slippage.
  • When one bar contains both your stop and your target, the backtest cannot know which came first. Adaptive ordering (low first when the low is nearer the open) says the stop hit; plain O-H-L-C says the target hit. With tight brackets on hourly bars this happens often, and the setting decides the result. If it matters to your strategy, test on smaller bars or ticks.

A slippage trap with limit orders

In Part 1 we added one tick of slippage with FillModel(prob_slippage=1.0). That is fine for market and stop orders, but on bar data it also slips limit orders: in our limit-entry test, 75 of 108 buy limits filled one tick above their limit price, which a real limit order can never do. The fix is to charge the tick as a cost on market and stop fills only, in the fee model, and leave limit fills at their price:

class ChargesAndSlippage(FeeModel):
    """Delivery charges, plus slippage charged as a cost on market and stop fills.

    FillModel(prob_slippage=1.0) also fills a limit order one tick past its limit
    on bar data, which a real limit order never does. Charging the tick here
    leaves limit fills at their own price.
    """

    def __init__(self, charges=True, slippage_ticks=1):
        super().__init__()
        self.charges = ZerodhaDeliveryFees() if charges else None
        self.ticks = slippage_ticks

    def get_commission(self, order, fill_qty, fill_px, instrument):
        fee = (
            self.charges.get_commission(order, fill_qty, fill_px, instrument).as_double()
            if self.charges
            else 0.0
        )
        if not order.has_price:  # market and stop-market orders
            fee += self.ticks * instrument.price_increment.as_double() * float(fill_qty)
        return Money(fee, INR)

On the stop strategy the two methods agree to the rupee: Rs 34,78,430 with slippage in the fill price, Rs 34,78,434 with slippage charged as a cost. The comparison below uses the cost method, so the limit orders are treated fairly.

Stop, market or limit: which entry is best?

The same breakout signal can be executed three ways. A stop buys only if price clears the breakout bar’s high (the Part 1 rule). A market order buys at the close of the breakout bar. A limit order waits for a pullback to that close. The exits are identical in all three: the sell stop from Part 1. The code is in the strategy’s on_close and place_pending methods:

    # ---- at the close: withdraw unfilled orders, apply the Keltner rules
    def on_close(self, bar):
        if self.working is not None and self.working.is_open:
            self.cancel_order(self.working)
        self.working = None

        account = self.portfolio.account(self.config.instrument_id.venue)
        shares = float(self.portfolio.net_position(self.config.instrument_id))
        close = bar.close.as_double()
        self.equity.append((bar.ts_event, account.balance_total(INR).as_double() + shares * close))

        middle, cross_up, cross_dn, atr = self.signals.get(
            bar.ts_event, (np.nan, False, False, np.nan)
        )
        if np.isnan(middle):
            return
        self.atr, long = atr, self.is_long()
        if self.entry_level is not None and (long or close < middle):
            self.entry_level = None
        if self.exit_level is not None and (not long or close > middle):
            self.exit_level = None

        if cross_up and not long:
            if self.config.entry == "market":  # buy now, at this close
                self.send(OrderSide.BUY, self.quantity(close), "market")
            elif self.config.entry == "limit":  # wait for a pullback to this close
                self.entry_level = close
            else:  # "stop": buy only if price clears the breakout bar's high
                self.entry_level = bar.high.as_double() + self.tick
        if cross_dn and long:
            self.exit_level = bar.low.as_double() - self.tick

        if not self.config.gap_aware:
            self.place_pending(None)
    # ---- at the open (or at the close when gap_aware is off): send pending orders
    def place_pending(self, open_px):
        if not self.is_long() and self.entry_level is not None:
            level = self.entry_level
            if self.config.entry == "limit":
                self.send(OrderSide.BUY, self.quantity(level), "limit", level)
            elif open_px is not None and open_px >= level:  # gapped above the stop
                self.send(OrderSide.BUY, self.quantity(open_px), "market")
            else:
                self.send(OrderSide.BUY, self.quantity(level), "stop", 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))
            if open_px is not None and open_px <= level:  # gapped below the stop
                self.send(OrderSide.SELL, qty, "market")
            else:
                self.send(OrderSide.SELL, qty, "stop", level)

    def send(self, side, qty, kind, level=None):
        if qty <= 0:
            return
        f, iid = self.order_factory, self.config.instrument_id
        quantity, reduce_only = self.instrument.make_qty(qty), side == OrderSide.SELL
        if kind == "market":
            order = f.market(iid, side, quantity, reduce_only=reduce_only)
        elif kind == "limit":
            order = f.limit(iid, side, quantity, price=Price(level, 2), reduce_only=reduce_only)
        else:
            order = f.stop_market(
                iid, side, quantity, trigger_price=Price(level, 2), reduce_only=reduce_only
            )
        if kind != "market":
            self.working = order
        self.submit_order(order)
EntryCAGRMax drawdownSharpeTradesEntry price vs signal close
Stop above the breakout bar13.29%-31.6%0.67102+31.1 bps
Market at the signal close13.95%-35.4%0.691080.0 bps
Limit at the signal close14.21%-35.2%0.70108-0.8 bps

The stop entry pays for its confirmation: on average it bought 31 basis points (0.31%) above the signal bar’s close, and it took 102 trades where the other two took 108, because setups that never cleared the high were never entered. The market and limit entries bought at the close or slightly better and earned more, 13.95% and 14.21% a year, but with a deeper drawdown of about 35%. The stop’s confirmation cost return and bought a shallower drawdown. There is no free lunch here, only a trade-off you can now measure.

3. Backtest settings: where they live and what they do

A Nautilus backtest is configured in five places. It helps to know which is which, because most surprises come from the venue and the data rather than the strategy.

Where backtest settings live in Nautilus Trader: strategy config, engine config, venue (account, positions, fee, fill and latency models, bar replay), instrument and data
The five places a Nautilus backtest is configured. Click to open the full-size image.
SettingWhereWhat we use for NSE deliveryWhy
Account typeadd_venueAccountType.CASHDelivery trades are paid in full; no shorting
Position modeadd_venueOmsType.NETTINGOne net position per instrument
Fee modeladd_venueZerodha delivery chargesSTT alone is 0.1% on each side
Fill modeladd_venueOne tick of slippageLiquid large cap; widen it for small caps
Bar replay orderadd_venuebar_adaptive_high_low_ordering=TrueSame assumption as TradingView
Risk engineBacktestEngineConfigRiskEngineConfig(bypass=True)Its cash-account check refuses protective sell stops (Part 1)
InstrumentEquityTick 0.05, lot 1, max quantity setThe risk sizer needs the maximum quantity
Dataadd_dataBars stamped at the close, plus opening eventsCorrect time order, and gap-aware stops

Here is the whole engine setup as one function, so each experiment can switch a single setting:

def run_backtest(
    df,
    signals,
    capital=1_000_000,
    charges=True,
    slippage="price",  # "price" (fill model), "cost" (fee model), or None
    adaptive=True,
    **strategy_settings,
):
    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),
        max_quantity=Quantity.from_int(10_000_000),  # FixedRiskSizer needs a limit
        ts_event=0,
        ts_init=0,
    )
    hourly = BarType.from_str("SBIN.NSE-1-HOUR-LAST-EXTERNAL")
    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),
        )
    )
    engine.add_venue(
        venue=nse,
        oms_type=OmsType.NETTING,  # one net position per instrument
        account_type=AccountType.CASH,  # delivery: pay in full, no shorting
        base_currency=INR,
        starting_balances=[Money(capital, INR)],
        fee_model=ChargesAndSlippage(charges, 1 if slippage == "cost" else 0),
        fill_model=FillModel(prob_slippage=1.0 if slippage == "price" else 0.0, random_seed=42),
        bar_adaptive_high_low_ordering=adaptive,
    )
    engine.add_instrument(sbin)
    engine.add_data(BarDataWrangler(hourly, sbin).process(bars))
    engine.add_data(BarDataWrangler(opening, sbin).process(opens))
    engine.portfolio.analyzer.register_statistic(MaxLosingStreak())
    engine.portfolio.analyzer.register_statistic(AverageDaysHeld())

    config = Part2Config(
        instrument_id=sbin.id, bar_type=hourly, open_bar_type=opening, **strategy_settings
    )
    strategy = KeltnerPart2(config, signals)
    engine.add_strategy(strategy)
    engine.run()
    return engine, strategy

How much each setting moves the result

We ran the Part 1 strategy with one setting changed at a time:

Bar chart of CAGR for the SBIN Keltner strategy as one backtest setting changes: charges, slippage, high low ordering, gap handling and bar timestamps
CAGR of the same strategy as one setting changes at a time. Click to open the full-size image.
SettingCAGR (change)Max drawdownSharpe
Baseline: charges, 1 tick slippage13.29%-31.6%0.67
No charges15.85% (+2.56)-30.6%0.77
No slippage13.58% (+0.29)-31.5%0.68
No charges and no slippage16.15% (+2.86)-30.4%0.79
High/low ordering fixed (O-H-L-C)13.29% (+0.00)-31.6%0.67
Stops sent at the close (no gap handling)14.07% (+0.78)-31.1%0.70
Bars stamped at the open14.07% (+0.78)-31.1%0.70
  • Charges cost 2.56% a year. The single biggest setting. Leave the fee model out and a 13.29% strategy looks like 15.85%.
  • One tick of slippage costs about 0.29% a year on a liquid stock like SBIN. Expect much more on a mid cap.
  • High/low ordering made no difference here, because this strategy never has more than one order live in a bar. It matters for brackets, as the table in the order types section showed.
  • Sending stops at the close flatters the result by 0.78% a year. Without the opening-price events, a stop the market gapped past fills at its own level, a price that never traded.
  • Bars stamped at the open give exactly the same flattered result, to the rupee. Here the reason is not classic lookahead, because a single series of bars is still replayed in order. It is that the opening-price events now land after the bar they belong to, which silently switches the gap handling off. Any logic tied to the clock, such as a timer that squares off at 15:15 or a second instrument, would be wrong as well.

4. Tearsheets: measuring the result properly

Nautilus’s built-in statistics

Every Nautilus engine has a portfolio analyzer with built-in statistics: win rate, expectancy, profit factor, Sharpe, Sortino and more. You can register your own by subclassing PortfolioStatistic and implementing whichever input it needs: realized P&L per trade, closed positions, or returns.

class MaxLosingStreak(PortfolioStatistic):
    """Longest run of losing trades, from the realized P&L of each position."""

    def calculate_from_realized_pnls(self, realized_pnls):
        best = run = 0
        for pnl in realized_pnls:
            run = run + 1 if pnl <= 0 else 0
            best = max(best, run)
        return best


class AverageDaysHeld(PortfolioStatistic):
    """Average holding period of closed positions, in calendar days."""

    def calculate_from_positions(self, positions):
        closed = [p for p in positions if p.is_closed]
        if not closed:
            return None
        return round(sum(p.duration_ns for p in closed) / len(closed) / 86_400e9, 1)

Both are registered in run_backtest above, and appear alongside the built-in ones (output abridged):

PnL (total):                    2_478_430.45
Max Winner:                     510_383.49
Max Loser:                      -281_133.51
Expectancy:                     24_298.337745098048
Win Rate:                       0.4215686274509804
Max Losing Streak:              7
Long Ratio:                     1.00
Average Days Held:              19.1

One caution. Nautilus builds its returns statistics from the realized return of each closed position, recorded on the day it closed: 102 numbers for 102 trades, not a daily equity curve. That is why its Sharpe ratio (0.60) and Sortino ratio (1.72) differ from the ones computed on daily equity (0.67 and 1.11). For a tearsheet you want the daily, marked-to-market equity, which the strategy records at every bar close:

def daily_equity(strategy):
    t, v = zip(*strategy.equity, strict=True)
    s = pd.Series(v, index=pd.to_datetime(t, unit="ns", utc=True).tz_convert("Asia/Kolkata"))
    daily = s.groupby(s.index.date).last()
    daily.index = pd.DatetimeIndex(daily.index)
    return daily


def key_stats(daily, capital=1_000_000):
    years = (daily.index[-1] - daily.index[0]).days / 365.25
    returns = daily.pct_change().dropna()
    drawdown = daily / daily.cummax().clip(lower=capital) - 1
    return {
        "Final equity": round(float(daily.iloc[-1])),
        "CAGR %": round(float(100 * ((daily.iloc[-1] / capital) ** (1 / years) - 1)), 2),
        "Max drawdown %": round(float(100 * drawdown.min()), 2),
        "Sharpe": round(float(returns.mean() / returns.std() * 252**0.5), 2),
    }

A full tearsheet with openstatz

openstatz is a modern rebuild of QuantStats maintained by OpenAlgo and marketcalls. It keeps the QuantStats maths unchanged, so the numbers match, and adds an interactive tearsheet that is written to a single offline HTML file: equity, rolling statistics, drawdowns, monthly and weekly heatmaps and a full metrics table, with light and dark themes and PDF export. It also ships an OpenAlgo data provider, so the Nifty 50 benchmark comes through the same OpenAlgo server as the rest of the data (set OPENALGO_API_KEY first):

def nifty_returns(start, end):
    """Daily Nifty 50 returns through OpenAlgo, for the tearsheet benchmark."""
    provider = OpenAlgoProvider(api_key=os.environ["OPENALGO_API_KEY"], exchange="NSE_INDEX")
    return provider.returns("NIFTY", start_date=start, end_date=end).rename("Nifty 50")


def tearsheet(strategy, benchmark, path="sbin_keltner_tearsheet.html"):
    returns = daily_equity(strategy).pct_change().dropna().rename("Keltner breakout")
    openstatz.dashboard(
        returns, benchmark=benchmark, output=path, title="SBIN Keltner breakout vs Nifty 50"
    )
    return returns


def main():
    df = load_bars()
    signals = keltner_signals(df)
    engine, strategy = run_backtest(df, signals)  # Part 1 settings: 99% of cash, stop entries
    print(key_stats(daily_equity(strategy)))
    print("\n".join(engine.portfolio.analyzer.get_stats_pnls_formatted()))
    print("\n".join(engine.portfolio.analyzer.get_stats_general_formatted()))

    daily = daily_equity(strategy)
    nifty = nifty_returns(f"{daily.index[0]:%Y-%m-%d}", f"{daily.index[-1]:%Y-%m-%d}")
    returns = tearsheet(strategy, nifty)
    st = openstatz.stats
    for name, r in (("Keltner breakout", returns), ("Nifty 50", nifty)):
        print(
            f"{name:<17} CAGR {st.cagr(r):.2%}  Sharpe {st.sharpe(r):.2f}  "
            f"Sortino {st.sortino(r):.2f}  Max drawdown {st.max_drawdown(r):.2%}"
        )
    engine.dispose()


if __name__ == "__main__":
    main()

Run python nautilus_part2.py and it prints:

{'Final equity': 3478430, 'CAGR %': 13.29, 'Max drawdown %': -31.65, 'Sharpe': 0.67}
...
Keltner breakout  CAGR 13.52%  Sharpe 0.67  Sortino 1.11  Max drawdown -31.65%
Nifty 50          CAGR 10.39%  Sharpe 0.69  Sortino 0.96  Max drawdown -38.44%

Why 13.52% here and 13.29% in Part 1? Both are right; they count years differently. openstatz counts trading days and divides by 252, so 2,478 days make 9.83 years. Part 1 counted calendar days, 9.99 years. NSE has about 248 sessions a year, and openstatz.stats.cagr() takes a periods argument if you want to use that instead. Whatever you choose, use the same convention for every strategy you compare.

And then open sbin_keltner_tearsheet.html in a browser:

openstatz tearsheet for the SBIN Keltner breakout against the Nifty 50: cumulative return, rolling Sharpe, rolling volatility, rolling win rate and return and risk by horizon
openstatz tearsheet: cumulative return against the Nifty 50, rolling statistics and return and risk by horizon. Click to open the full-size image.
openstatz underwater drawdown chart for the SBIN Keltner breakout strategy, 2016 to 2026
openstatz tearsheet: the underwater drawdown curve. Click to open the full-size image.
openstatz monthly returns heatmap for the SBIN Keltner breakout strategy, 2016 to 2026
openstatz tearsheet: monthly returns heatmap, in percent. Click to open the full-size image.

Against the Nifty 50, the strategy earned 13.52% a year against 10.39%, with a smaller maximum drawdown (31.6% against 38.4%) but higher volatility, so the Sharpe ratios are nearly equal (0.67 and 0.69). Its beta to the Nifty is only 0.45, because it spends much of its time out of the market. The tearsheet also shows what a single headline number hides. The Return and Risk by Horizon table puts the last three years at only 4.71% a year, far below the ten-year figure; the edge has been weaker recently. The monthly heatmap shows long runs of small losses paid for by a handful of big months, which is the normal shape of a breakout system and worth knowing before you trade one.

A checklist for your next backtest

  • Choose position sizing deliberately, and look at the drawdown it produces, not just the return.
  • With FixedRiskSizer, set max_quantity on the instrument and pass risk as a fraction.
  • Size entries from the total balance while flat, not the free balance.
  • Know how each order type fills on bars; avoid stop-limit orders on bar data.
  • Do not use prob_slippage with limit orders; charge slippage as a cost instead.
  • Model charges from the first run: here they were worth 2.56% a year.
  • Stamp bars at their close, and send stops at the open so gaps fill realistically.
  • Build tearsheets from daily equity, benchmark against the Nifty, and read the horizon table.

Coming up in this series

With sizing, execution, settings and measurement in place, the next parts will move on to options backtesting with OpenAlgo data, portfolios of several stocks, and running several strategies together in one engine. As before, backtest results are not a guarantee of future returns, and nothing here is investment advice.

Rajandran R Creator of OpenAlgo - OpenSource Algo Trading framework for Indian Traders. Building GenAI Applications. Telecom Engineer turned Full-time Derivative Trader. Mostly Trading Nifty, Banknifty, High Liquid Stock Derivatives. Trading the Markets Since 2006 onwards. Using Market Profile and Orderflow for more than a decade. Designed and published 100+ open source trading systems on various trading tools. Strongly believe that market understanding and robust trading frameworks are the key to the trading success. Building Algo Platforms, Writing about Markets, Trading System Design, Market Sentiment, Trading Softwares & Trading Nuances since 2007 onwards. Author of Marketcalls.in

Leave a Reply

Get Notifications, Alerts on Market Updates, Trading Tools, Automation & More