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.

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:

| CAGR | Max drawdown | Volatility | Sharpe | |
|---|---|---|---|---|
| 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

| CAGR | Max drawdown | Volatility | Sharpe | CAGR 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:


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.