Skip to content
Market History

Backtest a moving-average strategy

A 50/200-day crossover on SPY, long or flat, over ten years of daily bars. The bars are asked for with adjust=all, so a close-to-close return includes the dividend and a split is not a crash. It runs in a notebook as it stands.

You need the Python client with the pandas and arrow extras, and a key with bought bars: 1d is not paid for by the free plan’s monthly bars. Ten years of SPY is under 3,000 bars, about 1.5 cents at the first band’s $5 per million. On a free account, use the hourly variant below.

import numpy as np
import pandas as pd
from markethistory import Client
mh = Client() # reads MARKET_HISTORY_KEY
bars = mh.frame(
"stocks", "SPY", "1d", start="2015-01-01", end="2024-12-31", adjust="all"
)
bars = bars[bars.index.dayofweek < 5] # drop the small Saturday bars of winter Fridays
close = bars["close"]

frame() follows the cursor for you and returns a DataFrame indexed by UTC timestamp. A 1d bar is a UTC day of minute bars, pre- and after-hours included; in winter a Friday’s last after-hours hour lands on Saturday UTC, hence the filter. Bars has the details.

fast = close.rolling(50).mean()
slow = close.rolling(200).mean()
signal = (fast > slow).astype(float) # 1 long, 0 flat, known at the close of day t
position = signal.shift(1) # held from that close to the next one: no look-ahead
returns = close.pct_change()
cost_bp = 5 # per unit of position traded
strategy = position * returns - position.diff().abs() * cost_bp / 1e4

The shift(1) is the whole difference between a backtest and a wish: the position earning day t+1’s return is the one decided with closes up to day t. Trading at the very close the signal was computed on is still an idealisation; shift(2) trades a day later instead.

result = pd.DataFrame({"crossover": strategy, "buy_and_hold": returns}).iloc[200:]
def summary(r: pd.Series) -> pd.Series:
equity = (1 + r).cumprod()
years = len(r) / 252
return pd.Series(
{
"CAGR": equity.iloc[-1] ** (1 / years) - 1,
"volatility": r.std() * np.sqrt(252),
"Sharpe (rf = 0)": r.mean() / r.std() * np.sqrt(252),
"max drawdown": (equity / equity.cummax() - 1).min(),
}
)
print(result.apply(summary).round(3))

.iloc[200:] starts both series once the 200-day average exists, so the two are measured over the same days. After the weekend filter one row is one session, hence 252 a year.

On an ex-dividend date the raw close drops by the dividend, and pct_change() books it as a loss; a 4-for-1 split shows as −75% in one day. adjust=all back-adjusts every earlier price for both, so the return series is a total return. To see the difference, fetch the same range with adjust="raw" and compare close.pct_change() on an ex-date from mh.corporate_actions("SPY").

An adjusted history is restated each time a new dividend lands. To reproduce a result later, keep the raw bars and the actions you used, or save the adjusted frame.

The free plan’s monthly bars pay for 1h, with adjust= too. Build regular-session daily closes in New York time from them:

hours = mh.frame(
"stocks", "SPY", "1h", start="2015-01-01", end="2024-12-31", adjust="all"
)
hours = hours.tz_convert("America/New_York").between_time("09:00", "15:00")
close = hours["close"].groupby(hours.index.date).last()
close.index = pd.to_datetime(close.index)

Each day’s close is then the last trade before 16:00 New York, and no weekend bar appears. Ten years is about 40,000 hourly bars, pre- and after-hours included, spent from the monthly free bars (your dashboard shows how many you have). Steps 2 and 3 run unchanged.