Skip to content

Latest commit

 

History

History
1250 lines (907 loc) · 80.6 KB

DESIGN.md

File metadata and controls

1250 lines (907 loc) · 80.6 KB

Design

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.


1. The strategy

What cross-exchange perpetual arbitrage is

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.

Three sources of profit

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.

Why the opportunity survives

  1. DEX architectures differ: a custom ZK-rollup (Lighter) vs. Starknet (Extended) vs. StarkNet perps (Zero1) — different price-update and finality patterns.
  2. Liquidity is fragmented: different LPs, different users, different book depth.
  3. Few arbitrageurs: most of these DEXs are new, so competition is thin.
  4. 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%.


2. The trade lifecycle

Phase 1 — Signal detection

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.

Phase 2 — Entry execution

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 carries execution_price and liquidation_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)

Phase 3 — Holding the position

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.

Phase 4 — Exit-signal detection

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.

Phase 5 — Exit execution

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)

Timings from real trades

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.

Latency observability — per-leg UI columns

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 — always now_ns() at the moment the WS frame is processed (our clock). Feeds Recv→Sig and the max_recv_staleness filter.
  • exchange_ts_ns — the exchange matching engine's event time when available, otherwise now_ns() (same as ts_ns, not 0). Feeds Tick→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.


3. Exchanges

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.

Integrated exchanges

Lighter (lighter.xyz) — the primary leg

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.

Extended (extended.exchange) — the second leg

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.

Zero1 (01.xyz / N1 / Nord) — the third leg

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_price and liquidation_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_notional gate 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). fastwebsockets auto-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(), so Tick→Recv is structurally 0 and the UI shows —; Recv→Sig works 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=true guard in ws.rs), or a stale fill from a prior session would trigger report_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.

Hyperliquid — the fourth DEX leg

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.

Bybit — the CEX leg

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.

Research candidates (not integrated)

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.

Hibachi (hibachi.xyz) — an ETH leg

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.

Variational (variational.io) — a LIT leg

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.


4. Entry and exit rules

Entry — a VWAP spread through the top 5 book levels

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.

Exit

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_*.

Sizing

// 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

5. Order execution and leg risk

IOC vs Market on a DEX

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) ✗ —

Entry execution

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:

  1. The order request is built by build_entry_order() + resolve_entry_slippage(): an OrderRequest::Market or ::Ioc chosen by the exchange's entry_order_type; the IOC limit price = signal.price[eid] × (1 ± slippage), where signal.price[eid] is the leg's VWAP; and a slippage cap of min(entry_slippage_pct, per_symbol_limit_price_cap) — usually 5–15% of the exchange-side limit_price_cap, wide enough that an IOC leg doesn't miss on depth, narrow enough to protect against a flash crash.
  2. 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])
    
  3. The position is the hedged quantity, VWAP over both legs: entry_spread_bps = (vwap[eid_j] − vwap[eid_i]) / mid × 10000 for 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 on Extended

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

A new signal on a held symbol

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.)

Failure scenarios

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

One exchange unavailable (maintenance / outage)

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

Kill switch (RiskManager)

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.


6. Points / airdrop farming

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.

Status by exchange (March 2026)

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

Per-exchange detail

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).

Maximizing points

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.


7. Technical stack (Rust)

The full list is bot/Cargo.toml (source of truth); this is an overview by group.

Core

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

Exchanges and signing

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)

Storage and web

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

Build profile

# 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 = true

Exchange connectors

All 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

Runtime architecture

┌─────────────────────────────────────────────────────────────────┐
│                            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).


8. Project structure

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)

8.1 Startup: detect & resume

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".

8.2 Graceful shutdown

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.

8.3 Exchange health (bot/src/exchange/health.rs)

In-memory availability monitoring, with events written to the DB:

  • DashMap<(exchange_id, HealthChannel), ChannelState> — a per-channel AtomicBool + 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) and WS_STALE_HALT_MS = 45s (the executor gate blocks entries when data_age > 45s). Both read the same last_data_ms atomic, 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_events records every connect/disconnect/error; exchanges is the venue registry (id, name, fee_taker, fee_maker, fee_updated_at_ms).
  • CloudFront 403 — http_get/http_post detect 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.

8.4 Telegram notifications (bot/src/telegram/)

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 and getUpdates long-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 @userinfobot

Empty fields disable Telegram entirely and the task isn't spawned.


9. Key configuration parameters

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 disabled

Per-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.


10. Parameter optimizer

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).

Execution model (must emulate the bot exactly)

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.

N-exchange semantics

The simulator is generic over N exchanges. On each tick the hot loop iterates for &(i, j) in &params.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.

Pair iteration order

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.)

Apply best params — global mode

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.

Snapshot regression contract

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.

Shared with the bot (via common/)

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

Grid-search parameters

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.

Latency model

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.

Running it

# 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 funnel

Results are written to db/optimizer/optimize_YYYY-MM-DD_HHMMSS.csv. A deeper methodology write-up is in STRATEGY_OPTIMIZE.md.


10b. Roadmap

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.


11. Infrastructure

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.


12. Leverage, margin, and liquidation protection

Leverage

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 updateLeverage action on POST /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.rs tracks zero1_used_notional (the sum of open Zero1 notional) and try_reserve() rejects a new position that would exceed available_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.

Margin type

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.

Liquidation price

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().

Liquidation protection: stop-loss

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):

  1. Both legs are open (direction determined automatically).
  2. For each leg independently, liq_price comes from the exchange API (account WS / fetch_liquidation_price), not the fallback — real values account for MMF and cross-margin effects.
  3. 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)
  4. Execution price at trigger:
    • Market SL: price = midpoint(trigger, liq)
    • Limit SL: price = trigger ± 0.5% toward liquidation
  5. 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.

SL movement

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):

  1. For each SL order in active_orders: skip non-SL orders, closing positions, and stale data (>60s); if liq_price_at_placement == 0 (restored after a restart), initialize it from the current WS liq price.
  2. Compute drift: |current_liq − liq_at_placement| / liq_at_placement.
  3. If drift ≥ sl_movement_threshold_pct, spawn execute_sl_move().
  4. An sl_moves_in_flight set 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 SL

Per-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.triggerPrice and stopLoss.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, taking trigger_price + limit_price; modify is cancel + place (no atomic modify yet).

SL configuration

[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

13. Risks

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)

References

Exchange docs:

Reference implementations (for leg-risk and position management):

Research: