Mercury Trading Engine
Mercury is a high-performance, event-driven algorithmic trading engine written in Rust. It is designed for low-latency market-making, momentum, and ML-based strategies across multiple exchanges.
Key features
- Event-driven core — all components communicate through a single lock-free
EventBusbacked by a crossbeam bounded channel. - Multiple exchanges — Binance, Coinbase, Bybit, Kraken, OKX, and Yahoo Finance (equities).
- Paper trading — built-in
PaperGatewaywith configurable simulated latency; no API keys needed. - Backtesting — deterministic replay of recorded Parquet tick data.
- ML strategies — ONNX-based inference, online logistic regression, and reinforcement learning.
- Risk controls — position limits, daily-loss guard, per-order rate limiting, and a kill switch.
- TUI monitor — real-time ratatui dashboard connected via Unix socket IPC.
- Prometheus metrics — latency histograms (HDR), PnL tracking, and Prometheus export.
Design philosophy
Mercury favors explicitness and performance over abstraction. Financial values use rust_decimal::Decimal throughout — no floating-point arithmetic on prices or quantities. Hot-path data structures are stack-allocated where possible (e.g. Symbol is a [u8; 16] array).
Installation
Prerequisites
- Rust toolchain ≥ 1.85 (edition 2024) — install via rustup
- SQLite (system library, used by sqlx)
- OpenSSL or native-tls (used by reqwest / tokio-tungstenite)
On macOS:
brew install openssl sqlite
On Ubuntu/Debian:
apt install libssl-dev libsqlite3-dev pkg-config
Build
git clone https://github.com/amjadjibon/mercury
cd mercury
cargo build --workspace --release
Dev tools
The following tools are used for documentation and benchmarking. Install them once:
cargo install mdbook # build this book
cargo install cargo-criterion # benchmark runner
Verify
cargo test --workspace
All tests should pass with no warnings.
Quick Start
Paper trading (no API keys)
The fastest way to run Mercury is in paper-trading mode against a live Binance feed:
cargo run --bin mercury -- run \
--symbol BTCUSDT \
--paper \
--strategy market_maker
Mercury will connect to the Binance WebSocket, receive live order-book updates, and simulate order matching with a 5 ms round-trip latency. No real orders are placed.
Live trading
Set your exchange credentials as environment variables, then pass them at run time:
export BINANCE_API_KEY=your_key
export BINANCE_SECRET_KEY=your_secret
cargo run --bin mercury -- run \
--symbol BTCUSDT \
--strategy rsi
TUI monitor
In a second terminal, start the dashboard:
cargo run --bin mercury-tui
The TUI connects to Mercury via a Unix socket at /tmp/mercury.sock and displays live order-book depth, fills, PnL, and latency percentiles.
Adjust log verbosity
cargo run --bin mercury -- --log-level debug run --symbol BTCUSDT --paper --strategy rsi
Valid levels: trace, debug, info, warn, error.
Configuration
Mercury is configured primarily through CLI flags. There is no config file required for basic operation.
CLI flags
| Flag | Default | Description |
|---|---|---|
--symbol | — | Trading symbol, e.g. BTCUSDT or AAPL |
--exchange | binance | Exchange: binance, coinbase, yahoo |
--strategy | — | Strategy name: market_maker, rsi, momentum, arbitrage |
--paper | false | Enable paper trading via PaperGateway |
--log-level | info | Tracing log level |
Environment variables
| Variable | Description |
|---|---|
BINANCE_API_KEY | Binance REST/WS API key |
BINANCE_SECRET_KEY | Binance HMAC secret |
COINBASE_API_KEY | Coinbase Advanced Trade key |
COINBASE_SECRET_KEY | Coinbase secret |
Storage
Mercury writes its SQLite database to mercury.db in the current working directory. Override with --db-path:
cargo run --bin mercury -- run --symbol BTCUSDT --paper --strategy rsi --db-path /tmp/mercury.db
Use :memory: for ephemeral in-process storage (useful in tests).
Architecture Overview
Mercury is an event-driven trading engine. All inter-component communication flows through a single EventBus — a crossbeam bounded channel with a default capacity of 100,000 events.
Event flow
Market Feeds (WebSocket) → FeedManager → EventBus
│
┌────────────────────────────────┼──────────────────┐
▼ ▼ ▼
StrategyRunner OrderManager StorageManager
(on_book/on_trade) (submits orders (persists fills
│ via Gateway) to SQLite)
▼ │
Signal events Fill events
→ EventBus → EventBus
│
IpcServer
(/tmp/mercury.sock)
(broadcasts to TUI)
Key traits
| Trait | Crate | Purpose |
|---|---|---|
Strategy | mercury-strategy | Implement for new trading strategies. Methods: on_book, on_trade, on_fill, reset. |
ExchangeGateway | mercury-gateway | Implement for new exchanges. Async: submit_order, cancel_order, connect. |
FeedParser | mercury-market | Implement for new exchange WebSocket feeds. Methods: parse, ws_url, subscribe_message. |
Component lifecycle
Each long-running component is spawned as a tokio::spawn task:
- FeedManager — opens WebSocket connections, parses messages, publishes
BookUpdateandTradeevents. - StrategyRunner — subscribes to market events, calls strategy methods, publishes
Signalevents. - OrderManager — subscribes to
Signalevents, applies risk checks, submits orders via the gateway. - StorageManager — subscribes to
Fillevents and persists them to SQLite. - IpcServer — fans out events to connected TUI clients over a Unix socket.
Event Bus
The EventBus is the central nervous system of Mercury. It is a thin wrapper around a crossbeam bounded channel.
Design
- Capacity: 100,000 events (configurable at construction).
- Back-pressure: producers block when the channel is full, providing natural flow control.
- Lock-free: no mutex on the hot path — crossbeam’s channel uses atomic operations internally.
- Single channel: all event types share one channel. Components filter by
EventPayloadvariant.
Event types (EventPayload)
| Variant | Published by | Consumed by |
|---|---|---|
BookUpdate(Arc<BookUpdate>) | FeedManager | StrategyRunner, PaperGateway |
Trade(Trade) | FeedManager | StrategyRunner |
Signal(Signal) | StrategyRunner | OrderManager |
Fill(Fill) | OrderManager | StorageManager, StrategyRunner, IpcServer |
OrderUpdate(Order) | OrderManager | IpcServer |
Arc wrapping
BookUpdate is wrapped in Arc before publishing so that multiple subscribers can hold a reference to the same allocation without copying the order-book snapshot.
Usage
#![allow(unused)]
fn main() {
// Publish
bus.publish(EventPayload::Signal(signal));
// Subscribe (returns a Receiver)
let rx = bus.subscribe();
while let Ok(event) = rx.recv() {
match event.payload {
EventPayload::Fill(fill) => { /* handle */ }
_ => {}
}
}
}
Crate Structure
Mercury is a Cargo workspace. Crates are ordered by dependency depth.
Dependency order
core ← market, gateway, strategy, risk, execution, replay, metrics, storage ← cli, tui
Crate descriptions
mercury-core
Shared types and infrastructure used by every other crate.
Event,EventBus,EventPayloadOrderBook,BookUpdate,TradePool(object pool backed byparking_lot)IpcServer(Unix socket broadcaster)- All domain types:
Symbol,Exchange,Side,Order,Fill,Signal,OrderId,TimeInForce,FixedPoint
mercury-market
Market data ingestion.
FeedManager— manages WebSocket connections and reconnect logicBinanceParser,CoinbaseParser— implementFeedParserYahooFeed— HTTP polling feed for equitiesBookBuilder— reconstructs a full order book from incremental updates
mercury-gateway
Exchange connectivity for order submission.
BinanceGateway,CoinbaseGateway,BybitGateway,KrakenGateway,OkxGatewayPaperGateway— local matching engine, configurable RTT latency- All implement
ExchangeGateway
mercury-strategy
Strategy implementations and technical indicators.
StrategyRunner— dispatches events to strategies- Strategies:
MarketMaker,Momentum,RsiStrategy,ArbitrageStrategy,InferenceStrategy - Indicators:
SMA,EMA,RSI,MACD
mercury-risk
Pre-trade risk checks.
RiskManager— position limits, daily loss guard, per-order rate limiting, kill switch
mercury-execution
Order lifecycle management.
OrderManager— wraps gateway with risk checks, tracks open ordersSimulatedExchange— for backtestingSmartOrderRouter— best-execution routing across venuesExecutionMetrics
mercury-replay
Data recording and deterministic replay.
Recorder— writes tick data to Parquet filesPlayer— replays a Parquet file at configurable speed
mercury-metrics
Observability.
LatencyTracker— HDR histograms for fill-to-ack, event-to-signalPnlTracker— realized and unrealized PnL- Prometheus export endpoint
mercury-storage
Persistence.
StorageManager— SQLite via sqlx, persistsFillevents to atradestable- Migrations in
crates/storage/migrations/
mercury-cli (binary: mercury)
Top-level binary with subcommands: run, replay, backtest, record.
mercury-tui (binary: mercury-tui)
Real-time ratatui dashboard. Connects to mercury via /tmp/mercury.sock.
Concurrency Model
Hot path is lock-free
EventBususes crossbeam bounded channels — no mutex on publish/receive.Poolusesparking_lot::Mutex, which is fair and low-overhead.Symbolis a[u8; 16]stack array — no heap allocation on the critical path.
Task topology
Each long-running component runs as an independent tokio::spawn task:
| Task | Notes |
|---|---|
| FeedManager | One task per exchange connection. Handles WebSocket reconnection. |
| StrategyRunner | Single-threaded. Owns all strategy instances. Calls on_book/on_trade synchronously. |
| OrderManager | Awaits signals; submits orders to the gateway asynchronously. |
| StorageManager | Batches fill writes to SQLite. |
| IpcServer | Fans out to connected TUI clients. Non-blocking; slow clients are dropped. |
Strategy thread safety
Strategy is Send + Sync, but on_book and on_trade take &mut self. StrategyRunner owns the strategies and calls them from a single Tokio task — no concurrent mutation, no locking needed inside strategy implementations.
Back-pressure
The EventBus channel has a bounded capacity (default 100,000). If a slow consumer (e.g. StorageManager during a disk flush) falls behind, producers will block. This prevents unbounded memory growth but means the market feed task may stall. Size the channel according to your expected burst rate.
Data Types
Decimal arithmetic
All prices and quantities use rust_decimal::Decimal. Never use f64 for financial values — floating-point rounding errors compound over time and can cause incorrect P&L calculations.
Use the dec!() macro for literals:
#![allow(unused)]
fn main() {
use rust_decimal_macros::dec;
let price = dec!(42_150.50);
let qty = dec!(0.001);
let notional = price * qty; // Decimal, exact
}
Symbol
Symbol is a fixed-size [u8; 16] stack array. This avoids heap allocation on the hot path.
#![allow(unused)]
fn main() {
let sym: Symbol = Symbol::new("BTCUSDT");
let sym: Symbol = "BTCUSDT".into(); // same thing
}
Strings longer than 16 bytes are silently truncated. Keep symbol names short.
Order
#![allow(unused)]
fn main() {
pub struct Order {
pub id: OrderId,
pub symbol: Symbol,
pub exchange: Exchange,
pub side: Side, // Bid | Ask
pub order_type: OrderType, // Limit | Market | PostOnly
pub price: Decimal,
pub quantity: Decimal,
pub time_in_force: TimeInForce, // GTC | IOC | FOK | PostOnly
}
}
Fill
#![allow(unused)]
fn main() {
pub struct Fill {
pub order_id: OrderId,
pub symbol: Symbol,
pub exchange: Exchange,
pub side: Side,
pub price: Decimal,
pub quantity: Decimal,
pub fee: Decimal,
pub timestamp: i64, // Unix millis
}
}
Signal
Strategies emit Signal values that the OrderManager converts into orders:
#![allow(unused)]
fn main() {
pub struct Signal {
pub symbol: Symbol,
pub side: Side,
pub price: Decimal,
pub quantity: Decimal,
pub strategy: &'static str,
}
}
Market Feeds
The mercury-market crate manages all inbound market data.
FeedManager
FeedManager owns one or more WebSocket connections and routes parsed messages onto the EventBus. It handles reconnection automatically on disconnect.
Implementing a new feed
Implement the FeedParser trait:
#![allow(unused)]
fn main() {
pub trait FeedParser: Send + Sync {
/// Return the WebSocket URL for this exchange/symbol.
fn ws_url(&self, symbol: &Symbol) -> String;
/// Return the JSON subscription message to send after connecting.
fn subscribe_message(&self, symbol: &Symbol) -> String;
/// Parse a raw WebSocket message into zero or more events.
fn parse(&self, msg: &str) -> Vec<EventPayload>;
}
}
Once implemented, register your parser with FeedManager::add_feed.
Supported feeds
| Parser | Exchange | Data |
|---|---|---|
BinanceParser | Binance | Order book depth (L2), trades |
CoinbaseParser | Coinbase | Order book depth (L2), trades |
BybitParser | Bybit | Order book depth (L2), trades |
KrakenParser | Kraken | Order book depth (L2), trades |
OkxParser | OKX | Order book depth (L2), trades |
PolymarketParser | Polymarket CLOB | Order book snapshots and deltas |
KalshiParser | Kalshi | Order book snapshots and deltas |
YahooFeed | Yahoo Finance | OHLCV bars (HTTP polling) |
BookBuilder
BookBuilder maintains a full L2 order book from incremental BookUpdate events. It applies sequence-numbered deltas and publishes a fresh Arc<BookUpdate> snapshot after each delta is applied.
Gateways
Gateways implement the ExchangeGateway trait and handle order submission to exchanges.
ExchangeGateway trait
#![allow(unused)]
fn main() {
#[async_trait]
pub trait ExchangeGateway: Send + Sync {
async fn connect(&mut self) -> GatewayResult<()>;
async fn submit_order(&self, order: &Order) -> GatewayResult<OrderId>;
async fn cancel_order(&self, id: &OrderId, symbol: &Symbol) -> GatewayResult<()>;
fn exchange(&self) -> Exchange;
}
}
Supported gateways
| Gateway | Exchange | Notes |
|---|---|---|
BinanceGateway | Binance Spot | HMAC-SHA256 signing |
CoinbaseGateway | Coinbase Advanced Trade | HMAC-SHA256 signing |
BybitGateway | Bybit v5 | HMAC-SHA256 signing |
KrakenGateway | Kraken v0 | HMAC-SHA512 with nonce |
OkxGateway | OKX v5 | HMAC-SHA256, base64 |
PolymarketGateway | Polymarket CLOB | HMAC-SHA256; fills via WebSocket user channel |
KalshiGateway | Kalshi v2 | RSA-SHA256 private key signing |
PaperGateway | — | Local simulated matching |
PaperGateway
PaperGateway is a local matching engine that simulates order fills against live BookUpdate events on the bus. It is the recommended way to develop and test strategies.
- Default simulated RTT: 5 ms
- No API keys required
- Enabled with
--paperCLI flag
Configurable latency:
#![allow(unused)]
fn main() {
PaperGateway::with_latency(Duration::from_millis(10))
}
Adding a new gateway
- Create
crates/gateway/src/myexchange.rs - Implement
ExchangeGateway - Add the variant to
Exchangeenum inmercury-core - Register in
crates/cli/src/main.rs(gateway match and feed_manager match)
Order Manager
OrderManager sits between strategies and the exchange gateway. It applies risk checks before forwarding orders and tracks their lifecycle.
Responsibilities
- Receive
Signalevents from theEventBus. - Convert signals to
Ordervalues. - Call
RiskManager::check— reject orders that breach limits. - Submit approved orders via
ExchangeGateway::submit_order. - Track open orders by
OrderId. - Publish
Fillevents back onto theEventBuswhen acknowledgements arrive.
Smart order routing
SmartOrderRouter wraps multiple gateways and selects the venue with the best available price for the given symbol and side. It is opt-in; the default configuration uses a single gateway.
Execution algorithms
Large parent orders can be worked using algorithmic executors. Each executor runs as a background tokio task and emits child Signal events onto the EventBus, which OrderManager receives and processes normally.
| Executor | Description |
|---|---|
TwapExecutor | Splits a parent order into N equal-sized slices emitted at uniform time intervals (time-weighted average price). |
VwapExecutor | Sizes each slice in proportion to observed market trade volume for the interval; falls back to equal-weight when no volume is seen. |
PovExecutor | Participates as a configurable percentage of observed market volume, bounded by min/max slice sizes, with a wall-clock deadline that flushes any residual quantity. |
Usage
#![allow(unused)]
fn main() {
use mercury_execution::{TwapExecutor, VwapExecutor, PovExecutor};
// TWAP — 10 equal slices over 5 minutes
TwapExecutor::new(bus.clone()).execute(signal.clone(), 300, 10);
// VWAP — 8 volume-proportional slices over 5 minutes
VwapExecutor::new(bus.clone()).execute(signal.clone(), 300, 8);
// POV — 10% participation, min 0.01 / max 1.0 per slice, 10-minute deadline
PovExecutor::new(bus.clone()).execute(
signal,
0.10, // participation_rate
FixedPoint::from_decimal(dec!(0.01)), // min_slice_qty
FixedPoint::from_decimal(dec!(1.0)), // max_slice_qty
600, // deadline_secs
);
}
All three executors observe Trade events on the EventBus to measure market volume. No additional configuration is required beyond passing the shared Arc<EventBus>.
Risk Manager
RiskManager enforces pre-trade risk controls. Every order passes through it before reaching the gateway.
Checks
| Check | Description |
|---|---|
| Position limit | Rejects orders that would exceed the configured max position size |
| Daily loss guard | Halts trading when realized + unrealized loss exceeds the daily limit |
| Rate limit | Enforces a maximum number of orders per second |
| Kill switch | Immediately blocks all new orders; drains open orders via cancel |
Configuration
#![allow(unused)]
fn main() {
RiskConfig {
max_position: dec!(1.0), // max open qty per symbol
daily_loss_limit: dec!(500.0), // USD
max_orders_per_sec: 10,
}
}
Kill switch
Activate programmatically:
#![allow(unused)]
fn main() {
risk_manager.kill();
}
Or via the TUI with the K keybinding. Once triggered, the engine stops submitting new orders and attempts to cancel all open positions.
Storage
StorageManager persists Fill events to a SQLite database using sqlx.
Schema
Migrations live in crates/storage/migrations/. The current schema has one table:
CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
symbol TEXT NOT NULL,
exchange TEXT NOT NULL,
side TEXT NOT NULL, -- 'buy' | 'sell'
price TEXT NOT NULL, -- stored as decimal string
quantity TEXT NOT NULL,
fee TEXT NOT NULL,
timestamp INTEGER NOT NULL -- Unix millis
);
Prices and quantities are stored as decimal strings to preserve exact precision.
Configuration
Default database path: mercury.db in the working directory.
Use :memory: for tests:
#![allow(unused)]
fn main() {
StorageManager::new(":memory:").await?;
}
Adding new tables
- Create a new migration file in
crates/storage/migrations/following theNNNN_description.sqlnaming convention. - sqlx applies migrations automatically on
StorageManagerstartup.
Replay & Recorder
The mercury-replay crate provides tools for recording live market data and replaying it deterministically.
Recorder
Recorder subscribes to BookUpdate and Trade events on the bus and writes them to a Parquet file.
# Record 120 seconds of BTCUSDT tick data
cargo run --bin mercury -- record \
--symbol BTCUSDT \
--output data.parquet \
--duration 120
Default duration is 60 seconds. The output file can be replayed or used for backtesting.
Player
Player reads a Parquet file and replays events onto the EventBus at a configurable speed multiplier.
# Replay at real-time speed
cargo run --bin mercury -- replay --file data.parquet --speed 1.0
# Replay at 10x speed
cargo run --bin mercury -- replay --file data.parquet --speed 10.0
Backtest
The backtest subcommand combines Player with a SimulatedExchange to run a strategy over historical data and report PnL:
cargo run --bin mercury -- backtest \
--file data.parquet \
--strategy market_maker
Results are printed to stdout and written to backtest_results.json.
Parquet schema
Each row represents one tick:
| Column | Type | Description |
|---|---|---|
timestamp | INT64 | Unix millis |
symbol | BYTE_ARRAY | Trading symbol |
bid_price | BYTE_ARRAY | Best bid (decimal string) |
ask_price | BYTE_ARRAY | Best ask (decimal string) |
bid_qty | BYTE_ARRAY | Best bid quantity |
ask_qty | BYTE_ARRAY | Best ask quantity |
Metrics
mercury-metrics provides latency histograms, PnL tracking, and a Prometheus export endpoint.
LatencyTracker
Uses HDR histograms (via hdrhistogram) to record latency with microsecond precision across several orders of magnitude.
Key measurements:
- Feed-to-strategy: time from
BookUpdatepublish toon_bookreturn - Signal-to-submit: time from
Signalpublish tosubmit_ordercall - Submit-to-fill: exchange round-trip time
Access percentiles:
#![allow(unused)]
fn main() {
let p99 = tracker.percentile(99.0); // nanoseconds
let p999 = tracker.percentile(99.9);
}
PnlTracker
Tracks realized and unrealized PnL per symbol:
#![allow(unused)]
fn main() {
tracker.on_fill(&fill);
let pnl = tracker.unrealized_pnl(&symbol, current_mid_price);
}
Prometheus
Metrics are exported on 0.0.0.0:9090/metrics when --metrics is passed. Compatible with any Prometheus scrape target.
Available metrics:
mercury_fill_latency_ns— histogrammercury_pnl_realized_usd— gauge per symbolmercury_orders_submitted_total— countermercury_orders_cancelled_total— countermercury_risk_rejections_total— counter
TUI Monitor
mercury-tui is a standalone binary that connects to a running mercury process via a Unix socket and displays a real-time dashboard.
Starting the TUI
cargo run --bin mercury-tui
The TUI connects to /tmp/mercury.sock. Start mercury first.
Dashboard panels
- Order book — live L2 depth, colour-coded bid/ask
- Fills — scrolling list of recent fills with price, quantity, and exchange
- PnL — realized and unrealized PnL per symbol
- Latency — p50/p99/p999 for feed-to-signal and signal-to-fill
- Orders — open order list with status
Keybindings
| Key | Action |
|---|---|
q | Quit |
K | Activate kill switch (cancel all, halt trading) |
↑/↓ | Scroll fills / order list |
Tab | Switch panel focus |
IPC protocol
mercury broadcasts JSON-encoded Event values over /tmp/mercury.sock. The TUI deserializes them and updates its state. Multiple TUI instances can connect simultaneously.
Desktop GUI
Mercury features a premium, GPU-accelerated native desktop trading console built on the modern, functional APIs of Iced v0.14.0.
The GUI is themed around a highly polished Obsidian Cyberpunk palette, designed to deliver a gorgeous visual flow matching professional institutional trading terminals (like Bloomberg and TradingView):
- Deep Obsidian Black (
[10, 10, 12]/[20, 20, 25]): Solid, high-depth backgrounds. - Neon Teal (
[0, 240, 200]): Highlight color for buying, live status, inventory increases, and active input highlights. - Neon Pink/Coral (
[255, 60, 120]): Highlight color for selling, offline state, and risk alerts. - Slate Gray (
[150, 150, 155]): Muted color for secondary indicators, grid lines, and subtitles.
Architecture Overview
The GUI operates on a non-blocking asynchronous design conforming to the Elm architecture (State, Subscription, Update, View) without introducing heap-allocation overheads to the trading hot-path.
Desktop GUI (Iced)
├── Spawner Task ──►tokio::process::Child (Engine process)
│ │ (pipes stdout/stderr)
│ ▼
│ System Logs Terminal Panel
│
└── Telemetry Stream ◄──Unix Socket (/tmp/mercury.sock)
│ (receives L2 snapshots & latencies)
▼
- Depth Map Canvas
- Sparkline Canvas
- Header Metrics Card Deck
The GUI consists of three main architectural subsystems:
1. Detached Subprocess Spawner & Log Piping
To provide a comprehensive control console, the GUI allows starting and stopping the CLI trading engine directly:
- State Safety: Storing child process streams directly in Iced state structs breaks Rust’s compilation because the handles are not
Sync. To circumvent this, Mercury registers a thread-safe static communication channel (LOG_TX: Mutex<Option<mpsc::UnboundedSender<Message>>>) mapped to an unfolding asynchronous subscription stream. - stdout/stderr Piping: Spawns
mercuryexecutable (resolved dynamically viastd::env::current_exe()) in the background. Standard output and errors are captured, routed through a non-blockingtokio::select!block, and piped into the scrolling System Logs terminal panel. - Graceful Shutdown: Sends a termination trigger via a oneshot channel to safely kill (
child.kill()) and wait on (child.wait()) the background task, returning the dashboard to offline state.
2. Unix Socket Telemetry Stream
Upon launching, the GUI automatically begins subscribing to the Unix domain socket /tmp/mercury.sock published by the engine’s IpcServer. An auto-reconnecting stream reads serialized JSON events, unpacking:
BookUpdate: High-frequency order book depth layers.Trade: Active fills matching on the matching engine.LatencyReport: Latency calculations showing fast packet delivery statistics.
3. GPU-Accelerated Graphic Canvases
The L2 Liquidity Wall and Telemetry Sparklines are drawn inside Iced’s canvas::Program widgets, which leverage GPU acceleration for instantaneous redraws on every tick:
- L2 Depth Map (
depth_map.rs): Renders cumulative bid (Teal) and ask (Pink) volume walls. It maps price ranges (min_price,mid_price,max_price) and volume bars mathematically to dynamic grid ticks along the margins. - Telemetry Sparkline (
sparkline.rs): Tracks real-time p50 latency historical movements with a glowing line chart, calculating and presenting latency markers (e.g.max: 18.5 μs,mid,min) on the vertical margin.
Visual Control Features
The dashboard layout partitions into three specialized control zones:
1. Top Card Ticker Deck
An obsidian-style deck composed of individual sub-cards with thin borders and subtle padding:
- Branding Badge: Displaying
"MERCURY"in Neon Teal and subtitle"HFT ENGINE v1.0.0". - Active Symbol Card: Keeps active currency focus (e.g.,
BTCUSDT). - System Status Card: Real-time engine health using a dynamic green-teal or pink-red dot indicator and active label.
- Net Position Exposure: Instantly sums the net position size for the current symbol and color-codes the badge (Neon Teal for positive inventory, Neon Pink for short positions, Gray for neutral).
- Unrealized & Active PnL Card: Displays the live dollar-value strategy PnL, highlighting positive or negative returns with dynamic HFT color schemes.
- Bid/Ask Spread Ticker: Compares
best_bidandbest_askin real-time, displaying the absolute and percentage-based bid-ask spread dynamically.
2. Quick Execution & Launch Console
Located in the right panel:
- Engine Launcher: Type the target symbol, select strategy tabs (“RSI”, “Maker”, “Mom”, “Arb”, “ML”), select Paper or Live, and click START ENGINE. The action button glows vibrant Neon Teal (Start) or Neon Pink (Stop) on hover.
- Manual Overrides Console: Allows setting manual execution quantities and custom limit prices (with focused teal highlight borders) to place urgent BUY / LONG or SELL / SHORT orders.
- Emergency Kill Switch: A prominent red button that calls the risk engine to cancel all outstanding orders and immediately flatten active positions.
3. Middle Pane Tabs
Traders can swap content between:
- Price Chart: A high-end OHLCV Candlestick chart featuring real-time trade aggregation, bottom volume bars, dynamic right price tick scales, and transaction execution marks mapped as diamonds.
- Matching Prints: Live feed table showing historical ticks, fill times, buy/sell flags, and filled quantities.
- System Logs: A color-coded terminal viewport displaying real-time stdout/stderr lines from the running subprocess, highlighted for warnings and system updates.
Compiling & Running
Ensure you have the required graphics libraries on your OS (Vulkan, Metal, or DX12 compatible GPU drivers).
# Build and execute the GUI terminal
cargo run -p mercury-gui
Strategies Overview
Strategies implement the Strategy trait and are driven by the StrategyRunner.
Strategy trait
#![allow(unused)]
fn main() {
pub trait Strategy: Send + Sync {
fn on_book(&mut self, book: &Arc<BookUpdate>) -> Vec<Signal>;
fn on_trade(&mut self, trade: &Trade) -> Vec<Signal>;
fn on_fill(&mut self, fill: &Fill);
fn reset(&mut self);
fn name(&self) -> &'static str;
}
}
on_book— called on every order-book snapshot; return signals to trade.on_trade— called on every tape print.on_fill— called when one of this strategy’s orders is filled; use to update inventory.reset— clear all state (used before backtests and replays).
Built-in strategies
| Name | Flag | Description |
|---|---|---|
MarketMaker | market_maker | Symmetric quotes with inventory skew and HMM regime gating (2× spread when trending, 0.75× when mean-reverting) |
Momentum | momentum | Trend-following using EMA crossover |
RsiStrategy | rsi | Mean-reversion using RSI overbought/oversold |
ArbitrageStrategy | arbitrage | Cross-exchange price discrepancy |
InferenceStrategy | inference | ONNX or online Adam-SGD classifier on 12-feature vector with Welford z-score normalisation |
RlQuotePlacementStrategy | rl | DQN quote placement; 7-state input adds HMM trend probability alongside 6 market features |
Technical indicators
All indicators live in mercury-strategy and operate on Decimal values:
SMA(period)— simple moving averageEMA(period)— exponential moving averageRSI(period)— relative strength indexMACD(fast, slow, signal)— moving average convergence/divergence
Market Maker
MarketMaker posts symmetric limit orders on both sides of the mid-price and adjusts quotes on every book update.
Algorithm
- Compute
mid = (best_bid + best_ask) / 2. - Feed the mid price into the HMM regime filter and apply a spread multiplier based on the detected regime.
- Apply inventory skew: if long, widen the ask and tighten the bid to mean-revert.
- Place a bid at
mid - half_spreadand an ask atmid + half_spread. - On the next book update, cancel stale quotes and replace with fresh ones (peg mode reduces churn by only replacing when price moves beyond a threshold).
Parameters
| Parameter | Default | Description |
|---|---|---|
spread | dec!(0.002) | Base total spread as a fraction of mid |
order_qty | dec!(0.01) | Order size in base currency |
inventory_skew | dec!(0.5) | Multiplier for inventory-based skew |
peg_threshold | dec!(0.0005) | Min price move before requoting (peg mode) |
Regime-gated spread scaling
MarketMaker embeds an HmmFilter (two-state hidden Markov model) that classifies the market into Trending or MeanReverting based on the sign persistence of consecutive log returns.
| Regime | Spread multiplier | Rationale |
|---|---|---|
| Trending | 2.0× | Informed directional flow → widen to reduce adverse selection |
| MeanReverting | 0.75× | Noise-driven oscillation → tighten to capture more fills |
| Uncertain (< 0.60 confidence) | 1.0× | Default spread, no adjustment |
The HMM needs at least two price updates before emitting a confident regime. During warm-up the multiplier is 1.0.
Customise via RegimeConfig:
#![allow(unused)]
fn main() {
use mercury_strategy::{MarketMaker, RegimeConfig, HmmConfig};
let regime = RegimeConfig {
hmm: HmmConfig {
stay_probability: 0.95,
confidence_threshold: 0.65, // require higher confidence before scaling
..HmmConfig::default()
},
trending_spread_mult: 2.5, // even wider in strong trends
mean_revert_spread_mult: 0.80,
};
let mm = MarketMaker::new("BTCUSDT", dec!(0.002), dec!(0.01))
.with_regime_config(regime);
}
Peg mode
Peg mode (enabled by default) avoids unnecessary cancel/replace cycles. A quote is only refreshed when the mid-price moves more than peg_threshold from the last quoted price. This reduces exchange fees and order rate usage.
Momentum
Momentum is a trend-following strategy that uses an EMA crossover to generate directional signals.
Algorithm
- Maintain a fast EMA (default period 12) and a slow EMA (default period 26) over trade prices.
- When fast EMA crosses above slow EMA: emit a buy signal.
- When fast EMA crosses below slow EMA: emit a sell signal.
- No signal is emitted until both EMAs have warmed up (require
slow_periodtrades).
Parameters
| Parameter | Default | Description |
|---|---|---|
fast_period | 12 | Fast EMA period |
slow_period | 26 | Slow EMA period |
order_qty | dec!(0.01) | Order size per signal |
RSI Strategy
RsiStrategy is a mean-reversion strategy based on the Relative Strength Index.
Algorithm
- Compute RSI over the last
periodtrade prices (default 14). - When RSI drops below
oversold(default 30): emit a buy signal. - When RSI rises above
overbought(default 70): emit a sell signal. - No signal is emitted until the RSI has warmed up.
Parameters
| Parameter | Default | Description |
|---|---|---|
period | 14 | RSI lookback period |
oversold | 30 | RSI level that triggers a buy |
overbought | 70 | RSI level that triggers a sell |
order_qty | dec!(0.01) | Order size per signal |
Arbitrage Strategy
ArbitrageStrategy monitors the same symbol across two exchanges and trades when the price discrepancy exceeds transaction costs.
Algorithm
- Maintain best bid/ask for the symbol on both exchanges.
- When
exchange_a.ask < exchange_b.bid - threshold: buy on A, sell on B. - When
exchange_b.ask < exchange_a.bid - threshold: buy on B, sell on A. - Block re-entry until both legs of the previous trade are filled.
Parameters
| Parameter | Default | Description |
|---|---|---|
min_spread | dec!(0.001) | Minimum price discrepancy to trade |
order_qty | dec!(0.01) | Order size per leg |
Notes
True cross-exchange arbitrage requires near-simultaneous execution on both venues. Network latency between exchanges will erode the opportunity. This strategy is most useful in paper-trading mode to study spread behaviour.
ML Inference Strategy
InferenceStrategy generates buy/sell signals from a 12-feature order-book vector using either a pre-trained ONNX model or an online Adam-SGD classifier that trains itself in real-time.
How it works
- On every
on_bookcall,FeatureComputerbuilds a 12-element feature vector from the current book snapshot and recent trade history. RunningNormalizerstandardises the vector to approximately zero mean / unit variance (active after a 30-tick warm-up; raw features are used before that).- The normalised vector is either:
- ONNX path — fed to a
tract-onnxplan loaded from disk. - Online fallback — classified by an incremental 3-class softmax trained with Adam via delayed self-labelling (label assigned after
lookahead_ticksticks using mid-price movement).
- ONNX path — fed to a
[sell_prob, hold_prob, buy_prob]drives a threshold gate (0.65 for ONNX, 0.60 for online); a signal is emitted only when one class exceeds the threshold.
Feature vector (12 features)
| Index | Feature | Range |
|---|---|---|
| 0 | Order imbalance (bid_qty − ask_qty) / total | [−1, 1] |
| 1 | Spread bps / 100 | [0, ∞) |
| 2 | RSI-14 / 100 | [0, 1] |
| 3 | MACD histogram (sign × clamped magnitude) | [−1, 1] |
| 4 | Depth ratio top-5 bid / total | [0, 1] |
| 5 | EMA-50 deviation (mid − ema) / ema | uncapped |
| 6 | ATR-14 / mid | [0, ∞) |
| 7 | VWAP deviation, 100-trade rolling | uncapped |
| 8 | Depth slope (bid[0] − bid[4]) / mid | uncapped |
| 9 | Trade-flow imbalance, 20-trade rolling | [0, 1] |
| 10 | Kyle’s Lambda (tanh-squashed price-impact coeff) | (−1, 1) |
| 11 | VPIN trade toxicity | [0, 1] |
compute_extended() appends a 13th feature (index 12): Hawkes trade-arrival intensity.
Online normalisation
RunningNormalizer runs Welford’s one-pass algorithm to accumulate per-feature mean and variance. Features are normalised in-place before reaching the classifier:
z_i = (x_i − μ_i) / max(σ_i, 1e-8)
Normalisation activates after 30 observations. Before warm-up, raw features are used unchanged.
Online learning (no ONNX model)
When no model file is supplied, the strategy trains an in-process softmax classifier with the Adam optimizer (β₁ = 0.9, β₂ = 0.999, ε = 1e-8, lr = 0.001, L2 = 1e-4):
- Each tick the feature vector is stored alongside the current mid price.
- After
lookahead_ticksticks, the mid-price change determines the label:+1(price rose > threshold),-1(fell),0(flat). - An Adam gradient step updates the 3-class weight matrix
(3 × 12). - Signals are emitted only after
min_updates_before_signalupdates (default 25) to avoid noise during cold-start.
#![allow(unused)]
fn main() {
let config = OnlineLearningConfig {
lookahead_ticks: 10,
threshold: 0.0005, // 0.05% price move to assign a label
min_updates_before_signal: 25,
classifier: OnlineClassifierConfig {
learning_rate: 0.001,
l2: 0.0001,
beta1: 0.9,
beta2: 0.999,
epsilon: 1e-8,
},
};
let strat = InferenceStrategy::new("BTCUSDT", dec!(0.01), None, Some(bus))
.with_online_config(config);
}
Loading an ONNX model
cargo run --bin mercury -- run \
--symbol BTCUSDT \
--paper \
--strategy inference \
--model path/to/model.onnx
The expected ONNX input shape is [1, 12] with output shape [1, 3] (softmax over [SELL, HOLD, BUY]). A [1, 2, 20] LOB CNN model is also supported via InferenceStrategy::new_lob_cnn.
Model checkpointing
Online classifier weights can be saved and restored between sessions:
#![allow(unused)]
fn main() {
// Save after a live session
clf.save("btcusdt_weights.json")?;
// Restore at startup
let mut clf = OnlineClassifier::new(OnlineClassifierConfig::default());
clf.load("btcusdt_weights.json")?;
}
The checkpoint format is compact JSON containing updates, bias[3], and weights[36] (3 classes × 12 features).
ML Components
Mercury’s strategy crate ships self-contained machine-learning building blocks — no Python, no external service, all pure Rust.
Feature engineering (FeatureComputer)
FeatureComputer maintains running indicator state and produces a 12-element f32 vector on every on_book call.
| Index | Feature | Range | Notes |
|---|---|---|---|
| 0 | Order imbalance | [−1, 1] | (bid_qty − ask_qty) / total |
| 1 | Spread bps / 100 | [0, ∞) | |
| 2 | RSI-14 / 100 | [0, 1] | |
| 3 | MACD histogram | [−1, 1] | sign × clamped magnitude |
| 4 | Depth ratio top-5 | [0, 1] | bid depth fraction |
| 5 | EMA-50 deviation | uncapped | (mid − ema) / ema |
| 6 | ATR-14 / mid | [0, ∞) | |
| 7 | VWAP deviation | uncapped | 100-trade rolling |
| 8 | Depth slope | uncapped | (bid[0] − bid[4]) / mid |
| 9 | Trade-flow imbalance | [0, 1] | 20-trade rolling buy fraction |
| 10 | Kyle’s Lambda | (−1, 1) | tanh(λ × 1000) — price-impact coefficient |
| 11 | VPIN | [0, 1] | volume-synchronised toxicity |
compute_extended() appends a 13th feature at index 12: bounded Hawkes trade-arrival intensity.
Online normalisation (RunningNormalizer)
Implements Welford’s one-pass algorithm to track per-feature mean and variance without storing history.
#![allow(unused)]
fn main() {
pub struct RunningNormalizer {
mean: [f64; FEATURE_COUNT],
m2: [f64; FEATURE_COUNT], // Welford M2 accumulator
count: u64,
min_count: u64, // warm-up ticks before normalisation activates
}
}
Features are standardised in-place: z_i = (x_i − μ_i) / max(σ_i, 1e-8).
Before min_count observations, features are returned unchanged.
InferenceStrategy uses a 30-tick warm-up by default.
Adam optimizer (OnlineClassifier)
3-class softmax logistic regression trained with the Adam adaptive gradient method.
| Hyperparameter | Default | Description |
|---|---|---|
learning_rate | 0.001 | Adam α |
beta1 | 0.9 | First-moment decay |
beta2 | 0.999 | Second-moment decay |
epsilon | 1e-8 | Numerical stability |
l2 | 1e-4 | L2 weight decay |
Update rule (per weight):
m_t = β₁·m_{t-1} + (1−β₁)·g
v_t = β₂·v_{t-1} + (1−β₂)·g²
α_t = α · √(1−β₂ᵗ) / (1−β₁ᵗ)
w ← w − α_t · m̂_t / (√v̂_t + ε)
HMM regime filter (HmmFilter)
Two-state online forward filter classifying the market into Trending or MeanReverting.
Observation: sign persistence of consecutive log returns (+1 same direction, −1 alternating).
States:
Trending— emission mean +0.75 (returns persist in the same direction).MeanReverting— emission mean −0.75 (returns alternate sign).
#![allow(unused)]
fn main() {
let mut hmm = HmmFilter::default();
hmm.update_price(50_000.0); // 1st price — no output
hmm.update_price(50_050.0); // 2nd — computes 1st return, no output
let regime = hmm.update_price(50_100.0); // 3rd — first regime estimate
// Some(Regime::Trending)
let trend_prob = hmm.trend_probability(); // ∈ [0, 1]
}
A confident regime is declared only when trend_probability ≥ confidence_threshold (default 0.60).
Integration points:
MarketMaker— spread multiplier: 2.0× trending, 0.75× mean-reverting.RlQuotePlacementStrategy—trend_probability − 0.5as state dimension 6.
Microstructure estimators (microstructure.rs)
Kyle’s Lambda
Price-impact coefficient estimated via rolling OLS on signed order flow:
λ = Σ(Q_i · ΔP_i) / Σ(Q_i²)
where Q is signed volume (+buy, −sell) and ΔP is the concurrent mid-price change. Rising λ indicates informed directional flow.
#![allow(unused)]
fn main() {
let mut kl = KyleLambda::new(20); // 20-trade rolling window
kl.on_trade(1.0, Side::Buy, 0.01); // qty, side, price_change
let lambda = kl.lambda(); // Some(f64) once window is full
}
Roll Spread
Estimates the effective half-spread from the serial covariance of price changes (Roll, 1984):
c = √(−Cov(ΔP_t, ΔP_{t−1}))
Valid only when serial covariance is negative (as expected when bid-ask bounce dominates).
#![allow(unused)]
fn main() {
let mut rs = RollSpread::new(20);
rs.on_price(100.0);
let half_spread = rs.half_spread(); // Some(f64) once window full and Cov < 0
}
VPIN
Volume-synchronised probability of informed trading (Easley et al., 2012):
- Volume is divided into fixed-size buckets.
- Each bucket:
vpin = |buy_vol − sell_vol| / bucket_size. - Rolling average over the last
window_bucketsgives VPIN ∈ [0, 1]. - Values > 0.5 indicate a toxic order-flow environment.
#![allow(unused)]
fn main() {
let mut vpin = Vpin::new(10.0, 20); // bucket_size=10, 20-bucket window
vpin.on_trade(1.0, Side::Buy);
let score = vpin.vpin(); // 0.0 until first bucket completes
}
Model checkpointing (checkpoint.rs)
Online classifier (JSON)
#![allow(unused)]
fn main() {
// Save weights + bias + update count.
clf.save("model.json")?;
// Restore.
let mut clf = OnlineClassifier::new(OnlineClassifierConfig::default());
clf.load("model.json")?;
}
Format: {"updates": u64, "bias": [f32; 3], "weights": [f32; 36]} (3 × 12 features).
DQN network (binary f32 LE)
#![allow(unused)]
fn main() {
// Flatten each weight matrix then save as one binary blob.
save_f32_weights("dqn.bin", &[&w1_flat, &w2_flat, &w3_flat])?;
// Load and split by known sizes.
let weights = load_f32_weights("dqn.bin", total_params)?;
}
Dimension mismatch on load returns io::Error::InvalidData.
Writing a Custom Strategy
1. Create the file
Add crates/strategy/src/my_strategy.rs and implement the Strategy trait:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use mercury_core::{BookUpdate, Fill, Signal, Trade};
use crate::Strategy;
pub struct MyStrategy {
// your state here
}
impl MyStrategy {
pub fn new() -> Self {
Self {}
}
}
impl Strategy for MyStrategy {
fn name(&self) -> &'static str { "my_strategy" }
fn on_book(&mut self, book: &Arc<BookUpdate>) -> Vec<Signal> {
// compute signals from the order book
vec![]
}
fn on_trade(&mut self, _trade: &Trade) -> Vec<Signal> {
vec![]
}
fn on_fill(&mut self, _fill: &Fill) {
// update inventory / reinforce learning
}
fn reset(&mut self) {
// clear all state
*self = Self::new();
}
}
}
2. Export from the crate
In crates/strategy/src/lib.rs:
#![allow(unused)]
fn main() {
pub mod my_strategy;
pub use my_strategy::MyStrategy;
}
3. Register the CLI flag
In crates/cli/src/run.rs, add a match arm for the new strategy name:
#![allow(unused)]
fn main() {
"my_strategy" => Box::new(MyStrategy::new()),
}
4. Run it
cargo run --bin mercury -- run --symbol BTCUSDT --paper --strategy my_strategy
Tips
- Keep
on_bookfast — it is called on every tick. Avoid allocations on the hot path. - Use
rust_decimal::Decimalfor all price/quantity arithmetic. - Initialise warm-up state in
reset()so backtests start clean. - Use
on_fillto track inventory rather than counting signals, since orders may be partially filled or rejected.
Paper Trading
Paper trading runs Mercury against a live market feed but routes all orders through PaperGateway — a local matching engine. No real money is at risk.
Enable paper trading
cargo run --bin mercury -- run --symbol BTCUSDT --paper --strategy market_maker
How PaperGateway works
- Receives live
BookUpdateevents from the market feed. - When a new order arrives, checks whether it crosses the current best bid/ask.
- If it fills, publishes a synthetic
Fillevent onto theEventBusafter a configurable simulated delay. - Default simulated round-trip latency: 5 ms.
Custom latency
The paper gateway latency is set in code:
#![allow(unused)]
fn main() {
// crates/cli/src/run.rs
let gateway = PaperGateway::with_latency(Duration::from_millis(10));
}
Limitations
- No partial fills — orders either fill completely or not at all.
- No queue position — the paper gateway assumes your order is at the top of the queue.
- Simulated latency is constant; real latency is variable.
These simplifications make paper results optimistic. Use backtesting on recorded data for more realistic simulation.
Prediction Markets
Mercury supports Polymarket (CLOB-based) and Kalshi (regulated US) alongside crypto exchanges. Binary outcome contracts (YES/NO shares) map directly onto the existing OrderBook, BookUpdate, and Fill types — prices are stored as probabilities in FixedPoint (e.g. 0.42 = 42% probability).
Symbol derivation
Polymarket
Polymarket identifies markets by a 64-character hex condition_id. Because Symbol is 16 bytes, Mercury derives a stable key using the last 16 characters of the condition ID:
condition_id: 0xbd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af
Symbol key: 0ca09bd4532f84af
The low-order bytes of the uint256 are unique across all active markets, so no collision occurs in practice. The helper condition_id_to_symbol(cid) in mercury-market performs this mapping.
Kalshi
Kalshi market tickers (e.g. TRUMPWIN-2024) are ≤ 15 characters and fit directly in Symbol with no truncation.
Polymarket
Feed (market data)
PolymarketParser subscribes to the Polymarket CLOB WebSocket:
- URL:
wss://ws-subscriptions-clob.polymarket.com/ws/market - Subscription:
{"assets_ids": ["<condition_id>"], "type": "market"} - Messages:
book(full snapshot) →DepthSnapshot;price_change(delta) →DepthUpdate
Gateway (order execution)
PolymarketGateway uses the Polymarket CLOB REST API (https://clob.polymarket.com):
| Operation | Endpoint |
|---|---|
| Submit order | POST /order |
| Cancel order | DELETE /orders/{order_id} |
| Cancel all | DELETE /orders |
| Fills | WebSocket user channel wss://ws-subscriptions-clob.polymarket.com/ws/user |
Authentication: HMAC-SHA256 over {timestamp}{nonce}POST{body} using api_secret.
Configuration
#![allow(unused)]
fn main() {
PolymarketConfig {
api_key: String, // POLY-API-KEY header
api_secret: String, // HMAC signing key
api_passphrase: String, // POLY-PASSPHRASE header
}
}
Environment variables: EXCHANGE_API_KEY, EXCHANGE_SECRET_KEY, POLYMARKET_PASSPHRASE.
Usage
# Paper trade (live feed, simulated orders)
cargo run --bin mercury -- run \
--exchange polymarket \
--symbol 0xbd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af \
--paper \
--strategy momentum
# Live trading
EXCHANGE_API_KEY=your_api_key \
EXCHANGE_SECRET_KEY=your_api_secret \
POLYMARKET_PASSPHRASE=your_passphrase \
cargo run --bin mercury -- run \
--exchange polymarket \
--symbol 0x...condition_id... \
--strategy rsi
Kalshi
Feed (market data)
KalshiParser subscribes to the Kalshi WebSocket:
- URL:
wss://api.elections.kalshi.com/trade-api/ws/v2 - Subscription:
{"cmd": "subscribe", "params": {"channels": ["orderbook_delta"], "market_tickers": ["TICKER"]}} - Messages:
orderbook_snapshot→DepthSnapshot;orderbook_delta→DepthUpdate - Prices: Kalshi sends integer cents (e.g.
42= 42 cents = 0.42 probability). The parser converts toFixedPointautomatically.
Gateway (order execution)
KalshiGateway uses the Kalshi REST API (https://api.elections.kalshi.com/trade-api/v2):
| Operation | Endpoint |
|---|---|
| Submit order | POST /portfolio/orders |
| Cancel order | DELETE /portfolio/orders/{order_id} |
| Cancel all | DELETE /portfolio/orders?ticker={ticker} |
| Fills | WebSocket fills channel |
Authentication: RSA-SHA256 (PKCS#1v1.5). Signature message: {timestamp_ms}{method}{path}. The signed bytes are base64-encoded and sent in the KALSHI-ACCESS-SIGNATURE header.
Configuration
#![allow(unused)]
fn main() {
KalshiConfig {
api_key_id: String, // UUID from the Kalshi dashboard
private_key_pem: String, // PEM-encoded RSA private key
}
}
Environment variables: KALSHI_API_KEY_ID, KALSHI_PRIVATE_KEY (PEM string or path to a .pem file).
Usage
# Paper trade
cargo run --bin mercury -- run \
--exchange kalshi \
--symbol TRUMPWIN-2024 \
--paper \
--strategy rsi
# Live trading — pass PEM file path
KALSHI_API_KEY_ID=your-uuid \
KALSHI_PRIVATE_KEY=./kalshi_private.pem \
cargo run --bin mercury -- run \
--exchange kalshi \
--symbol TRUMPWIN-2024 \
--strategy rsi
Price representation
Both markets price YES shares between 0 and 1 (in cents: 0–100). FixedPoint handles this with 8 decimal places of precision — no floating-point rounding errors.
Kalshi: 42 cents → parser converts to 0.42 FixedPoint
Polymarket: "0.42" → FixedPoint::from_str("0.42")
Side::Buy means buying YES shares. Side::Sell means selling YES shares (equivalent to buying NO at 1 - price).
Backtesting
The backtest subcommand replays a recorded Parquet file through a strategy and reports PnL.
Run a backtest
cargo run --bin mercury -- backtest \
--file data.parquet \
--strategy market_maker
How it works
Playerreads the Parquet file and replaysBookUpdateevents in timestamp order.StrategyRunnerdrives the strategy as if events were live.SimulatedExchangematches orders against the replayed book with zero latency (conservative: assumes worst-case fill at the top of the book).- Results are printed to stdout and written to
backtest_results.json.
Output
Symbol: BTCUSDT
Strategy: market_maker
Trades: 1,243
Win rate: 54.2%
PnL: +$312.47
Max DD: -$48.20
Sharpe: 1.84
Tips
- Record a representative data set (at least a few hours) before running a backtest.
- Use
--speed 0to replay as fast as possible (no wall-clock sleep between ticks). - Run multiple strategies on the same file to compare them fairly.
Recording Market Data
The record subcommand captures live tick data to a Parquet file for later replay or backtesting.
Basic usage
# Record 60 seconds (default)
cargo run --bin mercury -- record --symbol BTCUSDT --output data.parquet
# Record 10 minutes
cargo run --bin mercury -- record --symbol BTCUSDT --output data.parquet --duration 600
Output format
The Parquet file contains one row per BookUpdate event with columns:
timestamp— Unix millisecondssymbol— trading symbolbid_price,bid_qty— best bidask_price,ask_qty— best ask
Prices and quantities are stored as UTF-8 decimal strings to preserve exact precision.
Storage size
Roughly 1–3 MB per minute for a single symbol at normal Binance update frequency (~10 updates/sec). A one-hour recording of BTCUSDT is typically 60–180 MB.
Using recorded data
# Replay at real-time speed
cargo run --bin mercury -- replay --file data.parquet --speed 1.0
# Run a backtest
cargo run --bin mercury -- backtest --file data.parquet --strategy rsi
Adding a New Exchange
Adding exchange support requires implementing two traits: FeedParser (market data) and ExchangeGateway (order routing).
1. Market feed
Create crates/market/src/myexchange.rs:
#![allow(unused)]
fn main() {
pub struct MyExchangeParser;
impl FeedParser for MyExchangeParser {
fn ws_url(&self, symbol: &Symbol) -> String {
format!("wss://stream.myexchange.com/ws/{}", symbol)
}
fn subscribe_message(&self, symbol: &Symbol) -> String {
serde_json::json!({ "op": "subscribe", "channel": symbol.to_string() }).to_string()
}
fn parse(&self, msg: &str) -> Vec<EventPayload> {
// deserialise msg and return BookUpdate / Trade events
vec![]
}
}
}
2. Gateway
Create crates/gateway/src/myexchange.rs and implement ExchangeGateway.
See crates/gateway/src/binance.rs as a reference for HMAC signing, REST error handling, and order ID mapping.
3. Add the Exchange variant
In crates/core/src/types.rs, add to the Exchange enum:
#![allow(unused)]
fn main() {
pub enum Exchange {
Binance,
Coinbase,
MyExchange, // add this
// ...
}
}
4. Wire up in the CLI
In crates/cli/src/run.rs, add a match arm for "myexchange" that constructs your feed parser and gateway.
5. Test
Write unit tests for parse() with real WebSocket message payloads captured from the exchange. Use cargo test -p mercury-market myexchange to run them.
Contributing
Development setup
git clone https://github.com/amjadjibon/mercury
cd mercury
cargo build --workspace
cargo test --workspace
Code conventions
- No
f64for financial values — userust_decimal::Decimaland thedec!()macro. Symbolis 16 bytes — strings longer than 16 bytes are silently truncated.- No heap allocation on the hot path — prefer stack types in
on_bookandon_trade. - Comments only when the why is non-obvious — well-named identifiers document the what.
Running the docs locally
cargo install mdbook
mdbook serve docs --open
Submitting changes
- Fork the repository and create a feature branch.
- Run
cargo test --workspaceandcargo build --workspace— both must pass with zero warnings. - Open a pull request with a description of what changed and why.