trading bots

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.

what we build

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.

TypeWhat it is forMain risk
Trend and breakoutRiding sustained moves once price leaves a rangeWhipsaw: false breakouts bleed money through stops and fees
Mean reversionFading stretched moves back toward an averageThe stretch is a new trend and the signal keeps saying "buy more"
Funding rate carryLong spot, equal short perpetual, collecting funding while it is positiveFunding flips, the basis moves, a thin short leg gets squeezed
DCA and martingale laddersScaling into a position in rungs as price moves against the entryConcentrated tail risk when the ladder has no ceiling
GridResting orders at fixed steps, harvesting back and forth movementPrice leaves the range and inventory piles up on the wrong side
Coin toss control groupA baseline with random entries and the same sizing, stops and feesNone in the logic. The risk is skipping it and fooling yourself
Announcement and listing watcherReacting to exchange announcements on predefined rulesLatency, thin books, misreading an announcement
Treasury collectorSweeping dust and leftovers into the trading currencySelling 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.

how we build them

nine habits that every bot gets

  1. 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)
  2. 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)
  3. One gate for every order. It returns reasons, not just yes or no, and it never blocks an order that only reduces a position.
  4. 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)
  5. Costs are in every test. Fees, spread and slippage, decisions on closed bars, fills at the next open. (more)
  6. 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)
  7. Test against a fake exchange that fails on command, then break your own safety code on purpose. (more)
  8. Soft pauses clear themselves, hard halts need a person who writes down why they re-armed. (more)
  9. Demo or testnet before real funds, always.
code

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.

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

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

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

a ladder with a ceiling

The martingale shape, with one shared stop and a fixed risk budget over the whole ladder.

Illustrative: a DCA ladder that splits one fixed risk budget across its rungspython
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 ladder

look up before you retry

A timeout means unknown, not failed. One decision can never become two orders.

Illustrative: look up before you retrypython
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.

Illustrative: a breaker with a soft pause and a hard haltpython
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.

Illustrative: a closed-bar loop with fees and slippagepython
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: dropped

a fake exchange for tests   the coin toss control group

limits

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

posts

about the bots