BlogDeveloper

Backtest a gold strategy with historical OHLC

Pull daily OHLC history from the goldprice.dev API and run a simple moving-average crossover backtest in Python to evaluate a gold trading strategy.

19 Jun 2026 · 4 min read

Backtest a gold strategy with historical OHLC

Before you act on any price signal, you want to know whether it would have worked historically. The /v1/prices/history endpoint provides daily OHLC: 30 days on Free, one year on Basic, and full history on Plus and Pro.

This post walks through fetching the history, computing a 50/200-day moving-average crossover, and evaluating the result: the classic "golden cross / death cross" signal applied to the metal itself. For a primer on what drives gold's price at a macro level, see What actually drives the gold price.

Fetching historical data

The history endpoint takes a symbol, a date range, and an interval:

Text
GET /v1/prices/history?symbol=XAU-USD-SPOT&from=2023-01-01&to=2026-06-01&interval=1d

The response is a series array of daily bars:

JSON
{  "series": [    { "date": "2023-01-02", "open": 1824.30, "high": 1831.50, "low": 1820.10, "close": 1829.40 },    ...  ]}

Here is the fetch code:

Python
import osimport requestsimport pandas as pdAPI_KEY = os.environ["GOLDPRICE_API_KEY"]BASE = "https://api.goldprice.dev"def fetch_ohlc(symbol: str, from_date: str, to_date: str) -> pd.DataFrame:    resp = requests.get(        f"{BASE}/v1/prices/history",        params={"symbol": symbol, "from": from_date, "to": to_date, "interval": "1d"},        headers={"Authorization": f"Bearer {API_KEY}"},        timeout=15,    )    resp.raise_for_status()    data = resp.json()    df = pd.DataFrame(data["series"])    df["date"] = pd.to_datetime(df["date"])    df = df.set_index("date").sort_index()    return dfdf = fetch_ohlc("XAU-USD-SPOT", "2021-01-01", "2026-06-01")print(df.tail())

You need requests and pandas. pip install requests pandas if you do not have them.

Computing the crossover

A moving-average crossover strategy generates a buy signal when a short-window average crosses above a long-window average, and a sell signal when it crosses back below. We use 50 and 200 days, which are widely watched levels.

Python
df["ma50"] = df["close"].rolling(50).mean()df["ma200"] = df["close"].rolling(200).mean()# Signal: 1 when ma50 > ma200, 0 otherwisedf["signal"] = (df["ma50"] > df["ma200"]).astype(int)# Position: difference of signal tells us when we crossdf["position"] = df["signal"].diff()# +1.0 = buy (golden cross), -1.0 = sell (death cross)crosses = df[df["position"] != 0][["close", "position"]].dropna()print(crosses.head(10))

Evaluating returns

We compare the strategy's return against simply holding gold for the same period.

Python
# Daily log returnsdf["log_return"] = (df["close"] / df["close"].shift(1)).apply(lambda x: x if pd.notna(x) else 1)import numpy as npdf["log_return"] = np.log(df["close"] / df["close"].shift(1))# Strategy return: only in market when signal == 1# Shift signal by 1 day to avoid look-ahead bias (we act on the next open)df["strategy_return"] = df["log_return"] * df["signal"].shift(1)# Cumulative returnsdf["cum_market"] = df["log_return"].cumsum().apply(np.exp)df["cum_strategy"] = df["strategy_return"].cumsum().apply(np.exp)final = df[["cum_market", "cum_strategy"]].dropna().iloc[-1]print(f"Buy-and-hold: {final['cum_market']:.2%}")print(f"MA crossover: {final['cum_strategy']:.2%}")

This gives you cumulative return multipliers. A value of 1.45 means 45% total return over the period.

Measuring risk

Raw return does not tell you much without knowing how volatile the path was. Annualized Sharpe ratio is a standard check:

Python
trading_days = 252strat_returns = df["strategy_return"].dropna()sharpe = (strat_returns.mean() / strat_returns.std()) * np.sqrt(trading_days)print(f"Annualized Sharpe: {sharpe:.2f}")# Max drawdowncum = df["cum_strategy"].dropna()rolling_max = cum.cummax()drawdown = (cum - rolling_max) / rolling_maxmax_dd = drawdown.min()print(f"Max drawdown: {max_dd:.2%}")

A Sharpe above 1.0 is generally considered acceptable. Max drawdown tells you the worst peak-to-trough loss the strategy experienced. That is the number that tests whether you would have held.

What to do with the results

A single backtest proves nothing. The 50/200 crossover is studied by enough people that any edge it had historically has likely been priced in. What this code gives you is a framework.

Swap in different window sizes (say, 20/50 or 10/100) and re-run. Add a filter: only take the long signal when the 200-day average is itself rising. Test on different sub-periods: how did the strategy behave during rate-hike cycles versus rate-cut cycles? Each question is a few lines of pandas.

The history endpoint supports any date range your plan covers. To get fresh data for a daily cron job:

Python
from datetime import date, timedeltatoday = date.today().isoformat()two_years_ago = (date.today() - timedelta(days=730)).isoformat()df = fetch_ohlc("XAU-USD-SPOT", two_years_ago, today)

Run this before your strategy logic and you always have an up-to-date dataset.

On data quality: the /v1/prices/history endpoint returns the same aggregated price used in live spot queries, computed from multiple sources. Because that aggregation can change over time as sources are added or adjusted, very old historical data and recently computed data may have subtle differences in methodology. For most backtesting purposes this does not matter, but if you are running a high-frequency strategy sensitive to exact price levels, verify that your historical assumptions match how the current aggregation works before drawing hard conclusions.

Get started at goldprice.dev/docs/quickstart. The free tier includes history access, and you can view current plan limits before upgrading.

Live gold prices, historical OHLC, and multi-source aggregation, available via REST and SSE.

Keep reading

Start with a free API key

5,000 requests a month on the free plan. No credit card.

Get a free key