Skip to content

Cross-margin accounts

Real prop accounts almost universally run cross-margin: equity is shared across all positions on the account, so a profitable BTC short backs a losing ETH long. Backtests that treat each position in isolation overstate liquidation risk for cross-margined portfolios (because cross has more shared cushion) and understate the systemic risk when one position drags the whole account.

The venue-stack's Account type owns the shared state — equity, the position book across symbols, per-symbol mark prices, and a 30-day rolling notional counter — and plugs into LiquidationEngine and FeeSchedule so they evaluate at the account level instead of per-position.

Build an account

import lrvx

acct = lrvx.Account(account_id=42, equity=10_000.0)
acct.open_position(symbol=1, quantity=5.0, entry_price=50_000.0)
acct.open_position(symbol=2, quantity=-30.0, entry_price=3_000.0)
import { Account } from "@lrvx/lrvx";

const acct = new Account(42, 10_000);
acct.openPosition(1, 5.0, 50_000);
acct.openPosition(2, -30.0, 3_000);

The default margin mode is cross. Switch to isolated per-account with set_margin_mode("isolated") / setMarginMode("isolated").

Closing a position

close_position(symbol) realises every leg on that symbol at the symbol's current mark before dropping it, and credits the result to account equity:

realised = quantity * (mark - entry_price) * contract_multiplier

That is the same expression total_unrealised_pnl() reports, so what the account showed as unrealised is exactly what the close books — there is no separate add_equity to remember. A leg on a symbol that was never marked is valued at entry and realises nothing, which is how it was already valued in every aggregate.

acct = lrvx.Account(account_id=42, equity=1_000_000.0)
acct.open_position(symbol=1, quantity=2.5, entry_price=30_000.0)
acct.set_mark(1, 31_234.5678)
acct.close_position(1)
acct.equity()  # 1_003_086.4195
const acct = new lrvx.Account(42, 1_000_000);
acct.openPosition(1, 2.5, 30_000);
acct.setMark(1, 31_234.5678);
acct.closePosition(1);
acct.equity();  // 1_003_086.4195

Before this, closing erased the legs and left equity untouched, so a run that opened and closed positions all day reported the equity it started with.

Cross-margin liquidation

Attach the account to a LiquidationEngine. The engine walks attached accounts on every on_marks tick: for accounts in cross mode, it evaluates the account-level maintenance-margin check (equity + total_uPnL vs total_notional * mm_fraction) and, when the account is underwater, closes the worst-PnL position first.

Use on_marks(...) with the full set of current marks per tick — it updates every attached account's marks atomically before walking. The legacy single-symbol on_mark(...) is still available but is a footgun for multi-symbol accounts: forgetting to set the other symbols' marks leaves the cross-margin check evaluating against stale data.

liq = lrvx.LiquidationEngine.binance_um_futures()
liq.attach_account(acct)

# Atomic multi-symbol update; engine walks every attached
# account once it has fresh marks for every position.
out = liq.on_marks([(1, 47_000.0), (2, 2_800.0)], ts_ns=now)
print(out["liquidations_count"])
const liq = new lrvx.LiquidationEngine();
liq.loadProfile("binance_um_futures");
liq.attachAccount(acct);

const n = liq.onMarks([[1, 47_000], [2, 2_800]], now);

Stale-mark guard

When a backtest must refuse to walk on stale data, set timestamps explicitly via set_mark(sym, price, ts_ns) (or pass ts_ns to on_marks) and check the account before driving the engine:

if acct.has_stale_marks(now_ns=now, budget_ns=60_000_000_000):
    raise RuntimeError("refresh marks before walking")
liq.on_marks(current_marks, ts_ns=now)
if (acct.hasStaleMarks(now, 60_000_000_000)) {
    throw new Error("refresh marks before walking");
}
liq.onMarks(currentMarks, now);

When a profitable short backs a losing long, the account stays solvent and no liquidation fires. When both legs bleed, the engine closes the worst leg, re-checks, and continues until the account is solvent or no positions remain. Any residual equity deficit hits the insurance fund (and ADL, if configured).

Shared 30-day fee tier

Real venues compute the VIP tier from aggregate 30-day notional across all symbols, not per-symbol. Binding the account to one or more FeeSchedules makes them read the aggregate counter:

btc_sched = lrvx.FeeSchedule.binance_um_futures()
eth_sched = lrvx.FeeSchedule.binance_um_futures()
btc_sched.bind_account(acct)
eth_sched.bind_account(acct)

btc_sched.record_fill(ts_ns=0, notional=150_000)
eth_sched.record_fill(ts_ns=0, notional=150_000)
# Aggregate 300k crosses Binance VIP 1 (>= 250k). Both
# schedules now resolve at the higher tier.
assert btc_sched.current_tier_index() >= 1
const btcSched = new lrvx.FeeSchedule();
btcSched.loadProfile("binance_um_futures");
const ethSched = new lrvx.FeeSchedule();
ethSched.loadProfile("binance_um_futures");
btcSched.bindAccount(acct);
ethSched.bindAccount(acct);

btcSched.recordFill(0, 150_000);
ethSched.recordFill(0, 150_000);

The account's rolling counter ages out fills older than 30 days automatically (matching the venue's window). The window's total is a fixed-point sum of the fills inside it, so evicting a fill returns exactly what recording it added -- one 5-billion fill no longer swallows the small ones beside it, and there is no clamp at zero hiding the drift when the large one ages out.

Isolated mode

Isolated accounts skip the cross-margin netting walk — each position carries its own posted-margin slice and liquidates independently. Switch via the margin mode and pass isolated_equity when opening each position:

acct = lrvx.Account(account_id=42, equity=0.0)
acct.set_margin_mode("isolated")
acct.open_position(symbol=1, quantity=5.0, entry_price=50_000.0,
                   isolated_equity=2_500.0)
acct.open_position(symbol=2, quantity=-10.0, entry_price=3_000.0,
                   isolated_equity=1_500.0)
const acct = new lrvx.Account(42, 0);
acct.setMarginMode("isolated");
acct.openPosition(1, 5.0, 50_000, 2_500);
acct.openPosition(2, -10.0, 3_000, 1_500);

In isolated mode the account's equity field is unused; each position's isolated_equity slice is what backs it under the maintenance-margin check. A profitable position on one symbol does NOT shelter an underwater position on another.

Fixed point

On the C++ side every number an Account and a LeveragedPosition carry is fixed point: Quantity quantity, Price entryPrice, Volume equity, Quantity contractMultiplier, Volume equity, marks as Price, and the rolling notional as Volume. Equity, notionals and unrealised PnL are summed through mulDivI64 and checked adds, so the aggregates the maintenance-margin check and the liquidation decision read are exact to the raw and identical on every toolchain.

The Python, Node and C surfaces stay float / number / double: they quantise once at the boundary (Volume::fromDouble and friends) instead of letting a double travel through the margin arithmetic. C++ callers should prefer the fixed-point overloads; the double-taking ones remain for configuration and for the bindings.

Notes

  • Account is non-owning from the engine's perspective. The caller manages lifetime; the language binding's keep-alive semantics prevent premature GC.
  • Multiple accounts may attach to the same engine — the walk iterates them all.
  • Multiple FeeSchedules sharing the same account see a consistent aggregate counter; current_tier_index() resolves on-demand when bound so a counter increment from one schedule is immediately visible to the others.
  • Cross-pool collateral (e.g. Binance USDT vs BUSD pools), multi-currency accounts, and venue-specific account-tier fee discounts are out of scope.