Multi-Leg Backtest (spot + perp delta-neutral arbitrage)¶
From 0.5.0,
BacktestEnginesupports multi-leg (spot + perp) backtesting, the foundation for delta-neutral arbitrage strategies.
Why Multi-Leg?¶
Traditional single-leg backtest assumes the strategy trades on one instrument (e.g., spot only) and cannot express:
- Delta-neutral arbitrage: simultaneously hold spot + opposite perp, earn funding rate (perpetual contract funding rate)
- Cross-market arbitrage: CEX ↔ DEX same-instrument spread
- Statistical arbitrage: spread reversion (pair trading / cross-instrument spread)
0.5.0 introduces the Instrument abstraction (see Python Bindings → 0.5.0 multi-leg API), upgrading "symbol string" to "instrument enum". Each leg has independent routing, position, and mark cache.
Instrument Abstraction¶
// axon-core/src/types/instrument.rs
pub enum Instrument {
Spot(SpotInstrument { base: Symbol, quote: Symbol }),
Swap(SwapInstrument {
base: Symbol,
quote: Symbol,
settle: SwapSettle, // UsdMargin | CoinMargin
contract_size: f64, // contract multiplier (Binance BTCUSDT perp = 1.0)
}),
}
Instrument as HashMap key: manually implemented Hash / Eq (because f64 doesn't implement these traits; contract_size uses f64::to_bits() for bitwise comparison).
Python Factories¶
from axon_quant.backtest import spot_instrument, swap_instrument
# Spot instrument dict
btc_spot = spot_instrument("BTC", "USDT")
# {"kind": "spot", "base": "BTC", "quote": "USDT"}
# Swap instrument dict (perpetual contract)
btc_perp = swap_instrument("BTC", "USDT", settle="usd_margin", contract_size=1.0)
# {"kind": "swap", "base": "BTC", "quote": "USDT",
# "settle": "usd_margin", "contract_size": 1.0}
swap_instrument settle accepts "usd_margin" (USD margin, Binance default) / "coin_margin" (coin-margined), case-insensitive; contract_size defaults to 1.0.
Order API (Order::spot / Order::swap)¶
0.5.0 removes Order::new in favor of explicit factories:
// Old (0.4.x): Order::new(1, "BTC/USDT", Side::Buy, order_type, qty, tif)
// New spot: Order::spot(id, base, quote, side, order_type, qty, tif)
let spot_order = Order::spot(1, "BTC", "USDT", Side::Buy,
OrderType::Limit { price: Price::from_f64(50_001.0) },
Quantity::from_f64(0.1),
TimeInForce::GTC,
);
// New swap: Order::swap(id, base, quote, settle, contract_size, side, ...)
let perp_order = Order::swap(2, "BTC", "USDT", SwapSettle::UsdMargin, 1.0,
Side::Sell,
OrderType::Limit { price: Price::from_f64(50_001.0) },
Quantity::from_f64(0.1),
TimeInForce::GTC,
);
Python side uses limit_order(id, instrument, side, price, qty) factory:
order = limit_order(1, btc_spot, "Buy", 50_001.0, 0.1)
# {"id": 1, "instrument": {"kind": "spot", "base": "BTC", "quote": "USDT"},
# "side": "Buy", "type": "limit", "price": 50001.0, "quantity": 0.1, "tif": "GTC"}
BacktestEngine Multi-Leg API¶
| Method | Purpose |
|---|---|
set_target_position(instrument, target) | Record strategy target position for this leg (record only, no order sent) |
get_target_position(instrument) -> Optional[float] | Read target (returns None if not set) |
get_position(instrument) -> float | Read current actual position (default 0.0) |
push_mark(instrument, price, ts_ns) | Write mark price (last-wins) |
begin_bar(price, instrument) | Seed virtual counterparty liquidity on this leg (requires with_seed_liquidity(...)) |
RunResult adds 3 per-instrument dicts:
positions: dict[instrument, float]— terminal positionsleg_targets: dict[instrument, float]— target snapshotmarks: dict[instrument, float]— latest mark prices
Chained with_* configuration (0.7.1+)¶
Since 0.7.1, all BacktestEngine.with_* methods return &mut Self (Python: PyRefMut<...>) instead of (), so you can chain configuration fluently:
bt = (BacktestEngine(initial_cash=100_000.0)
.with_seed_liquidity(half_spread=0.5, depth_levels=2, size_per_level=2.0)
.with_fee_config(0.0005)
.with_auto_rebalance(threshold=0.01)
.with_funding_schedule(period_secs=28_800)) # 8h funding
Affected methods: with_matching_engine, with_fee_config, with_force_liquidate, with_seed_liquidity, with_seed_liquidity_for, with_auto_rebalance, with_auto_rebalance_disable, with_funding_schedule, with_funding_schedule_disable. BREAKING (light): Python callers that bound the return value to a name will see engine instead of None; call-and-discard code is unaffected.
bar_nav_curve per-bar NAV curve (0.7.1+)¶
RunResult.equity_curve only samples on fill / mark / funding events. For a short backtest with zero fills the last frame is initial_cash, which makes Sharpe / max-drawdown calculations meaningless. Since 0.7.1, every begin_bar / begin_bar_multi call also appends one frame to bar_nav_curve: list[tuple[ts_ns, nav]] where nav = compute_nav(clock.now(), mark_fallback).
result = bt.run()
import numpy as np
arr = np.asarray(result.bar_nav_curve, dtype=np.float64) # shape (N, 2)
ts_s = arr[:, 0] * 1e-9
nav = arr[:, 1]
# Annualised Sharpe from per-bar returns (15-min bars → 35_040 bars/year)
log_r = np.diff(np.log(nav))
bar_per_year = 365 * 24 * 4 # 15-min
sharpe = log_r.mean() / log_r.std(ddof=1) * np.sqrt(bar_per_year)
Same-ts de-dup: if you call begin_bar multiple times with the same clock.now() (e.g. across multiple legs), the last frame overwrites the previous one — no duplicate points pollute the Sharpe series.
End-to-End Example: Delta-Neutral Entry (Funding > 0)¶
Strategy logic: when funding > 0, perp shorts receive funding (funding rate paid from longs to shorts), so the strategy simultaneously holds spot long + perp short, eating funding.
from axon_quant.backtest import (
BacktestEngine, limit_order, spot_instrument, swap_instrument,
)
spot = spot_instrument("BTC", "USDT")
perp = swap_instrument("BTC", "USDT", settle="usd_margin", contract_size=1.0)
bt = BacktestEngine(initial_cash=100_000.0).with_seed_liquidity(
half_spread=0.5, depth_levels=2, size_per_level=2.0,
)
# Each bar triggers independent seed liquidity (spot / perp isolated)
bt.begin_bar(50_000.0, spot)
bt.begin_bar(50_000.0, perp)
# Set leg targets: spot long +1, perp short -1 (delta neutral, eat funding > 0)
bt.set_target_position(spot, 1.0)
bt.set_target_position(perp, -1.0)
# Strategy orders
bt.push_event({
"type": "order_submitted", "timestamp_ns": 1_000,
"order": limit_order(1, spot, "Buy", 50_001.0, 0.5),
})
bt.push_event({
"type": "order_submitted", "timestamp_ns": 1_500,
"order": limit_order(2, perp, "Sell", 50_001.0, 0.5),
})
# Push mark prices (for 0.6.0 funding settlement / unrealized PnL valuation)
bt.push_mark(spot, 50_000.0, timestamp_ns=1_000_000)
bt.push_mark(perp, 50_100.0, timestamp_ns=1_500_000)
result = bt.run()
# spot long = +0.5, perp short = -0.5, net 0 (delta neutral)
assert result.positions[spot] == 0.5
assert result.positions[perp] == -0.5
assert bt.get_target_position(spot) == 1.0
assert bt.get_target_position(perp) == -1.0
assert result.marks[spot] == 50_000.0
assert result.marks[perp] == 50_100.0
L1MatchingEngine Multi-Instrument Routing¶
L1MatchingEngine internally upgrades from single book to HashMap<Instrument, L1Book>:
┌─ L1MatchingEngine ─────────────────────┐
│ books: HashMap<Instrument, L1Book> │
│ ┌─ L1Book(spot BTC/USDT) ─────────┐ │
│ │ bids: BTreeMap<Price, ...> │ │
│ │ asks: BTreeMap<Price, ...> │ │
│ │ order_index: HashMap<u64, ...> │ │
│ └─────────────────────────────────┘ │
│ ┌─ L1Book(perp BTC/USDT) ─────────┐ │
│ │ ...(independent book, matching │ │
│ │ is fully isolated) │ │
│ └─────────────────────────────────┘ │
│ trade_sequence: AtomicU64(shared) │
└─────────────────────────────────────────┘
submit(order) routing logic: fetch corresponding L1Book by order.instrument (HashMap::entry().or_default() auto-creates), matching happens only within the book. Spot matching does not touch perp book, and vice versa.
Known Limitations (0.6.0 Roadmap)¶
0.5.0 only validates structural correctness. The following capabilities are not yet implemented:
| Capability | Status | Target Version |
|---|---|---|
| Funding settlement (perp receives/pays funding rate) | ❌ | 0.6.0 |
Automatic leg balancing (auto-send orders after set_target_position) | ❌ | 0.6.0 |
Full Position / RiskEngine migration to HashMap<Instrument, _> | ❌ (uses Symbol bridge) | 0.6.0 |
| Mark-to-market unrealized PnL (only caches mark price) | ❌ | 0.6.0 |
Full funding settlement + automatic leg balancing + multi-leg NAV curve, see CHANGELOG.md 0.5.0 section "Known Limitations (0.6.0 Roadmap)".
Complete Test Coverage¶
- Rust integration tests:
crates/axon-integration-tests/src/delta_neutral_arb.rs(5 tests) two_legs_spot_match_only_spot_fills— spot fill doesn't affect perptwo_legs_orders_route_to_independent_books— two legs independent passive ordersleg_target_position_independent_per_instrument— cross-leg isolationleg_marks_independent_and_last_wins— mark cache isolationdelta_neutral_entry_orders_isolated— real delta-neutral entry- Python E2E:
python/tests/test_backtest_e2e.py(6+ 0.5.0 new tests) spot_instrument_factory/swap_instrument_factorybegin_barper-instrument independent seedingset_and_get_target_positioncross-leg isolationtwo_legs_isolated_positions— spot long + perp short = delta neutralleg_targets_persistinRunResult