Skip to content

Pre Trade Lock

Eugene Palchukovsky edited this page Jun 16, 2026 · 3 revisions

Pre-Trade Lock

The pre-trade lock is the receipt for a reservation. When a policy holds funds for an order, it also records how that hold must later be settled — the exact price it reserved at. That record is the lock. It travels with the order from the moment it passes pre-trade to the final execution report, and it is what lets the engine release or consume the reserved funds using the very same assumptions it accepted the order under.

Without it, post-trade reconciliation is guesswork. With it, every fill, partial, and cancel reconciles to the cent against what was actually reserved — deterministically, across process restarts, across machines.

Why It Matters

  • Reconciliation that can't drift. The amount released on a cancel or consumed on a fill is computed from the price the order was reserved at, not re-derived later from a live quote that has since moved. Held funds always net back to zero.
  • Crash-safe by design. The lock is a small, self-contained value with a compact wire format. Persist it next to the order and a restart loses nothing: the engine reconciles the order exactly as the pre-restart process would have.
  • Your storage, your format. Built-in JSON, MessagePack, and CBOR codecs ship in the box; if you keep your own schema, walk the lock's entries and store them however you like. The lock round-trips byte-for-byte either way.
  • Tiny on the wire. The format emits no field names, no map keys, no struct tags — a default-only lock with one price is nine bytes of JSON, six of MessagePack. It costs almost nothing to store one per working order.
  • Same contract in every language. Rust, Go, and Python expose the identical lifecycle and serialization surface.

What the Lock Carries

A lock is a set of (policy_group_id, price) records. Each pre-trade policy that needs post-trade context writes its prices under its own group identifier, so several policies can share one lock without colliding. Most orders carry a single price under the default group.

For Spot Funds, the recorded price is the effective settlement price the order was reserved at:

  • A buy always records its lock price. The policy held price × quantity of the settlement asset, and it must release or consume that exact amount as the order fills.
  • A sell records a price only in the rare case where it actually reserved settlement (a negative execution price). An ordinary sell reserves the underlying asset by quantity and needs no lock price.

Why Spot Funds Needs It

The lock is not optional bookkeeping for Spot Funds — it is required input to process a buy's execution report. When a fill or cancel arrives, the policy reads the lock price for its group and reconciles the settlement leg against it:

  • On a fill, it consumes lock_price × filled_quantity from held and credits the acquired asset, crediting back any price improvement.
  • On a cancel/partial, it releases lock_price × leaves_quantity from held back to available.

If a buy's execution report arrives without the lock price, the policy cannot know how much to reconcile and blocks the account with MissingRequiredField. That is why the lock must survive the whole order lifecycle. See Spot Funds for the holdings model the lock reconciles against.

The Order Lifecycle

  1. Reserve. execute_pre_trade accepts the order and produces a reservation. The lock is attached to it.
  2. Persist. Read the lock off the reservation and store it next to the order — serialized, so it outlives the process.
  3. Commit. Finalize the reservation once the venue has accepted the order.
  4. Attach. Every execution report for that order carries the stored lock back into apply_execution_report.
  5. Release. Keep the stored lock until the final report (filled, cancelled, or rejected) has been processed. Only then is it safe to drop.

Persisting and Restoring a Lock

The example below reserves a buy, serializes its lock, then — as if after a restart — restores the lock and feeds it back on the final fill so the held funds reconcile cleanly.

Go
// Limit-only spot funds: the lock price is required to reconcile fills.
engine, err := openpit.NewEngineBuilder().
    FullSync().
    Builtin(policies.BuildSpotFunds()).
    Build()
if err != nil {
    panic(err)
}
defer engine.Stop()

accountID := param.NewAccountIDFromUint64(99224416)
usd, _ := param.NewAsset("USD")
aapl, _ := param.NewAsset("AAPL")

