Skip to content

Account Blocking

Eugene Palchukovsky edited this page Aug 1, 2026 · 5 revisions

Account Blocking

The accounts handle exposed by the engine carries an admin API for blocking and unblocking accounts and account groups from outside any policy callback. This is distinct from the in-policy kill-switch facility (see Policies - Account Blocking by Engine), which triggers a block automatically when a policy returns a scope = account reject or when apply execution report emits an AccountBlock value.

The same engine-owned path covers a recovered panic from a Go policy's ApplyExecutionReport callback. The binding converts it to a SystemUnavailable account block before the engine records it; see When the engine records a block.

A third engine-owned trigger is a failed mutation finalizer. That one is documented in full below, in Mutation Finalizer Contract, because its reach is not always one account.

The admin API is reached through engine.accounts(). The same handle also owns the account group membership registry.

What Gets Blocked

An accounts block gates pre-trade order evaluation only. The engine short-circuits the entire policy chain for every incoming order on the blocked account - no policy runs, and the caller receives an AccountBlocked reject immediately. Account adjustments (non-trade operations on balances and positions) continue to work regardless of block state; the block is strictly a pre-trade signal.

Drop copy is the intentional exception. It applies an order that already happened, so existing account and account-group blocks do not gate it. Policies still run and may request another account block after recording the historical effects. The accepted drop-copy operation distinguishes the first block requested by that call from the effective blocked state when apply returned. That state is a snapshot taken before the operation was returned, and the block itself is outside the finalization boundary: rolling the operation back does not undo it.

The exception applies only after the engine has read the order account. That account is its routing and account-control key, so an unreadable account_id causes a fatal MissingRequiredField reject before any policy runs or state is changed. This does not create a global account block.

By-Account Blocking

block(account, reason) adds the account to the engine's blocked set. unblock(account) removes it - clearing the block regardless of its origin: it lifts both an admin block set through this API and a kill-switch block recorded by the engine from a policy (for example a PnL limit). If the account is already blocked, calling block again is a no-op - the first reason is kept. Use replace block reason to change the reason of an existing block.

By-Group Blocking (Live Predicate)

block group(group, reason) installs a group-level block. The engine evaluates group membership live on every pre-trade request: any account that is currently a member of a blocked group is rejected, including accounts that join the group after the block was set. Conversely, an account that leaves a blocked group is no longer group-blocked.

unblock group(group) removes the group-level block.

The default account group cannot be passed to block group, unblock group, or replace group block reason - these calls return an error for the reserved default.

Engine-Wide Blocking

An engine-wide block stops every pre-trade request on every account, whatever its account group. No admin call raises it - block and block group are always scoped. The engine raises it itself, in two cases:

  • an execution report signals a kill switch but exposes no readable account id, so exposure that already exists cannot be attributed to one account;
  • a mutation finalizer registered by a custom policy fails - see Mutation Finalizer Contract.

The engine-wide block carries the cause the engine recorded for it, and it is clearable without rebuilding the engine. unblock all lifts it and lifts nothing else: accounts and account groups blocked individually stay blocked, and their own recorded cause still wins over the engine-wide one, so clear those with unblock and unblock group. Calling unblock all while no engine-wide block is active is a no-op.

Language Clear the engine-wide block
Go accounts.UnblockAll(); on the async engine asyncAccounts.UnblockAll(ctx)
Python accounts.unblock_all()
JavaScript accounts.unblockAll()
C++ accounts.UnblockAll()
Rust accounts.unblock_all()
C openpit_engine_unblock_all_accounts(engine)

An engine-wide block is the engine reporting that its own bookkeeping may no longer be trustworthy. Clear it once the inconsistency behind it has been investigated, not as a way to get order flow moving again.

Mutation Finalizer Contract

A mutation finalizer - the commit or the rollback callback a policy registers with a mutation - has no right to fail. By the time it runs, the decision is already made and the state it finalizes was applied eagerly, so there is nothing left to compensate and no caller left to answer. commit and rollback are void on every surface, and they stay void when a finalizer reports failure.

A reported failure is never ignored either. The engine's own bookkeeping is then in an unknown state, so the engine arms a kill switch under its own cause:

Field Value
policy Engine
code SystemUnavailable
reason mutation finalizer failed
details a mutation commit or rollback callback failed; engine state may be inconsistent

The reach of that kill switch follows the failed mutation's provenance:

  • a mutation registered by an engine-owned built-in policy has a bounded reach, so the account the pipeline ran for is blocked;
  • a mutation registered by a custom policy has an unbounded reach - such a policy may write engine-wide barriers such as a broker-level rate limit or a P&L bound - so every account is blocked.

Every mutation registered through a binding is a custom-policy mutation, so the engine-wide reach is what Go, Python, JavaScript, C++, and C policies get.

The block is engine-owned and carries no account or account-group identifier. Nothing reports it to the caller that was finalizing; that caller learns the ordinary way, when the next pre-trade request is rejected with SystemUnavailable. Clear it with the engine-wide unblock.

The contract holds on every pipeline - pre-trade reservation finalization, drop-copy operation finalization, compensation of a fatal drop-copy evaluation exit, and the account-adjustment batch - and on implicit finalization, where a handle released without commit or rollback rolls back and the block is the only channel left.

What This Means for a Policy Author

