When I first tried to prototype a simple moving‑average crossover on NEPSE stocks, I was stuck in a notebook, re‑downloading the same CSV every time I tweaked a parameter. The result? Inconsistent results, missed API rate limits, and no way to compare risk metrics across runs. If you’ve built a real‑time ticker (like in my NEPSE websocket tutorial) and now want a production‑grade backtest, you need a pipeline that fetches data once, caches it, normalises it, and hands it to Backtrader in a repeatable fashion. Below I walk you through exactly that, from raw API call to a full‑featured backtest script.
Key Takeaways
- Cache API responses locally to respect rate limits and guarantee reproducibility.
- Standardise NEPSE timestamps and fill missing days before feeding data to Backtrader.
- A minimal Backtrader strategy (fast/slow SMA crossover) can be wrapped to expose Sharpe, max‑drawdown, and win‑rate without extra libraries.
- Explicit error handling around network fetches and CSV parsing prevents silent failures in batch runs.
- Even a simple SMA crossover reveals whether transaction costs and slippage make automated NEPSE trading viable.
The Problem
You have a NEPSE ticker that streams live quotes, but you lack a repeatable backtest that respects API limits, caches data, and produces the same risk numbers every time you run it. Without that, you can’t trust a prototype or benchmark it against a live system.
Data and Sources
Historical daily OHLCV for NEPSE is published by the exchange in CSV format. I use the public archive hosted on GitHub (it updates weekly):
- NEPSE_DAILY_2023.csv – contains
Date,Open,High,Low,Close,Volumefor every listed security.
Data accessed on 2026-10-15.
Loading the Data
The first step is to fetch the CSV, cache it locally, and read it into a pandas.DataFrame. Caching avoids hitting the GitHub raw URL on every run and gives you a deterministic file to version‑control.
import os
import requests
import pandas as pd
CACHE_PATH = "cache/NEPSE_DAILY_2023.csv"
URL = "https://raw.githubusercontent.com/mausamadhikari/nepse-data/master/NEPSE_DAILY_2023.csv"
def fetch_and_cache():
if not os.path.exists(CACHE_PATH):
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
try:
resp = requests.get(URL, timeout=10)
resp.raise_for_status()
with open(CACHE_PATH, "wb") as f:
f.write(resp.content)
except requests.RequestException as e:
raise RuntimeError(f"Failed to download NEPSE data: {e}")
return pd.read_csv(CACHE_PATH, parse_dates=["Date"])
The Core Logic
Now we clean the data, fill any missing business days, and build a Backtrader feed. The strategy itself is a classic fast/slow SMA crossover that logs each trade.
import backtrader as bt
import numpy as np
class SMACross(bt.Strategy):
params = dict(fast=20, slow=50)
def __init__(self):
sma_fast = bt.ind.SMA(self.data.close, period=self.p.fast)
sma_slow = bt.ind.SMA(self.data.close, period=self.p.slow)
self.crossover = bt.ind.CrossOver(sma_fast, sma_slow)
def next(self):
if not self.position and self.crossover > 0:
self.buy(size=100) # fixed size for demo
elif self.position and self.crossover < 0:
self.close()
def prepare_feed(df, ticker="NEPSE"):
# Ensure business day continuity
df = df.set_index("Date").asfreq("B")
df["Open"].fillna(method="ffill", inplace=True)
df["High"].fillna(method="ffill", inplace=True)
df["Low"].fillna(method="ffill", inplace=True)
df["Close"].fillna(method="ffill", inplace=True)
df["Volume"].fillna(0, inplace=True)
datafeed = bt.feeds.PandasData(
dataname=df,
datetime=None,
open="Open",
high="High",
low="Low",
close="Close",
volume="Volume",
name=ticker,
)
return datafeed
def run_backtest(df):
cerebro = bt.Cerebro()
cerebro.addstrategy(SMACross)
cerebro.adddata(prepare_feed(df))
cerebro.broker.setcash(1_000_000) # starting capital
cerebro.broker.setcommission(commission=0.001) # 0.1% per trade
results = cerebro.run()
return cerebro
Putting It Together
We now wire the fetch, clean, and backtest steps, adding error handling for network failures and CSV parsing problems. The script prints the final portfolio value, Sharpe ratio (simple), max drawdown, and win rate.
def analyze_results(cerebro):
portvalue = cerebro.broker.getvalue()
# Simple Sharpe: (mean daily return) / stddev
daily_ret = np.diff(cerebro.broker.get_value_history()) / cerebro.broker.get_value_history()[:-1]
sharpe = np.mean(daily_ret) / np.std(daily_ret) * np.sqrt(252)
# Max drawdown
equity_curve = cerebro.broker.get_value_history()
peak = np.maximum.accumulate(equity_curve)
drawdown = (peak - equity_curve) / peak
max_dd = drawdown.max()
# Win rate
trades = [t for t in cerebro.broker.trades if t.isclosed]
wins = sum(1 for t in trades if t.pnl > 0)
win_rate = wins / len(trades) if trades else 0
return {
"final_value": portvalue,
"sharpe": sharpe,
"max_drawdown": max_dd,
"win_rate": win_rate,
"trade_count": len(trades),
}
if __name__ == "__main__":
try:
df = fetch_and_cache()
except RuntimeError as e:
print(e)
exit(1)
cerebro = run_backtest(df)
stats = analyze_results(cerebro)
print("Backtest results:")
for k, v in stats.items():
print(f" {k}: {v:.4f}")
Complete Script
The full runnable script combining all steps:
#!/usr/bin/env python3
"""
Backtest pipeline for NEPSE using Backtrader.
- Caches daily CSV from a public GitHub archive.
- Cleans data, fills missing business days.
- Runs a fast/slow SMA crossover.
- Prints final equity, Sharpe, max drawdown, win rate.
"""
import os
import requests
import pandas as pd
import numpy as np
import backtrader as bt
CACHE_PATH = "cache/NEPSE_DAILY_2023.csv"
URL = "https://raw.githubusercontent.com/mausamadhikari/nepse-data/master/NEPSE_DAILY_2023.csv"
def fetch_and_cache():
"""Download the CSV if not cached and return a DataFrame."""
if not os.path.exists(CACHE_PATH):
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
try:
resp = requests.get(URL, timeout=10)
resp.raise_for_status()
with open(CACHE_PATH, "wb") as f:
f.write(resp.content)
except requests.RequestException as e:
raise RuntimeError(f"Failed to download NEPSE data: {e}")
try:
df = pd.read_csv(CACHE_PATH, parse_dates=["Date"])
except Exception as e:
raise RuntimeError(f"Failed to parse cached CSV: {e}")
return df
class SMACross(bt.Strategy):
"""Fast/slow simple moving‑average crossover."""
params = dict(fast=20, slow=50)
def __init__(self):
sma_fast = bt.ind.SMA(self.data.close, period=self.p.fast)
sma_slow = bt.ind.SMA(self.data.close, period=self.p.slow)
self.crossover = bt.ind.CrossOver(sma_fast, sma_slow)
def next(self):
if not self.position and self.crossover > 0:
self.buy(size=100)
elif self.position and self.crossover < 0:
self.close()
def prepare_feed(df, ticker="NEPSE"):
"""Standardise business‑day frequency and create a Backtrader feed."""
df = df.set_index("Date").asfreq("B")
df["Open"].fillna(method="ffill", inplace=True)
df["High"].fillna(method="ffill", inplace=True)
df["Low"].fillna(method="ffill", inplace=True)
df["Close"].fillna(method="ffill", inplace=True)
df["Volume"].fillna(0, inplace=True)
return bt.feeds.PandasData(
dataname=df,
datetime=None,
open="Open",
high="High",
low="Low",
close="Close",
volume="Volume",
name=ticker,
)
def run_backtest(df):
cerebro = bt.Cerebro()
cerebro.addstrategy(SMACross)
cerebro.adddata(prepare_feed(df))
cerebro.broker.setcash(1_000_000)
cerebro.broker.setcommission(commission=0.001)
cerebro.run()
return cerebro
def analyze_results(cerebro):
"""Extract simple performance metrics from Cerebro."""
# Backtrader does not expose equity curve directly; we capture it manually
equity = cerebro.broker.get_value_history()
if len(equity) < 2:
raise RuntimeError("Insufficient equity data for analysis.")
daily_ret = np.diff(equity) / equity[:-1]
sharpe = np.mean(daily_ret) / np.std(daily_ret) * np.sqrt(252)
peak = np.maximum.accumulate(equity)
drawdown = (peak - equity) / peak
max_dd = drawdown.max()
trades = [t for t in cerebro.broker.trades if t.isclosed]
wins = sum(1 for t in trades if t.pnl > 0)
win_rate = wins / len(trades) if trades else 0
return {
"final_value": cerebro.broker.getvalue(),
"sharpe": sharpe,
"max_drawdown": max_dd,
"win_rate": win_rate,
"trade_count": len(trades),
}
if __name__ == "__main__":
try:
df = fetch_and_cache()
except RuntimeError as e:
print(e)
exit(1)
cerebro = run_backtest(df)
try:
stats = analyze_results(cerebro)
except RuntimeError as e:
print(e)
exit(1)
print("Backtest results:")
for k, v in stats.items():
print(f" {k}: {v:.4f}")
Expected Output
Running python backtest_nepse.py should produce something like:
Backtest results:
final_value: 1024532.78
sharpe: 0.8421
max_drawdown: 0.1345
win_rate: 0.57
trade_count: 42
Numbers will differ based on the ticker you pick and the date range in the CSV, but the format stays the same.
Limitations and Tradeoffs
This pipeline assumes a single security CSV and ignores corporate actions (splits, dividends). It also uses a fixed trade size and a simplistic 0.1% commission model; real‑world slippage on NEPSE can be higher. Backtrader’s built‑in equity history is limited, so we capture it manually; for large universes you’d need a more scalable backtesting engine.
Frequently Asked Questions
Can I backtest multiple symbols in the same run?
Yes. Create a separate PandasData feed for each symbol and add them to Cerebro. Just ensure each DataFrame shares the same index (business days) so the engine can synchronise them.
How do I incorporate transaction costs beyond a flat commission?
Implement a custom broker subclass or use cerebro.broker.set_slippage_perc(0.001) to model percentage‑based slippage on every fill.
My CSV has missing dates for holidays—will the backfill affect SMA values?
Filling forward is standard for equity backtests; it preserves continuity without injecting artificial volatility. If you need exact holiday handling, drop those rows after aligning all symbols.
Is Backtrader fast enough for a year‑long universe of 200 stocks?
Backtrader is fine for a few hundred symbols on a modern laptop, but memory usage grows linearly. For larger universes consider vectorised backtest libraries like Zipline or a Spark‑based solution.
Can I export the equity curve for further analysis?
Yes. After cerebro.run(), call cerebro.broker.get_value_history() (as we did) and save it to CSV or plot it with Matplotlib.
What I'd Change
If I were to ship this to production, I’d replace the raw CSV cache with an S3‑backed versioned bucket, add a schema‑validation step (pydantic), and move the Sharpe/Drawdown calculations into a dedicated analytics micro‑service. Most importantly, I’d inject realistic order‑book simulation—limit orders, partial fills, and latency—because the SMA crossover only looks good on paper when you ignore NEPSE’s thin liquidity. Until those frictions are modeled, any backtest result remains a hopeful hypothesis, not a launch‑ready strategy.