// Seed 10000 USD so the buy can be reserved.
total, _ := param.NewPositionSizeFromString("10000")
seed, _ := model.NewAccountAdjustmentFromValues(model.AccountAdjustmentValues{
    BalanceOperation: optional.Some(
        model.NewAccountAdjustmentBalanceOperationFromValues(
            model.AccountAdjustmentBalanceOperationValues{Asset: optional.Some(usd)},
        ),
    ),
    Amount: optional.Some(
        model.NewAccountAdjustmentAmountFromValues(model.AccountAdjustmentAmountValues{
            Balance: optional.Some(param.NewAbsoluteAdjustmentAmount(total)),
        }),
    ),
})
if _, _, err := engine.ApplyAccountAdjustment(
    accountID, []model.AccountAdjustment{seed},
); err != nil {
    panic(err)
}

// Buy 10 AAPL @ 200 holds 2000 USD and records the lock price (200).
order := model.NewOrder()
op := order.EnsureOperationView()
op.SetInstrument(param.NewInstrument(aapl, usd))
op.SetAccountID(accountID)
op.SetSide(param.SideBuy)
qty, _ := param.NewQuantityFromString("10")
price, _ := param.NewPriceFromString("200")
op.SetTradeAmount(param.NewQuantityTradeAmount(qty))
op.SetPrice(price)

reservation, execRejects, err := engine.ExecutePreTrade(order)
if err != nil {
    panic(err)
}
if execRejects != nil {
    panic("unexpected post-trade rejects")
}

// Persist the lock with its built-in JSON serialization before committing.
lock := reservation.Lock()
payload, err := json.Marshal(lock)
if err != nil {
    panic(err)
}
reservation.CommitAndClose()

// --- After a process restart, rebuild the lock from your store. ---
var restored pretrade.Lock
if err := json.Unmarshal(payload, &restored); err != nil {
    panic(err)
}

// The final fill must carry the restored lock so the policy reconciles the
// 2000 USD it held against the real fill instead of blocking the account.
report := model.NewExecutionReport()
reportOp := model.NewExecutionReportOperation()
reportOp.SetInstrument(param.NewInstrument(aapl, usd))
reportOp.SetAccountID(accountID)
reportOp.SetSide(param.SideBuy)
report.SetOperation(reportOp)

filledQty, _ := param.NewQuantityFromString("10")
leaves, _ := param.NewQuantityFromString("0")
fill := report.EnsureFillView()
fill.SetLastTrade(model.NewExecutionReportTrade(price, filledQty))
fill.SetLeavesQuantity(leaves)
fill.SetLock(restored.Bytes())
fill.SetIsFinal(true)

result, err := engine.ApplyExecutionReport(report)
if err != nil {
    panic(err)
}
if len(result.AccountBlocks) > 0 {
    panic("unexpected account blocks")
}
Python
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(openpit.pretrade.policies.build_spot_funds())
    .build()
)

account_id = openpit.param.AccountId.from_int(99224416)

# Seed 10000 USD so the buy can be reserved.
seed = openpit.AccountAdjustment(
    operation=openpit.AccountAdjustmentBalanceOperation(asset="USD"),
    amount=openpit.AccountAdjustmentAmount(
        balance=openpit.param.AdjustmentAmount.absolute(
            openpit.param.PositionSize(10000)
        )
    ),
)
engine.apply_account_adjustment(account_id=account_id, adjustments=[seed])

# Buy 10 AAPL @ 200 holds 2000 USD and records the lock price (200).
order = openpit.Order(
    operation=openpit.OrderOperation(
        instrument=openpit.Instrument("AAPL", "USD"),
        account_id=account_id,
        side=openpit.param.Side.BUY,
        trade_amount=openpit.param.TradeAmount.quantity("10"),
        price=openpit.param.Price("200"),
    ),
)
result = engine.execute_pre_trade(order=order)

# Persist the lock with its built-in JSON serialization before committing.
payload = result.reservation.lock().to_json()
result.reservation.commit()

# --- After a process restart, rebuild the lock from your store. ---
restored = openpit.pretrade.Lock.from_json(payload)

