A stop that lives in your bot is not a stop
When the process crashes, the server reboots or the connection drops, a stop that exists only in the bot's memory does not exist. The stop belongs on the exchange, at entry.
There are two kinds of stop. One is a number the bot remembers and checks on every tick. The other is an order resting on the exchange. They look the same in a backtest and behave nothing alike on a bad night.
What goes wrong with the first kind#
- The process crashes between the tick that mattered and the next one.
- The server reboots for an update.
- The network drops for ninety seconds, and a fast market uses all of them.
- The bot is busy retrying something else when the price crosses the line.
In every one of those cases the position sits there with no protection, and the bot cannot tell you it is unprotected because it is not running.
The rule#
Every directional position gets its stop placed on the exchange, with the entry. If the stop cannot be placed, the entry is not taken. If the exchange rejects the stop after the fill, the bot closes the position. It does not "try again later".
The stop's distance comes from volatility, not from a round number. Then the size follows from the stop: the loss if the stop is hit, fees included, is a fixed slice of the account.
from decimal import Decimal, ROUND_DOWN
def position_size(equity: Decimal, entry: Decimal, stop: Decimal, *,
risk_per_trade: Decimal = Decimal("0.005"),
taker_fee: Decimal = Decimal("0.0006"),
slippage: Decimal = Decimal("0.0005"),
qty_step: Decimal = Decimal("0.001"),
max_notional: Decimal | None = None) -> Decimal:
"""Size so that a stop-out costs at most equity * risk_per_trade,
counting fees on both legs and some slippage on the stop fill."""
if entry <= 0 or stop <= 0 or entry == stop:
return Decimal("0")
price_risk = abs(entry - stop) + stop * slippage # stops fill worse than the trigger
fees = (entry + stop) * taker_fee # pay on entry and on exit
qty = (equity * risk_per_trade) / (price_risk + fees)
if max_notional is not None:
qty = min(qty, max_notional / entry) # leverage / exposure cap
return (qty / qty_step).to_integral_value(rounding=ROUND_DOWN) * qty_stepAnd one gate for every order#
Every order, without exception, goes through a single function before it is sent. It returns the reasons to refuse, not just yes or no, so a refusal is something you can read in a log. An entry with no stop is a refusal.
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderIntent:
symbol: str
side: str # "buy" or "sell"
qty: float
price: float
stop: float | None
reduce_only: bool = False
def refusals(o: OrderIntent, *, equity: float, open_notional: float,
allowed: frozenset[str], max_leverage: float,
min_notional: float, breaker_tripped: bool) -> list[str]:
why, notional = [], o.qty * o.price
if o.side not in ("buy", "sell"):
why.append(f"unknown side {o.side!r}")
if breaker_tripped and not o.reduce_only:
why.append("breaker tripped: only reduce-only orders may pass")
if o.symbol not in allowed:
why.append(f"{o.symbol} is not on the allowlist")
if o.qty <= 0 or o.price <= 0:
why.append("quantity and price must be positive")
elif notional < min_notional:
why.append(f"notional {notional:.2f} is below the exchange minimum")
if not o.reduce_only:
if o.stop is None:
why.append("directional entry without a stop")
elif not (o.stop < o.price if o.side == "buy" else o.stop > o.price):
why.append("stop is on the wrong side of the entry")
if open_notional + notional > equity * max_leverage:
why.append("would exceed the gross leverage cap")
return whyNotice what the gate lets through even when everything else is stopped: a reduce-only order. A bot that cannot close a position because it is "halted" is worse than a bot with no breakers.
This is how trading software can be built. It is not investment advice. Trading crypto assets, especially with leverage, carries a risk of losing money, and results from tests or simulations do not guarantee anything in live markets. The studio builds trading software and tooling. It does not manage anyone's money, hold client funds or trade on anyone's behalf.
The best of a hundred coin flips looks like talent
Try enough strategies and one of them will look brilliant, even when every one is a coin toss. The number of tries is part of the result, and it is the part nobody prints.
Slippage is a fee nobody prints
The exchange prints its fee. It does not print the spread, the thin book or the stop that fills worse than its trigger. A test that leaves them out is flattering itself.
Start from the task, not the strategy
A trading bot project begins with what the bot is for, what it may lose and what it must never do. The signal comes last, and it is the smallest part of the work.