Roadmap: Building a Sub-Microsecond HFT Triangular Arbitrage Engine in Elixir
This guide provides a step-by-step engineering roadmap for building an ultra-low latency cryptocurrency high-frequency trading (HFT) triangular arbitrage engine using Elixir, Rust, and the Notification-Oriented Paradigm (PON).
The Engineering Roadmap
The development process is structured into five successive optimization phases targeting both physical and logical bottlenecks:
graph TD
A["Phase 1: PON Engine & Adjacency Registry"] --> B["Phase 2: Decoupled Telemetry & TUI"]
B --> C["Phase 3: Rustler JSON Parser NIF"]
C --> D["Phase 4: 0ns Config Compiler Hot-Swap"]
D --> E["Phase 5: Private API HMAC Authentication"]
Phase 1: Base Architecture & PON Engine (Fact Object Base)
The primary objective is to build a sparse market adjacency matrix that routes updates point-to-point, avoiding costly global broadcasts or sweeping iterations.
Step 1.1: Graph Builder (DFS)
Query Binance's public /api/v3/exchangeInfo REST endpoint during boot and run a Depth-First Search (DFS) to identify closed 3-leg arbitrage cycles starting and ending at designated Hub assets (USDT, BTC, ETH, BNB).
Step 1.2: Fact Object Base (FOB) via ETS
Initialize a public ETS table (:tickers) with read concurrency optimization enabled (read_concurrency: true). This table stores the latest bid/ask order book data.
If the incoming bid/ask prices match the existing values in ETS, abort execution immediately at the entry point (Blocked Flickers), preventing downstream rules from consuming CPU cycles.
defmodule SignalPro.Engine do
use GenServer
@table :tickers
def init(state) do
# Create the ETS table with read concurrency optimized for HFT
:ets.new(@table, [:named_table, :public, read_concurrency: true])
{:ok, state}
end
def update_ticker(symbol, bid_price, bid_qty, ask_price, ask_qty) do
case get_ticker(symbol) do
{:ok, existing} when existing.bid == bid_price and existing.ask == ask_price and existing.bid_qty == bid_qty and existing.ask_qty == ask_qty ->
# Blocked Flicker: State has not mutated; abort immediately to save CPU
:ok
_ ->
now = System.monotonic_time(:nanosecond)
:ets.insert(@table, {symbol, bid_price, bid_qty, ask_price, ask_qty, now})
# Point-to-Point Notification Dispatch
Registry.dispatch(SignalPro.Registry, symbol, fn entries ->
for {pid, _value} <- entries, do: send(pid, {:ticker_changed, symbol})
end)
end
end
end
Step 1.3: O(1) Point-to-Point Notifications
Use Elixir's native :duplicate Registry module. Each rule process registers to the topics of the three symbol tickers that compose its triangle.
defmodule SignalPro.TriangleRule do
use GenServer
def init(cycle) do
# Register to the 3 symbols in the cycle
for trade <- cycle.trades do
{:ok, _} = Registry.register(SignalPro.Registry, trade.pair, nil)
end
{:ok, %{cycle: cycle}}
end
def handle_info({:ticker_changed, _symbol}, state) do
# Mailbox Conflation: drain stale messages to prevent queue backlog
drain_notifications()
evaluate_rule(state.cycle)
{:noreply, state}
end
defp drain_notifications do
receive do
{:ticker_changed, _symbol} -> drain_notifications()
after
0 -> :ok
end
end
end
Phase 2: Decoupled Telemetry & 5Hz TUI Dashboard
Console outputs (IO.puts/1) are among the slowest operations in any OS, introducing millisecond-level blockages. The arbitrage rule engines must remain completely silent on the Hot Path.
Step 2.1: Non-Blocking Telemetry Events
Arbitrage rule processes use the :telemetry library to emit execution results in nanoseconds. A stateless handler captures these events and issues a non-blocking GenServer.cast/2 to a dedicated satellite metrics process (TUI). This cast returns in nanoseconds and never blocks rule evaluation.
defmodule SignalPro.TelemetryLogger do
def setup do
:telemetry.attach("logger", [:signal_pro, :arbitrage], &__MODULE__.handle_event/4, nil)
end
def handle_event([:signal_pro, :arbitrage], measurements, metadata, _config) do
path = metadata.path
status = Map.get(metadata, :status, "UNKNOWN")
profit = measurements.profit
proc_latency_ns = measurements.latency
budget_ns = SignalPro.Config.max_latency_ns() - proc_latency_ns
type = if status == "VIABLE", do: :positive, else: :negative
# Async Cast to the satellite TUI process
SignalPro.TerminalDashboard.record_opportunity(type, path, profit, proc_latency_ns, budget_ns, status)
end
end
Step 2.2: Aggregated 5Hz Redraw Rate
The Terminal User Interface (TUI) process stores metrics in memory and runs a periodic redraw loop using Process.send_after/3 every 200ms (5Hz). This aggregates screen updates and isolates the hot path from terminal rendering bottlenecks.
defmodule SignalPro.TerminalDashboard do
use GenServer
@refresh_interval 200 # 5Hz
def init(_opts) do
schedule_redraw()
{:ok, %{positives_count: 0, negatives_count: 0, last_positive: nil, last_negative: nil}}
end
def handle_info(:redraw, state) do
# Clear screen and render aggregated layout
IO.write("\e[H\e[2J")
render_layout(state)
schedule_redraw()
{:noreply, state}
end
defp schedule_redraw, do: Process.send_after(self(), :redraw, @refresh_interval)
end
Phase 3: Native Ingestion Accelerator (Rustler NIF)
Although the BEAM VM excels at soft real-time concurrency, JSON parsing (Jason.decode/1) and numerical string casting (String.to_float/1) inside Elixir allocate heavily on the process heap, trigger GC pauses, and degrade tail latency.
Step 3.1: Rustler Setup
Add the :rustler package to your dependencies to automatically bridge Cargo with mix compile.
Step 3.2: Single-Pass Native Parser
Write a native Rust function using serde_json to deserialize raw WebSocket frames directly into f64 floats inside native thread memory. The NIF returns a clean 5-element tuple to Elixir. This cuts parsing latency from 12,000ns to under 600ns and significantly reduces the BEAM Garbage Collector footprint.
use rustler::NifTuple;
use serde::Deserialize;
#[allow(non_snake_case)]
#[derive(Deserialize)]
struct BinanceBookTicker {
s: String, // symbol
b: String, // bid
B: String, // bidQty
a: String, // ask
A: String, // askQty
}
#[derive(NifTuple)]
struct TickerTuple {
symbol: String,
bid: f64,
bid_qty: f64,
ask: f64,
ask_qty: f64,
}
#[rustler::nif]
fn parse_ticker(json_str: String) -> Option<TickerTuple> {
let ticker: BinanceBookTicker = serde_json::from_str(&json_str).ok()?;
let bid = ticker.b.parse::<f64>().ok()?;
let bid_qty = ticker.B.parse::<f64>().ok()?;
let ask = ticker.a.parse::<f64>().ok()?;
let ask_qty = ticker.A.parse::<f64>().ok()?;
Some(TickerTuple {
symbol: ticker.s.to_lowercase(),
bid,
bid_qty,
ask,
ask_qty,
})
}
rustler::init!("Elixir.SignalPro.Nif");
Phase 4: 0ns Hot-Swap Compiler
In low-latency HFT engines, querying runtime parameters from files or ETS tables adds 200ns–500ns in CPU cycles. Instead, we can inject these values directly into the VM instruction pointer as compiled bytecode literals.
Step 4.1: Runtime Compilation (Code.compile_string/2)
Build a code generator that takes a map of configuration parameters and outputs an Elixir source code string containing static getters:
defmodule SignalPro.ConfigCompiler do
@behaviour SignalPro.ConfigCompiler.Behaviour
def compile_and_load(opts \\ %{}) do
max_latency = Map.get(opts, :max_latency_ns) || 10_000_000
default_fee = Map.get(opts, :default_taker_fee) || 0.001
source = """
defmodule SignalPro.Config.Dynamic do
@moduledoc false
def max_latency_ns, do: #{max_latency}
def default_taker_fee, do: #{default_fee}
end
"""
# Compile source string in-memory and dynamically swap bytecode in VM
Code.compiler_options(ignore_already_consolidated: true)
[{SignalPro.Config.Dynamic, _binary}] = Code.compile_string(source)
:ok
end
end
Step 4.2: Stable Facade API
Create a stable, static SignalPro.Config facade module that exposes clean getters and checks if the dynamic module is loaded using Code.ensure_loaded?/1. The rule engines query this facade in 0ns (resolving to bytecode literals), and we eliminate all compilation warnings since the facade is a statically known module.
defmodule SignalPro.Config do
@default_max_latency 10_000_000
@default_fee 0.001
# Facade read calls (Resolves to precompiled Dynamic literals at runtime)
def max_latency_ns do
if Code.ensure_loaded?(SignalPro.Config.Dynamic) do
SignalPro.Config.Dynamic.max_latency_ns()
else
@default_max_latency
end
end
def default_taker_fee do
if Code.ensure_loaded?(SignalPro.Config.Dynamic) do
SignalPro.Config.Dynamic.default_taker_fee()
else
@default_fee
end
end
end
Phase 5: Live API Key & Commission Sync
HFT systems should never assume a hardcoded fee structure. Commissions change with VIP tiers, promotion pairs (Zero-Fee Pairs), and BNB discount options.
Step 5.1: HMAC-SHA256 Signatures
Implement SHA256 HMAC digital signatures to query Binance's private endpoints:
defmodule SignalPro.BinanceAuth do
def compute_signature(query_string, secret_key) do
:crypto.mac(:hmac, :sha256, secret_key, query_string)
|> Base.encode16(case: :lower)
end
end
Step 5.2: Periodic Warm Path Sync & Hot-Swap Trigger
Implement a background GenServer (SignalPro.FeeManager) that wakes up every 30 minutes to fetch live commission rates via the signed /api/v3/account REST endpoint.
defmodule SignalPro.FeeManager do
use GenServer
@refresh_interval 30 * 60 * 1000 # 30 mins
def handle_info(:refresh_fees, state) do
api_key = System.get_env("BINANCE_API_KEY")
secret_key = System.get_env("BINANCE_SECRET_KEY")
if api_key && secret_key do
case fetch_binance_fees(api_key, secret_key) do
{:ok, new_taker_fee} ->
# Trigger Dynamic Facade Bytecode Compilation
SignalPro.Config.compile_and_load(%{default_taker_fee: new_taker_fee})
_error ->
:ok
end
end
schedule_next()
{:noreply, state}
end
end
Verification and Testing Strategy
Ensure the codebase remains robust under parallel execution:
-
NIF Safety Tests: Validate that the NIF behaves predictably under malformed payloads, returning
nilinstead of panicking. -
Compiler State Isolation: Ensure test suites reset the compiled
SignalPro.Configstate inside test fixtures to avoid cross-suite race conditions. -
Performance Profiling: Run a micro-benchmark using
Bencheeto measure parsing and calculations to detect performance regressions.
Top comments (0)