# The final fill must carry the restored lock so the policy reconciles the
# 2000 USD it held against the real fill instead of blocking the account.
report = openpit.ExecutionReport(
    operation=openpit.ExecutionReportOperation(
        instrument=openpit.Instrument("AAPL", "USD"),
        account_id=account_id,
        side=openpit.param.Side.BUY,
    ),
    fill=openpit.ExecutionReportFillDetails(
        last_trade=openpit.param.Trade(
            price=openpit.param.Price("200"),
            quantity=openpit.param.Quantity("10"),
        ),
        leaves_quantity=openpit.param.Quantity("0"),
        lock=restored,
        is_final=True,
    ),
)
post = engine.apply_execution_report(report=report)
assert not post.account_blocks
Rust
use openpit::param::{
    AccountId, AdjustmentAmount, Asset, PositionSize, Price, Quantity, Side,
    Trade, TradeAmount,
};
use openpit::pretrade::policies::{SpotFundsPolicy, SpotFundsSettings};
use openpit::pretrade::PreTradeLock;
use openpit::{
    AccountAdjustmentAmount, AccountAdjustmentBalanceOperation, AccountAdjustmentBounds,
    Engine, ExecutionReportFillDetails, ExecutionReportOperation, FullSync, Instrument,
    OrderOperation, PolicyGroupId, SpotFundsMarketData, SpotFundsPricingSource,
    WithAccountAdjustmentAmount, WithAccountAdjustmentBalanceOperation,
    WithAccountAdjustmentBounds, WithExecutionReportFillDetails, WithExecutionReportOperation,
};

type SpotReport = WithExecutionReportOperation<WithExecutionReportFillDetails<()>>;
type SpotAdjustment = WithAccountAdjustmentAmount<
    WithAccountAdjustmentBounds<WithAccountAdjustmentBalanceOperation<()>>,
>;

let builder = Engine::builder::<OrderOperation, SpotReport, SpotAdjustment>().full_sync();
let policy = SpotFundsPolicy::<FullSync, FullSync>::new(
    SpotFundsSettings::new(0, SpotFundsPricingSource::Mark, [])?,
    None::<SpotFundsMarketData<FullSync>>,
    builder.storage_builder(),
);
let engine = builder.pre_trade(policy).build()?;

let account = AccountId::from_u64(99224416);
let instrument = Instrument::new(Asset::new("AAPL")?, Asset::new("USD")?);

// Seed 10000 USD so the buy can be reserved.
let seed = WithAccountAdjustmentAmount {
    inner: WithAccountAdjustmentBounds {
        inner: WithAccountAdjustmentBalanceOperation {
            inner: (),
            operation: AccountAdjustmentBalanceOperation {
                asset: Asset::new("USD")?,
                average_entry_price: None,
                realized_pnl: None,
            },
        },
        bounds: AccountAdjustmentBounds::default(),
    },
    amount: AccountAdjustmentAmount {
        balance: Some(AdjustmentAmount::Absolute(PositionSize::from_str("10000")?)),
        held: None,
        incoming: None,
    },
};
engine.apply_account_adjustment(account, &[seed])?;

// Buy 10 AAPL @ 200 holds 2000 USD and records the lock price (200).
let order = OrderOperation {
    instrument: instrument.clone(),
    account_id: account,
    side: Side::Buy,
    trade_amount: TradeAmount::Quantity(Quantity::from_str("10")?),
    price: Some(Price::from_str("200")?),
};
let mut reservation = engine.execute_pre_trade(order)?;

// Persist the lock in whatever format your store prefers. Here we walk the
// entries and keep `(group, price-as-string)` pairs; the built-in serde
// (`serde_json::to_string(reservation.lock())`) is an alternative.
let persisted: Vec<(u16, String)> = reservation
    .lock()
    .entries()
    .map(|(group, price)| (group.value(), price.to_string()))
    .collect();

reservation.commit();

// --- After a process restart, rebuild the lock from your store. ---
let restored = persisted
    .iter()
    .map(|(group, price)| {
        Ok::<_, Box<dyn std::error::Error>>((PolicyGroupId::new(*group), Price::from_str(price)?))
    })
    .collect::<Result<PreTradeLock, _>>()?;

