DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Roadmap: Building a Sub-Microsecond HFT Triangular Arbitrage Engine in Elixir

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verification and Testing Strategy

Ensure the codebase remains robust under parallel execution:

  1. NIF Safety Tests: Validate that the NIF behaves predictably under malformed payloads, returning nil instead of panicking.
  2. Compiler State Isolation: Ensure test suites reset the compiled SignalPro.Config state inside test fixtures to avoid cross-suite race conditions.
  3. Performance Profiling: Run a micro-benchmark using Benchee to measure parsing and calculations to detect performance regressions.

Top comments (0)