Your First Backtest in 30 Lines of Python

2025-03-06

Most backtesting tutorials skip the hard part: realistic transaction costs. You build a strategy that looks great on paper, then watch it bleed money in production because spread, slippage, and market impact weren't modeled.

cobweb-py gives you a full execution simulator behind a simple API. In this post, we'll go from zero to a friction-aware backtest in about 30 lines.

Prerequisites

pip install cobweb-py yfinance plotly

That's it. No API key required—the free tier is open.

Step 1: Get the Data

We'll use yfinance to grab 5 years of SPY daily bars, then normalize timestamps for the API.

import yfinance as yf
from cobweb_py import CobwebSim, BacktestConfig, fix_timestamps, print_signal
from cobweb_py.plots import save_equity_plot, save_metrics_table

df = yf.download("SPY", start="2020-01-01", end="2024-12-31")
df.columns = df.columns.get_level_values(0)  # flatten MultiIndex (yfinance quirk)
df = df.reset_index().rename(columns={"Date": "timestamp"})
rows = df[["timestamp","Open","High","Low","Close","Volume"]].to_dict("records")
data = fix_timestamps(rows)

fix_timestamps() normalizes any date format (including DD/MM/YYYY) into the ISO format the API expects. It also drops bad rows and sorts chronologically.

Step 2: Build a Signal

We'll use a classic momentum rule: go long when the close is above the 50-day simple moving average, stay flat otherwise.

close = df["Close"].values
sma50 = df["Close"].rolling(50).mean().values
signals = [1.0 if c > s else 0.0 for c, s in zip(close, sma50)]
signals[:50] = [0.0] * 50  # flat during warmup

Signals are just a list of floats: 1.0 = fully long, 0.0 = flat, -1.0 = fully short. One value per bar, same length as your data.

Step 3: Run the Backtest

sim = CobwebSim("https://web-production-83f3e.up.railway.app")

bt = sim.backtest(
    data,
    signals=signals,
    config=BacktestConfig(exec_horizon="swing", initial_cash=100_000),
)

Behind the scenes, every trade goes through the execution simulator:

Step 4: Check Results

print(f"Return:  {bt['metrics']['total_return']:.2%}")
print(f"Sharpe:  {bt['metrics']['sharpe_ann']:.2f}")
print(f"Max DD:  {bt['metrics']['max_drawdown']:.2%}")
print(f"Trades:  {bt['metrics']['trades']}")
print_signal(bt)

Step 5: Save Charts

save_equity_plot(bt, out_html="equity.html")
save_metrics_table(bt, out_html="metrics.html")
print("Done — open equity.html in your browser.")

You'll get an interactive Plotly chart showing the equity curve and position sizes over time, plus a styled metrics table with Sharpe, Sortino, max drawdown, VaR, and the current signal.

Full Code

Here's everything in one block you can copy-paste:

import yfinance as yf
from cobweb_py import CobwebSim, BacktestConfig, fix_timestamps, print_signal
from cobweb_py.plots import save_equity_plot, save_metrics_table

# 1. Grab SPY data
df = yf.download("SPY", start="2020-01-01", end="2024-12-31")
df.columns = df.columns.get_level_values(0)
df = df.reset_index().rename(columns={"Date": "timestamp"})
rows = df[["timestamp","Open","High","Low","Close","Volume"]].to_dict("records")
data = fix_timestamps(rows)

# 2. Connect (free, no key needed)
sim = CobwebSim("https://web-production-83f3e.up.railway.app")

# 3. Simple momentum: long when price > 50-day SMA
close = df["Close"].values
sma50 = df["Close"].rolling(50).mean().values
signals = [1.0 if c > s else 0.0 for c, s in zip(close, sma50)]
signals[:50] = [0.0] * 50

# 4. Backtest with realistic friction
bt = sim.backtest(data, signals=signals,
    config=BacktestConfig(exec_horizon="swing", initial_cash=100_000))

# 5. Results
print(f"Return:  {bt['metrics']['total_return']:.2%}")
print(f"Sharpe:  {bt['metrics']['sharpe_ann']:.2f}")
print(f"Max DD:  {bt['metrics']['max_drawdown']:.2%}")
print(f"Trades:  {bt['metrics']['trades']}")
print_signal(bt)
save_equity_plot(bt, out_html="equity.html")
save_metrics_table(bt, out_html="metrics.html")

What's Next

← Back to blog