Skip to content

From API to Trade: Building a Backtest Pipeline for NEPSE with Backtrader

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):

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.

Post a Comment

Hi! How can we help you? Send us a message and we'll get back to you.