Home › Guides › Trading bots & code
Trading bot risk controls: the 7 guards to code before the strategy
A bot follows its rules perfectly — including the bad ones, at 3 a.m., a hundred times in a row. Risk controls are the rules that decide how much can go wrong before it stops.
Why the guards come first
Strategy code decides when to trade. Risk code decides how much can go wrong.
A person who has a bad day usually notices and stops. A bot doesn't notice anything. It needs every "stop now" written down in code.
Here are 7 guards. Each one is short. Together they're often longer than the strategy itself — and that's normal.
Example code for learning — test it on paper first; it is not a ready-made trading system.
Guard 1: Size from the stop
Every trade risks a fixed slice of the balance. The distance to the Stop loss sets the size. A hard cap stops a tiny stop from creating a huge order.
Guard 2: A daily loss limit
After −2R in a day, the bot is done until tomorrow. R means one unit of risk, the amount lost if a stop is hit. That's our 2-loss rule: two full losses and you step away. More in daily loss limit.
Guard 3: A cap on trades per day
A bug or a choppy market can fire signal after signal. A cap of, say, 5 trades a day keeps a loop from running wild.
Guard 4: A kill switch
One way to stop everything at once, without touching the code. Here it's a file: create a file called STOP next to the bot and it halts until a human resets it.
Here are guards 1 to 4 in one small class:
import os
class RiskGuard:
def __init__(self, balance, risk_pct=1.0, max_day_loss_r=2.0,
max_trades_day=5, max_units=10_000):
self.balance, self.risk_pct = balance, risk_pct
self.max_day_loss_r, self.max_trades_day = max_day_loss_r, max_trades_day
self.max_units = max_units
self.day, self.day_r, self.trades_today = None, 0.0, 0
self.halted = False # only a human turns this back off
def can_trade(self, today):
if today != self.day: # new day: reset the daily tallies
self.day, self.day_r, self.trades_today = today, 0.0, 0
if os.path.exists("STOP"):
self.halted = True
if self.halted:
return False, "kill switch is on"
if self.day_r <= -self.max_day_loss_r:
return False, "daily loss limit reached"
if self.trades_today >= self.max_trades_day:
return False, "trade cap for today reached"
return True, "ok"
def size(self, entry, stop):
per_unit = abs(entry - stop)
if per_unit <= 0:
return 0
units = int(self.balance * self.risk_pct / 100 // per_unit)
return min(units, self.max_units) # hard cap, whatever the math says
def record(self, r):
self.day_r += r
self.trades_today += 1
Try it:
from datetime import date
guard = RiskGuard(balance=10_000)
print(guard.size(entry=50.0, stop=49.0)) # 1% of 10,000 = 100, 1 per unit: 100
today = date(2026, 10, 21)
print(guard.can_trade(today)) # start of the day: (True, 'ok')
for r in (-1.0, -1.0): # two full losses
guard.record(r)
print(guard.can_trade(today)) # (False, 'daily loss limit reached')
print(guard.can_trade(date(2026, 10, 22))) # new day: (True, 'ok')
Two losses of −1R each make −2R, so the bot stops for the day. The next morning the tally resets. A math example, not a promise. To check the size math by hand, use the position size calculator.
Guard 5: Check the data is fresh
If the data feed freezes, a bot can keep "seeing" an old price and trade on it. Before every decision, check how old the last finished candle is.
from datetime import datetime, timedelta, timezone
def data_is_fresh(last_close_time, candle_minutes=60, now=None):
"""False if the newest finished candle is older than 2 candles."""
now = now or datetime.now(timezone.utc)
return now - last_close_time <= timedelta(minutes=2 * candle_minutes)
t = datetime(2026, 10, 21, 14, 0, tzinfo=timezone.utc)
print(data_is_fresh(t, now=t + timedelta(minutes=30))) # True
print(data_is_fresh(t, now=t + timedelta(hours=5))) # False: feed looks stuck
Use one time zone everywhere. UTC is the usual choice. Mixing local time and exchange time is a classic bot bug.
Guard 6: Retry safely — and never send an order twice
Networks drop. The nasty case: you send an order, it goes through, but the reply gets lost. A simple "try again" would buy twice.
The fix is your own order ID. Many brokers and exchanges let you attach one (often called a client order ID). Make it once, reuse it on every retry. If the broker already has that ID, it's the same order, not a new one.
Here's a fake broker that loses the first reply, so we can test the fix:
import logging, time, uuid
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("bot")
class FakeBroker:
"""Practice stand-in. Accepts the order, then 'loses' the first reply."""
def __init__(self):
self.orders, self.calls = {}, 0
def place_order(self, client_id, side, qty):
self.calls += 1
if client_id in self.orders: # seen this ID: same order, not a new one
return self.orders[client_id]
self.orders[client_id] = {"id": client_id, "side": side, "qty": qty}
if self.calls == 1:
raise ConnectionError("reply lost") # the order went through anyway
return self.orders[client_id]
def place_with_retries(broker, side, qty, tries=3, wait=0.1):
client_id = str(uuid.uuid4()) # made ONCE, reused on every retry
for attempt in range(1, tries + 1):
try:
order = broker.place_order(client_id, side, qty)
log.info(f"order ok {side} {qty} id={client_id[:8]}")
return order
except (ConnectionError, TimeoutError) as e:
log.warning(f"attempt {attempt} failed: {e}")
time.sleep(wait * attempt) # wait a bit longer each time
raise RuntimeError("gave up: check the account by hand")
broker = FakeBroker()
place_with_retries(broker, "buy", 100)
print("orders at broker:", len(broker.orders)) # 1, not 2
The first call "fails", the retry succeeds, and the broker still holds just one order. If every retry fails, the bot stops and asks for a human. It never guesses.
Guard 7: Sanity-check every order, and log it
Last line of defence. Before an order leaves, check it isn't absurd.
def order_looks_sane(qty, price, last_price, max_units=10_000, max_gap_pct=1.0):
if qty <= 0 or qty > max_units:
return False, "size out of range"
if abs(price - last_price) / last_price * 100 > max_gap_pct:
return False, "price too far from the market"
return True, "ok"
print(order_looks_sane(100, 50.10, 50.00)) # (True, 'ok')
print(order_looks_sane(100, 55.00, 50.00)) # (False, 'price too far from the market')
And log everything: every signal, every skipped trade and why, every order, every fill, every error. When something odd happens, the log is the only witness. Our trading journal guide explains what's worth writing down.
Two guards that live at the broker
Some protection shouldn't depend on your bot staying alive.
- Send the stop with the entry. A Bracket order puts the entry, stop and target at the broker in one go. If your bot crashes, the stop is still there.
- Lock down the keys. Turn off withdrawals on the API key and limit it to your own IP address where offered. See API key security for trading bots.
What risk controls can't do
They keep losses to sizes you chose. They don't make a strategy good.
Prices can also jump past a stop on news or over a weekend — a Gap. Then a loss can be bigger than 1R. That's another reason to keep risk per trade small and to sit out big reports. And a bot needs watching: check the log every day.
Test the guards on purpose. Create the STOP file. Feed it an old timestamp. Make the fake broker fail. Each guard should stop the bot and say why in the log. If one doesn't, fix it before anything else.
Common mistakes
- Doubling size after a loss. "Win it back" logic grows losses fast. Size stays fixed to the stop.
- Retrying orders blindly. Without your own order ID, a retry can double a position.
- A kill switch that needs the code. It should work in seconds, without a deploy.
- Stops only inside the bot. If the bot dies, so does the stop. Put it at the broker.
Questions
What is a kill switch in a trading bot?
A way to stop the bot at once without editing code — for example, a file, a button or a setting it checks before every trade. Once on, it stays on until a person turns it off.
How much should a trading bot risk per trade?
Many traders keep it around 1% of the account per trade, sized from the stop distance. Write the number into the code and cap the size too.
How do I stop my bot from placing duplicate orders?
Attach your own order ID, make it once, and reuse it on every retry. If the broker already has that ID, it treats it as the same order.
Do I need a daily loss limit if my bot uses stop losses?
Yes. Stops limit each trade. A daily limit stops a string of trades. Our rule is to stop for the day after 2 full losses.
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: