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.
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
- Commission and slippage — Tadawul charges both, the script charges neither.
- Dividends and any corporate action.
- Survivorship: you are testing names that are listed today.
- Fills at the close, not at the next open.
- Liquidity: a paper fill in a thin name is not a real fill.
Treat the output as a check that your data pipeline is honest, not as evidence of an edge.
6. Errors you will hit
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_interval | Anything outside 1m, 5m, 15m, 30m, 1h, 1d |
| 400 | invalid_range | from later than to, or an unparseable date |
| 403 | symbol_not_entitled | Symbol outside the plan — free is 2222, 1120, 7010, TASI |
| 404 | symbol_not_found | Symbol is not in the instrument table |
| 429 | rate_limited | Free 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.