Most backtests lie. Not because traders cheat — because their tools let them.
You spend a weekend coding a brilliant Nifty Futures momentum strategy. The backtest shows a 42% CAGR and a Sharpe of 2.1. You feel invincible. You go live. The strategy loses money in three of the first four weeks.
What happened? Survivorship bias: your historical data only included contracts that survived, not the ones that were rolled, expired badly, or had zero liquidity. Look-ahead bias: your signal used today's closing price to generate today's trade — a physical impossibility. Your backtesting library silently permitted both, and you never knew. This post is about building a backtest that actually tells you the truth — in 50 lines of Python.
TL;DR
- vectorbt is the fastest Python backtesting library for vectorised strategies — often 100x faster than Backtrader on the same machine
- A 20/50 EMA crossover on Nifty Futures is implemented in ~50 lines with realistic slippage (0.05%) and brokerage (₹40/trade flat)
- The biggest hidden killers in Indian F&O backtests are look-ahead bias via
shift()errors and ignoring lot-size constraints - Always validate: Sharpe > 1, Max Drawdown < 20%, and out-of-sample performance before committing capital
Before we touch code: what does your current backtesting setup assume about execution that you have never actually verified?
Why vectorbt Over Other Backtesting Libraries
Every developer landing in algo trading eventually compares libraries. Here is the honest comparison for Indian markets:
| Library | Speed | Vectorised | NSE-Friendly | Learning Curve |
|---|---|---|---|---|
| vectorbt | Extremely fast (NumPy/Numba) | Yes — full vectorisation | Yes — works with any OHLCV source including yfinance NSE tickers | Medium (functional, not OOP) |
| Backtrader | Slow on large datasets | No — event-driven loop | Partial — needs custom data feeds for NSE | Low initially, steep for advanced use |
| Zipline | Medium | Partial | Poor — built for US equities, Quantopian-era | High — heavy setup, dated dependencies |
| bt | Medium | Partial | Partial — works but limited order types | Low — simple API, limited flexibility |
vectorbt wins on three dimensions that matter most for Indian F&O backtesting:
- Speed at scale: Running a parameter sweep across 500 EMA combinations on five years of Nifty minute data takes seconds, not hours. This matters when you are walk-forward testing.
- Realistic cost modelling: Commission functions, slippage models, and fixed-fee structures (like Zerodha's ₹40/order flat fee) are first-class citizens in vectorbt, not afterthoughts.
- Portfolio-level analysis: NSE traders often run multi-leg F&O positions. vectorbt handles portfolio-level metrics natively — Backtrader requires significant boilerplate for the same result.
The tradeoff: vectorbt thinks in arrays, not in "strategies as classes." If you come from an OOP background, the functional paradigm takes a few hours to internalise. The payoff is worth it.
<!-- IMAGE BRIEF 1: Side-by-side benchmark chart showing vectorbt vs Backtrader runtime on 5 years of Nifty 1-minute OHLCV data. Dark background, clean bar chart. Caption: "vectorbt completes a 500-combination EMA sweep in ~8 seconds. Backtrader takes ~14 minutes for the same task." -->
If speed and cost realism are the two metrics that matter most for your trading setup, which library would you have picked before reading this table — and does that answer change now?
The Strategy: 20/50 EMA Crossover on Nifty Futures
The logic: When the 20-period EMA crosses above the 50-period EMA, momentum is shifting upward — go long. When it crosses below, momentum is shifting downward — exit or go short. This is not a novel strategy. It is deliberately simple so the code is readable and the results are attributable to the strategy, not to hidden complexity.
Why Nifty Futures specifically? Nifty 50 Futures (ticker: ^NSEI as a proxy, or NIFTYBEES.NS as a liquid ETF substitute for retail backtesting) are the most liquid instrument on NSE. Lot size is 25 units (currently). The bid-ask spread on near-month contracts during market hours is typically 0.5–1 point. We will model this as slippage.
Here is the complete backtest. Read every comment — they explain the non-obvious decisions:
pythonimport vectorbt as vbt import yfinance as yf import pandas as pd import numpy as np # ── 1. DATA ────────────────────────────────────────────────────────────────── # Using ^NSEI (Nifty 50 index) as a proxy for Nifty Futures price behaviour. # For production: use actual futures OHLCV data from your broker's historical API. ticker = "^NSEI" start = "2019-01-01" end = "2024-12-31" raw = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False) close = raw["Close"].squeeze() # Series, not DataFrame # ── 2. SIGNALS ─────────────────────────────────────────────────────────────── fast_window = 20 slow_window = 50 # vbt.MA wraps pandas_ta / talib under the hood — vectorised, no loops. fast_ma = vbt.MA.run(close, fast_window, short_name="fast") slow_ma = vbt.MA.run(close, slow_window, short_name="slow") # Crossover signals — shift(1) on the comparison ensures we use YESTERDAY's # signal to enter TODAY's open. This is the single most important line # for preventing look-ahead bias. entries = fast_ma.ma_crossed_above(slow_ma) # returns boolean Series exits = fast_ma.ma_crossed_below(slow_ma) # ── 3. COST MODEL ──────────────────────────────────────────────────────────── # NSE Futures realistic costs (2024): # - Brokerage: ₹40 flat per executed order (Zerodha / Kotak Neo model) # - STT: 0.01% on sell side (futures) # - Slippage: ~0.05% per trade (conservative estimate for near-month Nifty) # We combine these into a single commission rate for simplicity. # ₹40 on a typical Nifty Futures trade of ~₹5L ≈ 0.008% + STT ≈ 0.018% total. # Add slippage: 0.05%. Combined: ~0.07% per trade side. COMMISSION = 0.0007 # 0.07% per trade side SLIPPAGE = 0.0005 # 0.05% additional slippage buffer # ── 4. PORTFOLIO ───────────────────────────────────────────────────────────── portfolio = vbt.Portfolio.from_signals( close, entries=entries, exits=exits, direction="longonly", # Change to "both" for long+short init_cash=500_000, # ₹5 lakh starting capital fees=COMMISSION, slippage=SLIPPAGE, freq="1D", # Daily bars — critical for correct annualisation ) # ── 5. RESULTS ─────────────────────────────────────────────────────────────── stats = portfolio.stats() print(stats) # Key metrics summary print("\n── Strategy Summary ──") print(f"Total Return : {portfolio.total_return() * 100:.2f}%") print(f"Sharpe Ratio : {portfolio.sharpe_ratio():.2f}") print(f"Max Drawdown : {portfolio.max_drawdown() * 100:.2f}%") print(f"Win Rate : {portfolio.trades.win_rate() * 100:.2f}%") print(f"Total Trades : {portfolio.trades.count()}") # Optional: plot equity curve (requires matplotlib) # portfolio.plot().show()
That is 52 lines including blank lines, comments, and print statements. The core logic — data, signals, portfolio, results — is under 20 lines.
Common Mistake: Look-Ahead Bias with shift()
The vectorbt ma_crossed_above() method internally compares the current bar's fast MA against the current bar's slow MA. If you generate your own signals using pd.Series.shift(), an off-by-one error is catastrophically easy to make:
python# WRONG — uses today's signal to execute today's trade (look-ahead bias) entries = (fast_ma.ma > slow_ma.ma) # CORRECT — uses yesterday's crossover to decide today's entry entries = (fast_ma.ma > slow_ma.ma) & (fast_ma.ma.shift(1) <= slow_ma.ma.shift(1))
A backtest with look-ahead bias can show 2–3x inflated returns. When you go live, the strategy will underperform systematically because it was never actually possible to execute those trades at those prices.
What would change in your strategy's performance if you removed every assumption about perfect execution — if every fill was 0.1% worse than your signal price?
Reading Your Results
vectorbt's portfolio.stats() returns ~30 metrics. Most traders read three and ignore the rest. Here is what to actually look at for Indian F&O:
| Metric | What It Means | Good Threshold for India F&O |
|---|---|---|
| Total Return | Gross gain over the period, before taxes | Context-dependent; compare to Nifty buy-and-hold benchmark |
| Sharpe Ratio | Return per unit of risk (annualised) | > 1.0 minimum; > 1.5 is solid for F&O |
| Max Drawdown | Worst peak-to-trough equity decline | < 20% for most retail traders; < 30% maximum |
| Calmar Ratio | CAGR divided by Max Drawdown | > 1.0; higher is better; tells you if the return justified the pain |
| Win Rate | Percentage of trades that were profitable | > 45% for trend strategies; > 55% for mean reversion |
| Avg RR Ratio | Average profit on winners vs loss on losers | > 1.5 for trend following; compensates for lower win rate |
| Total Trades | Number of trades executed | Too few (< 30) = insufficient statistical confidence |
| Profit Factor | Gross profits / Gross losses | > 1.3 is the minimum viable threshold |
| Benchmark Return | What buy-and-hold Nifty returned in the same period | Your strategy must beat this after costs to be worth the operational complexity |
The number most Indian traders overlook: benchmark-adjusted return. If your momentum strategy returned 18% CAGR but Nifty returned 15% CAGR in the same period — and your strategy had higher drawdown and 60 trades generating tax events — it is not a better strategy. It is a worse one with extra steps.
Pro Tip: Set the frequency explicitly for correct annualisation
Always include freq='1D' in your Portfolio.from_signals() call when working with daily bars:
pythonvbt.settings.array_wrapper['freq'] = '1D'
Or pass freq='1D' directly to from_signals(). Without this, vectorbt cannot correctly infer trading days per year, and your Sharpe ratio and CAGR will be calculated incorrectly — sometimes by a factor of sqrt(252). This is a silent bug that produces impressive-looking but meaningless numbers.
<!-- IMAGE BRIEF 2: Clean terminal screenshot showing vectorbt portfolio.stats() output for the Nifty EMA crossover strategy. Highlight rows: Sharpe Ratio, Max Drawdown, Win Rate, Total Trades. Dark terminal theme, readable font size. -->
Real Tradeoffs
No strategy works everywhere. Here are the honest tradeoffs of EMA crossover momentum on Nifty Futures:
| Tradeoff | When It Works | When It Fails |
|---|---|---|
| Trend-following in a trending market | Strong directional moves with follow-through (2020 crash, 2021 bull run, 2023 rally) | Sideways, choppy markets (Aug–Oct 2022 rangebound Nifty): generates whipsaws and losses |
| Daily timeframe signals | Reduces overtrading and brokerage costs; avoids intraday noise; suitable for position traders | Misses intraday momentum; signals may arrive when next-day gap opens against you |
| Long-only direction | Works in sustained bull phases; cleaner tax treatment (LTCG/STCG thresholds) | Leaves money on the table during bear markets; short side of Nifty Futures has its own dynamics |
The uncomfortable truth: a basic EMA crossover on Nifty will underperform buy-and-hold in strongly trending years. Its value is in capital preservation during drawdowns — it gets you out when things go badly. If you want to evaluate it fairly, look at drawdown-adjusted metrics, not raw returns.
What market regime were you implicitly assuming when you designed your current strategy — trending, mean-reverting, or volatile? Have you tested it explicitly in the opposite regime?
If your strategy only works in a bull market, is it a strategy or is it just leveraged beta exposure?
Choose Your Scenario
Scenario A: Quick-and-dirty daily momentum check
You are: A discretionary trader adding a systematic lens to your existing Nifty Futures view. You want to know if the trend is with you before entering a trade.
What to do: Run the 50-line script above. Look at the current position (long or flat) implied by the latest EMA crossover. Use it as one input among several, not as a standalone signal. Do not automate execution yet. Run it weekly, not daily.
Risk of this approach: You will start over-trusting the backtest without understanding its failure modes. Run it through 2022 specifically — the rangebound year — to see how it performs when it is designed to fail.
Scenario B: Production-grade strategy validation
You are: Building a systematic strategy that will trade automatically via OpenAlgo or a broker API. Real money, real consequences.
What you need before going live:
- Replace
^NSEIindex data with actual Nifty Futures OHLCV data (from broker API or NSEpy/NSE Data) - Account for lot size (25 units per lot) and margin requirements (~₹1.2L per lot as of 2024)
- Run a walk-forward test: train on 2019–2022, validate on 2023–2024. If out-of-sample Sharpe drops below 0.5, the strategy is over-fitted.
- Add transaction cost sensitivity: what happens if slippage doubles? Triple? If the strategy breaks at 0.15% slippage, it is fragile.
- Paper trade for 30 trading days. Compare live fills to backtested assumptions.
<!-- IMAGE BRIEF 3: Split-screen comparison diagram. Left side: "Scenario A" showing a simple flowchart (fetch data → compute EMA → check signal → human decision). Right side: "Scenario B" showing a full pipeline (data validation → signal generation → walk-forward test → paper trade → live execution via OpenAlgo). Clean diagram, blue/white colour scheme. -->
5-Minute Framework to Evaluate Any Backtest
Use this flowchart before committing capital to any backtested strategy:
This is not optional. Every node is a gate, not a suggestion. The most dangerous place in this flowchart is the shortcut between A and L — which is where most retail algo traders actually jump.
Over-fitting: The Strategy Killer Nobody Talks About
If you optimise your EMA windows (fast=20, slow=50) by running 500 combinations and picking the one with the highest Sharpe — you have not found a good strategy. You have found the parameters that fit historical noise best. This is called curve-fitting, and it is the reason most backtests fail in live trading.
The rule: parameters must be chosen before looking at the results, or validated on data that was never used in optimisation. If you optimised on 2019–2023 data, the 2024 out-of-sample result is the only honest measure of strategy quality. A Sharpe of 1.8 in-sample and 0.3 out-of-sample is a failed strategy, not a promising one.
Mini-Exercise
Before running any backtest, complete this sentence in writing. Literally write it down:
"My strategy uses [indicator] on [timeframe] for [instrument]. My entry signal is [X], exit is [Y]. Expected Sharpe: [Z]. I will validate on [date range] that was not used in development."
Example: "My strategy uses a 20/50 EMA crossover on daily bars for Nifty Futures. My entry signal is fast EMA crossing above slow EMA, exit is the reverse cross. Expected Sharpe: 1.0–1.3. I will validate on January 2024 – December 2024 data that was held out during development."
This exercise forces three things: specificity (no vague "momentum"), an honest expected outcome (not "it will definitely work"), and a commitment to out-of-sample validation. Most traders skip all three.
After filling in that template — does your expected Sharpe feel like an honest prediction, or like wishful thinking dressed up as a number?
What would change if you had to publicly post your backtest methodology and assumptions before seeing the results?
Lead Magnet
Download: vectorbt NSE Backtesting Starter Kit
The kit includes:
- Python notebook — the complete 50-line EMA crossover backtest with walk-forward validation, parameter sweep, and equity curve visualisation
- Data fetching script — pulls NSE futures-proxied OHLCV data from yfinance with error handling, auto-adjustment for splits, and data quality checks
- Metrics cheatsheet — one-page PDF with every vectorbt metric explained in Indian F&O context, with thresholds and red flags
Keep Learning
- Next: Strategy Showdown: Momentum vs Mean-Reversion on Nifty — /blog/post.php?slug=momentum-vs-mean-reversion
- Then: Position Sizing with Kelly Criterion for Indian F&O — /blog/post.php?slug=position-sizing-kelly-criterion
- Also: OpenAlgo Webhooks + TradingView: Fire Real Orders From Alerts — /blog/post.php?slug=openalgo-webhook-tradingview
Comment and Let's Pressure-Test Your Assumptions
What instrument are you backtesting first, and what's the single biggest assumption your backtest currently makes that worries you? NSE stock, F&O, or commodity — drop it in the comments below. Every assumption named is an assumption that can be tested.
FAQ
Q1: Can I use vectorbt with actual NSE Futures OHLCV data instead of the Nifty index proxy?
Yes, and you should for production work. The ^NSEI index proxy used in this post is a behavioural approximation — it tracks the index, not the futures contract, which has its own basis, roll costs, and expiry dynamics. For real NSE Futures data, use your broker's historical data API (Zerodha Kite Connect, Fyers API, AngelOne SmartAPI), or NSEpy for older data. The vectorbt code remains identical — only the data loading step changes.
Q2: How do I account for the Nifty Futures lot size (25 units) in the portfolio sizing?
The simplest approach: set size=25 in Portfolio.from_signals() and size_type='shares' to trade exactly one lot per signal. For multi-lot sizing based on capital allocation, use size_type='value' and divide your risk capital by the current contract value. Margin requirements (~₹1.2–1.5L per lot as of 2024) should be factored into your init_cash — never assume you can deploy 100% of capital into margin positions.
Q3: Is this strategy profitable after taxes? STT and STCG add up quickly for active traders.
This is where Indian F&O taxation bites hard. Futures profits are taxed as business income (not STCG/LTCG), so your effective tax rate is your income tax slab — up to 30% for higher earners. STT on futures sell side is 0.01% of turnover. For a strategy generating 30+ trades per year, add your CA's fees and audit requirements. The net-of-tax Sharpe can be 20–35% lower than the gross Sharpe shown in the backtest. Always model post-tax returns for live decisions.
Q4: How long should I paper trade before going live?
Minimum 30 trading days (about 6 weeks of Indian market sessions). This must include at least one full trend phase and one choppy/sideways phase if possible. The goal is not to confirm the strategy works — it is to confirm that your execution assumptions match reality: fill prices, slippage experienced, and signal timing versus your backtest assumptions. If paper trade Sharpe is less than 60% of backtest Sharpe, revisit your cost model before committing capital.
Q5: Can vectorbt handle options strategies for NSE?
vectorbt handles linear instruments (futures, equities, ETFs) natively. For options strategies on NSE — straddles, strangles, iron condors — the payoff structure is non-linear and requires additional modelling. vectorbt has a Portfolio.from_orders() interface that can accommodate custom payoff functions, but building a realistic NSE options backtest requires option chain data (greeks, IV, bid-ask spreads) that is significantly harder to source historically. For options, consider purpose-built tools like QuantLib integration or optionstrategy.in's historical data service.
Do This Next
- Install vectorbt:
pip install vectorbtand verify withimport vectorbt as vbt; print(vbt.__version__) - Run the 50-line EMA crossover script with
^NSEIdata and write down your three headline numbers: Total Return, Sharpe Ratio, Max Drawdown - Re-run the backtest using only 2019–2021 data as in-sample, then test signals on 2022–2024 without touching the parameters — compare the two Sharpe ratios
- Change
direction="longonly"todirection="both"and observe how adding short-side trades affects drawdown and win rate in the 2022 bear phase - Identify the worst three-month period in your backtest results and ask: "Could I have emotionally survived this drawdown in live trading?" If no, your position size is too large
- Fill in the Mini-Exercise template above and share it in the comments — making your assumptions explicit is the first step to testing them honestly
- Bookmark the 5-Minute Evaluation Framework flowchart and use it as a literal checklist for the next strategy you test, regardless of how promising it looks on first run
Be the first to comment