-
Notifications
You must be signed in to change notification settings - Fork 1
Balance Reconciliation
When funds move, the engine reports each change twice: as a signed delta and
as an absolute snapshot. The delta is the authoritative number for
bookkeeping; the absolute is a convenience reading of the field at the moment
the policy returned. A caller that maintains its own ledger — its own view of
how much each account may spend — must reconcile from delta and treat
absolute as diagnostic only.
This matters because the engine is an embedded, in-process component, not the system of record. Your application still owns the durable account ledger. These outcomes are how the two stay in agreement.
Three engine surfaces return position outcomes, all carrying the same
AccountAdjustmentOutcome shape:
-
apply account adjustmentreturns a batch result whoseoutcomeslist has one entry per asset each adjustment touched. -
apply execution reportreturns a post-trade result whoseaccount adjustmentslist carries the same outcome shape, produced as fills settle. - the pre-trade path returns a
PreTradeReservationwhoseaccount adjustmentslist reports the reserve a passing order applied — typicallyavailabledown andheldup for the reserved amount. Read it withaccount adjustmentsfromreservation.
Each outcome entry names one asset and exposes an optional balance, held,
and incoming amount. A field is present only when it changed. Every present
amount carries both delta and absolute.
The reserve reported by PreTradeReservation is real engine state — it is what
makes the held funds unavailable to the next order — but it is provisional, and
that changes how you reconcile it:
-
commitandrollbackthemselves report nothing. The outcome is delivered once, on the reservation.commitonly makes the already-applied reserve durable;rollbackreleases it. Neither returns a fresh outcome list. -
Reservations now emit an
incomingleg. A buy reservation produces two outcome entries: one for the settlement asset (availabledown,heldup) and one for the base asset (incomingup). A priced sell reservation produces an entry for the underlying asset (availabledown,heldup) and an entry for the settlement asset (incomingup, representing the expected proceeds). Reconcilers that mirror theincomingbucket must apply these deltas from the reservation outcome, not only from fills. -
The matching fill nets the reserve out through the execution report. When
the order fills,
apply execution reportreportsheldcoming back down (and the acquired asset creditingavailable); it also reportsincomingcoming back down on the acquiring leg. A cancel releases the unfilled remainder the same way. Those are separate outcomes you reconcile normally. -
incomingis informational and never gates orders. It does not factor into spendable capacity (available + min(held, 0)), so it never affects accept/reject decisions. Track it for position-awareness, but do not use it to compute available-to-spend.
If your durable ledger tracks only settled balances and leaves the
held/available split to the engine, you can ignore the reservation outcome
for bookkeeping and reconcile solely from the account-adjustment and
execution-report paths — the reserve is netted out before anything settles. If
your ledger mirrors held/available, treat the reservation outcome as a first
class delta source.
The pre-trade path also produces one result that is not a balance at all: it can
latch an account block (a kill switch). It carries no delta, so it sits
outside the outcome model — but it is in-memory engine state your application
must track. See Account Blocks Are State Too.
-
delta— the signed change this one operation applied. Sum your own deltas to keep your ledger aligned with the engine, regardless of how operations interleave across accounts or chains. -
absolute— the field value at the instant the policy returned. It is a read-only snapshot. Underfull syncoraccount sync, another chain may move the same field a moment later, so theabsoluteyou received can already be out of date by the time you act on it. Never persistabsoluteas your source of truth.
The example below seeds an account twice. The first seed sets available funds to
10000, so both delta and absolute read 10000. The second seed raises the
target to 15000: now delta is 5000 (the change) while absolute is 15000
(the new level). Bookkeeping that added absolute instead of delta would
double-count.
Go
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")
seed := func(amount string) model.AccountAdjustment {
total, _ := param.NewPositionSizeFromString(amount)
adj, _ := 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)),
}),
),
})
return adj
}
// First seed: available USD goes from 0 to 10000.
_, firstOutcomes, err := engine.ApplyAccountAdjustment(
accountID, []model.AccountAdjustment{seed("10000")},
)
if err != nil {
panic(err)
}
firstUSD, _ := firstOutcomes[0].Entry.Balance.Get()
want10000, _ := param.NewPositionSizeFromString("10000")
if !firstUSD.Delta.Equal(want10000) {
panic("unexpected delta")
}
if !firstUSD.Absolute.Equal(want10000) {
panic("unexpected absolute")
}
// Second seed: available USD goes from 10000 to 15000.
_, secondOutcomes, err := engine.ApplyAccountAdjustment(
accountID, []model.AccountAdjustment{seed("15000")},
)
if err != nil {
panic(err)
}
secondUSD, _ := secondOutcomes[0].Entry.Balance.Get()
// delta is the change to add to your own ledger; absolute is just a snapshot.
want5000, _ := param.NewPositionSizeFromString("5000")
want15000, _ := param.NewPositionSizeFromString("15000")
if !secondUSD.Delta.Equal(want5000) {
panic("unexpected delta")
}
if !secondUSD.Absolute.Equal(want15000) {
panic("unexpected absolute")
}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)
def seed(amount: int) -> openpit.AccountAdjustment:
return openpit.AccountAdjustment(
operation=openpit.AccountAdjustmentBalanceOperation(asset="USD"),
amount=openpit.AccountAdjustmentAmount(
balance=openpit.param.AdjustmentAmount.absolute(
openpit.param.PositionSize(amount)
)
),
)
# First seed: available USD goes from 0 to 10000.
first = engine.apply_account_adjustment(account_id=account_id, adjustments=[seed(10000)])
assert first.ok
usd = first.outcomes[0].entry.balance
assert usd.delta == openpit.param.PositionSize(10000)
assert usd.absolute == openpit.param.PositionSize(10000)
# Second seed: available USD goes from 10000 to 15000.
second = engine.apply_account_adjustment(account_id=account_id, adjustments=[seed(15000)])
assert second.ok
usd = second.outcomes[0].entry.balance
# delta is the change to add to your own ledger; absolute is just a snapshot.
assert usd.delta == openpit.param.PositionSize(5000)
assert usd.absolute == openpit.param.PositionSize(15000)C++
#include "openpit/account_adjustment.hpp"
#include "openpit/engine.hpp"
#include "openpit/param.hpp"
#include "openpit/pretrade/pretrade.hpp"
#include <cassert>
namespace aa = openpit::accountadjustment;
namespace param = openpit::param;
namespace policies = openpit::pretrade::policies;
openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::SpotFundsPolicy{});
openpit::Engine engine = builder.Build();
const param::AccountId accountId = param::AccountId::FromUint64(99224416);
auto seed = [](const char* amount) {
aa::AccountAdjustment adj;
aa::BalanceOperation op;
op.asset = param::Asset("USD");
adj.operation = aa::Operation::OfBalance(op);
aa::Amount amountGroup;
amountGroup.balance =
param::AdjustmentAmount::OfAbsolute(param::PositionSize::FromString(amount));
adj.amount = amountGroup;
return adj;
};
// First seed: available USD goes from 0 to 10000.
const openpit::AdjustmentResult firstResult = engine.ApplyAccountAdjustment(
accountId, std::vector<aa::AccountAdjustment>{seed("10000")});
assert(firstResult.Passed());
const aa::OutcomeAmount& firstUSD =
*firstResult.accountAdjustmentOutcomes[0].entry.balance;
assert(firstUSD.delta == param::PositionSize::FromString("10000"));
assert(firstUSD.absolute == param::PositionSize::FromString("10000"));
// Second seed: available USD goes from 10000 to 15000.
const openpit::AdjustmentResult secondResult = engine.ApplyAccountAdjustment(
accountId, std::vector<aa::AccountAdjustment>{seed("15000")});
assert(secondResult.Passed());
const aa::OutcomeAmount& secondUSD =
*secondResult.accountAdjustmentOutcomes[0].entry.balance;
// delta is the change to add to your own ledger; absolute is just a snapshot.
assert(secondUSD.delta == param::PositionSize::FromString("5000"));
assert(secondUSD.absolute == param::PositionSize::FromString("15000"));Rust
use openpit::param::{AccountId, AdjustmentAmount, Asset, PositionSize};
use openpit::pretrade::policies::{SpotFundsPolicy, SpotFundsSettings};
use openpit::{
AccountAdjustmentAmount, AccountAdjustmentBalanceOperation, AccountAdjustmentBounds,
Engine, FullSync, OrderOperation, SpotFundsMarketData, SpotFundsPricingSource,
WithAccountAdjustmentAmount, WithAccountAdjustmentBalanceOperation,
WithAccountAdjustmentBounds, WithExecutionReportFillDetails, WithExecutionReportOperation,
};
// Report and account-adjustment shapes composed from public SDK wrappers.
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 seed = |amount: &str| -> Result<
SpotAdjustment,
Box<dyn std::error::Error>,
> {
Ok(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(amount)?)),
held: None,
incoming: None,
},
})
};
// First seed: available USD goes from 0 to 10000.
let first = engine.apply_account_adjustment(account, &[seed("10000")?])?;
let usd = first.outcomes[0].entry.balance.expect("balance changed");
assert_eq!(usd.delta, PositionSize::from_str("10000")?);
assert_eq!(usd.absolute, PositionSize::from_str("10000")?);
// Second seed: available USD goes from 10000 to 15000.
let second = engine.apply_account_adjustment(account, &[seed("15000")?])?;
let usd = second.outcomes[0].entry.balance.expect("balance changed");
// delta is the change to add to your own ledger; absolute is just a snapshot.
assert_eq!(usd.delta, PositionSize::from_str("5000")?);
assert_eq!(usd.absolute, PositionSize::from_str("15000")?);The engine holds spendable funds only in memory; restarting it loses that state. Keep the durable copy in your own database and reconcile it with deltas:
-
On startup, replay your ledger into the engine. Read each account's
persisted balance from your database and apply it through the
account adjustment pipeline. The engine and your store
now start aligned. Replay all of
available,held, andincoming, not just spendable funds — and for any order still working at shutdown, also reload its persisted pre-trade lock so its fills still reconcile. See Pre-Trade Lock → Surviving a Restart. -
On every settled outcome, apply the delta. For each
apply account adjustmentandapply execution report, read each outcome'sdeltaand add the same signed value to your store — in the same transaction that records the operation, so the two never drift. If your store also mirrorsheld/available, fold in the provisionalPreTradeReservationoutcomes per The reservation outcome is provisional; if it tracks only settled balances, skip them. -
Treat
absoluteas diagnostic. Log it, alert on a mismatch against your own running total, but never write it back as the authoritative balance. - Repeat. Because reconciliation is delta-based, it is order-independent: concurrent chains can interleave freely and your store still converges to the engine's view.
A mismatch between your delta-summed total and a reported absolute is a signal
worth investigating — it usually means an operation was applied to one side but
not the other, not that absolute is wrong.
Balances are not the only in-memory state the engine holds. When a policy
emits a reject with scope = account from any pre-trade stage, or when
apply execution report returns a non-empty account blocks list (a kill
switch), the engine latches a block for that account in its internal
blocked-accounts registry. Every later request for that account is then
short-circuited before any policy runs. See
Account Blocking by Engine for the full
mechanics.
This block is a reconcilable result of the same kind as a balance — it lives only in memory and is lost on restart — but it behaves differently and must be handled differently:
-
It is not a
delta. A block is latching, binary state, not a signed number. There is nothing to sum. Reconcile it by recording the fact of the block, not by accumulating amounts. -
The pre-trade path surfaces it as a reject, not an outcome. A start- or
main-stage block arrives as
Err/rejects withscope = account; the engine records it as a side effect. Subsequent rejects on that account carrycode = AccountBlocked. The post-trade path surfaces it explicitly inPostTradeResultaccount blocks.
- Spot Funds — the policy that produces these outcomes as funds move.
- Account Adjustments — the pipeline that emits adjustment outcomes and the place to replay your ledger on startup.
- Pre-Trade Lock — persist the reservation lock per working order so fills reconcile after a restart.
- Policies — how the engine latches and enforces account blocks, the second kind of in-memory state to reconcile.
-
Pre-trade Pipeline — where pre-trade rejects and
PostTradeResultaccount blocks surface to the caller. - Policies — the full built-in policy catalog.