# Backtest a moving-average strategy

> A 50/200-day crossover on SPY with pandas, on daily bars adjusted for splits and dividends.

Source: https://getmarkethistory.com/docs/guides/moving-average

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](https://getmarkethistory.com/docs/quickstart#4-into-pandas) 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](#on-a-free-account) below.

## 1. Fetch adjusted daily bars

```python
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](https://getmarkethistory.com/docs/bars) has the details.

## 2. Signal, position, returns

```python
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.

## 3. Compare with buy and hold

```python
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.

## Why `adjust=all`

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")`](https://getmarkethistory.com/docs/corporate-actions).

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.

## On a free account

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

```python
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.
