Short answer: Trading bot architecture covers how the parts talk and what survives a crash: log each order intent before sending it, rebuild state from that log, reconcile with the exchange on every start, and give the order path its own process. A tested asyncio skeleton built this way, run on 5 November 2026, recovered from a crash mid-order and a partial fill with one order at the fake venue. Architecture decides how failures behave; it does not make any rule profitable, and automated trading can lose money.
The components are listed in trading bot development; this page covers the boundaries between them and what happens when a process dies between two lines of code.
Everything measured here ran on synthetic data on one Windows 11 machine with Python 3.11.3; the trading rule is a stub. No exchange is recommended; which platforms you may use depends on your jurisdiction and their terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized.
Trading bot structure: one event loop or separate processes?
Start with one event loop if a single strategy trades a single venue, and move the order path into its own process once a stalled strategy could leave orders unmanaged.
| Layout | What it buys | What can go wrong |
|---|---|---|
| One process, one event loop | One order of events, simple replay | One blocking call stops everything, limits included |
| Strategy process plus gateway | A strategy bug cannot skip limits or hold up cancels; keys live in one place | The gateway needs a supervisor |
| Services on a message broker | Several strategies and accounts on one view | Delivery order and duplicates at every hop |
Order events must never be dropped or reordered; market data can be thinned.
How to code a trading bot: start with the event log
Write the event log and a fake exchange first, then the real adapter, and the strategy last, because the failure path cannot be added later.
- Define the events: intent, acknowledged, fill, absent.
- Write the log and a pure function that folds events into state.
- Build a fake exchange that can fail at any line, and test crashes against it.
- Add the real adapter, on a test environment.
Because the intent is durable before the send, a restart knows what it may have sent.
Durability has a price: on this machine on 5 November 2026, an append with fsync took a median of 1.14 to 1.22 ms over three runs of 1,000, against 0.47 to 0.52 ms without. NautilusTrader takes the other side: its built-in Redis and PostgreSQL backends write asynchronously, and its documents warn of a "persistence gap": a venue can receive an order before the local record is durable.
Trading bot code: a crash test with a fake exchange
This skeleton survives a crash after the venue accepted an order, a partial fill during the downtime and a repeated fill, restarting from the log alone.
import asyncio, json, math, os, tempfile
class Crash(BaseException):
"""The process dying mid-call: except Exception cannot catch it."""
class FakeExchange:
"""Outlives restarts. A repeated client ID creates nothing new."""
def __init__(self):
self.orders, self.die_after_accept = {}, False
async def place(self, cid, qty):
await asyncio.sleep(0)
self.orders.setdefault(cid, {"qty": qty, "fills": []})
if self.die_after_accept:
self.die_after_accept = False
raise Crash
def fill(self, cid, fill_id, qty):
self.orders[cid]["fills"].append({"id": fill_id, "qty": qty})
class EventLog:
"""Append-only JSON lines. An event exists once fsync returns
(on Linux, fsync the directory too after creating the file)."""
def __init__(self, path):
self.path = path
if os.path.exists(path): # dying mid-append leaves a torn last line
with open(path, "rb+") as f:
f.truncate(f.read().rfind(b"\n") + 1)
def append(self, **event):
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(event) + "\n")
f.flush()
os.fsync(f.fileno())
def read(self):
if not os.path.exists(self.path):
return []
with open(self.path, encoding="utf-8") as f:
return [json.loads(line) for line in f]
def fold(events):
orders, seen = {}, set()
for e in events:
if e["type"] == "intent":
orders[e["cid"]] = {"qty": e["qty"], "filled": 0.0, "state": "INTENT"}
elif e["type"] == "acked" and orders[e["cid"]]["state"] == "INTENT":
orders[e["cid"]]["state"] = "OPEN"
elif e["type"] == "absent":
orders[e["cid"]]["state"] = "ABSENT"
elif e["type"] == "fill" and (e["cid"], e["id"]) not in seen: # repeats change nothing
seen.add((e["cid"], e["id"]))
o = orders[e["cid"]]
o["filled"] += e["qty"]
o["state"] = "FILLED" if o["filled"] >= o["qty"] else "PARTIAL"
return orders, sum(o["filled"] for o in orders.values())
class Bot:
def __init__(self, log, exchange, name="bot1"):
self.log, self.ex, self.name, self.halted = log, exchange, name, False
def next_id(self): # repeats only if the log is lost
used = sum(e["type"] == "intent" for e in self.log.read())
return f"{self.name}-{used + 1:06d}"
async def submit(self, qty):
if self.halted:
raise RuntimeError("halted")
if not (math.isfinite(qty) and qty > 0): # "1e400" parses to inf, "1e-400" to 0.0
raise ValueError(f"bad quantity {qty}")
cid = self.next_id()
self.log.append(type="intent", cid=cid, qty=qty) # durable before the send
await self.ex.place(cid, qty)
self.log.append(type="acked", cid=cid)
return cid
def on_fill(self, cid, fill_id, qty):
if not (fill_id and math.isfinite(qty) and qty > 0) or cid not in fold(self.log.read())[0]:
self.halted = True
return
self.log.append(type="fill", cid=cid, id=fill_id, qty=qty)
async def reconcile(self):
"""Runs on every start, before the strategy sees one event."""
local, _ = fold(self.log.read())
venue = dict(self.ex.orders) # a real fetch: halt if an ID matches twice
for cid, order in local.items():
if cid in venue:
if order["state"] == "INTENT":
self.log.append(type="acked", cid=cid)
for f in venue[cid]["fills"]:
self.on_fill(cid, f["id"], f["qty"])
elif order["state"] == "INTENT":
self.log.append(type="absent", cid=cid)
if set(venue) - set(local): # an order nobody here wrote
self.halted = True
return fold(self.log.read())
async def main(path):
ex = FakeExchange()
bot = Bot(EventLog(path), ex)
ex.die_after_accept = True
try:
await bot.submit(1.0)
except Crash:
print("crash; log holds:", [e["type"] for e in bot.log.read()])
ex.fill("bot1-000001", "t1", 0.4) # partial fill while the process is down
bot = Bot(EventLog(path), ex) # restart: only log and venue survive
orders, position = await bot.reconcile()
print("after restart:", orders, "position", position, "halted", bot.halted)
bot.on_fill("bot1-000001", "t1", 0.4) # the stream repeats a fill
ex.fill("bot1-000001", "t2", 0.6)
bot.on_fill("bot1-000001", "t2", 0.6)
print("after fills:", fold(bot.log.read()), "orders at venue", len(ex.orders))
if __name__ == "__main__":
with tempfile.TemporaryDirectory() as d:
asyncio.run(main(os.path.join(d, "events.jsonl")))Output:
crash; log holds: ['intent']
after restart: {'bot1-000001': {'qty': 1.0, 'filled': 0.4, 'state': 'PARTIAL'}} position 0.4 halted False
after fills: ({'bot1-000001': {'qty': 1.0, 'filled': 1.0, 'state': 'FILLED'}}, 1.0) orders at venue 1What happened. The log held only the intent, so the restarted bot asked the venue, recorded the partial fill and reported 0.4, not the 1.0 it asked for. Other checks passed on 5 November 2026: a crash before the send, a torn last line, a fill before its acknowledgement, an unknown order at the venue, a fill with no ID or a bad quantity, or for an unknown order (halts; logging it would break restarts), a self-trade's shared trade ID, and inf, NaN, zero or negative quantities (refused).
A real venue may answer a lookup late, and its rule for repeated IDs may be narrower than this fake's. A timed-out order request leaves the same unknown as a crash; retry rules are in CCXT error handling, and the full order state machine in the crypto OMS guide.
Backpressure and clocks: when a consumer falls behind
A slow strategy must not grow an unbounded queue, because it then acts on old prices while every health check stays green.
| Policy | Deepest queue | Dropped | Worst lag |
|---|---|---|---|
| Unbounded queue | 1,334 | 0 | 1,333 ticks |
| Bounded at 100, producer blocks | 100 | 0 | 99 ticks here; the backlog moves into the socket |
| Bounded at 100, drop oldest | 100 | 1,234 | 99 ticks |
| Keep newest only | 1 | 1,333 | 0 ticks |
Dropping is only safe for data a newer message fully replaces, such as a ticker. Order book deltas cannot be skipped without a resync, as the WebSocket order book guide shows. Count drops and alert on them.
- Timeouts and heartbeats: a monotonic clock, which Python's documentation says "cannot go backwards".
- Request timestamps: a synchronized wall clock; signed requests fail when it drifts.
- Event order: store venue time and receive time; never sort fills by arrival alone.
- Replay: inject the clock so recorded data drives the same code.
A risk process that can stop orders when the strategy hangs
Keep the limits and the order path in a process separate from the strategy, because a watchdog inside a blocked event loop cannot run.
import asyncio, json
async def gateway(exchange, strategy_cmd, silence_limit=0.5, startup_limit=10.0):
"""Holds the keys and the order path. The strategy only writes lines to a pipe."""
child = await asyncio.create_subprocess_exec(*strategy_cmd, stdout=asyncio.subprocess.PIPE)
limit = startup_limit
try:
while line := await asyncio.wait_for(child.stdout.readline(), limit):
limit = silence_limit
message = json.loads(line)
if message.get("type") == "intent": # add limits here; json.loads accepts NaN
await exchange.submit(message)
reason = "strategy exited"
except asyncio.TimeoutError:
reason = "strategy silent" # alive or not, it no longer gets to trade
except Exception as e: # a stray line or failed send still cancels
reason = "gateway error: " + repr(e)
finally:
if child.returncode is None:
child.kill()
await exchange.cancel_all(reason)
await child.wait()
return reason| Watchdog | The stall | Reacted after |
|---|---|---|
| Task in the same event loop | time.sleep(2) in a callback | 2.0 s in all three runs, only once the block ended |
| Gateway, separate process | Child alive but silent | 0.515, 0.5 and 0.515 s after its last message |
In these runs the earlier intent was handled before cancel_all, and an abrupt exit, a non-JSON line or a failed send also ended in cancel_all. What the cancel should cover and who may reset it are in trading bot risk management.
Crypto trading bot architecture in three open-source projects
Three published designs answer these questions differently; this describes their documents and source, read 5 November 2026, and ranks nothing.
| Question | Freqtrade | Hummingbot | NautilusTrader |
|---|---|---|---|
| Control flow | One bot loop; process_throttle_secs defaults to 5 s | A clock (tick_size 1.0) ticks child iterators; strategies read state each tick | A single-threaded core; network and persistence run elsewhere and feed it through channels |
| How parts talk | Direct calls and strategy callbacks | Connectors standardize each venue's API for strategies | A message bus: publish/subscribe, request/response, commands and events |
| Durable state | SQL database, SQLite by default | A recorder saves and restores connector tracking states | Cache with optional backing; durability depends on that store |
| On start | Re-fetches orders its database lists as open, except in dry-run | Restores saved tracking states | Reconciles against venue reports before strategies start |
| On a fault | Temporary errors retry after 30 s; operational errors stop the bot | Tick errors are logged and the loop continues; an order not found over 3 times is marked lost | Release builds abort on panic; an external supervisor restarts |
| License, latest release (GitHub, 5 November 2026) | GPL-3.0; 2026.10, 30 October | Apache-2.0; v2.17.0, 22 September | LGPL-3.0; 1.231.0, 2 August; 2.0.0 candidates since |
NautilusTrader's architecture page says: "Venue commands are not generally safe to retry blindly." Repository checks are in the open source bot guide.
Trading bot programming language: what each layer needs
No language suits every layer; match each layer's needs and keep boundaries simple enough to change one side later.
| Layer | What it needs |
|---|---|
| Exchange adapter | Async I/O, signing, a maintained library for your venue |
| Strategy and research | Data tools, fast iteration, the same code in replay and live |
| Event log and state | Exact decimals (the sketch uses floats for brevity), simple serialization |
| Limits and gateway | Small code, few dependencies, easy for someone else to review |
| Latency-critical path | Predictable pauses; measure before blaming the language |
The projects above show the range: GitHub showed Freqtrade at 98.5% Python, Hummingbot at 97.2% Python and NautilusTrader at 93.7% Rust on 5 November 2026. The algorithmic trading page describes commissioning such a system; rules and decisions stay with you.
Frequently asked questions
What is the best architecture for a trading bot?
No single one. One event loop suits one strategy on one venue; a gateway process makes sense once a stalled strategy could leave orders unmanaged.
Which programming language is best for a trading bot?
None for every layer; the limits and order path mainly need small, testable code.
Why log an order before sending it?
So a restart after a crash between send and reply knows what it may have sent, and asks the exchange instead of sending a duplicate.
Does a good architecture make a bot profitable?
No. It controls how the software behaves under failure. Trades can still lose money.
Sources
- NautilusTrader documentation: Architecture (threading model, crash-only design) (opens in a new tab)
- NautilusTrader documentation: Execution policies (command outcomes, persistence before transport) (opens in a new tab)
- NautilusTrader documentation: Execution reconciliation (opens in a new tab)
- Freqtrade documentation: Freqtrade basics (bot execution logic) (opens in a new tab)
- Freqtrade documentation: Configuration (process_throttle_secs, db_url) (opens in a new tab)
- Freqtrade source: worker.py (error handling) (opens in a new tab)
- Freqtrade source: freqtradebot.py (startup_update_open_orders) (opens in a new tab)
- Hummingbot documentation: Strategies (clock tick) (opens in a new tab)
- Hummingbot documentation: Connectors (opens in a new tab)
- Hummingbot source: core/clock.pyx (opens in a new tab)
- Hummingbot source: connector/client_order_tracker.py (lost orders) (opens in a new tab)
- Hummingbot source: connector/markets_recorder.py (tracking states) (opens in a new tab)
- Python documentation: time.monotonic (opens in a new tab)
- Linux manual page: fsync(2), directory entries need their own fsync (opens in a new tab)
- Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish) (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.