Lesson 6 — Backtesting & analysis (Jupyter)

Skip to content

Lesson 6 — Backtesting & analysis (Jupyter)

Use Jupyter to replay historical market data, simulate order execution and compute P/L and performance metrics. We’ll build small, testable backtest components you can evolve into more advanced systems.

Estimated time: 60–90 minutes • Skill level: Intermediate

Use this as a starting point. Don’t commit secrets.

Prerequisites

  • Lessons 1–5 completed (env, auth, market lookup, orders, webhooks)
  • Python environment with Jupyter / JupyterLab and pandas installed
  • Historical market data (CSV/Parquet) or a delayed API data dump

Overview

We’ll cover: loading tick/candlestick data, a simple event-driven backtester loop, execution simulation (slippage/partial fills), logging trades, computing P/L and common metrics (win rate, expectancy, Sharpe-like ratio).

1) Jupyter setup

Install Jupyter and essentials if you haven’t already:

# install Jupyter and common libs
pip install jupyterlab pandas numpy matplotlib

Open a notebook: jupyter lab or jupyter notebook and create a new Python 3 notebook for the backtest.

2) Load historical data (example)

Load market ticks or candles into a pandas DataFrame. Use Parquet when possible for performance.

import pandas as pd

# CSV example: columns: timestamp, marketId, selectionId, price, size
df = pd.read_csv('market_ticks.csv', parse_dates=['timestamp'])
df = df.sort_values(by='timestamp')
df.head()

Tip: Normalize column names and data types; store microsecond timestamps if you need finer replay granularity.

3) Simple event-driven backtest loop

Below is a minimal backtester that replays rows and calls a strategy decide() function for signals. Implement your own strategy object with on_tick / on_bar methods.

from collections import deque

class BacktestEngine:
    def __init__(self, data, strategy, starting_cash=100.0):
        self.data = data  # pandas DataFrame iterator
        self.strategy = strategy
        self.cash = starting_cash
        self.positions = {}  # key: selectionId -> position size/avg price
        self.trades = []  # list of trade dicts

    def run(self):
        for _, row in self.data.iterrows():
            # convert row to a tick object or dict
            tick = row.to_dict()
            # strategy inspects tick and may return orders
            orders = self.strategy.on_tick(tick)
            for order in orders or []:
                # simulate execution
                exec_report = self.execute(order, tick)
                self.trades.append(exec_report)
                # update P/L / positions
                self._apply_exec(exec_report)
        return self.trades

    def execute(self, order, tick):
        # naive execution: if price crosses desired price, fill; else partial or no-fill
        executed = {'order': order, 'filled_size': 0.0, 'avg_price': None, 'status': 'NO_FILL'}
        # Example simplistic fill logic (replace with more realistic sim)
        market_price = tick.get('price')
        if order['side'] == 'BACK' and market_price <= order['price']:
            executed['filled_size'] = order['size']
            executed['avg_price'] = order['price']
            executed['status'] = 'FILLED'
        elif order['side'] == 'LAY' and market_price >= order['price']:
            executed['filled_size'] = order['size']
            executed['avg_price'] = order['price']
            executed['status'] = 'FILLED'
        return executed

    def _apply_exec(self, exec_report):
        # update positions/cash
        order = exec_report['order']
        size = exec_report['filled_size']
        price = exec_report['avg_price']
        if size and price:
            side = order['side']
            if side == 'BACK':
                # buy exposure
                self.positions.setdefault(order['selectionId'], {'size':0.0,'avg_price':0.0})
                pos = self.positions[order['selectionId']]
                new_size = pos['size'] + size
                pos['avg_price'] = ((pos['avg_price']*pos['size']) + price*size)/new_size if pos['size'] else price
                pos['size'] = new_size
                self.cash -= price * size
            else: # LAY simulation (simplified)
                # LAY increases liability; track separately in real systems
                pass

Note: This engine is intentionally minimal. Replace execution logic with slippage models, partial fills and available liquidity limits for more fidelity.

4) Example SMA strategy (notebook cell)

Compute moving averages in the notebook and generate simple cross signals.

import pandas as pd

# df: DataFrame with 'timestamp' and 'price' columns
df['ma_short'] = df['price'].rolling(window=5).mean()
df['ma_long'] = df['price'].rolling(window=20).mean()

class SMAStrategy:
    def __init__(self):
        self.last_signal = None

    def on_tick(self, tick):
        # In notebook, you may use row-by-row iteration
        if tick.get('ma_short') and tick.get('ma_long'):
            if tick['ma_short'] > tick['ma_long'] and self.last_signal != 'BUY':
                self.last_signal = 'BUY'
                return [{'selectionId': tick['selectionId'], 'side':'BACK', 'size':1.0, 'price': tick['price']}]
            elif tick['ma_short'] < tick['ma_long'] and self.last_signal != 'SELL':
                self.last_signal = 'SELL'
                return [{'selectionId': tick['selectionId'], 'side':'LAY', 'size':1.0, 'price': tick['price']}]
        return []

This strategy is intentionally simple — use it to validate your pipeline, then add risk sizing, stop logic and realistic fees.

5) Compute P/L and performance metrics

After running trades, compute basic metrics: total P/L, number of trades, win rate, average P/L per trade, max drawdown.

import pandas as pd
import numpy as np

# trades: list of exec reports with filled_size, avg_price, order side, timestamp
trades_df = pd.DataFrame(trades)

# Example P/L computation (simplified)
def compute_pnl(trades_df):
    pnl_list = []
    for _, t in trades_df.iterrows():
        if t['order']['side'] == 'BACK':
            pnl = (t.get('exit_price', t['avg_price']) - t['avg_price']) * t['filled_size']
        else:
            pnl = (t['avg_price'] - t.get('exit_price', t['avg_price'])) * t['filled_size']
        pnl_list.append(pnl)
    pnl_series = pd.Series(pnl_list)
    total_pnl = pnl_series.sum()
    win_rate = (pnl_series > 0).mean()
    avg_pnl = pnl_series.mean()
    return {'total_pnl': total_pnl, 'win_rate': win_rate, 'avg_pnl': avg_pnl}

metrics = compute_pnl(trades_df)

Tip: Include fees/commission/slippage in the P/L calculation. Keep detailed logs to reproduce any surprising results.

6) Replay fidelity & common pitfalls

  • Tick vs candle resolution: candles hide intra-bar price moves — prefer tick-level data for execution-sensitive strategies.
  • Survivorship bias: ensure event lists reflect the historical universe at the time, not the current one.
  • Lookahead bias: don’t use future information in your decision logic (e.g., next-bar close to decide current order).
  • Transaction costs: include commission, exchange fees and latency slippage models.

What you'll build next

Lesson 7 will focus on risk, hedging and deployment: green‑up hedges, idempotent deployment patterns, logging and Docker/VPS deployment guidance.

Back to course hub

← Previous
Author: Stephane Patteux • Part of the Build your own bot series

Don’t miss our blog post!

We don’t spam! Read our privacy policy for more info.

Leave a Reply

Your email address will not be published. Required fields are marked *