← blog  /  bots

A stop that lives in your bot is not a stop

2026-10-20  ·  3 min read  ·  bots

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.

Illustrative: position size from a risk budget, fees and slippage includedpython
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_step

And 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.

Illustrative: a pre-trade gate that explains itselfpython
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 why

Notice 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.

not advice

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.