Home › Guides › Trading bots & code
Moving average crossover bot in Python: from one rule to a running loop
A moving average crossover is the "hello world" of trading bots. Two lines, one rule. Building it teaches the parts every bot needs: a signal, a size, a limit and a log.
The rule in plain words
A Moving average is the average closing price of the last few candles. A short one (say 20 candles) reacts fast. A long one (say 50) moves slowly.
The crossover rule:
- When the fast line crosses above the slow line, buy.
- When it crosses back below, get out.
It's popular because it's simple to code. It also has two well-known weak spots, and we'll look at both. More on the lines themselves in moving averages explained.
Example code for learning — test it on paper first; it is not a ready-made trading system.
What a bot needs (beyond the rule)
The signal is the smallest part. A bot that only has a signal is missing the parts that keep it safe:
- A signal that reads only finished candles.
- A size worked out from the Stop loss, so each loss is a known amount.
- A daily limit that stops trading after a bad day.
- A log of every order, so you can check what it did.
We'll build all four. Everything runs on made-up prices, so you need no account and no keys.
Step 1: Practice data
import numpy as np
import pandas as pd
# 2,000 fake hourly candles from a random walk. Made-up data, not a real market.
rng = np.random.default_rng(seed=11)
close = 100 + rng.normal(0, 0.5, 2000).cumsum()
open_ = np.r_[close[0], close[:-1]]
wiggle = np.abs(rng.normal(0, 0.3, 2000))
df = pd.DataFrame({
"open": open_,
"high": np.maximum(open_, close) + wiggle,
"low": np.minimum(open_, close) - wiggle,
"close": close,
}, index=pd.date_range("2026-01-05", periods=2000, freq="h"))
Each row is one hour. The index holds the time, which we need for the daily limit.
Step 2: The signal, on finished candles only
def crossover_signal(closes, fast=20, slow=50):
"""closes = finished candles only. Returns 'buy', 'sell' or 'hold'."""
f = closes.rolling(fast).mean()
s = closes.rolling(slow).mean()
if f.iloc[-2] <= s.iloc[-2] and f.iloc[-1] > s.iloc[-1]:
return "buy"
if f.iloc[-2] >= s.iloc[-2] and f.iloc[-1] < s.iloc[-1]:
return "sell"
return "hold"
In plain words: compare the last two finished candles. If the fast line was below or level, and now it's above, that's a cross up.
Why "finished"? A candle that's still forming can cross and then uncross before it closes. A bot that acts mid-candle trades signals that later vanish. Same idea as our body-close rule: only a closed candle counts.
Step 3: Size from the stop
def position_size(balance, risk_pct, entry, stop):
"""Units to buy so that hitting the stop costs risk_pct of the balance."""
risk_money = balance * risk_pct / 100
per_unit = abs(entry - stop)
return int(risk_money // per_unit) if per_unit > 0 else 0
print(position_size(10_000, 1, 100.0, 98.0)) # 100 risk / 2 per unit = 50 units
Risk 1% of 10,000 = 100. The stop is 2.00 away. So 100 ÷ 2 = 50 units. A math example, not a promise. The position size calculator does the same sum, and how much to risk per trade explains the 1%.
Step 4: The bot loop
A live bot wakes up when a candle closes, looks, and maybe acts. Here we fake that by walking through the data one candle at a time. The bot only ever sees candles that have finished.
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("ma-bot")
balance, risk_pct, fee = 10_000.0, 1.0, 0.05 # fee = cost per unit, in price
position, day_r, all_r = None, {}, []
def close_trade(pos, price, when, why):
global balance
r = (price - pos["entry"] - fee) / pos["risk"] # result in units of risk
balance += (price - pos["entry"] - fee) * pos["qty"]
day_r[when.date()] = day_r.get(when.date(), 0) + r
all_r.append(r)
log.info(f"{when} SELL {pos['qty']} @ {price:.2f} ({why}) {r:+.2f}R")
return None
for t in range(60, len(df)):
closed, now = df.iloc[:t], df.iloc[t] # finished candles / the next one
signal = crossover_signal(closed["close"])
if position and signal == "sell":
position = close_trade(position, now["open"], now.name, "cross down")
if position is None and signal == "buy" and day_r.get(now.name.date(), 0) > -2:
risk = 2 * (closed["high"] - closed["low"]).tail(14).mean()
entry = now["open"]
qty = position_size(balance, risk_pct, entry, entry - risk)
if qty > 0:
position = {"entry": entry, "risk": risk, "qty": qty,
"stop": entry - risk, "target": entry + 2 * risk}
log.info(f"{now.name} BUY {qty} @ {entry:.2f} stop {entry - risk:.2f}")
if position and now["low"] <= position["stop"]:
position = close_trade(position, position["stop"], now.name, "stop")
elif position and now["high"] >= position["target"]:
position = close_trade(position, position["target"], now.name, "target")
r = pd.Series(all_r)
print(f"{len(r)} trades, average {r.mean():+.2f}R, total {r.sum():+.1f}R")
What each part does:
- close_trade works out the result in R (one R = the distance from entry to stop), takes off the fee, updates the balance and the day's tally, and logs the order.
- The sell check exits at the next open when the lines cross back down.
- The buy check only fires if the bot is flat and today's tally is above −2R. That's a daily loss limit, our 2-loss rule in code.
- The stop and target checks run on every candle, stop first. If a candle touches both, we count the loss. That's the cautious choice.
Reading what it printed
You'll see a line for every buy and sell, then a summary in R.
Ignore the total. This data is random, so no rule can have a real edge on it. Change the seed and the total changes. Results on synthetic data mean nothing about real markets. The point of this run is to check the plumbing: does it size right, stop right, log right?
Want to see if your loop cheats? Run it on random data many times. If it "wins" on average, something is peeking at the future. See how to backtest in Python.
The two weak spots of crossovers
1. They're late. An average is built from past prices, so it turns after price turns. By the time the lines cross, much of the move may be over.
2. They chop in ranges. When price goes sideways, the lines cross back and forth. Each cross is a new trade, a new fee and often a small loss. This is called Chop.
That's why many traders add a filter: only take crosses in the direction of the bigger chart's Trend. A trend means higher highs and higher lows. Check the bigger chart first, then trade with it. See multiple timeframe analysis.
From practice loop to paper trading
To connect this to a broker, you'd swap two things:
- The data: instead of slicing df, fetch the latest finished candles from the broker.
- The orders: instead of log.info, send an order to a paper account — and still log it.
Everything else stays: the size, the limit, the stop check. Do that only on a paper account first. Paper trading a bot walks through it, and trading bot risk controls adds a kill switch and error handling.
Keep the log forever. Every order line is a row in your journal. When the bot surprises you, the log tells you why.
Common mistakes
- Acting on the forming candle. Signals flicker until the close.
- A fixed size. Buying "100 units" every time means every loss is a different amount. Size from the stop.
- No daily limit. A choppy day can mean many crosses in a row.
- Tuning the two numbers until the past looks great. 20/50 isn't magic, and neither is any pair you find by trying 100 of them.
Questions
Does a moving average crossover strategy work?
No rule works all the time. Crossovers tend to catch long trends late and lose small amounts in sideways markets. Test any version on past data and paper first, with fees.
What are the best moving averages for a crossover?
There's no best pair. 20/50 and 50/200 are common examples. Picking the pair that looked best on past data is a classic way to fit noise.
Can I run this bot on a real account?
Not as is. It's learning code on made-up data. A real bot needs a broker connection, error handling and weeks of paper trading first.
Should a bot trade on the forming candle or the closed candle?
The closed candle. A forming candle can cross and uncross before it closes, so the signal may disappear.
Want the next step? We'll walk you through it.
Four free lessons by email, one every few days — each with a 10-minute exercise on a real chart.
Send Me the Free Lessons 4 short emails · No spam · Unsubscribe anytimeOr see the whole method, step by step:
See What’s Inside the Course Try everything free first. Decide when you're ready. · Open the free chart tool (works in the TradingView phone app)One chart idea a day, in plain English. Follow along: