GUIDE · PYTHON

Backtest Saudi stocks in Python

A 50/200 moving-average crossover on Tadawul daily bars, from API key to equity curve, in about forty lines. Everything below runs on the free tier with three symbols: Saudi Aramco (2222), Al Rajhi Bank (1120) and stc (7010).

Published 23 October 2026 · every endpoint and parameter on this page is live today.

Free covers three symbols plus the TASI index, 500 requests a day, and needs no card. One symbol per request is one request.

1. Get a key

Sign up at app.tasi-api.com/signup, copy the Market Data key, and keep it out of source control:

export TASI_API_KEY="your-key"
pip install requests pandas matplotlib

2. Pull daily bars

The window defaults to the last 30 days when you omit from and to, so pass both explicitly. limit defaults to 500 and caps at 5000 bars.

import os, requests, pandas as pd

BASE = "https://api.tasi-api.com/api/v1"
H = {"X-API-Key": os.environ["TASI_API_KEY"]}

r = requests.get(f"{BASE}/history/2222",
                 params={"interval": "1d", "from": "2021-10-01",
                         "to": "2026-10-22", "limit": 2000},
                 headers=H, timeout=20)
r.raise_for_status()
body = r.json()

# The response echoes the window it actually served. Read it instead of
# assuming your requested range was honoured.
print(body["symbol"], body["count"], body["from"], body["to"])

df = pd.DataFrame(body["data"]).assign(date=lambda d: pd.to_datetime(d["t"]))
df = df.set_index("date").sort_index()
print(df[["open", "high", "low", "close", "volume"]].tail())

Bars come back oldest first, each with t, open, high, low, close, volume, value and quality. quality is final or provisional — a provisional daily bar can still be revised, so filter on it before you publish a number.

The same call works for 1120 and 7010. Any other symbol answers 403 symbol_not_entitled on the free tier; the body names the symbol and the upgrade path.

3. The strategy

df["sma50"]  = df["close"].rolling(50).mean()
df["sma200"] = df["close"].rolling(200).mean()
df["pos"]    = (df["sma50"] > df["sma200"]).astype(int).shift(1)   # trade the next bar
df["ret"]    = df["close"].pct_change()
strategy     = df["pos"] * df["ret"]

shift(1) is the whole point: without it you trade on the same close that produced the signal, which is look-ahead bias, and every number you print afterwards is inflated.

4. Results

equity = (1 + strategy.fillna(0)).cumprod()
years  = (df.index[-1] - df.index[0]).days / 365.25
cagr   = equity.iloc[-1] ** (1 / years) - 1
dd     = (equity / equity.cummax() - 1).min()
print(f"CAGR {cagr:.1%}  MaxDD {dd:.1%}")

equity.plot(title="SMA 50/200 — 2222")

Run it, look at the shape of the curve, then change one thing at a time. A backtest that only ever gets better as you tune it is fitting the sample, not the market.

5. What this backtest does not model

Treat the output as a check that your data pipeline is honest, not as evidence of an edge.

6. Errors you will hit

StatusCodeCause
400invalid_intervalAnything outside 1m, 5m, 15m, 30m, 1h, 1d
400invalid_rangefrom later than to, or an unparseable date
403symbol_not_entitledSymbol outside the plan — free is 2222, 1120, 7010, TASI
404symbol_not_foundSymbol is not in the instrument table
429rate_limitedFree daily cap reached; Retry-After says when it resets

7. Next

Run the same signal against a paper portfolio instead of a DataFrame — see Paper Trading. For the data-side details (intervals, request window, CSV export) read TASI historical data, and for the full contract see the history endpoint. When the backtest becomes a running system, the two calls that replace the download are live quotes and the WebSocket.

Get a key and run this today.

Free covers three Saudi stocks plus the index — no card, no expiry.