Home › Guides › Trading bots & code
Breakout bot in Python: code the level, the body-close rule and the stop
A breakout bot buys when price pushes past a recent high. The hard part isn't the buy. It's telling a real push from a quick poke — and code can check that the same way every time.
The idea in plain words
Price often stalls at the last place it turned around. That's a Resistance level. When price finally gets through it, that's a Breakout.
But many breakouts fail. Price pokes through, then falls straight back. That's a Fakeout.
Our rule for telling them apart is simple: only a candle body closing past the level counts. A tail (the thin line above the body) poking through doesn't. A bot is good at this, because it never "sees what it wants to see". The longer story is in breakout vs fakeout.
Example code for learning — test it on paper first; it is not a ready-made trading system.
Step 1: Practice data
import numpy as np
import pandas as pd
# 3,000 fake candles from a random walk. Made-up data, not a real market.
rng = np.random.default_rng(seed=5)
close = 100 + rng.normal(0, 0.5, 3000).cumsum()
open_ = np.r_[close[0], close[:-1]]
wiggle = np.abs(rng.normal(0, 0.3, 3000))
df = pd.DataFrame({
"open": open_,
"high": np.maximum(open_, close) + wiggle,
"low": np.minimum(open_, close) - wiggle,
"close": close,
})
Random prices, so nothing here is a real market. That's on purpose. It lets us test the code without any account or keys.
Step 2: Mark the level
The level is the highest high of the previous 20 candles.
level = df["high"].rolling(20).max().shift(1) # previous 20 candles, NOT this one
The shift(1) is easy to miss and it matters. Without it, the 20 candles include the current one. A candle can never close above its own high, so the bot would almost never fire — or, in other setups, it would use a level it couldn't have known yet.
Step 3: Body close vs poke
poked = (df["high"] > level) & (df["close"] <= level) # tail only
closed_past = (df["close"] > level) & (df["close"].shift(1) <= level.shift(1))
print("Pokes (tail only):", int(poked.sum()))
print("Body closes past the level:", int(closed_past.sum()))
- poked is True when the high went past the level but the close didn't. That's the fakeout shape.
- closed_past is True on the first candle that closes above the level. The second half — the candle before was not above — stops the bot buying the same breakout again and again.
Run it and you'll see plenty of both. The bot only acts on the second kind.
Step 4: Stop, target, and no look-ahead
stop_level = df["low"].rolling(10).min() - 0.05 # under the last turning point
df["enter"] = closed_past.shift(1, fill_value=False) # buy at the NEXT candle's open
df["stop"] = stop_level.shift(1) # the stop we could see at the close
The stop goes just under the lowest low of the last 10 candles — the last place price turned around. If price gets back there, the breakout idea is wrong.
Both shift(1) lines move the decision to the next candle. The close is only known when the candle ends. So the earliest real fill is the next open. Skip this and your test buys at prices that were already gone.
Step 5: Backtest in units of risk
def backtest(df, rr=2.0, fee=0.05):
"""One trade at a time. Results in R (1R = entry minus stop)."""
results, i, n = [], 0, len(df)
while i < n:
if not df["enter"].iloc[i]:
i += 1
continue
entry, stop = df["open"].iloc[i], df["stop"].iloc[i]
risk = entry - stop
if not risk > 0:
i += 1
continue
target, result = entry + rr * risk, None
for j in range(i, n):
if df["low"].iloc[j] <= stop: # stop first: the cautious choice
result = -1.0
break
if df["high"].iloc[j] >= target:
result = rr
break
if result is None:
j = n - 1
result = (df["close"].iloc[j] - entry) / risk
results.append(result - fee / risk) # spread + commission, in R
i = j + 1
return pd.Series(results)
r = backtest(df)
curve = r.cumsum()
print(f"Trades: {len(r)} Average: {r.mean():+.2f}R Total: {r.sum():+.1f}R")
print(f"Worst dip: {(curve - curve.cummax().clip(lower=0)).min():.1f}R")
R is one unit of risk: entry minus stop. +2R = the target was hit. −1R = the stop was hit. Each trade also pays the fee, turned into R.
The "worst dip" is the biggest fall from a high point, in R. That's the Drawdown — the number that tells you whether you could live with a rule.
What the numbers mean: nothing
These prices are random. No breakout rule has an edge on random data, so the total is luck.
Here's proof. With seed 5, our run happened to come out well ahead. We then ran seeds 1 to 9. The totals ranged from about −13R to +15R — same code, same rule, only the random numbers changed. One lucky run on real data can fool you just as easily.
Results on synthetic data mean nothing about real markets.
Use the run to check the plumbing:
- Does it fire only on body closes?
- Is every stop below the entry?
- Do fees come off every trade?
Then test on real past data, then on paper. Past results never promise future ones.
Size from the stop
Breakout stops can be wide, because the last turning point may be far below. That's why size comes from the stop, not from a fixed number of shares.
def position_size(balance, risk_pct, entry, stop):
risk_money = balance * risk_pct / 100
per_unit = entry - stop
return int(risk_money // per_unit) if per_unit > 0 else 0
print(position_size(10_000, 1, 105.0, 101.0)) # risk 100, 4 per unit: 25 units
Wide stop, smaller size. Tight stop, bigger size. The loss at the stop stays the same: 1% of 10,000 = 100. A math example, not a promise. The position size calculator does it for you.
Making it a bot
Two ways a breakout bot can enter. Pick one and say it in your plan.
| Way | How it works | Trade-off |
|---|---|---|
| Close, then buy | Wait for the candle to close past the level, then buy at market | Follows the body-close rule; the fill can be well above the level |
| Stop order above the level | A Stop order waits above the level and fills the moment price touches it | Fills on pokes too, so it buys fakeouts |
Our rule means the first one. The bot loop is the same shape as the moving average crossover bot: wake on each close, check, size, place the order on paper with the stop attached, log it.
Add the safety parts from trading bot risk controls: a 2-loss daily limit, a cap on trades per day, and a check that the data is fresh.
Watch the news calendar. Breakouts often happen on big reports, when spreads widen and prices jump. Many traders have the bot sit out around them. See how to read an economic calendar.
Common mistakes
- Including the current candle in the level. Shift the rolling high by one.
- Counting tails. A poke is not a close. Code the body-close rule.
- Buying the same breakout ten times. Only act on the first close past the level.
- One stop distance for every trade. Let the chart set the stop and the math set the size.
- No fees. Breakouts often fill at a worse price than the level. Count costs.
Questions
How do you code a breakout strategy in Python?
Mark the highest high of the previous candles with rolling max plus shift(1), flag the first candle that closes above it, then enter on the next candle's open with a stop under the last turning point.
How does a bot avoid fakeouts?
It can't avoid all of them. Requiring a candle body to close past the level filters out the quick pokes, but some closes still fail. That's why the stop comes first.
What is the best lookback for a breakout bot?
There isn't one. 20 candles is just an example. Choosing the number that looked best on past data usually fits noise, so test any choice on data you didn't tune on.
Should a breakout bot use stop orders?
Stop orders above the level fill faster but also fill on fakeout pokes. Waiting for a close is slower but follows the body-close rule. Write your choice into your plan.
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: