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 3: Portfolio and Multi-Strategy Backtesting with OpenAlgo Historify

13 min read

Nautilus Trader portfolio backtest: twelve strategy instances (Keltner and mean reversion on six NSE stocks) sharing a cash book, portfolio and equity recorder actor, one cash account and Historify data

In Part 1 we built an event-driven backtest in Nautilus Trader and ran a Keltner Channel breakout on ten years of SBIN hourly bars. In Part 2 we looked at position sizing, order types, backtest settings and tearsheets. Both parts traded one stock with one strategy. Real accounts are rarely like that: you hold several stocks, you may run more than one strategy, and all of it draws on the same cash.

Part 3 builds that. Six NSE large caps, two strategies, one shared cash account, all in one Nautilus engine, with the data served from OpenAlgo’s Historify store. Along the way we meet three traps that only appear once strategies share an account, and a result that every trader should see at least once: what happens when a strategy that worked on one stock is run on five others.

  • Data: five years of hourly bars for six stocks from Historify with source="db", and a check that they match the broker’s bars
  • Architecture: one strategy instance per stock per strategy, a shared cash book, and an Actor that records equity
  • Three traps: strategy tags, whose position is whose, and two orders spending the same rupees
  • Results: selection bias, what diversification does and does not do, and what charges do to a second strategy
  • Tearsheet: the combined portfolio against the Nifty 50 with openstatz

Setup

Keep the code from Parts 1 and 2 as nautilus_part1.py and nautilus_part2.py in the same folder; Part 3 imports the data loader and charges from Part 1 and the Keltner signals from Part 2. The packages are the same as in Part 2:

pip install -U nautilus_trader openalgo openstatz pyarrow

Tested with nautilus_trader 1.220.0, openalgo 2.0.5 and openstatz 0.4.1 on Python 3.13. Put the code blocks into nautilus_part3.py in the order they appear, starting with the imports:

import os

import numpy as np
import openstatz
import pandas as pd
from nautilus_part1 import BUY_COST, ZerodhaDeliveryFees, load_bars
from nautilus_part2 import keltner_signals
from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
from nautilus_trader.backtest.models import FillModel
from nautilus_trader.common.actor import Actor
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

SYMBOLS = ["SBIN", "ICICIBANK", "RELIANCE", "INFY", "TCS", "ITC"]
NSE = Venue("NSE")

1. Portfolio data from Historify

Historify is OpenAlgo’s local DuckDB store. You download market data into it once, and every later request with source="db" reads from disk instead of your broker: fast, offline, and identical from one run to the next. For this post it held one-minute bars for SBIN, ICICIBANK, RELIANCE, INFY, TCS, ITC and the Nifty 50 from September 2021 to September 2026. Ask for interval="1h" and OpenAlgo builds hourly bars from the stored minutes.

Before trusting stored data, compare it with the source. We put Historify’s SBIN hourly bars next to the broker’s own hourly bars from Part 1: on all 8,561 bars they share, open, high, low and close are identical, and volume matches on 99.98% of them. The only differences were the two Muhurat special sessions, which the two sources bucket into hours differently, and the last two weeks, which had not been downloaded into Historify yet.

def load_universe(symbols=SYMBOLS, years=5):
    """Hourly bars for every symbol from Historify, trimmed to the dates all share."""
    data = {s: load_bars(s, "NSE", years=years, source="db") for s in symbols}
    start = max(df.index[0] for df in data.values())
    end = min(df.index[-1] for df in data.values())
    return {s: df[(df.index >= start) & (df.index <= end)] for s, df in data.items()}

Part 1’s load_bars already accepts source, so loading six stocks is one dictionary comprehension. Trimming every stock to the dates they all share keeps the portfolio from starting or ending with some stocks missing. The result is 8,565 hourly bars per stock, from 24 September 2021 to 11 September 2026.

2. One engine, one account, twelve strategies

Nautilus runs any number of instruments and strategies in one engine against one venue. The simplest pattern, and the one used here, is one strategy instance per stock: the Keltner strategy becomes six instances, one watching each stock, and the mean-reversion strategy another six. All twelve trade through the same simulated NSE and the same Rs 10 lakh cash account.

Nautilus Trader portfolio backtest: twelve strategy instances (Keltner and mean reversion on six NSE stocks) sharing a cash book, portfolio and equity recorder actor, one cash account and Historify data
One engine, one account, twelve strategy instances. Click to open the full-size image.

