Account Adjustments¶
Account adjustment models non-trade operations (NTO): direct state
corrections that do not originate from order execution.
Typical examples:
- initial account load;
- balance top-up or withdrawal;
- position correction after a corporate action;
- settlement or funding transfer represented as an explicit adjustment batch.
Balance vs Position Operations¶
Account adjustment balance operation: physical asset balance correction for oneasset.Account adjustment position operation: derivatives-like position correction for oneinstrument+collateral asset.
Both forms may include optional amount and bounds groups:
Account adjustment amount:balance,held,incoming.Account adjustment bounds: inclusive lower/upper constraints for each amount field.
Batch Semantics¶
Apply account adjustments validates the input as an atomic batch:
- evaluation order is deterministic: per-adjustment slice order, then per-policy registration order;
- validation stops at the first reject;
- outcome is all-or-nothing - a reject means the full batch is rejected;
- on reject, the caller receives the
failed indextogether with the policy reject payload.
Operational guidance:
- If external logic needs to correct policy-related state while the engine
is active, prefer
apply account adjustmentsover direct parallel mutation of custom-policy fields. - Direct parallel access to custom-policy state still requires host-side synchronization.
Outcomes¶
A successful batch returns one outcome per asset each adjustment touched. Every
outcome carries a signed delta and an absolute snapshot. Use the delta to
keep your own ledger aligned with the engine; treat absolute as diagnostic
only. See Balance Reconciliation for the full
delta-versus-absolute contract and a scheme for persisting limits in your own
store.
Examples¶
Go
accountID := param.NewAccountIDFromUint64(99224416)
usd, err := param.NewAsset("USD")
if err != nil {
panic(err)
}
spx, err := param.NewAsset("SPX")
if err != nil {
panic(err)
}
entryPrice, _ := param.NewPriceFromString("95000")
totalCash, _ := param.NewPositionSizeFromString("10000")
totalPosition, _ := param.NewPositionSizeFromString("-3")
cashAdj, err := 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(totalCash)),
}),
),
})
if err != nil {
panic(err)
}
posAdj, err := model.NewAccountAdjustmentFromValues(model.AccountAdjustmentValues{
PositionOperation: optional.Some(
model.NewAccountAdjustmentPositionOperationFromValues(
model.AccountAdjustmentPositionOperationValues{
Instrument: optional.Some(param.NewInstrument(spx, usd)),
CollateralAsset: optional.Some(usd),
AverageEntryPrice: optional.Some(entryPrice),
Mode: optional.Some(param.PositionModeHedged),
},
),
),
Amount: optional.Some(
model.NewAccountAdjustmentAmountFromValues(model.AccountAdjustmentAmountValues{
Balance: optional.Some(param.NewAbsoluteAdjustmentAmount(totalPosition)),
}),
),
})
if err != nil {
panic(err)
}
// On accept, rejects.IsSet() is false; the second result holds the
// per-asset account-adjustment outcomes.
rejects, _, err := engine.ApplyAccountAdjustment(
accountID,
[]model.AccountAdjustment{cashAdj, posAdj},
)
_ = rejects
_ = err
Python
import openpit
import openpit.pretrade.policies
# Build one batch that mixes balance and position adjustments.
account_id = openpit.param.AccountId.from_int(99224416)
adjustments = [
openpit.AccountAdjustment(
operation=openpit.AccountAdjustmentBalanceOperation(asset="USD"),
amount=openpit.AccountAdjustmentAmount(
balance=openpit.param.AdjustmentAmount.absolute(
openpit.param.PositionSize(10000)
)
),
),
openpit.AccountAdjustment(
operation=openpit.AccountAdjustmentPositionOperation(
instrument=openpit.Instrument("SPX", "USD"),
collateral_asset="USD",
average_entry_price=openpit.param.Price(95000),
mode=openpit.param.PositionMode.HEDGED,
),
amount=openpit.AccountAdjustmentAmount(
balance=openpit.param.AdjustmentAmount.absolute(
openpit.param.PositionSize(-3)
)
),
),
]
# The engine validates the whole batch atomically.
engine = (
openpit.Engine.builder()
.no_sync()
.builtin(openpit.pretrade.policies.build_order_validation())
.build()
)
result = engine.apply_account_adjustment(
account_id=account_id,
adjustments=adjustments,
)
assert result.ok
C++
#include <cassert>
namespace aa = openpit::accountadjustment;
namespace param = openpit::param;
namespace policies = openpit::pretrade::policies;
// Build one batch that mixes balance and position adjustments.
const param::AccountId accountId = param::AccountId::FromUint64(99224416);
aa::AccountAdjustment cashAdj;
{
aa::BalanceOperation balance;
balance.asset = param::Asset("USD");
cashAdj.operation = aa::Operation::OfBalance(std::move(balance));
aa::Amount amount;
amount.balance =
param::AdjustmentAmount::OfAbsolute(param::PositionSize::FromString("10000"));
cashAdj.amount = std::move(amount);
}
aa::AccountAdjustment posAdj;
{
aa::PositionOperation position;
position.instrument = openpit::model::Instrument("SPX", "USD");
position.collateralAsset = param::Asset("USD");
position.averageEntryPrice = param::Price::FromString("95000");
position.mode = openpit::model::PositionMode::Hedged;
posAdj.operation = aa::Operation::OfPosition(std::move(position));
aa::Amount amount;
amount.balance =
param::AdjustmentAmount::OfAbsolute(param::PositionSize::FromString("-3"));
posAdj.amount = std::move(amount);
}
const std::vector<aa::AccountAdjustment> adjustments{std::move(cashAdj),
std::move(posAdj)};
// The engine validates the whole batch atomically.
openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::OrderValidationPolicy{});
const openpit::Engine engine = builder.Build();
// On accept the result passes and carries the per-asset account-adjustment
// outcomes.
const openpit::AdjustmentResult result =
engine.ApplyAccountAdjustment(accountId, adjustments);
assert(result.Passed());
Rust
use openpit::param::{
AccountId, AdjustmentAmount, Asset, PositionMode, PositionSize, Price,
};
use openpit::{
AccountAdjustmentAmount, AccountAdjustmentBalanceOperation,
AccountAdjustmentPositionOperation, Engine, Instrument,
};
#[derive(Clone)]
enum AccountAdjustmentOperation {
Balance(AccountAdjustmentBalanceOperation),
Position(AccountAdjustmentPositionOperation),
}
#[derive(Clone)]
struct AccountAdjustment {
operation: AccountAdjustmentOperation,
amount: AccountAdjustmentAmount,
}
// Build one batch that mixes balance and position adjustments.
let account_id = AccountId::from_u64(99224416);
let adjustments = vec![
AccountAdjustment {
operation: AccountAdjustmentOperation::Balance(
AccountAdjustmentBalanceOperation {
asset: Asset::new("USD")?,
average_entry_price: None,
realized_pnl: None,
},
),
amount: AccountAdjustmentAmount {
balance: Some(AdjustmentAmount::Absolute(
PositionSize::from_f64(10000.0)?,
)),
held: None,
incoming: None,
},
},
AccountAdjustment {
operation: AccountAdjustmentOperation::Position(
AccountAdjustmentPositionOperation {
instrument: Instrument::new(
Asset::new("SPX")?,
Asset::new("USD")?,
),
collateral_asset: Asset::new("USD")?,
average_entry_price: Price::from_f64(95000.0)?,
mode: PositionMode::Hedged,
leverage: None,
},
),
amount: AccountAdjustmentAmount {
balance: Some(AdjustmentAmount::Absolute(
PositionSize::from_f64(-3.0)?,
)),
held: None,
incoming: None,
},
},
];
struct AcceptAllAdjustments;
impl<Sync> openpit::pretrade::PreTradePolicy<(), (), AccountAdjustment, Sync>
for AcceptAllAdjustments
where
Sync: openpit::SyncMode,
{
fn name(&self) -> &'static str {
"AcceptAllAdjustments"
}
fn apply_account_adjustment(
&self,
_ctx: &openpit::AccountAdjustmentContext<
<Sync as openpit::SyncMode>::StorageLockingPolicyFactory,
>,
_account_id: openpit::param::AccountId,
_adjustment: &AccountAdjustment,
_mutations: &mut openpit::Mutations,
) -> Result<Vec<openpit::AccountOutcomeEntry>, openpit::pretrade::Rejects> {
Ok(Vec::new())
}
}
// The engine validates the whole batch atomically.
let engine = Engine::builder::<(), (), AccountAdjustment>()
.no_sync()
.pre_trade(AcceptAllAdjustments)
.build()?;
let result = engine.apply_account_adjustment(account_id, &adjustments);
assert!(result.is_ok());
Writing an Account Adjustment Policy with Rollback¶
Account adjustment policies can register rollback actions that undo intermediate state if a later element in the batch is rejected. The engine applies rollback actions in reverse registration order (last registered = first rolled back).
Rollback Safety¶
Account adjustment batches run within a single engine call. No external system (venue, risk aggregator) observes intermediate state between elements, so rollback by absolute value is safe: a policy can capture the current value before modification and restore it on rollback without risking inconsistency.
The pre-trade pipeline is different: a reservation may be observed by external systems between creation and finalization, so pre-trade policies should prefer delta-based rollback.
Example: Balance Limit Policy¶
The policy below tracks cumulative adjustment totals per asset and rejects the batch if any asset exceeds a configured limit. On rejection, all previously accumulated totals are rolled back to their state before the batch started.
Go
type CumulativeLimitPolicy struct {
maxCumulative param.Volume
totals map[string]param.Volume
}
func (v *CumulativeLimitPolicy) Close() {}
func (v *CumulativeLimitPolicy) Name() string { return "CumulativeLimitPolicy" }
func (v *CumulativeLimitPolicy) PolicyGroupID() model.PolicyGroupID {
return model.DefaultPolicyGroupID
}
func (v *CumulativeLimitPolicy) CheckPreTradeStart(
pretrade.Context,
model.Order,
) []reject.Reject {
return nil
}
func (v *CumulativeLimitPolicy) PerformPreTradeCheck(
pretrade.Context,
model.Order,
tx.Mutations,
pretrade.Result,
) []reject.Reject {
return nil
}
func (v *CumulativeLimitPolicy) ApplyExecutionReport(
pretrade.PostTradeContext,
model.ExecutionReport,
pretrade.PostTradeAdjustments,
) []reject.AccountBlock {
return nil
}
func (v *CumulativeLimitPolicy) ApplyAccountAdjustment(
_ accountadjustment.Context,
accountID param.AccountID,
adjustment model.AccountAdjustment,
mutations tx.Mutations,
outcomes pretrade.AccountOutcomes,
) []reject.Reject {
_ = accountID
_ = adjustment
_ = outcomes
return nil
}
Python
import openpit
class CumulativeLimitPolicy(openpit.pretrade.Policy):
"""Tracks cumulative totals per asset, rejects batch on limit breach."""
def __init__(self, max_cumulative: openpit.param.Volume) -> None:
self._max = max_cumulative
self._totals: dict[str, openpit.param.Volume] = {}
@property
def name(self) -> str:
return "CumulativeLimitPolicy"
def apply_account_adjustment(
self,
ctx: openpit.AccountAdjustmentContext,
account_id: openpit.param.AccountId,
adjustment: openpit.AccountAdjustment,
) -> (
list[openpit.pretrade.PolicyReject]
| tuple[openpit.Mutation, ...]
| None
):
del ctx, account_id
# Use the asset as the aggregation key for the cumulative limit.
asset_id = adjustment.operation.asset
prev = self._totals.get(asset_id, openpit.param.Volume("0"))
# Simplified - real code would add delta to prev.
new_total = prev
# Reject if limit breached.
if new_total > self._max:
return [
openpit.pretrade.PolicyReject(
code=openpit.pretrade.RejectCode.RISK_LIMIT_EXCEEDED,
reason="cumulative limit exceeded",
details=f"{asset_id}: {new_total} > {self._max}",
scope=openpit.pretrade.RejectScope.ACCOUNT,
)
]
# Apply immediately so later adjustments in the same batch
# see the updated total.
self._totals[asset_id] = new_total
# Rollback by absolute value - safe in account adjustment pipeline
# because no external system sees intermediate batch state.
prev_value = prev
asset_key = asset_id
return (
openpit.Mutation(
# Commit is empty: state was applied eagerly.
commit=lambda: None,
rollback=lambda: self._totals.__setitem__(asset_key, prev_value),
),
)
C++
The C++ binding has no custom account-adjustment policy hook: a host policy cannot register per-element rollback inside the engine's account-adjustment batch. In C++, enforce a cumulative balance limit by keeping running totals in your own ledger, screening the prospective totals *before* calling the engine, and relying on the engine's atomic batch semantics for rollback. If the limit would be breached, the batch is never submitted and engine state is untouched; if a submitted batch is rejected, the engine restores every element of the batch as one unit (see [Rollback Safety](#rollback-safety)).#include <cassert>
namespace aa = openpit::accountadjustment;
namespace param = openpit::param;
namespace policies = openpit::pretrade::policies;
// Caller-side cumulative limit: the maximum absolute balance any single asset
// may reach across the adjustments seen so far. Money value types are opaque
// and carry no arithmetic, so the running totals live in a host-side ledger
// keyed by asset; the engine still receives exact `param` absolute values.
class CumulativeLimitPolicy {
public:
explicit CumulativeLimitPolicy(std::int64_t maxCumulative)
: m_maxCumulative(maxCumulative) {}
// Returns true when `asset` may be set to `absoluteUnits`; records the new
// total only when it stays within the limit, so a rejected screen leaves the
// ledger untouched (rollback by absolute value is safe here).
[[nodiscard]] bool Admit(const std::string& asset, std::int64_t absoluteUnits) {
if (absoluteUnits > m_maxCumulative) {
return false;
}
m_totals[asset] = absoluteUnits;
return true;
}
private:
std::int64_t m_maxCumulative;
std::map<std::string, std::int64_t> m_totals;
};
// Build one batch that tops a USD cash balance up to an absolute value.
const param::AccountId accountId = param::AccountId::FromUint64(99224416);
CumulativeLimitPolicy limit(/*maxCumulative=*/1'000'000);
aa::AccountAdjustment cashAdj;
{
aa::BalanceOperation balance;
balance.asset = param::Asset("USD");
cashAdj.operation = aa::Operation::OfBalance(std::move(balance));
aa::Amount amount;
amount.balance =
param::AdjustmentAmount::OfAbsolute(param::PositionSize::FromString("10000"));
cashAdj.amount = std::move(amount);
}
// Screen the prospective total before touching the engine.
assert(limit.Admit("USD", 10000));
const std::vector<aa::AccountAdjustment> adjustments{std::move(cashAdj)};
openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::OrderValidationPolicy{});
const openpit::Engine engine = builder.Build();
// The atomic batch is the rollback: on reject no engine state changes; on
// accept the result passes.
const openpit::AdjustmentResult result =
engine.ApplyAccountAdjustment(accountId, adjustments);
assert(result.Passed());
Rust
use std::sync::Arc;
use openpit::param::{AccountId, Volume};
use openpit::pretrade::{Reject, RejectCode, RejectScope, Rejects};
use openpit::storage::{
CreateStorageFor, LockingPolicyFactory, Storage, StorageBuilder,
};
use openpit::pretrade::PreTradePolicy;
use openpit::{AccountAdjustmentContext, Mutation, Mutations};
struct BalanceLimitPolicy<StorageLockingPolicyFactory>
where
StorageLockingPolicyFactory: LockingPolicyFactory,
{
max_total: Volume,
totals: Arc<
Storage<
String,
Volume,
StorageLockingPolicyFactory::Policy,
>,
>,
}
impl<StorageLockingPolicyFactory>
BalanceLimitPolicy<StorageLockingPolicyFactory>
where
StorageLockingPolicyFactory: LockingPolicyFactory
+ CreateStorageFor<String>,
{
fn new(
max_total: Volume,
storage_builder: &StorageBuilder<StorageLockingPolicyFactory>,
) -> Self {
Self {
max_total,
totals: Arc::new(storage_builder.create_for_bound_key()),
}
}
}
/// Adjustment type must expose an asset and a delta amount.
trait HasAssetDelta {
fn asset_id(&self) -> &str;
fn delta(&self) -> Volume;
}
impl<Order, ExecutionReport, A, Sync, StorageLockingPolicyFactory>
PreTradePolicy<Order, ExecutionReport, A, Sync>
for BalanceLimitPolicy<StorageLockingPolicyFactory>
where
A: HasAssetDelta,
Sync: openpit::SyncMode,
StorageLockingPolicyFactory: LockingPolicyFactory
+ CreateStorageFor<String>,
StorageLockingPolicyFactory::Policy: 'static,
{
fn name(&self) -> &str {
"BalanceLimitPolicy"
}
fn apply_account_adjustment(
&self,
_ctx: &AccountAdjustmentContext<
<Sync as openpit::SyncMode>::StorageLockingPolicyFactory,
>,
_account_id: AccountId,
adjustment: &A,
mutations: &mut Mutations,
) -> Result<Vec<openpit::AccountOutcomeEntry>, Rejects> {
let asset_id = adjustment.asset_id().to_owned();
let delta = adjustment.delta();
let prev_total = self
.totals
.with(&asset_id, |total| *total)
.unwrap_or(Volume::ZERO);
let new_total = prev_total.checked_add(delta).map_err(|error| {
Rejects::from(Reject::new(
"BalanceLimitPolicy",
RejectScope::Account,
RejectCode::RiskLimitExceeded,
"invalid adjustment total",
error.to_string(),
))
})?;
if new_total > self.max_total {
return Err(Rejects::from(Reject::new(
"BalanceLimitPolicy",
RejectScope::Account,
RejectCode::RiskLimitExceeded,
"cumulative adjustment exceeds limit",
format!("asset {asset_id}: {new_total} > {}", self.max_total),
)));
}
// Apply immediately so later adjustments in the same batch see the updated total.
self.totals.with_mut(
asset_id.clone(),
|| Volume::ZERO,
|entry, _is_new| {
*entry = new_total;
},
);
// Register rollback: restore previous absolute value.
// Safe because account adjustment batches are fully internal.
let rollback_totals = Arc::clone(&self.totals);
let rollback_asset = asset_id;
mutations.push(Mutation::new(
|| {
// Commit is empty: state was applied eagerly.
},
move || {
// Rollback: restore absolute value captured before modification.
rollback_totals.with_mut(
rollback_asset,
|| Volume::ZERO,
|entry, _is_new| {
*entry = prev_total;
},
);
},
));
Ok(Vec::new())
}
}