"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.
You send an order. The connection times out. Did the exchange get it?
There are three honest answers: yes, no, and you do not know. Most bots handle the first two. The third is where double orders, orphaned positions and bad nights come from.
Never retry blind#
Every order carries its own client order id. After a timeout, the bot asks the exchange whether that id exists before it sends anything again. One decision can then 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)If the order's state is still unknown after the last lookup, the function raises, and the caller treats it as a reason to stop opening new positions until a person or a later reconciliation settles it.
Compare the books every cycle#
Even with idempotent orders, the bot's idea of its positions can drift from the exchange's. So before every decision it compares its own book with the exchange's. A position the bot never opened is an emergency: cancel the orders on that market, flatten it reduce-only, halt, and alert a person.
from dataclasses import dataclass
@dataclass(frozen=True)
class Drift:
symbol: str
ours: float # signed size the bot believes it holds
theirs: float # signed size the exchange reports
def diff_books(book: dict[str, float], exchange: dict[str, float], qty_step: float) -> list[Drift]:
out = []
for sym in sorted(book.keys() | exchange.keys()):
ours, theirs = book.get(sym, 0.0), exchange.get(sym, 0.0)
if abs(ours - theirs) >= qty_step / 2:
out.append(Drift(sym, ours, theirs))
return out
def reconcile(client, book, breaker, alert) -> bool:
"""Runs every cycle, before any new decision. Returns True when clean."""
if client.has_orders_in_flight():
return False # fills still settling: compare next cycle
drifts = diff_books(book.positions(), client.fetch_positions(), book.qty_step)
for d in drifts:
if d.ours == 0.0: # a position the bot never opened
client.cancel_all_orders(d.symbol)
client.close_reduce_only(d.symbol, d.theirs)
breaker.halt(f"unknown position {d.symbol} {d.theirs:+}")
else:
breaker.halt(f"size drift {d.symbol}: book {d.ours:+} vs exchange {d.theirs:+}")
alert(d)
return not driftsThe principle is the same in both: when the bot is not sure, it stops adding risk and asks. The code that tests this path is in the fake exchange post.
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.