Sharing an account is where single-stock code breaks. We hit three problems, each of which Nautilus either reports loudly or, worse, lets through silently.

Trap 1: every strategy needs a unique tag

A strategy’s ID is its class name plus an order_id_tag. Tag the six Keltner instances with the stock symbol and it works; add the six mean-reversion instances with the same tags and Nautilus refuses to start:

RuntimeError: strategy `order_id_tag` conflict for 'SBIN', explicitly define all `order_id_tag` values in your strategy configs

The tag must be unique across every strategy in the engine, not just within one class. Here each class carries a short code and the tag is SBIN_KC, SBIN_MR and so on.

Trap 2: whose position is it?

Parts 1 and 2 checked for an open position with self.portfolio.is_net_long(instrument_id). With one strategy per stock that was fine. With two, it is wrong, and it fails silently. We tested it: strategy A bought 100 SBIN and later sold them, while strategy B bought 50. After A had sold, is_net_long still returned True for A, because the portfolio adds up every strategy’s shares. A would have thought it was still long and never re-entered. Each strategy must ask for its own position:

mine = self.cache.positions_open(instrument_id=self.config.instrument_id, strategy_id=self.id)

Nautilus keeps a separate position per strategy per instrument (the position IDs were SBIN.NSE-S-A and SBIN.NSE-S-B), so the information is there; you just have to ask for yours.

Trap 3: two orders spending the same rupees

Nautilus reserves cash for an order only once the exchange has accepted it. When two strategies size their entries in the same step, both see the full free balance. We tested that too: two buy stops worth Rs 59,740 each against Rs 1 lakh of cash. Both were accepted, both triggered in the same bar, and the second fill took the balance to minus Rs 19,480. Nautilus does not just record that; it stops the whole backtest with AccountBalanceNegative. The fix is a small shared cash book: every entry reserves its cash the moment it is sent, and releases it when the order fills, is cancelled or is rejected.

class CashBook:
    """Cash promised to entry orders that have not filled yet, shared by every strategy.

    Nautilus reserves cash for an order only once the exchange accepts it, so two
    strategies sizing in the same step would both see the full balance. If both
    orders then fill, the account goes negative and the backtest stops.
    """

    def __init__(self):
        self.reserved = {}

    def available(self, account):
        return account.balance_total(INR).as_double() - sum(self.reserved.values())

The base class

Everything a strategy needs to live in a shared account goes into one base class: its own position, the account’s total equity (cash plus the market value of every holding, from net_exposures), and entry sizing. Each instance may put up to capital_share of total equity into a position: one sixth each when one strategy runs on six stocks, one twelfth when two do.

class SlotConfig(StrategyConfig, frozen=True):
    instrument_id: InstrumentId
    bar_type: BarType
    open_bar_type: BarType
    capital_share: float  # share of total equity one position may use


