Brindle
Automated trading bots, and the risk limits that actually stop them
A platform that runs automated trading strategies against broker adapters on a one-second tick. The engineering problem is not the strategies — it is enforcing risk limits that protect a real account, when the cheap limits and the expensive ones cannot be checked by the same mechanism.
- Built for
- Ballo Innovations
- My role
- Architecture and implementation of the execution gateway, the risk engine and the runtime supervision loop.
- When
- 2026
- Python
- FastAPI
- Pydantic v2
- SQLAlchemy 2
- Alembic
- asyncio
- Prometheus
What made it hard
The problems worth reading about. Everything else in this system was ordinary work.
- 01
Position size can be checked in microseconds from a snapshot already in memory. Drawdown needs the broker's real balance, which means a network call. Putting both in front of every order would mean either broker latency on every tick or a limit that is configured and never actually checked.
- 02
So they run in two places with two different consequences: the pre-order gate rejects the order, and the runtime breaker pauses the bot. A position breach means "not this order"; a drawdown breach means "no more orders until a human looks at it".
- 03
Strategies never call a broker adapter directly. An earlier version let each one size its own orders, and one promptly computed its own stake without reading the risk context — so the configured per-trade cap simply did not apply to it. A limit each caller has to remember to apply is not a limit.
What it measures
Each figure says where it came from, so you can judge how much weight to give it. Some are measurements and some are chosen thresholds; the basis line tells you which.
- 0
- I/O calls on the pre-order path
- 4
- Runtime conditions that pause a bot
The risk engine takes an injected portfolio snapshot and mark price and makes no network or database calls. Verifiable rather than measured: its unit tests run with no fixtures and no database.
Consecutive risk rejections, daily loss or drawdown breach, consecutive losing trades, and allocation depletion. Each writes its reason into the pause record.
The longer version
The problem
The failure I was designing against is a bot that keeps trading while losing, because the limit that would have caught it was too expensive to check on the order path and therefore was not checked at all.
That is not hypothetical. An earlier version of this system accepted a
max_drawdown_pct in its configuration, validated it, displayed it, and enforced
it only in the pre-order gate — where the number it needed was not available. The
runtime ignored it entirely. The schema was making a promise to the operator that
the running system did not keep.
What I built
One execution gateway that every order passes through, a pure risk function in front of it that does no I/O, and a separate supervision loop for the limits that need a broker round-trip and only make sense over a series of trades. Rejections are persisted and audited — a rejection is an event, not a silent no-op. When state is uncertain, because an adapter is unhealthy or market data is stale, the system does nothing and alerts rather than guessing.
What is still wrong
The open-orders limit is an instance of exactly the bug described above, still present. It is schema-validated, bounded, and unit-tested against a synthetic snapshot — and the runtime builds that snapshot with the count hardcoded to zero, so in the running system the gate never fires.
The right fix is not to patch the field. It is a test that walks the risk configuration schema and asserts every limit in it is reachable from the snapshot the runtime actually constructs. That would have caught both this and the drawdown gap.
The close calls, written up
Where a choice was genuinely arguable, I wrote it up the way a team records a design decision internally: the constraint, the options I turned down and why, and what it would cost to reverse.
- ADR-002acceptedTrading Systems08 May 2026
Two-tier risk limits
Some risk limits can be checked in microseconds from state you already hold. Others need a broker round-trip and only make sense over a series of trades. Putting both in front of every order would have meant either blocking the order path on network I/O or leaving the slow limits unenforced, so they run in two places with two different consequences: reject the order, or pause the bot.
3 options evaluated