Taking a strategy live: controllers, a missing simulator, and a 7× faster engine
Strategy V2 Controllers
Python / Cython
Performance Optimization
Ansible / Docker
Once the research framework produced parameters worth trading, they needed an execution layer I could trust with real orders. I chose not to write one — Hummingbot is a mature open-source trading framework, and reinventing order management is how people lose money to their own bugs.
So I forked it and contributed what was missing: my own strategy controllers, a grid-executor simulator that did not exist upstream, a ~7× speed-up of the backtesting engine, a corrected MEXC rate-limit model, and an Ansible deployment onto a VPS.
~2 200 authored lines across 28 files inside a repository with 26 455 upstream commits. Working with a large foreign codebase is its own skill — every change here is backwards-compatible by design.
The strategy: a regime-filtered ATR grid
AtrDrift is a Strategy V2 ControllerBase that computes indicators vectorised over pandas/NumPy and then delegates execution to the stock GridExecutor — trading logic in the controller, order mechanics in the framework’s hands.
A grid is only opened when five conditions hold simultaneously:
- the ratio
max_true_range / min_true_rangeis below a threshold — no volatility explosion in progress, ATR / closeis above a minimum — enough movement to pay for the fees,- average quote volume in USDT exceeds a floor — the instrument is actually tradable,
- the share of candles escaping the ATR channel (
error_rate) stays under an allowance — the channel is descriptive, - the drift of the mean mid-price is small — the market is ranging, not trending through the grid.
The grid bounds are the ATR channel around a moving average of the mid price. This is a regime filter: the strategy is designed to sit on its hands and only engage a market shaped like the one it was fitted on.
Take-profit derived from real fees, not a guessed constant
A grid whose take-profit is smaller than round-trip costs is a machine for donating money to the exchange. So the minimum viable take-profit is computed from the connector’s actual maker fee:
min_TP = fee · (2 − fee) / (1 − fee)² # break-even across both sides of the trade
max_TP = half-width of the ATR channel
TP = interpolate(min_TP, max_TP, TP_risk_factor)
If min_TP >= max_TP, or price sits outside the channel, no executor is created at all. The fee itself is pulled live from the connector via get_fee(..., OrderType.LIMIT_MAKER) rather than hardcoded per exchange.
Compounding budget
The controller’s available balance subtracts the budget of currently active executors and adds the realised net_pnl_quote of completed ones, so successive grids scale geometrically instead of being pinned to a fixed total_amount_quote — with an explicit precision-margin field so rounding never makes the bot ask for more than it has.
Writing the simulator upstream was missing
Hummingbot could backtest position executors, but had no simulator for the grid executor — the exact component my strategy depends on. I wrote it from scratch and registered it in the engine’s dispatcher.
It models the full state machine of every grid level — NOT_ACTIVE → OPEN_ORDER_PLACED → CLOSE_ORDER_PLACED — with fills resolved against candle low/high, position accumulation across levels, a global stop-loss on the average entry price, a time limit, and final liquidation at the end of the run. Fees and PnL accumulate in pre-allocated NumPy arrays rather than per-row Python objects.
Without this file, none of the research in this section could have been validated against the framework that actually places the orders.
Making the backtest engine ~7× faster
Parameter search means running the same backtest tens of thousands of times, so engine latency is the research budget. I profiled the upstream engine and rebuilt the hot path — as an opt-in mode, not a rewrite:
run_backtesting(..., backtesting_mode="precision" | "fast")
precision is byte-for-byte the original behaviour and remains the default.
| Phase | Time (~43 000 one-minute candles) |
|---|---|
| Upstream baseline | 2 200 ms |
to_numpy() instead of iterrows | 1 850 ms |
model_construct() — skip Pydantic validation in the loop | 1 300 ms |
| Precomputed close timestamps + O(1) executor update | 1 000 ms |
Skipping signal == 0 rows with no active executors | ~300 ms |
Two things I consider more important than the number itself:
- A compatibility audit came first. Before touching anything I catalogued which
ExecutorInfofields are read by controllers inside the loop versus only insummarize_results, and verified that none of the 23+ stock controllers overridesimulate_execution/update_state/update_executors_info. - The limitations are documented in the code itself. The engine’s
__init__states plainly whichDecimalfields are placeholders in fast mode and for which controllers the equivalence was checked. A fast path whose caveats are undocumented is a trap for the next reader — usually yourself, six months later.
I also hardened summarize_results along the way: zero positions, zero-variance Sharpe and zero cumulative volume no longer raise — the engine stopped crashing on runs that simply produced no trades, which during a wide sweep is most of them.
Exchange-level work
Rate limits. I rewrote the MEXC constants after establishing that the upstream model was wrong: independent per-endpoint pools rather than one shared pool, both IP and UID pools on authenticated endpoints, and different weights for GET versus POST/DELETE on /api/v3/order. The full reverse-engineering, with citations from the exchange’s own docs, is written up separately: Hummingbot MexC Rate Limits Research. The infrastructure-level anti-spam threshold was calibrated empirically against observed behaviour — and the code comment says so, because a number obtained from experiment should never be mistaken for a documented one.
Cython. Extending paper trading required going into the compiled layer: adding public attributes to a cdef class in the .pxd, wiring InFlightOrder / TradeUpdate / OrderUpdate tracking into c_execute_buy / c_execute_sell, and casting C++ pointers via <object>. Extensions built for both CPython 3.12 and 3.13.
Deployment
The Ansible playbook provisions the VPS (SSH keys, UFW allowing only OpenSSH and 443, Docker via role import) and then does something deliberately lightweight: it computes git diff --name-only hummingbot-base my_strategies, rsyncs only the files that differ from the fork’s base branch, and mounts each of them as an individual volume into the official hummingbot/hummingbot:latest image.
No image rebuild, no registry, no drift between what I tested and what runs. Branch discipline (hummingbot-base / my_strategies / my_edits_paper_trade / backtest_workspace) isn’t cosmetic here — the deployment mechanism is built on it.