Strategy, trade lifecycle, order execution, and liquidation-anchored risk. Module layout is in ARCHITECTURE.md; coding conventions in PATTERNS.md; how to add a venue in EXCHANGE_ADD_GUIDE.md.
The same perpetual contract — say LIT-PERP — trades on several DEXs at once. Differences in architecture, liquidity, user base, and LP models keep their prices drifting apart. The bot harvests that drift.
It is generic over N exchanges. Five are integrated today — Lighter, Extended, Zero1, and Hyperliquid (non-custodial DEXs) plus Bybit (a CEX) — and the spread detector automatically walks every C(N, 2) pair. The examples below use one pair (Lighter ↔ Extended) for clarity, but the logic is identical for any pair (exchange_i, exchange_j).
The bot trades in both directions — it doesn't matter which venue is cheaper:
Direction A: bid_j > ask_i → buy exchange_i, sell exchange_j
Direction B: bid_i > ask_j → sell exchange_i, buy exchange_j
Example (i = Lighter, j = Extended):
Lighter ask = $1.8147 Extended bid = $1.8152
→ buy Lighter (long) → sell Extended (short)
Either way:
→ a delta-neutral position (long one leg, short the other, equal qty)
→ wait for the spread to converge
→ close both legs (opposite orders)
→ profit = open_spread − close_cost
The bot picks the direction with the largest positive spread across all available pairs. Profit is not locked in at entry — it's realized only on close. Until then the position is delta-neutral: a price move gains on one leg and loses the same on the other, so they cancel. The only real exposure is the spread widening further — and in practice spreads converge.
| Source | Mechanism | Hold time | Status |
|---|---|---|---|
| Price spread | bid_j > ask_i → enter; wait for convergence → exit |
seconds to 30+ min | The bot's core strategy |
| Funding-rate differential | The gap between venues' funding rates | hours to days | Observability only (public poll + Extended per-position cum_funding); strategy not implemented |
| Points / airdrop farming | Trading volume → points → tokens at TGE | until each venue's TGE | A side effect, not a separate strategy |
The insight on points: if you're already generating volume for arbitrage, points are a free bonus on top of the arbitrage profit.
Current focus is price-spread arbitrage across the integrated venues. Funding observability feeds accurate liquidation-drift math (see §12), but funding arbitrage as a standalone strategy is not built.
- DEX architectures differ: a custom ZK-rollup (Lighter) vs. Starknet (Extended) vs. StarkNet perps (Zero1) — different price-update and finality patterns.
- Liquidity is fragmented: different LPs, different users, different book depth.
- Few arbitrageurs: most of these DEXs are new, so competition is thin.
- DEX spreads live for seconds, not the milliseconds of a CEX — so the latency bar is far lower.
From an MDPI study (January 2026, 35.7M observations across 26 exchanges): 17% of all observations show a spread ≥ 20 bps — economically meaningful opportunities. Only about 40% of those are actually profitable after fees and slippage. The bot's job is to land in that profitable 40%.
The bot reads both order books continuously over WebSocket:
Every ~50ms, an order-book update arrives:
A: best_ask = $1.8147, depth = 9176 LIT
B: best_bid = $1.8152, depth = 165 LIT
Cross-exchange spread = bid_B − ask_A = $1.8152 − $1.8147 = +$0.0005
Check: spread > min_threshold?
→ $0.0005 > $0.0003 (min after fees)? YES
→ depth_A ≥ min_size AND depth_B ≥ min_size? YES
→ create an intention
In the logs, entry is bid_B − ask_A at the moment the intention is created.
Both orders are sent at the same time to capture the spread before it closes. The path is generic: execute_open_parallel spawns two async tasks, each calling ctx.client(eid).place_order(...). Each leg's order type comes from ExchangeExecConfig.entry_order_type (set in [exchanges.<name>] entry_order_type).
t=0ms: signal detected (spread_bps > threshold) for pair (eid_i, eid_j)
t=2ms: leg_i + leg_j sent SIMULTANEOUSLY (each in its own task)
ctx.client(eid_i).place_order(OrderRequest{type=Market|Ioc, ...})
ctx.client(eid_j).place_order(OrderRequest{type=Market|Ioc, ...})
t=~100ms: fills return via PendingOrders + the fill_tx channel
t=~130ms: evaluate:
A) both filled → position = min(qty_i, qty_j); excess mismatch-closed
B) one filled → close the filled leg immediately (mismatch loss → orders)
C) none filled → release_intention, capital released, no cost
The whole thing takes ~130ms. Spreads live for seconds, so speed matters.
Per-exchange order type:
- Lighter: Market (
type=1) or IOC (type=0), set by[exchanges.lighter].entry_order_type. Fee-tier-dependent (see §3). - Extended: IOC limit with a widened slippage cap, set by
[exchanges.extended].entry_order_type. - Zero1: Market or IOC, set by
[exchanges.zero1].entry_order_type. Synchronous REST — the HTTP response already carriesexecution_priceandliquidation_price.
build_entry_order() and resolve_entry_slippage() build the OrderRequest with a widened slippage cap (5–15% of the per-symbol limit_price_cap, the exact value from the exchange API) so an IOC leg doesn't miss on depth. Real rejects are almost always margin- or risk-side from the exchange.
Quantity-mismatch handling:
hedged_qty = min(filled_i, filled_j)
excess_i = filled_i − hedged_qty → close on exchange i (mismatch-close)
excess_j = filled_j − hedged_qty → close on exchange j (mismatch-close)
One-sided fill (a failed parallel open) is caught in execute_open_parallel: the filled leg is closed through the same generic ctx.client(eid).close_position(...), and the mismatch loss is booked to the orders table. Asymmetric closes are forbidden.
Entry spread is the real executed spread, VWAP over both legs:
vwap_i = Σ(fill_qty × fill_price) / total_qty_i
vwap_j = Σ(hedge_qty × hedge_price) / total_hedged
actual_entry_spread_bps = (vwap_j − vwap_i) / mid × 10000 (for direction A)
After entry the bot holds a delta-neutral position:
- Long A + Short B in equal size.
- Market risk ≈ 0 — a price move profits one leg and loses the same on the other.
- The exposure is the spread widening against us.
Each open position is tracked as its own intention with its own entry spread and its own close threshold. A new signal on the same symbol opens a new, independent intention (subject to the max_open_intentions and max_used_capital_usd limits). Each intention closes on its own terms.
There are two exit modes, set in [strategy].exit_mode. They are mathematically equivalent — both compute the same captured PnL and differ only in units (% vs bps). The include_fees_in_exit toggle switches between a NET and GROSS threshold, identically for both.
pnl mode estimates the position's full PnL as a percentage of notional:
gross_pnl = compute_gross_pnl(direction, qty, entry_prices, current_prices)
open_fees = qty × entry_l × lighter_fee + qty × entry_e × extended_fee
close_fees = qty × current_l × lighter_fee + qty × current_e × extended_fee
net_pnl = gross_pnl − open_fees − close_fees
pnl_pct_check = include_fees_in_exit ? net_pnl / notional × 100
: gross_pnl / notional × 100
Close when: pnl_pct_check ≥ min_exit_pnl_pct (from config, e.g. 0.1%)
When it computes close fees, the bot always assumes a taker fee on Extended. If the close actually fills as maker (0%), the position closes slightly early — a conservative bias, since real PnL comes out higher than estimated. The Lighter close is always a market order, so always taker.
Fee rates are fetched at startup from authenticated REST endpoints (/api/v1/user/fees for Extended, /api/v1/accountLimits for Lighter). Switch a Lighter account between Standard and Premium and a restart pulls the new fees automatically — fees are deliberately never hardcoded in config.
spread mode does the same in bps, through a captured-profit formula:
Long-Lighter / Short-Extended: close_spread = bid_Lighter − ask_Extended
Short-Lighter / Long-Extended: close_spread = bid_Extended − ask_Lighter
close_spread_bps = close_spread_vwap / mid × 10000 // VWAP through the book
gross_pnl_bps = position.entry_spread_bps + close_spread_bps
net_profit_bps = include_fees_in_exit ? gross_pnl_bps − round_trip_fees
: gross_pnl_bps
Close when: net_profit_bps ≥ min_exit_spread_bps
Conversion: min_exit_pnl_pct = 0.2% ≡ min_exit_spread_bps = 20 (1% = 100 bps). Which mode you pick is a matter of taste — traders think in bps, dashboards often in %.
The formula gross_pnl_bps = entry_spread + close_spread works because closing crosses the book the other way: close_spread carries the cost of exit (the book widths on both venues), while entry_spread is the mispricing that was captured. Their sum ≈ the captured mispricing. The shared helper common/src/pnl.rs::spread_exit_net_bps_* is used by both the bot and the backtester.
Spike protection: min_exit_checks (default 1) is the number of consecutive ticks above target required before closing — it guards against single-tick order-book spikes.
Both close orders go out at once through the same generic path: execute_close_parallel spawns two ctx.client(eid).close_position(...) tasks. Each leg's exit order type comes from ExchangeExecConfig.exit_order_type.
t=0ms: close signal (PnL ≥ target, or spread ≥ target)
t=2ms: leg_i + leg_j sent SIMULTANEOUSLY
t=~100ms: both filled → position closed, PnL computed
Per-exchange exit_order_type:
Lighter: Market or IOC — [exchanges.lighter].exit_order_type
Extended: IOC (~0.025% taker) or GTT (0% maker) — [exchanges.extended].exit_order_type
Zero1: Market or IOC — [exchanges.zero1].exit_order_type (GTT unused)
After a REST-confirmed close, the bot waits 500ms to settle before the clean-close verify, so the exchange's /positions endpoint has time to update (an Extended edge case).
Error handling:
Both legs fail → CloseFailed, position → Active (retry on the next signal)
One leg fills → close the residual through the generic path (same close_position on the survivor)
External close → PositionEventSink detects it (SL fired, liquidation, manual close
from the exchange UI) → the executor symmetrically closes the surviving leg
An asymmetric close — one leg filled, the other left open indefinitely — is forbidden.
Fees at close (Lighter ↔ Extended example):
| Lighter | Extended | Combined close |
|---|---|---|
| Market taker (tier-dependent) | IOC taker ~0.025% | ~2.5 bps (Standard Lighter) / ~5.3 bps (Premium tier 0) |
Round-trip (entry + exit) is 2 × (fee_i + fee_j) — both entry legs plus both exit legs. Real fees are fetched at each exchange's startup (Lighter → /api/v1/accountLimits, Extended → /api/v1/user/fees, Zero1 → its per-market endpoint) and land in the exchange_symbols.taker_fee column, the source of truth. Nothing is hardcoded.
| Configuration | Lighter taker | Extended taker | Round-trip |
|---|---|---|---|
| Standard Lighter | 0% | 0.025% | ~5 bps |
| Premium Lighter tier 0 | 0.028% | 0.025% | ~10.6 bps |
| Premium Lighter tier 500k LIT | 0.0196% | 0.025% | ~8.9 bps |
For other pairs (Lighter ↔ Zero1, Extended ↔ Zero1) the formulas are the same; the Zero1 fee comes from its market_info().
Total PnL = gross_pnl − open_fees − close_fees
= (entry_spread × qty) − Σ over 4 legs (notional × fee_rate)
| Pair | Venues | Entry spread | Exit spread | PnL | Hold time |
|---|---|---|---|---|---|
| LIT | Lighter ↔ Extended | 0.0008 | 0.0005 | +$0.08 | ~17 min (partial fill) |
| LIT | Variational ↔ Extended | 0.0015 | 0.0004 | +$0.45 | ~5 min |
| ETH | Lighter ↔ Hibachi | $1.16 | $66.93 | +$75.67 | ~30 min (partial fill) |
Hold time depends on how fast the spread converges and on the liquidity available to fill both legs completely.
The trade lifecycle is measured as four consecutive segments. Every exchange leg in the UI (active orders, trade drill-down, closed orders) shows all four:
[exchange: matching] ─ Tick→Recv ─ [bot: WS recv] ─ Recv→Sig ─ [detector: decision] ─ Sig→Ord ─ [bot: HTTP send] ─ Ord→Fill ─ [bot: fill rx]
| Column | Formula | What it measures |
|---|---|---|
| Tick→Recv | recv_ts − exchange_ts |
Wire latency, exchange → bot. — for venues that don't send event time (Zero1). |
| Recv→Sig | signal_ts − recv_ts |
Age of the leg's book at the detector's decision. 0ms for the triggering leg (its tick drove the pass); for the other leg, the gap between the two venues' updates. |
| Sig→Ord | placed_ts − signal_ts |
CPU time between the detector's decision and the HTTP POST. Should be < 1ms. |
| Ord→Fill | first_fill_ts − placed_ts |
Full round-trip from send to fill confirmation. One synchronous HTTP round-trip on Zero1; a REST submit + async WS push on Extended/Lighter — so not comparable across venues. |
Two timestamps in BestLevels:
ts_ns— alwaysnow_ns()at the moment the WS frame is processed (our clock). FeedsRecv→Sigand themax_recv_stalenessfilter.exchange_ts_ns— the exchange matching engine's event time when available, otherwisenow_ns()(same asts_ns, not 0). FeedsTick→Recv.
Zero1 provides no event time on the order-book WS, so Tick→Recv for Zero1 is always —; Extended (msg.ts) and Lighter (delta.last_updated_at) give a real number. Splitting the old single Tick→Sig metric into two gives two honest measurements with the same meaning everywhere — you can compare wire latency across venues and separate "the network is slow" from "the detector is thinking."
The pipeline in code — entry path: last_recv_ns / last_exchange_ts are a hot cache in bot/src/strategy/spread_detector.rs, updated on every top-of-book change. A snapshot at signal time flows OpenSignal.{ts_ns, recv_ns_1/2} → Position.{signal_at_ns, recv_ns_1/2} → ActiveOrder.recv_ts_ns → orders.recv_ts_ms + trades.signal_at_ms → UI snapshots. The close path mirrors it through CloseSignal and PositionTracker::start_closing_with_signal.
Signal times live on trades, not orders, because a signal is a single detector event that spawns both legs at once — the value is identical for both legs by construction, so storing it per order would just duplicate the number. The UI resolves the right one per order with a SQL CASE WHEN o.purpose LIKE 'exit%' THEN t.exit_signal_at_ms ELSE t.signal_at_ms END.
Backtest parity: common/src/sim.rs computes the entry- and close-side metrics with the same formula, both anchored on the signal tick (the loop variable, not the fill ticks). The Parquet schema already stores both timestamps (ts_ms, exchange_ts_ms), so no schema change was needed; synthetic closes (Gap, End-of-data) use a sentinel that the UI renders as —.
The max_recv_staleness filter (§9) uses last_recv_ns (our clock), independent of exchange event time. The max_tick_age_ms filter uses last_exchange_ts → now − min(last_exchange_ts), covering Tick→Recv + Recv→Sig in one gate; for venues that forward exchange_ts = ts (Zero1) it degrades cleanly to pure Recv→Sig on that leg.
Status today: five venues are integrated and trading live — Lighter, Extended, Zero1, and Hyperliquid (non-custodial DEXs) plus Bybit (a CEX). Hibachi and Variational are research candidates with no code yet. Empirical notes on the integrated venues (hosting, WS incidents, HOL blocking, gotchas) are in EXCHANGE_NOTES.md; the candidate comparison is in EXCHANGE_CANDIDATES.md.
| Property | Value |
|---|---|
| Architecture | Custom ZK-rollup (bespoke L2 on Ethereum) |
| Soft finality | ~5ms (sequencer ACK) |
| WS updates | Order-book snapshot every 50ms |
| Throughput | 10,000 orders/sec |
| Daily volume | $2.7B |
| Backing | Robinhood |
| Endpoint | wss://mainnet.zklighter.elliot.ai/stream |
Order types: Market, Limit, Post-Only, IOC, GTT, Stop-Loss, Take-Profit, TWAP.
| Account | Maker fee | Taker fee | Maker latency | Taker latency | Req/min |
|---|---|---|---|---|---|
| Standard | 0% | 0% | 200ms | 300ms | n/a |
| Premium (0 LIT) | 0.004% | 0.028% | 0ms | 200ms | 4,000 |
| Premium (10K LIT) | 0.0036% | 0.0252% | 0ms | 180ms | 7,000 |
| Premium (100K LIT) | 0.0032% | 0.0224% | 0ms | 160ms | 12,000 |
| Premium (500K LIT) | 0.0028% | 0.0196% | 0ms | 140ms | 40,000 |
The bot runs a Standard account, and that pays off. Standard is 0% fees → a round-trip with Extended is ~5 bps (Extended taker only). Premium is 0.028% taker → ~10.6 bps, twice as expensive. The latency difference (300ms vs 200ms) doesn't matter for opportunities that live for seconds: you either make it over the wire or you don't, and 100ms at the exchange layer doesn't decide that. Order-book data arrives over WebSocket push, so rate limits never bind, and REST is only used to place orders.
Risk: the sequencer sees every order before the ZK proof, so a front-run is theoretically possible. The proof verifies execution correctness, not ordering fairness.
| Property | Value |
|---|---|
| Architecture | Off-chain CLOB matching + on-chain settlement on Starknet |
| API latency | 10–20ms |
| Starknet finality | ~2s |
| Backing | $6.5M (Tioga, StarkWare, a Lido co-founder) |
| Servers | AWS Tokyo (ap-northeast-1) |
| Markets | 50+ (crypto, FX, commodities, indices) |
Fees: Maker 0% (+ rebates up to 2 bps) / Taker 0.025%. Order types: Market, Limit, Post-Only (ALO), IOC, FOK, Conditional (TP/SL), TWAP, Scaled.
FOK (fill-or-kill) is rare among DEXs and unique to Extended here: the order either fills in full or not at all — ideal for atomic leg execution with no partial-fill risk.
Points: Season 1 is live — 1.2M points/week split across all traders, 30% of token supply earmarked for the community airdrop (TGE expected Q2 2026). Volume is the criterion, so a high-volume arbitrage bot captures a large share of the weekly pool. Current OTC estimate: ~$6/point.
For minimum latency, place the server in AWS Tokyo (ap-northeast-1) — Extended's own recommendation.
| Property | Value |
|---|---|
| Architecture | StarkNet perpetuals (off-chain matching, on-chain settlement) |
| Endpoint | wss://zo-mainnet.n1.xyz |
| Hosting | Tokyo bare-metal (AS396356 Latitude.sh) |
| Signing | Solana-wallet ed25519 + ephemeral session key (CreateSession → per-trade session sign) |
| Account-WS auth | Public — knowing the account_id is enough |
| Wire format | Protobuf (zero1_schema.proto; REST /action envelope with a current_timestamp ±60s replay guard) |
Things that matter to the bot:
- Synchronous REST for orders. The HTTP response already carries
execution_priceandliquidation_price, so the WS isn't needed to confirm a fill — only for position events and external closes. - Leverage isn't configurable over the API — the exchange always uses max market leverage. The bot emulates its target leverage locally through a
risk/manager.rs::zero1_used_notionalgate that rejects new positions once total Zero1 notional would exceed the cap (see §12). - WS limits: at most 10 subscriptions per connection, so the bot batches or goes per-market (25–26 markets total).
- Server pings every 20s; with no pong it closes after 60s (3 pings).
fastwebsocketsauto-pong answers — no client ping needed. - No matching-engine timestamp on the OB-delta or trades WS — a structural limitation. The bot sets
exchange_ts_ns = now_ns(), soTick→Recvis structurally 0 and the UI shows—;Recv→Sigworks normally on wall-clock recv time. - Delta-only order book (AbsoluteReplace). No REST snapshot needed — the book is built from deltas starting at the first frame.
- The first account frame is a snapshot, not a realtime delta: after every (re)connect, Zero1 sends a snapshot of recent state (fills/places/cancels/balances from before the subscription). The bot ignores fill events from this frame (an
is_snapshot=trueguard inws.rs), or a stale fill from a prior session would triggerreport_external_close()and close the counterparty leg on the other venue. Don't touch that guard.
Incidents and empirics (HOL-blocking measurements, ping schedule, account-WS snapshot semantics): EXCHANGE_NOTES.md → Zero1. Zero1 has no public points program at time of writing, so there's no farming strategy built around it.
| Property | Value |
|---|---|
| Architecture | On-chain CLOB on its own L1 (HyperBFT) |
| Block cadence | ~0.1s median, p99 <0.5s |
| Volume | $10–15B/day |
| Maker / Taker | 0.015% (−0.02% rebate at diamond tier) / 0.045% |
| Rust SDK | Official + Ferrofluid (HFT: simd-json + fastwebsockets) |
Deep liquidity allows large position sizes. Its market-data feeds are block-gated — the BBO channel updates only when the best changes on a block — so the freshness floor is ~150–200ms (Tokyo), set by consensus rather than the network. Keep max_tick_age_ms at 500–750ms and use Hyperliquid legs only for opportunities that outlive a block; a fast Lighter ↔ Extended arb gains nothing from a leg that's always a block staler. Details: EXCHANGE_NOTES.md → Hyperliquid.
| Property | Value |
|---|---|
| Architecture | Centralized off-exchange matching — the one CEX in the set |
| Latency floor | Network + processing, not block-gated |
| Order book | orderbook.1 BBO stream (AbsoluteReplace) |
| Signing | HMAC (api_key + api_secret) |
| Max leverage | 100× (BTC) |
The one centralized venue. Its proprietary matching engine isn't block-gated, so the latency floor is network only; it settles in USDT and quotes both sides even on the thinnest perps. It runs with mmf: None, taking the liquidation price straight from the WS position stream. Details: EXCHANGE_NOTES.md → Bybit.
These have no code in the workspace. They're documented because each opens a distinct source of spread; integrate one only when the economics are clear.
| Property | Value |
|---|---|
| Architecture | Off-chain CLOB + Celestia data availability + Succinct ZK proofs |
| Latency | 5–6ms (their benchmark: 40× faster than Hyperliquid) |
| Settlement | Arbitrum / Base |
| Distinctive | Private order book — positions are encrypted even from the exchange |
| Launched | Q1 2025 |
Order types: GTC, IOC, ALO (Post-Only); Python SDK (hibachi-xyz). Points active since March 2025, ~1.45 points per $1,000 of volume, TGE expected Q3 2026.
Why an ETH spread with Lighter once hit $66.93 (~3%): a brand-new venue with almost no arbitrageurs, an encrypted book competitors can't see the depth of, thin liquidity that a large order moves, and funding-rate divergence. The pattern shrinks as open interest grows and arbitrageurs arrive.
| Property | Value |
|---|---|
| Architecture | P2P RFQ (request-for-quote), not an order book |
| Network | Arbitrum |
| Products | Omni (retail) + Pro (institutional OTC) |
| Markets | 500+ (crypto, vol indices, RWAs, IR swaps) |
| Fees | 0% trading; only 0.1 USDC deposit/withdrawal |
On Omni, an LP quotes prices from CEX/DEX/OTC sources and can be slow to update on fast moves, creating a systematic gap against CLOB venues. That's better than an order book for arbitrage: the LP always posts a bid and an ask (no "no liquidity" risk), its quote staleness is a structural spread source rather than random, and with 0% fees the whole spread is profit. With 50% of supply going to the airdrop and only ~9–10M points total, Variational offers the most incentive per dollar of volume of any candidate.
The spread is computed not on best bid/ask (level 1) but as a VWAP through the top 5 levels of each book, which accounts for the real depth available to fill order_size_usd.
Example: order_size_usd = $10, symbol MON
Lighter ask levels (we buy): Extended bid levels (we sell):
Level 1: $0.0225 × 200 = $4.50 Level 1: $0.0228 × 300 = $6.84
Level 2: $0.0226 × 400 = $9.04 Level 2: $0.0227 × 500 = $11.35
Lighter VWAP buy (eat $10 through the asks):
Level 1: take all 200 × $0.0225 = $4.50 ($5.50 left)
Level 2: take $5.50 / $0.0226 = 243 × $0.0226 = $5.50
VWAP = $10.00 / (200 + 243) = $0.02254
Extended VWAP sell (eat $10 through the bids):
Level 1: take all 300 × $0.0228 = $6.84 ($3.16 left)
Level 2: take $3.16 / $0.0227 = 139 × $0.0227 = $3.16
VWAP = $10.00 / (300 + 139) = $0.02278
spread_bps = (sell_vwap − buy_vwap) / mid × 10000
= (0.02278 − 0.02254) / 0.02266 × 10000
= 10.6 bps
Both directions are checked at once and the better one wins:
Direction A: buy Lighter asks, sell Extended bids
→ compute_vwap_spread(lighter_ask_levels, extended_bid_levels, order_size_usd)
Direction B: sell Lighter bids, buy Extended asks
→ compute_vwap_spread(extended_ask_levels, lighter_bid_levels, order_size_usd)
spread_A ≥ spread_B and spread_A ≥ threshold → Direction A
spread_B > spread_A and spread_B ≥ threshold → Direction B
otherwise → no signal
Why VWAP and not best bid/ask: level 1 might hold $0.50 of liquidity against a $10 order. At best bid/ask the spread might read 30 bps, but filling $10 walks four levels deep at worsening prices. VWAP shows the spread you'll actually get on execution — if it's below threshold, don't enter.
Entry conditions:
Enter when:
1. vwap_spread_bps ≥ min_entry_spread_bps (config default 20 bps)
2. optionally: net_spread = vwap_spread − round_trip_fees (if entry_deduct_fees = true)
3. per-symbol signal cooldown has elapsed (default 5s)
4. data isn't stale (exchange timestamp < stale threshold)
From real trades: LIT Lighter ↔ Extended entered at entry_spread = 0.0008 (0.044% of a $1.82 asset); LIT Variational ↔ Extended at 0.0015 (0.096%); ETH Lighter ↔ Hibachi opened at 1.16 (0.056% of $2070) but expanded to 67.42 by the time it executed.
Two modes ([strategy].exit_mode), mathematically equivalent, differing only in units. include_fees_in_exit selects NET vs GROSS for the threshold in both.
pnl mode: pnl_pct_check = include_fees_in_exit ? net_pnl_pct : gross_pnl_pct
exit when pnl_pct_check ≥ min_exit_pnl_pct (default 0.1%)
spread mode: gross_pnl_bps = position.entry_spread_bps + close_spread_bps_vwap
net_profit_bps = include_fees_in_exit ? gross_pnl_bps − round_trip_fees
: gross_pnl_bps
exit when net_profit_bps ≥ min_exit_spread_bps (default 5.0 bps)
Conversion: min_exit_pnl_pct = 0.2% ≡ min_exit_spread_bps = 20. Shared helper: common/src/pnl.rs::spread_exit_net_bps_*.
// SpreadDetector.on_update():
available_qty = min(lighter_qty, extended_qty) // the shallower of the two books
order_qty = min(available_qty, max_order_size_usd / lighter_price)
// Rounded for both exchanges BEFORE sending:
order_qty = extended_info.round_qty_down(signal.order_qty.trunc(lighter.size_decimals))
// RiskManager.try_reserve():
margin_needed = order_qty_usd / leverage
if used_capital + margin_needed > max_used_capital_usd → reject
On all of these venues a market order and an IOC are functionally the same: both execute immediately at the best available price, and any unfilled remainder is cancelled at once. The only difference is that an IOC lets you set a price limit.
Market order: fills at ANY price (catastrophic in a thin market)
IOC with limit: fills only if price ≤ ask_A × 1.005 (5 bps allowance)
→ protection against a sudden liquidity gap
Hyperliquid has no separate "market" type — a market order there is a { order_type: "limit", tif: "Ioc", price: mid × 1.05 }.
Order types by exchange:
| Exchange | IOC | FOK | Market | GTT / Post-Only | SL type |
|---|---|---|---|---|---|
| Lighter (integrated) | ✓ | ✗ | ✓ (price ceiling) | ✓ | Market (Type=2) or Limit (Type=3) — [exchanges.lighter].stop_loss_type |
| Extended (integrated) | ✓ | ✓ | ✓ | ✓ (ALO / GTT) | StopLossLimit GTT only (Type=1002); no pure stop-market |
| Zero1 (integrated) | ✓ | ✓ | ✓ | ✗ (GTT unused) | Trigger-based stop via REST /action |
| Hyperliquid (integrated) | ✓ (via limit) | ✗ | via IOC limit | ✓ (ALO) | Trigger order (msgpack TriggerSpec) |
| Bybit (integrated) | ✓ | ✓ | ✓ | ✓ | Conditional stop (trigger) |
| Hibachi (candidate) | ✓ | ✗ | ? | ✓ (ALO) | — |
| Variational (candidate) | via Market | ✗ | ✓ (slippage limit) | ✗ | — |
Implementation (bot/src/execution/open.rs::execute_open_parallel): two async tasks, each calling ctx.client(eid).place_order(OrderRequest{...}). Each leg's order type comes from ExchangeExecConfig.entry_order_type (Market or IOC); the pair (eid_i, eid_j) comes from the SpreadDetector.
t=0ms: signal detected for pair (eid_i, eid_j)
t=2ms: leg_i + leg_j sent SIMULTANEOUSLY
Task 1: ctx.client(eid_i).place_order(...)
Task 2: ctx.client(eid_j).place_order(...)
t=~100ms: fills return via PendingOrders + fill_tx (per exchange)
t=~130ms: hedged_qty = min(filled_i, filled_j); excess → mismatch-close on the same exchange
Key details:
- The order request is built by
build_entry_order()+resolve_entry_slippage(): anOrderRequest::Marketor::Iocchosen by the exchange'sentry_order_type; the IOC limit price =signal.price[eid] × (1 ± slippage), wheresignal.price[eid]is the leg's VWAP; and a slippage cap ofmin(entry_slippage_pct, per_symbol_limit_price_cap)— usually 5–15% of the exchange-sidelimit_price_cap, wide enough that an IOC leg doesn't miss on depth, narrow enough to protect against a flash crash. - Quantity is rounded per exchange before sending and signing (or the signature mismatches):
for each leg eid in (eid_i, eid_j): qty[eid] = ctx.client(eid).market_info(symbol).round_qty_down(target_qty) final_qty = min(qty[eid_i], qty[eid_j]) - The position is the hedged quantity, VWAP over both legs:
entry_spread_bps = (vwap[eid_j] − vwap[eid_i]) / mid × 10000for direction A.
Entry fees are the real per-leg values from exchange_symbols.taker_fee, loaded at each exchange's startup. For a Standard Lighter ↔ Extended pair the round-trip is ~5 bps; other pairs are in §2.
FOK (fill-or-kill) fills in full or not at all — supported on Extended, Zero1, and Bybit, and rare for a DEX. The bot leans on Extended's when a full fill must be guaranteed:
FOK Sell 1000 LIT on Extended:
bid depth ≥ 1000 → fills 1000 ✓
bid depth < 1000 → cancelled entirely, 0 filled
→ no risk of "filled 600, now 400 unhedged" — it's all-arbitrage-or-nothing
Say a Long-Lighter / Short-Extended intention on LIT is already open, and a new signal fires — the spread is wide again.
| New signal direction | Meaning | Action |
|---|---|---|
Same (bid_B > ask_A > threshold) |
The spread reopened — a new opportunity | Open a new independent intention |
Opposite (bid_A > ask_B — the spread collapsed) |
A close condition for the existing position | Handle as a close signal, not a new entry |
Opening an additional intention on the same symbol is allowed when the direction matches (never stack an opposite position), count(active_intentions) < max_open_intentions, and sum(open_capital) + new_size < max_used_capital_usd. Each intention is independent — its own entry spread, its own close threshold, closing on its own terms without affecting the others. Net delta stays neutral: two 100-LIT intentions = Long 200 LIT (Lighter) + Short 200 LIT (Extended) → delta 0.
Why not merge them into one position: each entered at a different spread (say 10 bps and 8 bps) and so has a different break-even for closing. Merging loses that and complicates the close logic. (Surveying four public bots, most block re-entry outright for simplicity, but the one built specifically on Lighter allows accumulation — so the choice here is several independent intentions: more correct than a scalar cap, doesn't forgo opportunities, keeps points-farming maximal, and bounds risk through the existing limits.)
| Scenario | What happens | Response |
|---|---|---|
| Leg B rejected (no liquidity) | Directional long on A | close_market(A, fill_a_qty) immediately |
| Legs A and B partially filled by different amounts | Delta imbalance | Close the excess on the larger leg down to the smaller |
| No exchange response (10s timeout) | Unknown status | Query status → cancel if open, close if filled |
| Price moved before the IOC arrived | Fill at a worse price than the signal | The price limit protects: a bad-priced IOC is rejected |
| Spread widened after entry | Unrealized loss | Hold (delta-neutral), wait for convergence |
Extended once went down for ~4 hours. Three sub-cases:
1. The WS dropped — no market data from exchange B:
→ Stop opening new intentions on any pair involving B (no book → no signal)
→ Leave open positions alone: Long A + Short B is delta-neutral, no market risk
while the exchange is down — hold both legs as they are
→ Telegram alert naming the unavailable exchange
→ Reconnect with exponential backoff: 1s → 2s → 4s → ... → 60s (cap)
→ On reconnect: resync the order book, resume monitoring
2. The REST API is down too, and a position needs closing:
→ Do NOT close the leg on the available exchange alone
Closing Lighter without Extended = a naked short on Extended = a directional position
→ Wait for B to return, close both legs together
→ Exception: if margin on the available exchange is critically low,
force-close both legs even with one exchange down (the lesser evil —
better an unhedged close than a liquidation)
3. The spread converged during the outage — the optimal exit was missed:
→ When the exchange returns, the close detector sees the current spread and exits
→ The spread may have gone to zero or slightly against us — acceptable
→ A delta-neutral position takes no loss from price movement while waiting
→ Points kept accruing the whole time the position was open
RiskManager checks and reserves resources atomically in try_reserve():
try_reserve(signal):
1. kill_switch active? → reject
2. open_count ≥ max_open_positions? → reject
3. used_capital + signal.qty_usd / leverage > max_used_capital_usd? → reject
→ increment open_count and used_capital
The kill switch fires when daily_pnl < daily_loss_limit_usd (e.g. −$50), blocking all new entries; existing positions keep closing normally. It resets at 00:00 UTC. On close, on_position_closed() decrements the counters, adds realized PnL, and re-checks the switch; on a failed open, release_intention() rolls the reservation back.
The bot generates large trading volume, so DEX points are a free bonus on top of arbitrage profit — especially attractive on venues that haven't had a TGE yet.
| Exchange | Status | Criterion | $/point (est.) | Priority |
|---|---|---|---|---|
| Variational | Active (through Q3 2026) | Volume | $25–100+ | Highest |
| Extended | Active (Season 1) | Volume, liquidity | ~$6 OTC | Highest |
| Hibachi | Active | Volume + tickets | ~$1.5–3 | Medium |
| Lighter | Closed | TGE December 2025 | ~$25 (LIT price) | — |
| Hyperliquid | Post-TGE | HYPE staking | Fee discount | Rebate only |
Variational — top priority. 0% trading fees mean 100% of the spread is arbitrage profit and the points come free; 50% of supply goes to the airdrop (the most generous here); only ~9–10M points total, so few competitors; points distribute every Friday. The math at a $1B FDV: 50% of supply = $500M for the airdrop, / 10M points = ~$50/point. A bot doing $100K/day × 30 days = $3M/month captures a meaningful share of the 9–10M total.
Extended — second. 1.2M points distribute every Tuesday across all traders; your share = your_volume / total_volume × 1.2M. 30% of supply goes to the airdrop (TGE expected Q2 2026), current OTC ~$6/point, plus extra points for vault liquidity. High volume → high share of the weekly pool.
Hibachi — third. ~1.45 points per $1,000 of volume, a ticket system with multipliers as cumulative volume grows, 20–30% of supply to the airdrop (TGE ~Q3 2026). API trading earns the same points as the UI; Python SDK pip install hibachi-xyz.
Lighter — farm closed. TGE December 2025; LIT ~$1.24 (March 2026), ATH $7.86. The remaining value is staking LIT for a Premium account (0ms maker latency instead of 200ms).
Route trades toward the venues with active, generous programs — Variational first (0% fees + best $/point), then Extended (active distribution, 30% supply), then Hibachi (new, thin, ETH pairs, little competition), with Lighter mandatory for LIT pairs even though its points are done. No special "farming" is needed — just prioritize active-points venues when choosing pairs.
What doesn't qualify: wash trading (self-dealing) is filtered by every venue, and multiple accounts on one strategy hit Sybil filters. Genuine cross-exchange arbitrage — real market risk across two independent venues — qualifies everywhere.
The full list is
bot/Cargo.toml(source of truth); this is an overview by group.
| Component | Library | Why |
|---|---|---|
| Async runtime | tokio (full) |
The async Rust standard; spawn_blocking for SQLite/disk |
| WebSocket | fastwebsockets + simd + unstable-split |
SIMD unmasking, raw frames, split read/write. read_frame() is not cancel-safe — dedicated task only |
| TLS | rustls + aws_lc_rs |
Faster than OpenSSL, hardware AES |
| HTTP | hyper (http1) + hyper-rustls + hyper-util |
A persistent connection per exchange; every venue is http1 |
| JSON (fast path) | sonic-rs |
2.5–6.8× faster than serde_json (ByteDance, production) |
| JSON (structs) | serde + serde_json |
Derive helpers and fallback |
| TOML config | toml with preserve_order |
Critical: preserves [exchanges.X] section order → a stable eid across restarts (below) |
| Ordered map | indexmap |
Config.exchanges: IndexMap<...> — the eid is a position in keys(), so it must not be randomized |
| Numbers | rust_decimal |
Precise financial math; all qty/price rounding |
| Concurrent map | dashmap |
Per-exchange health, PendingOrders, active orders, positions |
| Locks | parking_lot |
Faster than std for rare-lock paths |
| Allocator | tikv-jemallocator |
A production standard for Rust trading |
| Logging | tracing + tracing-subscriber |
Async-safe structured logs |
| Errors | thiserror + anyhow |
thiserror for types, anyhow for propagation; always {:#} in logs |
| Channels | tokio::sync::{mpsc, oneshot, Notify} |
The bot isn't SPSC HFT, so lock contention isn't the bottleneck — no rtrb/crossbeam-channel |
| Component | Library | Used in |
|---|---|---|
| ZK Schnorr (Lighter) | Own implementation in signing/ecgfp5.rs |
Lighter order/leverage signing, auth token — ECgFp5 curve, Poseidon2 hash |
| StarkNet ECDSA (Extended) | starknet-crypto |
Extended order signing, Pedersen hash, rfc6979 |
| Ed25519 (Zero1) | ed25519-dalek |
Solana-wallet sign (CreateSession) + ephemeral session key |
| Solana keypair decode | bs58 |
Parse the 64-byte secret from a Solana JSON keypair |
| Protobuf wire (Zero1) | prost + protoc-bin-vendored |
Wire format for POST /action (zero1_schema.proto generated in build.rs) |
| Component | Library | Used in |
|---|---|---|
| SQLite | rusqlite (bundled) |
The single store: trades, orders, positions, halts, exchange_symbols, symbol_overrides, auth_users |
| Parquet | parquet (snap) + arrow |
Tick and funding storage under data/ |
| Web | axum (json) + tokio-stream |
Dashboard, SSE, auth, credentials UI, backtest/optimizer UI |
| Password hashing | argon2 + rand |
Web auth (admin/user, role-based permissions) |
| Optimizer parallelism | rayon |
Grid-search and per-generation CMA-ES parallelism |
# root Cargo.toml (workspace)
[profile.release]
lto = "thin" # LTO across all crates
codegen-units = 1 # one codegen unit for maximum optimization
opt-level = 3
panic = "abort" # no unwinding overhead
strip = trueAll five integrated venues have hand-written clients under bot/src/exchange/<name>/ — no vendor SDKs, built on fastwebsockets + hyper + exchange/net.rs.
| Exchange | Folder | What's inside |
|---|---|---|
| Lighter | bot/src/exchange/lighter/ |
client.rs, ws.rs — dual-phase startup, atomic SL modify (tx_type=17) |
| Extended | bot/src/exchange/extended/ |
client.rs, ws.rs — REST POST orders, all-markets OB WS, account WS, per-position funding refresh |
| Zero1 | bot/src/exchange/zero1/ |
client.rs, factory.rs, rest.rs, ws.rs, session.rs, nonce.rs, liq_price.rs, wire.rs, funding_source.rs, zero1_schema.proto |
| Hyperliquid | bot/src/exchange/hyperliquid/ |
client.rs, ws.rs, factory.rs — EIP-712 msgpack signing, block-gated BBO feed, shared-connection WS |
| Bybit | bot/src/exchange/bybit/ |
client.rs, ws.rs, factory.rs, rest.rs, symbols.rs — HMAC auth, orderbook.1 BBO, the one CEX |
┌─────────────────────────────────────────────────────────────────┐
│ arb_bot │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Market data (per-exchange WS) │ spawned by ExchangeFactory │
│ │ Lighter WS ──┐ │ │
│ │ Extended WS ──┼──► BestLevels │ Arc<BestLevels> over │
│ │ Zero1 WS ──┘ │ tokio::mpsc │
│ └──────────────┬───────────────────┘ │
│ │ market_rx (BestLevels stream) │
│ ┌──────────────▼───────────────────┐ │
│ │ ArbEngine (main loop) │ tokio::select! on channels │
│ │ SpreadDetector: │ │
│ │ for each pair (eid_i, eid_j): │ │
│ │ compute VWAP spreads │ │
│ │ apply per-symbol overrides │ │
│ │ check filters (depth, │ │
│ │ recv-staleness, spike) │ │
│ │ if signal → execute_open │ │
│ │ for each active position: │ │
│ │ if should_close → execute_close │
│ │ check_sl_drift → sl_monitor │ │
│ └──────────────┬───────────────────┘ │
│ │ tokio::spawn per trade │
│ ┌──────────────▼───────────────────┐ │
│ │ Execution tasks (generic) │ │
│ │ execute_open_parallel: │ │
│ │ ctx.client(eid_i).place_order │ │
│ │ ctx.client(eid_j).place_order │ │
│ │ place_stop_losses (both legs) │ │
│ │ execute_close_parallel: │ │
│ │ ctx.client(eid_*).close_pos │ │
│ │ PositionEventSink: │ │
│ │ exchange reports external close │
│ │ → symmetric counterparty close│ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ┌──────────────▼─────────────────────────────────────────────┐ │
│ │ RiskManager │ PositionTracker │ Storage (SQLite) │ │
│ │ - per-exch │ - active positions│ trades / orders │ │
│ │ balance │ - per-symbol count│ - signals (audit log) │ │
│ │ - daily PnL │ - gross/net PnL │ - halts / exchange_events │ │
│ │ - kill switch│ - SL in_flight │ Tick Parquet writer │ │
│ │ - zero1 cap │ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Web (Axum SPA) + Telegram worker — both on tokio::spawn, │
│ reading DashState through Arc. Hot-reload of params, halts, UI. │
└─────────────────────────────────────────────────────────────────┘
WS feeds are separate tokio tasks per exchange (spawned inside ExchangeFactory::build()); the ArbEngine is a tokio::select! main loop; open/close execution is a tokio::spawn per trade with an Arc ExecContext; DashMap holds the shared state (price cache, active orders, pending fills, exchange health).
A Cargo workspace of three crates: bot/, common/, optimizer/.
| Crate | Responsibility |
|---|---|
common/ |
Shared logic (PnL, SL, signal detection, types, sim, funding writer), used by both the bot and the optimizer. Source of truth for constants — common/src/constants.rs. |
bot/ |
Runtime: exchange connectors, strategy, execution, risk, storage, signing, web, Telegram. |
optimizer/ |
Offline backtesting: grid / tpe / cmaes / funnel over arb_common::sim. |
The full bot/src/ module tree is in ARCHITECTURE.md → Modules — deliberately not duplicated here, so the two can't drift.
Key directories outside the crates:
| Path | Description |
|---|---|
config/default.toml |
Defaults (git-tracked) |
config/local.toml |
Local overrides + [startup] mode (git-ignored, auto-created) |
config/connections.toml |
Exchange REST/WS URLs |
config/optimizer.toml |
Optimizer search space (grid/tpe/cmaes/funnel) |
db/arb_bot.db |
SQLite — the single runtime store; exchange_symbols holds per-symbol venue params fetched from the APIs at startup |
data/ticks/{exchange}/{symbol}/ |
Parquet ticks, 60s rotation + daily compaction |
data/funding/{exchange}/{symbol}/ |
Funding-rate Parquet |
tools/bot_start.sh |
Launcher (--clean / --resume / --reset; see LOCAL_SETUP.md) |
tools/deploy.sh |
Production deploy (CI usually runs it) |
bot/tests/exchanges/<name>/ |
Per-exchange integration tests (live, #[ignore] by default) |
bot/tests/integration/ |
Cross-exchange full_flow, close_all, margin_reject, apply_overrides |
bot/tests/tools/ |
Diagnostics (latency_measure, spread_analyzer, ws_many_vs_single, ws_sniffer) |
On startup the bot doesn't wipe existing positions. It reconciles each exchange through the generic ExchangeFactory::build() + clean_close_done path (contract in ARCHITECTURE.md → Exchange abstraction):
For each configured exchange, in parallel:
1. cancel_all_orders() — clear stale SL/orders
2. fetch_open_positions() — what's really on the exchange
3. set_leverage() (if the exchange supports it; a no-op for Zero1)
DB: load_active_positions() — what we believe is open
For each (symbol, pair), classify:
┌──────────┬──────────┬──────┬───────────────────────────────────────┐
│ leg_i │ leg_j │ DB │ Action │
├──────────┼──────────┼──────┼───────────────────────────────────────┤
│ present │ present │ yes │ RESUME — keep monitoring │
│ present │ absent │ yes │ ORPHAN — leg_j closed externally, │
│ │ │ │ close leg_i symmetrically │
│ absent │ present │ yes │ ORPHAN — close leg_j symmetrically │
│ absent │ absent │ yes │ GHOST — both legs gone, update DB │
│ any │ any │ no │ CLOSE — unknown, close it │
└──────────┴──────────┴──────┴───────────────────────────────────────┘
In clean mode, additionally:
each exchange's clean_close_done future closes all positions before
the ArbEngine begins. Three invariants (breaking any leaves an
asymmetric leg): wait for WS-ready before the first close, REST-fallback
on a WS timeout, hard-fail on a residual after N retries.
ArbEngine::new() → load reconciled positions from the DB
rearm_stop_losses() — re-place SLs for resumed positions
engine.run()
Startup mode lives in config/local.toml (written by bot_start.sh or by hand): [startup] mode = "resume" | "clean" | "reset".
On SIGINT/SIGTERM the bot logs "shutting down, positions preserved" and doesn't close positions — they're protected by SL orders on the exchanges, and the next startup's reconciliation picks them up.
In-memory availability monitoring, with events written to the DB:
DashMap<(exchange_id, HealthChannel), ChannelState>— a per-channelAtomicBool+AtomicU64, lock-free reads ~1ns.- Pre-trade gate in the executor —
all_channels_connected()blocks entries on a disconnect; closes keep working. - Two-tier WS freshness watchdog (constants in
common/src/constants.rs):WS_STALE_RECONNECT_MS = 30s(the per-WS task reconnects itself) andWS_STALE_HALT_MS = 45s(the executor gate blocks entries whendata_age > 45s). Both read the samelast_data_msatomic, so reconnect and halt never drift; the halt is a safety net for when a reconnect doesn't restore the stream 15s past its threshold. - Pause/resume is logged once on transition (not spammed each tick).
exchange_eventsrecords every connect/disconnect/error;exchangesis the venue registry (id, name, fee_taker, fee_maker, fee_updated_at_ms).- CloudFront 403 —
http_get/http_postdetect an HTML response and don't try to parse it as JSON (Lighter sits behind CloudFront; see EXCHANGE_NOTES.md). - Per-exchange enabled toggle — an exchange can be disabled from the web UI without a restart; the SpreadDetector stops including it in the pair walk, and the toggle is honored at startup too.
Push notifications for trading events, plus interactive commands (Status, Trades).
- No new dependencies — HTTP over the existing
net::http_post()/http_get()(hyper + rustls). - A bounded mpsc channel (cap 64) —
try_send()fire-and-forget from trading logic. - One tokio task — a
select! { biased; }between outgoing notifications andgetUpdateslong-polling. - HTML parse_mode — escapes
<,>,&(simpler than MarkdownV2). - chat_id filtering — replies only to the configured chat.
- Rate limiting — ≥1s between sends (Telegram's 1 msg/sec/chat limit).
- Token redaction — the bot token is stripped from error messages before logging.
Push events: bot started, position opened/closed, open failed, leg risk (single-leg closed), asymmetric close, trading halted/resumed — severity CRITICAL for the leg-risk and halt events. Pull commands (reply keyboard): Status (/status) — session PnL, win rate, active positions, balances, halts; Trades (/trades) — the last 10 closes plus active positions with live PnL; /help.
[telegram]
bot_token = "123456:ABC-DEF..." # from @BotFather
chat_id = "987654321" # from @userinfobotEmpty fields disable Telegram entirely and the task isn't spawned.
All thresholds are in basis points (1 bps = 0.01%); the bot converts to USD on the fly: threshold_usd = mid_price × bps / 10_000.
Fees are fetched at startup from each exchange's authenticated endpoint into exchange_symbols.taker_fee / maker_fee — deliberately never hardcoded, so switching Lighter Standard ↔ Premium or changing an Extended tier needs no edit.
The source of truth is config/default.toml; the outline below is schematic (live defaults and per-field comments are in the file).
[strategy]
min_entry_spread_bps = ... # minimum spread to enter (bps)
exit_mode = "..." # "pnl" (%) or "spread" (bps) — equivalent
min_exit_pnl_pct = ... # close threshold in % (exit_mode = "pnl")
min_exit_spread_bps = ... # close threshold in bps (exit_mode = "spread")
include_fees_in_entry = true # NET vs GROSS for the entry threshold
include_fees_in_exit = true # NET vs GROSS for the exit threshold
min_exit_checks = 1 # consecutive ticks above target before close
signal_cooldown_secs = 5.0 # minimum seconds between signals on one symbol
max_recv_staleness_secs = 1.5 # max gap between our two legs' wall-clock recv ts
max_tick_age_ms = 1500 # max age of signal data (Tick→Recv + Recv→Sig);
# = now − oldest(last_exchange_ts). For Zero1
# (exchange_ts = recv_ts) it degrades to Recv→Sig.
[position]
leverage = 3 # target leverage (Zero1 emulates it locally — §12)
max_order_size_usd = 10 # max order size (notional, before leverage)
max_used_capital_usd = 50 # max total margin (USD)
max_open_positions = 5 # max concurrent positions (all symbols)
max_positions_per_symbol = 1 # 1 = no stacking
stop_loss_buffer_pct = 1.0 # SL trigger = liq ± entry × buffer/100
sl_movement_enabled = true # move the SL as the liq price drifts (§12)
sl_movement_threshold_pct = 0.5 # min drift % to move the SL
[risk]
daily_loss_limit_usd = -50 # kill switch: stop new entries
sl_expiry_days = 7 # max SL-order lifetime
[exchanges.lighter]
enabled = true # hot-reloadable per-exchange toggle
entry_order_type = "ioc" # "ioc" or "market"
entry_slippage_pct = 10 # slippage cap for the IOC entry
exit_order_type = "market" # "market" or "ioc" — both taker
margin_mode = "isolated" # isolated = risk only the position's margin
stop_loss_type = "market" # "market" (Type=2) or "limit" (Type=3)
[exchanges.extended]
enabled = true
entry_slippage_pct = 10
exit_order_type = "ioc" # "ioc" (taker) or "gtt" (maker 0%)
exit_slippage_pct = 10
emergency_slippage_pct = 50 # emergency-close slippage
margin_mode = "cross" # Extended is cross-only
stop_loss_type = "limit" # Extended: StopLossLimit GTT only — §12
symbol_aliases = { CL = "WTI" } # Extended CL-USD → canonical WTI
[exchanges.zero1]
enabled = true
entry_order_type = "ioc"
exit_order_type = "ioc"
# leverage is NOT configurable — Zero1 always uses max market leverage;
# the bot emulates the target locally via the risk.zero1_used_notional gate (§12)
[startup]
mode = "resume" # "resume" / "clean"; set via bot_start.sh
[monitoring]
web_enabled = true # SSE dashboard at http://127.0.0.1:3030
web_addr = "127.0.0.1:3030"
[telegram]
bot_token = "" # @BotFather
chat_id = "" # empty fields = Telegram disabledPer-symbol overrides live in the SQLite symbol_overrides table, not in TOML. They're managed from the web UI (Strategy panel) or written automatically by "Apply Best Result" in the optimizer UI. The key is (symbol_id, exchange_id_1, exchange_id_2), normalized to (smaller, larger); fields are optional and the runtime spread detector merges them over the global params via SymbolOverride (including per-pair negative thresholds). The optimizer's sim doesn't yet read overrides — see OPTIMIZER_VS_BOT.md.
Market params (exchange_symbols): per-symbol venue data — precision, max_leverage, MMF (for the SL math), limit_price_cap — fetched at startup via ExchangeFactory::fetch_market_meta() and upserted. Operator overrides (mmf, api_aliases) live in a separate exchange_symbol_overrides table and aren't overwritten automatically.
Symbol blocks: the bot auto-discovers the symbols common to all exchanges (the intersection). Blocking is managed through symbol_blocks (web UI: Strategy → Blocked Symbols; Optimizer → Disable) at three levels — global (all pairs), per-exchange (all pairs with exchange X), and per-pair (a specific X ↔ Y). With 3+ exchanges, a symbol blocked on one pair can still trade on others.
The Rust optimizer (optimizer/src/) searches parameters over historical ticks in four modes — grid (exhaustive), TPE (Bayesian), CMA-ES (evolutionary), and funnel (coarse grid → CMA-ES refinement) — to find the (min_entry_spread_bps, take_profit_pct, ...) that maximizes Sharpe ratio (or profit, profit factor, win rate).
The simulation follows the bot's execution flow: an entry signal (taker spread > threshold, liquidity check, cooldown); a parallel entry with both fills at max(lighter_fill_delay_ms, extended_taker_fill_delay_ms); a TP exit (net_pnl_pct ≥ take_profit_pct with min_exit_checks confirmations); an SL exit (check_sl_trigger_with_dist() from common/pnl.rs); close fees (Extended taker ~0.025% IOC or 0% GTT maker, Lighter tier-dependent taker); and a daily PnL reset on the day boundary. The full list of differences: OPTIMIZER_VS_BOT.md.
The simulator is generic over N exchanges. On each tick the hot loop iterates for &(i, j) in ¶ms.pairs, where pairs = enumerate_pairs(N) = C(N, 2) in lex order. Each pair gets an independent shot at opening a position, but the capital pool is shared across all pairs via max_used_capital_usd — if the first pair takes a slot, the rest break. This matches the live bot, where the SpreadDetector evaluates all pairs independently but the capital is one pool. The per-symbol cap max_positions_per_symbol is likewise global across pairs: a symbol open on pair (0, 1) blocks the same coin on (0, 2) when the cap is 1.
enumerate_pairs(n) returns lex order: (0,1), (0,2), (1,2), (0,3), .... So the first pair in iteration order wins capital ties — even against a later pair with a wider spread. For N=3, (0,1) is always first and (1,2) always last.
This is deliberate, not a bug. The alternatives (sort by signal strength, round-robin offset) break determinism or need an extra pre-pass over all pairs. The current sim is deterministic (so backtests reproduce), the snapshot regression net guards against accidental change, and the bias is pinned by the test multi_pair::pair_iteration_order_bias_first_pair_wins_capital_ties. Change the iteration strategy and that test fails, forcing a deliberate review plus an audit of optimizer outputs for systematic shifts. The enumerate_pairs doc-comment points at the test. (If it ever matters in practice, watch the PnL delta between pairs — if (0, *) systematically gets more fills than (1, *), the bias is affecting real allocation.)
The optimizer runs one sim over all C(N, 2) pairs with a shared capital pool and finds one best parameter set. When you click "Apply best result," the backend (apply_optimizer_result_inner in bot/src/web/optimizer/handlers.rs) writes one symbol_overrides row per pair — 1 row for N=2, 3 for N=3, 6 for N=4 — all in one transaction, all linked by a shared source_run_id. It's "global" because one parameter set applies to every pair. Per-pair mode (pick one pair → tune only it → write 1 row) isn't built yet; the current single-pair workaround is to run the optimizer with --exchanges X,Y, which persists exchanges: [X, Y] in config_json so Apply writes exactly one row.
An earlier bug took the first two exchanges ORDER BY exchange_id regardless of the run's config — harmless for N=2 (one pair), silent corruption for N=3 (params landed on the wrong pair). The fix reads config_json["exchanges"] and resolves each name via Storage::resolve_exchange_ids, erroring loudly on an unknown name. Regression test: apply_overrides::apply_n3_writes_three_rows_global_mode.
common/tests/snapshot_2leg.rs and snapshot_3leg.rs pin the byte-exact output of run_simulation() on deterministic synthetic fixtures. The contract: after any change to common/src/sim.rs or common/src/backtest.rs, both must pass byte-exact with no baseline regeneration; a failure is a refactor bug, not intentional drift. Regenerate only via INSERT_SNAPSHOT=1, and only after inspecting the diff by hand. Alongside them, multi_pair.rs (5 cases) documents the behavioral invariants that byte-exactness can't express: capital-pool sharing, the per-symbol cap across pairs, per-eid funding-cursor indexing, simultaneous opens on multiple pairs, and the pair-iteration bias. Together these three files are the sim regression net.
| Function | Module | Used for |
|---|---|---|
compute_pnl_f64() |
common/pnl.rs |
PnL at close |
check_sl_trigger_with_dist() |
common/pnl.rs |
SL check |
load_ticks(), build_sym_indices() |
common/tick.rs |
Loading tick data |
load_shared_toml(), PairsParams |
common/config.rs |
Config + market params (from SQLite) |
ArbDirection, ExitReason |
common/types.rs |
Types |
Optimized (ranges in config/optimizer.toml or via CLI): entry_spread_bps, take_profit_pct, signal_cooldown_sec, min_liquidity_ratio, min_exit_checks. Fixed (from config/default.toml): leverage, max_order_size_usd, max_open_positions, stop_loss_buffer_pct, fees.
The optimizer simulates execution delays via find_delayed_tick_idx(): signal_delay_ms = 130 (detection → send), lighter_fill_delay_ms = 590, extended_taker_fill_delay_ms = 460, close_delay_ms = 500. These are calibrated to the bot's measured latencies.
# grid search, default ranges
cargo run --release -p optimizer_rs -- --config config/default.toml
# filter by symbol
cargo run --release -p optimizer_rs -- --config config/default.toml --symbol ETH
# extra grid dimensions
cargo run --release -p optimizer_rs -- --config config/default.toml \
--optimize-cooldown --optimize-liquidity
# TPE (Bayesian) — faster for large spaces
cargo run --release -p optimizer_rs -- --config config/default.toml --mode tpe
# funnel — coarse grid → CMA-ES refinement
cargo run --release -p optimizer_rs -- --config config/default.toml --mode funnelResults are written to db/optimizer/optimize_YYYY-MM-DD_HHMMSS.csv. A deeper methodology write-up is in STRATEGY_OPTIMIZE.md.
More exchanges. Five venues are integrated — Lighter, Extended, Zero1, Hyperliquid, and Bybit. Hibachi and Variational (§3) are research candidates with no code — add one only when a clear economic motive appears (a new pair or a new spread source). Process: EXCHANGE_ADD_GUIDE.md; comparison: EXCHANGE_CANDIDATES.md.
Funding-rate arbitrage. Observability exists — funding/rates_poll.rs polls every integrated venue's public funding endpoint into data/funding/, and Extended per-position cum_funding is polled over authenticated REST for accurate close accounting; the optimizer's sim already uses funding for funding-aware SL drift. The strategy isn't built: there's no detector that opens 20–30 min before a funding settlement and exits on a sign flip. Build it only if price-spread arbitrage hits a volume ceiling.
HFT tuning. The production servers are already in AWS Tokyo (Extended, Zero1); Lighter's Tokyo CloudFront edge is picked up by geo-DNS. CPU pinning and hot-path profiling haven't been done, because the bottleneck isn't CPU — the detector and executor both fit inside Sig→Ord < 1ms (§2). Add them only when a profile shows a real problem, not preemptively.
| Item | Cost/month | Note |
|---|---|---|
| AWS Tokyo (ap-northeast-1) | $50–200 | Extended's recommended region — their servers are here too |
| VPN (if needed) | $5–10 | For local-machine access during development |
| Lighter Premium stake | LIT locked | 0ms maker latency instead of 200ms |
| Capital (start) | $2,000–5,000 | ~$1–2.5K per exchange |
| Capital (scale) | $10,000–50,000 | ~$2–10K per exchange once the strategy is confirmed |
AWS from day one — it makes no sense to develop on a cheap VPS and migrate later: latency to Extended is fundamentally different there and the bot behaves differently. Test in the same conditions as production.
The target is 3× on every exchange — identical, so liquidation is symmetric. How that's achieved differs: Lighter, Extended, Hyperliquid, and Bybit accept leverage over their APIs; Zero1 doesn't.
- Lighter: set via a WS transaction (tx_type=20), expressed as an initial margin fraction:
IMF = 10000 / leverage, so 3× = IMF 3333. Sent per market at startup; signed (Poseidon2 + Schnorr). - Extended: set via REST
PATCH /api/v1/user/leverage({"market": "ETH-USD-PERP", "leverage": "3"}), per market at startup; API key only. - Hyperliquid: set via the
updateLeverageaction onPOST /exchange, per market at startup. - Bybit: set via the set-leverage REST endpoint, per symbol.
- Zero1: not configurable — the exchange always applies the market's maximum. To hold the same exposure as the others, the bot emulates the target locally:
risk/manager.rstrackszero1_used_notional(the sum of open Zero1 notional) andtry_reserve()rejects a new position that would exceedavailable_balance × target_leverage. Zero1's API still reports max leverage, but the real capital allocation matches the other venues.
Max leverage: BTC/ETH are 50× on Lighter/Extended, 40×/100× on Hyperliquid/Bybit, and per-market on Zero1 — all stored in exchange_symbols.max_leverage, imported at factory.fetch_symbols.
| Exchange | Margin mode | Behavior |
|---|---|---|
| Lighter | Isolated (mode 1) | A liquidation loses only the position's allocated margin, not the account |
| Extended | Cross (only option) | All positions share one equity pool; an SL is mandatory |
| Zero1 | Cross (exchange default) | Same as Extended; an SL is mandatory |
| Hyperliquid | Cross / isolated | Isolated is available (unlike Zero1) — the bot can cap per-position loss |
| Bybit | Cross / isolated | CEX; the liquidation price comes from the WS position stream |
Isolated is critical on Lighter so one bad position can't drain the account. On the cross-margin venues the mandatory SL plus the daily_loss_limit_usd kill switch compensates.
Computed in place_stop_losses() (executor.rs):
// Simplified fallback (when MMF data is unavailable):
liq_distance = 1 / leverage // 3× → 33.3%
LONG: liq = entry × (1 − liq_distance) // $40 → $26.67
SHORT: liq = entry × (1 + liq_distance) // $40 → $53.33
// Accurate, using per-symbol MMF (Maintenance Margin Fraction):
// source: exchange REST APIs → the SQLite exchange_symbols table
liq_distance = max(1/leverage, 1/max_leverage) − mmf
// per-leg: l_dist and e_dist computed independently from lighter_mmf, extended_mmf
// both legs protected at min(l_dist, e_dist)
The real formula (common/pnl.rs::check_sl_trigger_with_dist) uses the per-symbol MMF from exchange_symbols (fetched from the APIs at startup). MMF is the fraction of margin the exchange keeps before liquidating; max(1/lev, 1/max_lev) − mmf gives the exact distance. The 1/leverage fallback is used only when MMF data is missing.
All five integrated APIs supply a liquidation price for monitoring and SL movement: Lighter via account WS positions[].liquidation_price (realtime); Extended via account WS liquidationPrice + REST fallback; Hyperliquid via clearinghouseState; Bybit via the WS position liqPrice; and Zero1 computed in zero1/liq_price.rs from position + market leverage + funding state (the one venue that returns no liq price, so the bot derives it).
Approximate distances at 3×:
| Long (Lighter, isolated) | Short (Extended, cross) | |
|---|---|---|
| ETH $2000 | liq ~$1333 (−33.3%) | liq ~$2667 (+33.3%) |
| BTC $70000 | liq ~$46667 (−33.3%) | liq ~$93333 (+33.3%) |
Real distances can be tighter because of MMF; the exchange_symbols data refreshes on every startup via fetch_market_meta().
Rather than watch a margin ratio, the bot places native stop-losses on each leg, set just short of liquidation.
Algorithm (bot/src/execution/open.rs::place_stop_losses, a background task after a successful entry):
- Both legs are open (direction determined automatically).
- For each leg independently,
liq_pricecomes from the exchange API (account WS /fetch_liquidation_price), not the fallback — real values account for MMF and cross-margin effects. - Compute buffer and trigger per leg:
buffer = entry_price × stop_loss_buffer_pct / 100- LONG leg (liq below entry):
trigger = liq + buffer(above liquidation, toward entry) - SHORT leg (liq above entry):
trigger = liq − buffer(below liquidation, toward entry)
- Execution price at trigger:
- Market SL:
price = midpoint(trigger, liq) - Limit SL:
price = trigger ± 0.5%toward liquidation
- Market SL:
- Placement is generic —
ctx.client(eid).place_stop_loss(...); each exchange knows its own format (see the table in §5).
So stop_loss_buffer_pct = 1.0 fires the SL 1% of entry price before liquidation — at 3× leverage, liquidation is ~−33% of entry, so the SL sits at ~−32%.
Real example (from an integration test, HYPE, direction A):
Lighter LONG:
entry = $39.52
liq = $32.58 (−17.5% of entry)
buffer = $0.40 (1% × $39.52)
trigger = $32.97 (liq + buffer, −16.5% of entry)
market price = $32.78 (midpoint trigger↔liq)
→ margin to liquidation: 1.0% of entry ✓
Extended SHORT:
entry = $39.54
liq = $134.14 (+239.2% of entry)
buffer = $0.40 (1% × $39.54)
trigger = $133.75 (liq − buffer, +238.2% of entry)
market price = $133.95 (midpoint trigger↔liq)
→ margin to liquidation: 1.0% of entry ✓
When one leg's SL fires, the other leg is closed too (market order): the bot sees the close over account WS and immediately sends a close to the other exchange.
The liquidation price drifts over time (funding payments, margin changes), so the bot moves the SL to keep the configured buffer from the current liquidation price.
Algorithm (check_sl_drift() in executor.rs, run on every snapshot):
- For each SL order in
active_orders: skip non-SL orders, closing positions, and stale data (>60s); ifliq_price_at_placement == 0(restored after a restart), initialize it from the current WS liq price. - Compute drift:
|current_liq − liq_at_placement| / liq_at_placement. - If drift ≥
sl_movement_threshold_pct, spawnexecute_sl_move(). - An
sl_moves_in_flightset prevents concurrent moves on one order.
Modify, per exchange (ExchangeClient::modify_stop_loss()): Lighter does an atomic modify (tx_type=17 over WS) — no gap in protection, the order_id is preserved; Extended and Zero1 do cancel + place (the trait default), non-atomic, retrying the place up to 3× if the cancel succeeded but the place failed. InFlightGuard (panic-safe RAII in sl_monitor.rs) holds the in_flight_sl entry so concurrent check_sl_drift() calls can't start a second move on the same (exchange, order_id); a panic drop still clears the set, which is why it's RAII rather than a manual try/finally.
[position]
sl_movement_enabled = true
sl_movement_threshold_pct = 0.5 # min liq-price drift (%) to move the SLPer-exchange SL details:
- Lighter: Market = Type=2 (StopLoss) + TIF=0 (IOC), trigger → IOC at an aggressive price; Limit = Type=3 (StopLossLimit) + TIF=1 (GTT), trigger → limit order. The price can't be 0 or 1 — it needs a real value. Expiry: max 7 days.
- Extended: a TPSL order (StopLossLimit, Type=1002) with
stopLoss.triggerPriceandstopLoss.price; a pure stop-market isn't available, so "market" behavior is a limit far from the trigger (midpoint toward liq). qty and price are rounded before signing, or the exchange rejects the StarkEx signature. - Zero1: a trigger-based stop over REST
/action, takingtrigger_price+limit_price; modify is cancel + place (no atomic modify yet).
[position]
leverage = 3 # equal leverage → symmetric liquidation
stop_loss_buffer_pct = 1.0 # trigger = liq ± entry × buffer/100
# 1.0 at 3×: SL ≈ −32.3%, liq ≈ −33.3%
sl_movement_enabled = true
sl_movement_threshold_pct = 0.5
[exchanges.lighter]
margin_mode = "isolated"
stop_loss_type = "market" # Type=2 (StopLoss, IOC, price = midpoint to liq)
# or "limit" = Type=3 (StopLossLimit, GTT, price = trigger ± 0.5%)
[exchanges.extended]
margin_mode = "cross" # Extended's only mode
stop_loss_type = "limit" # Extended: StopLossLimit only (TPSL with a limit price)
[exchanges.zero1]
# margin_mode not configurable (exchange default = cross)
stop_loss_type = "trigger" # trigger-based stop via REST /action| Risk | Likelihood | Mitigation |
|---|---|---|
| Leg risk: one leg doesn't fill | Medium | Parallel execution + immediate market-close of the filled leg on failure |
| Slippage larger than expected | Medium | Check book depth before entry; price limit on the IOC |
| Lighter sequencer front-run | Low | No defense; monitor adverse-selection stats |
| Thin liquidity (Hibachi, Variational) | High | Smaller max size, higher min threshold for thin venues |
| Smart-contract exploit | Low | Don't hold all capital on one exchange |
| Funding-rate flip | Medium | Auto-exit on a sign change (a future funding strategy) |
| One leg liquidated | Low | 3× leverage + native stop-losses (real liq price from the API) + isolated margin on Lighter + SL movement on drift |
| External leg close (SL fired, manual close, liquidation) | Medium | PositionEventSink detects it on any exchange → the executor symmetrically closes the counterparty leg; an in-flight whitelist (!StopLoss) keeps a fresh entry from being closed by its own fills |
| API downtime / maintenance | Medium | ExchangeHealth pre-trade gate + two-tier WS freshness watchdog (30s reconnect / 45s halt), auto-resume on recovery, exchange_events for post-mortems, CloudFront-403 detection |
| Geo-blocking / WAF | Medium | Production servers in Tokyo (Extended/Zero1 co-located, Lighter via its Tokyo CloudFront edge); CloudFront HTML detection in http_get/http_post |
| Per-exchange WS head-of-line blocking | Medium | Per-market WS mode is the default on the three DEXs that support it (Lighter, Extended, Zero1) — measured better than all-markets at every percentile; Hyperliquid and Bybit use shared/single connections (EXCHANGE_NOTES.md) |
Exchange docs:
- Lighter API docs — WebSocket, order types, sendtx
- Lighter whitepaper — ZK-rollup architecture
- Extended docs and architecture blog
- Hibachi by Succinct — architecture, ZK proofs
- Variational docs — RFQ model, Omni API
- Hyperliquid API
Reference implementations (for leg-risk and position management):
- rustjesty/hyperliquid-drift-arbitrage-bot — Rust; a strong public reference for leg-risk handling
- earthskyorg/perp-dex-toolkit — EdgeX, Lighter, Extended, Paradex
- hyperliquid-dex/hyperliquid-rust-sdk and ControlCplusControlV/ferrofluid — HFT SDK
- your-quantguy/cross-exchange-arbitrage — Python, edgeX + Lighter + Extended; allows pyramiding via a
--max-positionscalar; the most relevant reference - Alex-bitok/okbitok-arbitrage-bot, 50shadesofgwei/funding-rate-arbitrage, cgaspart/HL-Delta — various position-management approaches
Research:
- The Two-Tiered Structure of Funding-Rate Markets (MDPI, Jan 2026) — 35.7M observations, 17% of opportunities ≥20 bps, 40% profitable
- Funding-Rate Arbitrage CEX/DEX (ScienceDirect)
- Arbitrage in Perpetual DEXs (PI2 Network)
- Stanford Blockchain Review: Lighter ZK risks