Tasilab
Home Pricing Docs EN · عربي Sign in Start free →
Home / Guides / First trading bot

Build your first trading bot.

A trading bot is just a loop: watch the price, decide, act. Here's a complete one for the Saudi Exchange in about 30 lines of Python — and because it trades paper, you can run it live with zero risk.

We'll build a moving-average crossover bot on Saudi Aramco (2222): track a short (5-period) and long (20-period) average of the price, buy when the short crosses above the long (a "golden cross"), and sell when it crosses back below (a "death cross"). It's the oldest signal in technical trading precisely because it's simple to reason about — the short average reacts to price faster than the long one, so a crossover is a rough, mechanical way of saying "the recent trend just changed direction." It runs against your paper portfolio on Tasilab, so no real order is ever placed.

Setup

Terminal
pip install tasilab
export TASILAB_API_KEY="your-api-key"

Don't have a key yet? Sign up free — you get one plus SAR 100,000 in paper capital.

The bot

sma_bot.py
import os
import time
from collections import deque
from tasilab import Tasilab

SYMBOL, SHORT, LONG, QTY, POLL = "2222", 5, 20, 100, 30

tasi = Tasilab(api_key=os.environ["TASILAB_API_KEY"])
prices = deque(maxlen=LONG)

# Are we already holding this symbol?
in_position = any(p["symbol"] == SYMBOL and p["quantity"] > 0
                  for p in tasi.positions())

while True:
    price = float(tasi.get_quote(SYMBOL)["price"])
    prices.append(price)

    if len(prices) < LONG:                       # warm up first
        print(f"collecting… {len(prices)}/{LONG}")
        time.sleep(POLL)
        continue

    short_ma = sum(list(prices)[-SHORT:]) / SHORT
    long_ma  = sum(prices) / LONG

    if short_ma > long_ma and not in_position:
        order = tasi.buy(SYMBOL, quantity=QTY)    # paper market order
        in_position = True
        print("BUY ", price, "->", order["status"])
    elif short_ma < long_ma and in_position:
        order = tasi.sell(SYMBOL, quantity=QTY)
        in_position = False
        print("SELL", price, "->", order["status"])

    time.sleep(POLL)

Run it with python sma_bot.py. It polls the quote every 30 seconds, keeps the last 20 prices, and acts only on a crossover. Every fill applies the real 0.155% commission plus 15% VAT and lands in your paper wallet.

What each part is doing

The deque(maxlen=LONG) is the bot's entire memory — a fixed-length rolling window that automatically drops the oldest price as a new one comes in, so prices always holds exactly the last 20 quotes with no manual trimming logic. The in_position line right after it matters more than it looks: it asks Tasilab what you're actually holding before the loop starts, instead of assuming you're starting flat. Restart the script after it already bought, and without that line it would try to buy again on the next golden cross — a duplicate order the loop should never place. The warm-up branch (if len(prices) < LONG) exists because an average of fewer than 20 points isn't the 20-period average yet; it just collects quietly until it has enough data to mean anything.

Once warmed up, every iteration computes both averages fresh and checks exactly two conditions: cross up while flat → buy, cross down while holding → sell. Nothing else happens on any other iteration — no signal means no action, which is correct; a crossover strategy doesn't have an opinion between crossovers.

What you'll see

For the first LONG polls (10 minutes at the default 30-second interval), the console just prints the warm-up counter — collecting… 1/20, collecting… 2/20, and so on. After that, most iterations print nothing at all, because most iterations aren't a crossover. When one does fire, you'll see a line shaped like BUY <price> -> <status> or SELL <price> -> <status>, where <status> comes straight back from the order response — filled for a market order during trading hours, or pending if you placed it outside TASI's Sunday–Thursday, 10:00–15:00 AST window (Tasilab queues it and fills at the next open rather than rejecting it).

Backtest before you run it. A bot that trades is only as good as the idea behind it. Before you let this loop, test the same rule against history with the backtesting guide — you'll often find the naive crossover loses to buy-and-hold, which is exactly the kind of thing you want to learn on paper, not with money.

Why a naive crossover isn't a strategy yet

Two well-known weaknesses come with any crossover signal, and this bot has both of them. The first is lag: a moving average is, by definition, a summary of the past, so a crossover confirms a trend only after it has already been underway for several bars. You're never buying the bottom or selling the top with this kind of rule — you're buying confirmation, which costs you the first leg of the move every time.

The second is whipsaw: in a sideways, range-bound market the short and long averages drift back and forth across each other repeatedly with no real trend behind it, generating a buy, then a sell a few bars later, then a buy again — each one paying the real commission and VAT this bot applies, with no net move in the underlying price to show for it. A trending market is where this shape of strategy earns its keep; a choppy one is where it bleeds fees. That's precisely why the callout above exists: the only way to know which regime a given window falls into is to run the numbers, not guess from a chart.

Just as important is what this minimal version deliberately leaves out: no stop-loss, no take-profit, and no position sizing beyond a flat QTY = 100 regardless of account size or conviction. That's a reasonable starting point for learning the loop shape — it is not a reasonable shape for anything you'd run unattended for real money, paper or otherwise.