class PortfolioStrategy(Strategy):
    """One instrument, its own position, sized from a share of the whole account."""

    def __init__(self, config, signals, book):
        super().__init__(config)
        self.signals = signals
        self.book = book
        self.working = None

    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 shares_held(self):
        # Never portfolio.net_position() here: it adds up every strategy's shares.
        mine = self.cache.positions_open(
            instrument_id=self.config.instrument_id, strategy_id=self.id
        )
        return float(mine[0].quantity) if mine else 0.0

    def equity(self):
        account = self.portfolio.account(NSE)
        exposure = self.portfolio.net_exposures(NSE) or {}
        return account.balance_total(INR).as_double() + sum(
            m.as_double() for m in exposure.values()
        )

    def entry_qty(self, price):
        budget = min(
            self.equity() * self.config.capital_share,
            self.book.available(self.portfolio.account(NSE)),
        )
        return max(0, int(budget // ((price + self.tick) * (1 + BUY_COST))))

    def send(self, side, qty, kind, price):
        """Market order, or a stop at `price`. A buy reserves its cash until it fills."""
        if qty <= 0:
            return
        f, iid = self.order_factory, self.config.instrument_id
        quantity = self.instrument.make_qty(qty)
        if kind == "market":
            order = f.market(iid, side, quantity, reduce_only=side == OrderSide.SELL)
        else:
            order = f.stop_market(
                iid,
                side,
                quantity,
                trigger_price=Price(price, 2),
                reduce_only=side == OrderSide.SELL,
            )
            self.working = order
        if side == OrderSide.BUY:
            self.book.reserved[order.client_order_id] = qty * (price + self.tick) * (1 + BUY_COST)
        self.submit_order(order)

    def release(self, event):
        self.book.reserved.pop(event.client_order_id, None)

    on_order_filled = on_order_canceled = on_order_rejected = on_order_denied = release

3. Two strategies

The first is Part 1’s Keltner Channel breakout, unchanged in its rules, rewritten on the base class: stop entries above the breakout bar, stop exits below the breakdown bar, sent at the open so a gap fills at the open.

class KeltnerPortfolio(PortfolioStrategy):
    """Part 1's Keltner breakout: stop entries and exits, sent at the open so gaps fill honestly."""

    code = "KC"

    def __init__(self, config, signals, book):
        super().__init__(config, signals, book)
        self.entry_level = self.exit_level = None

    def on_bar(self, bar: Bar):
        if bar.bar_type == self.config.open_bar_type:
            self.on_open(bar.open.as_double())
        else:
            self.on_close(bar)

    def on_close(self, bar):
        if self.working is not None and self.working.is_open:
            self.cancel_order(self.working)
        self.working = None
        middle, cross_up, cross_dn, _ = self.signals.get(
            bar.ts_event, (np.nan, False, False, np.nan)
        )
        if np.isnan(middle):
            return
        close, long = bar.close.as_double(), self.shares_held() > 0
        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:
            self.entry_level = bar.high.as_double() + self.tick
        if cross_dn and long:
            self.exit_level = bar.low.as_double() - self.tick

    def on_open(self, open_px):
        held = self.shares_held()
        if not held and self.entry_level is not None:
            level = self.entry_level
            if open_px >= level:  # gapped above the stop
                self.send(OrderSide.BUY, self.entry_qty(open_px), "market", open_px)
            else:
                self.send(OrderSide.BUY, self.entry_qty(level), "stop", level)
        elif held and self.exit_level is not None:
            level = self.exit_level
            self.send(OrderSide.SELL, held, "market" if open_px <= level else "stop", level)

The second is deliberately different: a mean-reversion strategy that buys weakness instead of strength. At the close of any hour where the price is below the lower Bollinger band (20 hours, 2 standard deviations) but still above its 200-hour EMA, it buys; it sells at the close of the first hour back above the middle band. The bands come from ta.bbands in OpenAlgo’s library, which uses a simple moving average and population standard deviation, the same as TradingView.

def meanrev_signals(df, length=20, std=2.0, trend=200):
    """Buy a close below the lower Bollinger band in an uptrend; sell back above the middle."""
    close = df["close"].to_numpy(dtype=float)
    upper, middle, lower = ta.bbands(close, length, std)
    ema = ta.ema(close, trend)
    ts = pd.DatetimeIndex(df["bar_close"]).tz_convert("UTC").as_unit("ns").asi8
    buy = (close < lower) & (close > ema)  # comparisons with NaN are False
    sell = close > middle
    return dict(zip(ts, zip(buy, sell, strict=True), strict=True))
class MeanReversion(PortfolioStrategy):
    """Buy the close under the lower Bollinger band in an uptrend; sell the close above the middle."""

    code = "MR"

    def on_bar(self, bar: Bar):
        if bar.bar_type != self.config.bar_type:
            return  # acts on closes only
        buy, sell = self.signals.get(bar.ts_event, (False, False))
        held = self.shares_held()
        if not held and buy:
            close = bar.close.as_double()
            self.send(OrderSide.BUY, self.entry_qty(close), "market", close)
        elif held and sell:
            self.send(OrderSide.SELL, held, "market", bar.close.as_double())

A trend strategy and a mean-reversion strategy tend to make and lose money at different times, which is exactly why traders combine them. Whether that works here is a question for the backtest.

4. An Actor to record equity

Part 1 described Actors as components that receive data and events but never trade. Measuring a portfolio is a perfect job for one. Instead of every strategy recording equity, one EquityRecorder subscribes to all six stocks’ bars and records the account’s marked-to-market value after each one.

class EquityRecorder(Actor):
    """Watches every hourly bar and records the account's marked-to-market value."""

    def __init__(self, bar_types):
        super().__init__()
        self.bar_types = bar_types
        self.values = {}  # bar time (ns) -> equity

    def on_start(self):
        for bar_type in self.bar_types:
            self.subscribe_bars(bar_type)

    def on_bar(self, bar: Bar):
        account = self.portfolio.account(NSE)
        exposure = self.portfolio.net_exposures(NSE) or {}
        cash = account.balance_total(INR).as_double()
        self.values[bar.ts_event] = cash + sum(m.as_double() for m in exposure.values())

    def daily(self):
        s = pd.Series(self.values).sort_index()
        s.index = pd.to_datetime(s.index, unit="ns", utc=True).tz_convert("Asia/Kolkata")
        out = s.groupby(s.index.date).last()
        out.index = pd.DatetimeIndex(out.index)
        return out

5. Wiring it together

run_portfolio takes the data and a plan: a list of strategies, each with its share of the capital. It adds one instrument and two bar streams per stock (the hourly bars and the opening-price events from Part 1), then one instance of every strategy in the plan for every stock.

def make_equity(symbol):
    return Equity(
        instrument_id=InstrumentId(Symbol(symbol), NSE),
        raw_symbol=Symbol(symbol),
        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),
        ts_event=0,
        ts_init=0,
    )


def run_portfolio(data, plan, capital=1_000_000):
    """plan: (strategy class, signal function, share of capital for that strategy) tuples."""
    engine = BacktestEngine(
        BacktestEngineConfig(
            logging=LoggingConfig(log_level="ERROR"), risk_engine=RiskEngineConfig(bypass=True)
        )
    )
    engine.add_venue(
        venue=NSE,
        oms_type=OmsType.NETTING,
        account_type=AccountType.CASH,
        base_currency=INR,
        starting_balances=[Money(capital, INR)],
        fee_model=ZerodhaDeliveryFees(),
        fill_model=FillModel(prob_slippage=1.0, random_seed=42),
        bar_adaptive_high_low_ordering=True,
    )
    book, hourly_types = CashBook(), []
    for symbol, df in data.items():
        instrument = make_equity(symbol)
        engine.add_instrument(instrument)
        hourly = BarType.from_str(f"{symbol}.NSE-1-HOUR-LAST-EXTERNAL")
        opening = BarType.from_str(f"{symbol}.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.add_data(BarDataWrangler(hourly, instrument).process(bars))
        engine.add_data(BarDataWrangler(opening, instrument).process(opens))
        hourly_types.append(hourly)
        for strategy_class, signal_fn, share in plan:
            config = SlotConfig(
                instrument_id=instrument.id,
                bar_type=hourly,
                open_bar_type=opening,
                capital_share=share / len(data),
                order_id_tag=f"{symbol}_{strategy_class.code}",  # unique across ALL strategies
            )
            engine.add_strategy(strategy_class(config, signal_fn(df), book))
    recorder = EquityRecorder(hourly_types)
    engine.add_actor(recorder)
    engine.run()
    return engine, recorder


def pnl_by_strategy(engine):
    """Realized P&L of every closed position, by strategy and by stock."""
    report = engine.trader.generate_positions_report()
    pnl = report["realized_pnl"].astype(str).str.replace(" INR", "").astype(float)
    strategy = report["strategy_id"].astype(str).str.split("-").str[0]
    stock = report["instrument_id"].astype(str).str.split(".").str[0]
    return pnl.groupby([stock, strategy]).sum().unstack(1).round(0)

Finally, the Nifty 50 benchmark also comes from Historify, as daily bars, and main() runs three plans: Keltner alone, mean reversion alone, and both with half the capital each.

def nifty_daily_returns(start, end):
    """Nifty 50 daily returns from Historify, the benchmark for the tearsheet."""
    client = api(api_key=os.environ["OPENALGO_API_KEY"], host="http://127.0.0.1:5000")
    df = client.history(
        symbol="NIFTY",
        exchange="NSE_INDEX",
        interval="D",
        start_date=start,
        end_date=end,
        source="db",
    )
    close = df["close"].astype(float)
    close.index = pd.DatetimeIndex(close.index.date)
    return close.pct_change().dropna().rename("Nifty 50")


def main():
    data = load_universe()
    plans = {
        "Keltner": [(KeltnerPortfolio, keltner_signals, 1.0)],
        "Mean reversion": [(MeanReversion, meanrev_signals, 1.0)],
        "Both, 50/50": [
            (KeltnerPortfolio, keltner_signals, 0.5),
            (MeanReversion, meanrev_signals, 0.5),
        ],
    }
    returns = {}
    for name, plan in plans.items():
        engine, recorder = run_portfolio(data, plan)
        returns[name] = recorder.daily().pct_change().dropna()
        if name == "Both, 50/50":
            print(pnl_by_strategy(engine))
        engine.dispose()

    returns = pd.DataFrame(returns)
    start, end = f"{returns.index[0]:%Y-%m-%d}", f"{returns.index[-1]:%Y-%m-%d}"
    nifty = nifty_daily_returns(start, end).reindex(returns.index).fillna(0.0)
    print("Correlation of daily returns:\n", returns.corr().round(2))
    st = openstatz.stats
    for name, r in list(returns.items()) + [("Nifty 50", nifty)]:
        print(
            f"{name:<15} CAGR {st.cagr(r):.2%}  Sharpe {st.sharpe(r):.2f}  "
            f"Max drawdown {st.max_drawdown(r):.2%}  Volatility {st.volatility(r):.2%}"
        )
    # Keep the column's own name: openstatz 0.4.1 caches returns by their values,
    # and a renamed copy of a series it has already seen draws blank charts.
    openstatz.dashboard(
        returns["Both, 50/50"],
        benchmark=nifty,
        output="portfolio_tearsheet.html",
        title="Six-stock portfolio, two strategies vs Nifty 50",
    )


if __name__ == "__main__":
    main()

It runs all three six-stock backtests in about 15 seconds and prints (the P&L table is realized P&L by stock and strategy in the 50/50 run):

strategy_id    KeltnerPortfolio  MeanReversion
instrument_id
ICICIBANK               23407.0        -1592.0
INFY                   -20126.0       -16326.0
ITC                     -5830.0        -7181.0
RELIANCE                 3769.0       -15185.0
SBIN                    47173.0       -14505.0
TCS                    -28812.0       -13250.0
Correlation of daily returns:
                 Keltner  Mean reversion  Both, 50/50
Keltner            1.00            0.32         0.95
Mean reversion     0.32            1.00         0.59
Both, 50/50        0.95            0.59         1.00
Keltner         CAGR 0.52%  Sharpe 0.10  Max drawdown -15.91%  Volatility 8.65%
Mean reversion  CAGR -2.62%  Sharpe -0.81  Max drawdown -13.44%  Volatility 3.21%
Both, 50/50     CAGR -1.01%  Sharpe -0.17  Max drawdown -11.01%  Volatility 5.10%
Nifty 50        CAGR 5.69%  Sharpe 0.47  Max drawdown -17.23%  Volatility 13.82%

6. What the results say

First, can we trust them?

Two checks before reading anything into the numbers. Run on SBIN alone, the Part 3 Keltner strategy finishes at exactly the same equity as Part 2’s engine on the same bars, Rs 16,45,259.28, with 100 fills each, so the shared-account plumbing changes nothing when there is nothing to share. And a plain pandas replay of the mean-reversion rules produces the same 142 fills, on the same bars at the same prices, as Nautilus. The numbers below are the strategies, not bugs.

Lesson 1: selection bias

Part 1 chose SBIN. Here is what the same Keltner rules did on each of the six stocks over these five years, each with Rs 10 lakh of its own:

Bar chart of the Keltner Channel strategy CAGR on SBIN, ICICIBANK, RELIANCE, ITC, INFY and TCS from 2021 to 2026, with the six-stock portfolio, equal weight buy and hold and the Nifty 50
Same Keltner rules on six stocks, September 2021 to September 2026. Click to open the full-size image.
CAGRMax drawdownVolatilitySharpe
SBIN+10.72%-24.0%17.5%0.67
ICICIBANK+5.28%-19.9%15.0%0.42
RELIANCE-0.03%-21.7%15.5%0.08
ITC-1.93%-35.6%14.7%-0.06
INFY-5.81%-36.9%17.6%-0.25
TCS-7.33%-36.5%15.3%-0.42
Portfolio of all six+0.52%-15.9%8.7%0.10
Buy and hold, equal weight+5.36%-17.5%14.4%0.43
Nifty 50+5.69%-17.2%13.8%0.47

SBIN was the best of the six by a distance. ICICIBANK made money too, RELIANCE broke even, and the Keltner rules lost money on ITC, INFY and TCS. Had Part 1 happened to pick TCS, this series would have opened with a strategy losing 7.3% a year. This is selection bias: a strategy that looks good on the stock you tested it on may simply be a good fit for that stock’s history. The cure is the test in this section: run the same rules, unchanged, on instruments you did not choose them on.

Lesson 2: diversification cuts risk, not return

Put the six together in one account, one sixth each, and the ride gets much smoother. The portfolio’s volatility was 8.7% a year against 15% to 18% for the single stocks, and its worst drawdown 15.9% against 20% to 37%. But its return is the average of its parts: 0.52% a year, far behind simply buying and holding the same six stocks (5.36%) or the Nifty (5.69%). Diversification spreads risk; it cannot create an edge that the parts do not have.

Lesson 3: a second strategy only helps if it makes money

Equity curves of the Keltner portfolio, the mean reversion portfolio, a 50/50 mix and the Nifty 50 from Rs 10 lakh, 2021 to 2026
Keltner, mean reversion and a 50/50 mix in one account, against the Nifty 50. Click to open the full-size image.
CAGRMax drawdownVolatilitySharpeCAGR with no charges
Keltner portfolio+0.52%-15.9%8.7%0.10+3.06%
Mean reversion portfolio-2.62%-13.4%3.2%-0.81-0.04%
Both, 50/50-1.01%-11.0%5.1%-0.17+1.64%
Nifty 50+5.69%-17.2%13.8%0.47

The two strategies really are different: the correlation of their daily returns is 0.32, and mixing them 50/50 brings volatility down to 5.1% and the worst drawdown to 11%, lower than either alone. That is diversification doing its job.

The trouble is the mean-reversion strategy itself. It won 66% of its 332 trades, which looks excellent, yet its average trade made 0.0% before charges: the many small wins were paid back by a few larger losses. Every round trip also pays about 0.23% in delivery charges, mostly STT, so after charges it lost 2.6% a year. Mixing a strategy that earns a little with one that loses a little gives you something in between, -1.0% a year. Low correlation is only worth having between strategies that each make money after costs.

The table’s last column makes the same point from the other side. Without charges, the Keltner portfolio would have made 3.06% a year and the mix 1.64%. On hourly bars with delivery charges, a strategy needs a much larger edge per trade than it would on paper.

7. The portfolio tearsheet

The last line of main() writes an openstatz tearsheet for the 50/50 portfolio against the Nifty 50. Two sections tell the story:

openstatz tearsheet of the 50/50 two-strategy portfolio against the Nifty 50: cumulative return, rolling Sharpe, volatility and win rate, and return and risk by horizon
openstatz tearsheet: the 50/50 portfolio against the Nifty 50. Click to open the full-size image.
openstatz annual returns of the 50/50 two-strategy portfolio against the Nifty 50, 2021 to 2026
openstatz tearsheet: calendar-year returns against the Nifty 50. Click to open the full-size image.

The cumulative return chart shows the portfolio’s white line barely leaving zero while the Nifty climbs. The annual returns explain why. The portfolio lost less than the Nifty in its falling years (2021 and 2026) but missed most of the rise in 2023, 2024 and 2025, when being out of the market or in the wrong stocks cost far more than the protection was worth. The horizon table also shows the last three years at -2.87% a year, weaker than the full period.

What to take away

  • Test a strategy on instruments you did not design it on before you trust a single-stock result.
  • Diversification reduces volatility and drawdown; it does not add return.
  • Combine strategies with low correlation, but only ones that each make money after charges.
  • A high win rate says nothing on its own; look at the average trade after costs.
  • Give every strategy instance a unique order_id_tag.
  • Ask for your own position with cache.positions_open(..., strategy_id=self.id), never the portfolio’s net position.
  • Reserve cash for entries in a shared account, or two fills in one hour can stop the backtest.
  • Measure with an Actor, and use Historify with source="db" for repeatable research.

Coming up: options

Part 4 moves to options. That needs historical option prices, which brokers generally stop serving once a contract expires, so options research depends on storing the contracts you care about while they trade. Historify can download F&O contracts, so it is worth starting to collect the option chains you want to study now. As always, 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