trading bots, built to a task.
We build trading bots and the tooling around them, and fit each one to the job: a trend follower, a funding carry, a ladder, a watcher. This page is about how we build them. The code pieces are illustrative, written for this site.
Not investment advice. Trading crypto assets, especially with leverage, carries a risk of loss. Results from tests or simulations do not guarantee anything in live markets.
one bot per task, not one bot for everything
Different jobs need different bots, and each type fails in its own way. We start from the job and the failure we can afford, then pick the shape.
| Type | What it is for | Main risk |
|---|---|---|
| Trend and breakout | Riding sustained moves once price leaves a range | Whipsaw: false breakouts bleed money through stops and fees |
| Mean reversion | Fading stretched moves back toward an average | The stretch is a new trend and the signal keeps saying "buy more" |
| Funding rate carry | Long spot, equal short perpetual, collecting funding while it is positive | Funding flips, the basis moves, a thin short leg gets squeezed |
| DCA and martingale ladders | Scaling into a position in rungs as price moves against the entry | Concentrated tail risk when the ladder has no ceiling |
| Grid | Resting orders at fixed steps, harvesting back and forth movement | Price leaves the range and inventory piles up on the wrong side |
| Coin toss control group | A baseline with random entries and the same sizing, stops and fees | None in the logic. The risk is skipping it and fooling yourself |
| Announcement and listing watcher | Reacting to exchange announcements on predefined rules | Latency, thin books, misreading an announcement |
| Treasury collector | Sweeping dust and leftovers into the trading currency | Selling into a thin book, or converting something meant to be held |
Exchange examples on this site use the shape of public exchange APIs, such as Bybit's, to keep things concrete. No partnership or endorsement is implied.
nine habits that every bot gets
- Start from the task. What is the bot for, what may it lose, what must it never do? Those answers become numbers and code paths before any signal exists. (more)
- The stop comes first and lives on the exchange. Size follows from the stop distance and a fixed risk budget, fees included. No stop, no entry. (more)
- One gate for every order. It returns reasons, not just yes or no, and it never blocks an order that only reduces a position.
- A timeout is a third state. Every order carries its own id, a retry looks the id up first, and the bot compares its book with the exchange every cycle. (more)
- Costs are in every test. Fees, spread and slippage, decisions on closed bars, fills at the next open. (more)
- Beat a coin toss, then beat the future. Same machinery with random entries, walk-forward windows, and a count of how many variants were tried. (coin toss, walk-forward)
- Test against a fake exchange that fails on command, then break your own safety code on purpose. (more)
- Soft pauses clear themselves, hard halts need a person who writes down why they re-armed. (more)
- Demo or testnet before real funds, always.
illustrative code pieces
The functions below were written for this page to show the shape of an idea. They are not taken from a particular bot, and their numbers are placeholders, not recommendations.
the stop comes first, the size follows
Position size is derived from how far away the stop is and how much of the account we accept to lose there, fees included.
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_stepone gate for every order
Every order passes one function that lists its reasons for refusing. An entry without a stop is a refusal. Reducing a position never is.
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 whya ladder with a ceiling
The martingale shape, with one shared stop and a fixed risk budget over the whole ladder.
def dca_ladder(entry: float, stop: float, risk_budget: float, *, rungs: int = 4,
spacing: float = 0.02, mult: float = 1.6, fee_rate: float = 0.0006):
"""Long ladder: rung k rests spacing*k below entry; every rung shares ONE stop.
Rung k gets a slice of the risk budget proportional to mult**k * (loss per unit
from rung k to the stop). Sizes grow geometrically, yet if every rung fills and
the stop hits, the total loss equals risk_budget, never more."""
prices = [entry * (1 - spacing * k) for k in range(rungs)]
if prices[-1] <= stop:
raise ValueError("the deepest rung must sit above the stop")
loss_per_unit = [p - stop + (p + stop) * fee_rate for p in prices]
weights = [mult ** k * d for k, d in enumerate(loss_per_unit)]
total = sum(weights)
ladder = []
for k, (p, d, w) in enumerate(zip(prices, loss_per_unit, weights)):
risk_k = risk_budget * w / total
ladder.append({"rung": k, "price": p, "qty": risk_k / d, "risk_at_stop": risk_k})
return ladderlook up before you retry
A timeout means unknown, not failed. One decision can never become two orders.
import time
import uuid
class OrderStateUnknown(Exception):
"""Could not prove whether the order exists. Caller must halt new entries."""
class SendTimeout(Exception): ...
class DuplicateClientId(Exception): ...
def send_once(client, order: dict, attempts: int = 3, backoff_s: float = 0.5) -> dict:
"""At most one live order per client_order_id (orderLinkId on Bybit V5).
A timeout means 'unknown', not 'failed': look the id up before every retry."""
cid = order.setdefault("client_order_id", "ord-" + uuid.uuid4().hex[:20])
for attempt in range(attempts):
if attempt:
time.sleep(backoff_s * 2 ** (attempt - 1))
found = client.find_order(order["symbol"], cid) # open orders + recent history
if found is not None:
return found # it landed after all
try:
return client.create_order(**order)
except (SendTimeout, DuplicateClientId):
continue # unknown, or already exists: resolve it by lookup
found = client.find_order(order["symbol"], cid)
if found is not None:
return found
raise OrderStateUnknown(cid)two kinds of halt
A soft pause waits for a time. A hard halt waits for a person, who has to say why.
import logging, time
from enum import Enum
class State(Enum):
RUNNING, PAUSED, HALTED = "running", "paused", "halted"
class CircuitBreaker:
def __init__(self, daily_loss=0.02, drawdown=0.10, streak=6, api_errors=20, cooldown_s=3600):
self.lim = dict(daily_loss=daily_loss, drawdown=drawdown, streak=streak, api_errors=api_errors)
self.cooldown_s, self.state, self.reason, self.until = cooldown_s, State.RUNNING, "", 0.0
def check(self, day_pnl: float, drawdown: float, streak: int, api_errors: int) -> State:
if self.state is State.HALTED:
return self.state # never clears itself
if drawdown >= self.lim["drawdown"] or api_errors >= self.lim["api_errors"]:
return self._trip(State.HALTED, f"drawdown={drawdown:.3f} api_errors={api_errors}")
if -day_pnl >= self.lim["daily_loss"] or streak >= self.lim["streak"]:
return self._trip(State.PAUSED, f"day_pnl={day_pnl:.3f} streak={streak}")
if self.state is State.PAUSED and time.monotonic() >= self.until:
self.state, self.reason = State.RUNNING, "" # condition gone + cooled down
return self.state
def _trip(self, state: State, reason: str) -> State:
if self.state is not state:
logging.warning("breaker -> %s: %s", state.value, reason)
self.state, self.reason = state, reason
self.until = time.monotonic() + self.cooldown_s
return self.state
def rearm(self, operator: str, note: str) -> None:
if not operator or not note:
raise ValueError("re-arming needs a named operator and a written reason")
logging.warning("re-armed by %s (%s); was: %s", operator, note, self.reason)
self.state, self.reason = State.RUNNING, ""no look-ahead in the simulation
The strategy sees closed bars only, and an order decided at a close fills at the next open.
def backtest(bars, decide, cash: float, fee_rate=0.0006, slippage=0.0002):
"""bars: oldest-first dicts with 'open' and 'close'.
decide(closed_bars) returns a target position in units.
A decision taken at the close of bar i fills at the OPEN of bar i+1."""
pos, target, curve = 0.0, None, []
for i, bar in enumerate(bars):
if target is not None and target != pos:
delta = target - pos
px = bar["open"] * ((1 + slippage) if delta > 0 else (1 - slippage))
cash -= delta * px + abs(delta) * px * fee_rate
pos = target
target = decide(bars[: i + 1]) # sees closed bars only, never bar i+1
curve.append(cash + pos * bar["close"])
return curve # the final decision has no next open: droppedwhat we do not do
- We do not manage anybody's money, hold client funds or trade on anyone's behalf.
- We do not sell signals and we do not run copy trading.
- We do not promise a return. Nothing on this site is a forecast.
- We do not use tricks to get around an exchange's regional rules.
Nothing on this site is investment advice. Trading crypto assets, especially derivatives, can lose all of the money put into it. Past and simulated results do not guarantee future results.
about the bots
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.
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.
"Did my order fill?": designing for the unknown
A timeout is not a failure. It is a third state between success and failure, and the safe answer to "still unknown" is to stop opening new positions.
The fee line eats the grid
Grid and DCA bots advertise small, steady profits. Each step's gross edge is often smaller than the round trip fees plus the spread, and a backtest without fees can get the sign of the result wrong.
Testing a trading bot against a fake exchange
The dangerous code paths in a trading bot are the rare ones: timeouts, partial fills, rejects, duplicate ids. A fake exchange that fails on command lets you hit each of them on purpose, every time the tests run.
Why every strategy has to beat a coin toss
Run the same sizing, stops and fees with seeded random entries across many seeds and you get a distribution of luck. A strategy is interesting only if it clearly sits outside it.
Walk-forward testing against in-sample optimization
Optimizing on all of the history finds the best past, not a good future. Fit on one window, judge on the next untouched one, roll forward, and count how many variants you tried.
Kill switches people actually re-arm
Soft pauses and hard halts: which conditions may clear themselves, which need a person, and why a re-arm should leave a written reason behind.