A custom policy that cannot guarantee its finalizer will succeed is choosing an engine-wide outage as its failure mode. Everything that can fail belongs before the mutation is registered: apply the tentative state first, then leave the pair with nothing but bookkeeping that cannot fail - no allocation that may throw, no I/O, no lock that may be poisoned, no call back into the engine. A finalizer that only adjusts a counter or restores a value captured at registration time has no failure mode to report.

Language-Level Surfacing

Arming the kill switch does not replace a binding's own callback-error channel. Both effects happen for the same failure:

  • Go recovers a panicking callback at the SDK boundary and reports it to the core as exactly such a failure. Commit, Rollback, and Close stay void, so the kill switch is the whole of what the caller can observe.
  • Python re-raises the original exception, with its original type and message, from commit() or rollback() once every remaining callback of the batch has run.
  • JavaScript throws PolicyCallbackError with the original value as cause.
  • C++ rethrows the original exception from Commit() or Rollback(), likewise after the batch finishes. A destructor cannot rethrow, so on implicit rollback the kill switch is the only channel.
  • Rust adds no containment around policy or mutation callbacks, so a native Rust finalizer must not panic.

On the one path that has a reject channel of its own - compensation of a fatal drop-copy evaluation exit - the SystemUnavailable reject is appended after the fatal policy rejects, so rejects[0] still answers why the operation failed rather than how the cleanup failed.

Reason

The reason is an operator-supplied free-text cause string. It may be empty; the SDK does not enforce non-emptiness. The reason is replayed in the AccountBlocked reject that the engine returns to the caller on every blocked pre-trade attempt. Enforcing a non-empty reason for operator accountability is the caller's responsibility.

Examples

The examples build an engine, block account 99224416 with a reason, unblock it, and show a one-line group block/unblock.

Go
engine, err := openpit.NewEngineBuilder().
 FullSync().
 Builtin(policies.BuildOrderValidation()).
 Build()
if err != nil {
 log.Fatal(err)
}
defer engine.Stop()

accounts := engine.Accounts()

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.Block(param.NewAccountIDFromUint64(99224416), "compliance hold")

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.Unblock(param.NewAccountIDFromUint64(99224416))

// Block every current and future member of a group in one call.
desk, err := param.NewAccountGroupIDFromUint32(7)
if err != nil {
 log.Fatal(err)
}
if err := accounts.BlockGroup(desk, "desk suspended"); err != nil {
 log.Fatal(err)
}
if err := accounts.UnblockGroup(desk); err != nil {
 log.Fatal(err)
}
Python
engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(openpit.pretrade.policies.build_order_validation())
    .build()
)

accounts = engine.accounts()

# Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(openpit.param.AccountId.from_int(99224416), "compliance hold")

# Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(openpit.param.AccountId.from_int(99224416))

# Block every current and future member of a group in one call.
desk = openpit.param.AccountGroupId.from_int(7)
accounts.block_group(desk, "desk suspended")
accounts.unblock_group(desk)
JavaScript
import { Engine } from "@openpit/engine";
import { buildOrderValidation } from "@openpit/engine/pretrade/policies";

const engine = Engine.builder()
  .builtin(buildOrderValidation())
  .build();

const accounts = engine.accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(99224416, "compliance hold");

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(99224416);

// Block every current and future member of a group in one call.
const desk = 7;
accounts.blockGroup(desk, "desk suspended");
accounts.unblockGroup(desk);
C++
#include "openpit/accounts/accounts.hpp"
#include "openpit/engine.hpp"
#include "openpit/param/account_id.hpp"
#include "openpit/pretrade/policies.hpp"

openpit::EngineBuilder builder(openpit::SyncPolicy::Full);
builder.Add(openpit::pretrade::policies::OrderValidationPolicy{});
openpit::Engine engine = builder.Build();

openpit::accounts::Accounts accounts = engine.Accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.Block(openpit::param::AccountId::FromUint64(99224416),
               "compliance hold");

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.Unblock(openpit::param::AccountId::FromUint64(99224416));

// Block every current and future member of a group in one call.
openpit::param::AccountGroupId desk =
    openpit::param::AccountGroupId::FromUint32(7);
if (auto err = accounts.BlockGroup(desk, "desk suspended")) {
  // handle err->message
}
if (auto err = accounts.UnblockGroup(desk)) {
  // handle err->message
}
Rust
use openpit::param::{AccountGroupId, AccountId};
use openpit::pretrade::policies::OrderValidationPolicy;
use openpit::{Engine, OrderOperation};

let engine: openpit::LocalEngine<OrderOperation> = Engine::builder()
    .no_sync()
    .pre_trade(OrderValidationPolicy::new())
    .build()?;

let accounts = engine.accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(AccountId::from_u64(99224416), "compliance hold".to_string());

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(AccountId::from_u64(99224416));

// Block every current and future member of a group in one call.
let desk = AccountGroupId::from_u32(7)?;
accounts.block_group(desk, "desk suspended".to_string())?;
accounts.unblock_group(desk)?;

Related Pages

  • Policies - Account Blocking by Engine: the in-policy kill-switch that blocks accounts automatically from policy callbacks
  • Account Groups: account group membership registry and the account group id type
  • Pre-trade Pipeline: request and reservation semantics, including how blocked accounts surface in results
  • Policy API: custom policy hooks and the mutation pair a policy registers
  • Reject Codes: standard business reject codes, including AccountBlocked and SystemUnavailable

Clone this wiki locally