Making it production-shaped

Four changes turn this from a teaching example into something closer to a bot you'd actually leave running.

1. Trade the calendar

The loop above polls around the clock, including nights, Fridays, and Saturdays when TASI is closed and every quote is just yesterday's closing price repeated. Guard the trading logic with the market status the API already computes for you:

Python
status = tasi.market_status()
if not status["is_open"]:
    time.sleep(POLL)
    continue

2. Size positions from real cash, not a constant

A fixed QTY = 100 means the same order size whether your account has grown or shrunk. Pull the live cash balance instead — note the nesting: portfolio() returns a summary envelope with the wallet itself under a "portfolio" key:

Python
cash = float(tasi.portfolio()["portfolio"]["current_cash"])
qty = int((cash * 0.10) // price)   # risk 10% of cash on this entry
if qty > 0:
    order = tasi.buy(SYMBOL, quantity=qty)

3. Log every run as an experiment

A bot you can't compare against its own past versions is hard to improve. Wrap a run in create_experiment the same way the backtesting guide does for a historical run, and log each live fill as it happens:

Python
from datetime import date

today = date.today().isoformat()
exp = tasi.create_experiment(
    name="SMA 5/20 live bot — Saudi Aramco",
    symbol=SYMBOL, start_date=today, end_date=today,
    parameters={"short": SHORT, "long": LONG, "qty": QTY},
)
# ... inside the loop, right after a fill:
exp.log_trade(side="BUY", symbol=SYMBOL, price=price, quantity=QTY, date=today)

Do this across a few weeks of live paper runs and you get the same Experiments dashboard — parameters, trade list, equity curve — that the backtesting guide uses to compare strategies, except built from what the bot actually did instead of a simulation.

4. Swap the signal

Nothing about the loop shape is specific to a moving-average crossover — it's "compute an indicator from the price stream, act on a rule." Replace the two sum(...) / period lines with an RSI, a MACD histogram, or a Bollinger Band width, and the buy/sell/in_position plumbing around it stays exactly the same.

Running it somewhere it won't just stop

A while True loop in a foreground terminal dies the moment you close the laptop lid or the SSH session drops — worth knowing before you point it at anything you intend to leave running for a few days. The usual fix doesn't need to be complicated: run it inside tmux or screen so the process survives a disconnected terminal, or hand it to a real process supervisor (systemd on Linux, or a small supervisord config) if you want it to also restart automatically after a crash. Whichever you pick, keep the API key out of the code the same way the setup step above already does — an environment variable, never a string literal in the script — so the bot is safe to keep in version control.

Redirect stdout to a file (python sma_bot.py >> bot.log 2>&1 &) if you're running it unattended, so the warm-up counter and every BUY/SELL line are there to review later instead of scrolling past in a terminal you've already closed.

FAQ

Does this bot place real orders?

No. tasi.buy() and tasi.sell() only ever place paper orders against your simulated Tasilab portfolio — nothing reaches the real Saudi Exchange. That's what makes it safe to run continuously while you're still learning whether the strategy itself is any good.

What happens if the script crashes and I restart it?

The in_position line at the top re-derives your current holding from tasi.positions() instead of assuming it's zero, so a restart picks up where you left off rather than risking a duplicate buy. The rolling prices window does reset empty, though — the bot re-enters its warm-up phase and won't act on a crossover until it's collected 20 fresh quotes again.

Why 30-second polling instead of something faster?

A 5/20-period moving-average crossover on daily-relevant price movement doesn't need sub-second reaction time — polling faster mostly just spends more API requests without changing when the signal actually fires. 30 seconds is a reasonable default for this signal shape; a much shorter-period strategy would need to poll more often to see its own signal in time.

Can I run more than one of these at once, on different symbols?

Yes — each is just a Python process with its own SYMBOL, and Tasilab's positions and orders are tracked per symbol, not exclusively one-at-a-time. Running several means several independent polling loops (one process per symbol is simpler to reason about than one process juggling many), each holding its own prices deque and in_position flag.

How do I stop it cleanly?

Ctrl+C in the foreground, or kill the process if it's running under a supervisor — there's no in-flight state to corrupt, because every order is placed synchronously and confirmed before the loop moves on. The one thing worth doing before you stop it for good: check tasi.positions() to see whether it's currently holding a position, since stopping the bot doesn't close out whatever it's already bought.

Get your API key

Free account, an API key, and SAR 100,000 in paper capital — then point the bot at it.

Start free →
Tasilab

An API-native paper-trading sandbox for the Saudi Exchange. Trade with discipline.

Product
  • Paper trading
  • Tadawul API
  • Python SDK
  • Documentation
Guides
  • Backtest in Python
  • Historical data
  • First trading bot
Legal
  • Terms of use
  • Privacy
  • Disclaimer

Tasilab is a simulation-only paper-trading environment. No real orders are placed on the Saudi Exchange. Tasilab is not licensed by the Capital Market Authority and does not provide investment advice.

© 2026 Tasilab