Skip to content

Balance Reconciliation

Eugene Palchukovsky edited this page Jun 27, 2026 · 5 revisions

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.

Where Outcomes Come From

Three engine surfaces return position outcomes, all carrying the same AccountAdjustmentOutcome shape:

  • apply account adjustment returns a batch result whose outcomes list has one entry per asset each adjustment touched.
  • apply execution report returns a post-trade result whose account adjustments list carries the same outcome shape, produced as fills settle.
  • the pre-trade path returns a PreTradeReservation whose account adjustments list reports the reserve a passing order applied — typically available down and held up for the reserved amount. Read it with account adjustments from reservation.

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 reservation outcome is provisional

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:

  • commit and rollback themselves report nothing. The outcome is delivered once, on the reservation. commit only makes the already-applied reserve durable; rollback releases it. Neither returns a fresh outcome list.
  • Reservations now emit an incoming leg. A buy reservation produces two outcome entries: one for the settlement asset (available down, held up) and one for the base asset (incoming up). A priced sell reservation produces an entry for the underlying asset (available down, held up) and an entry for the settlement asset (incoming up, representing the expected proceeds). Reconcilers that mirror the incoming bucket 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 report reports held coming back down (and the acquired asset crediting available); it also reports incoming coming back down on the acquiring leg. A cancel releases the unfilled remainder the same way. Those are separate outcomes you reconcile normally.
  • incoming is 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 Versus Absolute

  • 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. Under full sync or account sync, another chain may move the same field a moment later, so the absolute you received can already be out of date by the time you act on it. Never persist absolute as 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")?);

Tracking Limits in Your Own Store

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:

  1. 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, and incoming, 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.
  2. On every settled outcome, apply the delta. For each apply account adjustment and apply execution report, read each outcome's delta and 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 mirrors held/available, fold in the provisional PreTradeReservation outcomes per The reservation outcome is provisional; if it tracks only settled balances, skip them.
  3. Treat absolute as diagnostic. Log it, alert on a mismatch against your own running total, but never write it back as the authoritative balance.
  4. 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.

Account Blocks Are State Too

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 with scope = account; the engine records it as a side effect. Subsequent rejects on that account carry code = AccountBlocked. The post-trade path surfaces it explicitly in PostTradeResult account blocks.

Related Pages

  • 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 PostTradeResult account blocks surface to the caller.
  • Policies — the full built-in policy catalog.

Clone this wiki locally