// The final fill must carry the restored lock so the policy reconciles the
// 2000 USD it held against the real fill instead of blocking the account.
let report = WithExecutionReportOperation {
    inner: WithExecutionReportFillDetails {
        inner: (),
        fill: ExecutionReportFillDetails {
            last_trade: Some(Trade {
                price: Price::from_str("200")?,
                quantity: Quantity::from_str("10")?,
            }),
            leaves_quantity: Quantity::from_str("0")?,
            lock: restored,
            is_final: true,
        },
    },
    operation: ExecutionReportOperation {
        instrument,
        account_id: account,
        side: Side::Buy,
    },
};
let result = engine.apply_execution_report(&report);
assert!(result.account_blocks.is_empty());

Serialization Formats

The lock has serialization built in, and the wire format is deliberately minimal: a sequence of sublists with no field names or tags. The first sublist is the default group's prices; each following sublist is one non-default group (its identifier followed by its prices). The same lock in three formats:

// default-only lock with a single price 185
JSON         [["185"]]                 // 9 bytes
MessagePack  91 91 a3 31 38 35         // 6 bytes (hex)
CBOR         81 81 63 31 38 35         // 6 bytes (hex)

Because the encoding only uses sequences, every self-describing serde format works — JSON (the canonical FFI exchange format), MessagePack, CBOR, and others.

Built-in codecs. Each binding exposes ready-made encoders and decoders:

  • GoLock implements json.Marshaler/Unmarshaler, msgpack.Marshaler, and cbor.Marshaler, so json.Marshal(lock) and friends just work; or call lock.MarshalJSON / pretrade.NewLockFromJSON (MarshalMsgpack / NewLockFromMsgPack, MarshalCBOR / NewLockFromCBOR) directly. lock.Bytes() / pretrade.NewLockFromBytes round-trip the in-process representation used on execution reports.
  • Pythonlock.to_json() / Lock.from_json(text), lock.to_msgpack() / Lock.from_msgpack(data), lock.to_cbor() / Lock.from_cbor(data).
  • Rust — with the serde feature, the lock implements Serialize and Deserialize, so serde_json::to_string(&lock) (or any serde format) works.

Bring your own format. If you persist into a schema of your own, you do not need the built-in codecs at all. Iterate the lock's (policy_group_id, price) entries, store them in your columns/rows/protobuf, and rebuild the lock from those entries later:

  • Golock.Entries() returns []Entry{PolicyGroupID, Price}; pretrade.NewLockFromEntries(entries) rebuilds it.
  • Pythonlock.entries() returns (policy_group_id, price) tuples; openpit.pretrade.Lock(entries) rebuilds it.
  • Rustlock.entries() yields (PolicyGroupId, Price); PreTradeLock::from_entries(...) (or .collect()) rebuilds it. This is the path shown in the Rust example above, and it needs no feature flag.

A lock rebuilt from entries is identical to one decoded from JSON — the engine treats them the same on the execution report.

Surviving a Restart

Spot Funds keeps all state in memory. After a restart the engine starts empty, so you are responsible for restoring it before resuming trading. Two things must be reloaded, and missing either one corrupts reconciliation:

  1. Every balance bucket, for every (account, asset). Replay your balances through the account-adjustment pipeline — and not just available. You must also restore held (funds reserved against orders that were still working at shutdown) and incoming (expected inflows that had not yet settled). An AccountAdjustmentAmount carries all three fields; seed them together so the engine's view matches reality. Restoring only available silently understates committed exposure and lets the account over-commit.

  2. The lock of every non-finalized order. For each order that had not reached a final execution report (open, partially filled, pending cancel), reload its persisted lock and keep it until that order's final report is processed. An order whose lock is lost cannot have its buy fills reconciled — the next report blocks the account with MissingRequiredField.

In short: persist (available, held, incoming) per holding and the lock per working order, restore both on startup, and the engine resumes exactly where it left off. See Balance Reconciliation for keeping those balances in step while the process runs.

Rejects

Code Scope When
MissingRequiredField account A buy execution report arrives without the lock price needed to reconcile the settlement leg (lock dropped or never persisted).

Related Pages

Clone this wiki locally