-
Notifications
You must be signed in to change notification settings - Fork 1
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.
- 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.
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 × quantityof 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.
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_quantityfromheldand credits the acquired asset, crediting back any price improvement. - On a cancel/partial, it releases
lock_price × leaves_quantityfromheldback toavailable.
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.
-
Reserve.
execute_pre_tradeaccepts the order and produces a reservation. The lock is attached to it. - Persist. Read the lock off the reservation and store it next to the order — serialized, so it outlives the process.
- Commit. Finalize the reservation once the venue has accepted the order.
-
Attach. Every execution report for that order carries the stored lock
back into
apply_execution_report. - Release. Keep the stored lock until the final report (filled, cancelled, or rejected) has been processed. Only then is it safe to drop.
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_blocksRust
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());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:
-
Go —
Lockimplementsjson.Marshaler/Unmarshaler,msgpack.Marshaler, andcbor.Marshaler, sojson.Marshal(lock)and friends just work; or calllock.MarshalJSON/pretrade.NewLockFromJSON(MarshalMsgpack/NewLockFromMsgPack,MarshalCBOR/NewLockFromCBOR) directly.lock.Bytes()/pretrade.NewLockFromBytesround-trip the in-process representation used on execution reports. -
Python —
lock.to_json()/Lock.from_json(text),lock.to_msgpack()/Lock.from_msgpack(data),lock.to_cbor()/Lock.from_cbor(data). -
Rust — with the
serdefeature, the lock implementsSerializeandDeserialize, soserde_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:
-
Go —
lock.Entries()returns[]Entry{PolicyGroupID, Price};pretrade.NewLockFromEntries(entries)rebuilds it. -
Python —
lock.entries()returns(policy_group_id, price)tuples;openpit.pretrade.Lock(entries)rebuilds it. -
Rust —
lock.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.
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:
-
Every balance bucket, for every
(account, asset). Replay your balances through the account-adjustment pipeline — and not justavailable. You must also restoreheld(funds reserved against orders that were still working at shutdown) andincoming(expected inflows that had not yet settled). AnAccountAdjustmentAmountcarries all three fields; seed them together so the engine's view matches reality. Restoring onlyavailablesilently understates committed exposure and lets the account over-commit. -
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.
| 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). |
- Spot Funds — the policy that produces and consumes the lock.
-
Account Adjustments — restore balances, including
heldandincoming, after a restart. - Balance Reconciliation — keep your books in step with engine outcomes.
- Pre-trade Pipeline — where reservations and execution reports flow through the engine.
- Policies — the full built-in policy catalog.
- Dynamic Policy Reconfiguration — retune policies.