Trading bot architecture: the layers you must not merge
Almost every bot that becomes unmaintainable made the same mistake — it let strategy logic reach directly for the exchange.
Vizanix engineering · about the author
- SECTION
- Bot architecture
- PUBLISHED
- 2026-08-28
- CHAPTERS
- 6
- READ NEXT
- 3
- LANGUAGE
- written in English
A trading bot is not one program. It is five concerns that happen to run in the same process, and the difference between a system you can change and one you cannot is whether those concerns can see each other.
The five layers
| Layer | Owns | Must not know about |
|---|---|---|
| Transport | REST client, WebSocket, signing, rate limits, retries | Strategies, positions, P&L |
| Instrument registry | Symbols, filters, categories, tick and lot sizes | Anything time-varying |
| Market state | Order books, candles, funding, sequence integrity | Orders, intentions |
| Strategy | Signals: I want to be long 3 units of X | Exchange APIs, order IDs, HTTP |
| Risk & execution | Turning intent into orders under constraints | Why the strategy wanted it |
The critical boundary is the fourth row. A strategy expresses intent — a desired position — and never places an order itself. Everything downstream is free to refuse, resize, defer or split that intent.
Why the strategy boundary matters
When strategy code calls the exchange directly, four things become impossible at once, and you usually discover all four on the same bad day:
- Backtesting. You cannot replay a strategy that expects an HTTP client. Mocking the exchange gets you a test of your mock.
- Paper trading. Same problem, live.
- Global risk. With three strategies each placing their own orders, nothing knows the total exposure until it is already on.
- Venue changes. A new exchange means rewriting every strategy rather than one adapter.
# Strategy speaks intent. It has no idea an exchange exists.
@dataclass(frozen=True)
class Intent:
symbol: str
target_qty: Decimal # signed: negative is short
stop: Decimal | None
reason: str # goes into the audit log verbatim
class ImpulseReversal:
def on_bar(self, bar, state) -> Intent | None:
...
return Intent(bar.symbol, -size, stop=level, reason="rsi_overbought_series_4")
# Risk and execution decide what actually happens to that intent.
def handle(intent: Intent):
approved = risk.evaluate(intent, portfolio.snapshot())
if approved.rejected:
audit.log(intent, approved.reason)
return
executor.reconcile_to(approved.target_qty, intent.symbol)The reason field looks like a nicety. It is the thing that lets you answer “why did the bot do that” six weeks later. Our signal engine stores the reason for every evaluation including the rejections, which is why “why are there no entries today” is a query rather than a debugging session.
Target position, not buy and sell
Express intent as a desired end state rather than an action. “Be long 3” is idempotent: if the bot restarts and re-emits it while already holding 3, the reconciler does nothing. “Buy 3” is not: the same restart buys 3 more.
This one choice removes an entire class of double-execution bugs, and it makes recovery after a disconnect trivial — reconcile to target, whatever happened during the gap.
The control plane runs beside, not inside
Operators need to start, stop, adjust and inspect. That interface must not be able to stall the trading loop. In our microstructure engine the Telegram control plane is a separate process talking over a Unix socket; the realtime loop never makes a network call to a chat API, never waits on one, and cannot be blocked by one being slow.
The general rule: anything with unpredictable latency — chat APIs, dashboards, log shipping, model training — lives outside the loop that must respond to a market event in milliseconds.
Storage is not on the hot path
Recording everything is what makes a system honest, and writing synchronously is what makes it slow. Our signal engine keeps 5.76 million evaluations from a fifteen-day window — every evaluation, not only the ones that fired — and it does that with batched writes off the evaluation path and indexes chosen for the queries actually run.
A scanner that blocks on a disk write has stopped being a scanner. Buffer, batch, and let the writer fall behind if it must; the trading decision does not depend on the row being durable yet.
What good looks like
- You can run the strategy layer against historical data with no network at all.
- You can add a second venue by writing one adapter.
- You can answer “what was the total exposure at 14:32” from storage.
- Killing the control plane does not affect trading.
- Restarting the process re-derives state from the exchange, not from a local file.
None of this is exotic. It is ordinary layering applied to a domain where the cost of a leaky boundary is measured in money rather than in refactoring time.
This article describes engineering practice. It is not investment advice. Vizanix develops software and does not promise trading returns.