Skip to content

Policies

Eugene Palchukovsky edited this page May 21, 2026 · 11 revisions

Policies

OpenPit exposes two policy stages:

  • Start stage: cheap checks that must see every request and do not need rollback support.
  • Main stage: deeper checks that may emit multiple rejects or register reversible mutations.

Exact interface names differ by SDK and are listed in the language-specific sections below.

Configuration Model

Built-in policies use an axis-based configuration model. Each policy has one or more axes - dimensions along which a limit or barrier is applied. An axis becomes active only when a barrier is explicitly configured for it.

Common axes across built-in policies:

  • Broker axis: applies to all orders regardless of account or asset.
  • Asset axis: applies to all orders with a given settlement asset.
  • Account axis: applies to all orders from a given account.
  • Account+Asset axis: applies to orders from a specific account and settlement asset pair.

An axis without a matching barrier never rejects the order. At least one axis must be configured; constructors return an error if all axes are empty.

Synchronization

The sync policy is chosen on the engine builder. It controls how stateful built-in policies synchronize their internal storage and defines the engine handle's threading capability (see Threading Contract for the per-mode contract).

Mode Behavior Use case
Full sync Thread-safe handle; concurrent invocation on the same handle is safe. Engine is shared across threads, or migration patterns make sequential pinning impractical.
No sync Single-threaded handle; calls must stay on the OS thread that created the engine. Single-threaded embedding; zero synchronization overhead.
Account sync Sequential cross-thread access; caller pins each account to one processing chain. Account-sharded workloads where one worker / queue owns each account.

Storage owned by a built-in or custom policy is always account-keyed when used through binding-facing storage contracts: the top-level key carries an AccountId, and the per-account value can be any structure the policy needs. The choice of sync mode does not change this rule.

Method names per language:

Go
  • openpit.NewEngineBuilder().FullSync()
  • openpit.NewEngineBuilder().NoSync()
  • openpit.NewEngineBuilder().AccountSync()

If you are unsure, start with FullSync(): goroutines migrate between OS threads at any await point, so even a single goroutine calling the engine sequentially can wake up on a different OS thread than the one it suspended on. FullSync() is the safe default.

AccountSync() only requires that calls for the same account are never concurrent - it does not require a fixed OS thread. The standard Go pattern is a sharded worker pool: hash the account ID to one of N channels; one goroutine drains each channel, so the same account always lands on the same worker.

const shards = 256

type task struct {
    accountID string
    order     model.Order
}

workers := make([]chan task, shards)
for i := range workers {
    ch := make(chan task, 1024)
    workers[i] = ch
    go func(ch <-chan task) {
        for t := range ch {
            req, _, _ := engine.ExecutePreTrade(t.order)
            if req != nil {
                req.Close()
            }
        }
    }(ch)
}

func dispatch(t task) {
    workers[fnv32(t.accountID)%shards] <- t
}

Different shards run in parallel; all orders for the same account always go to the same shard and are processed sequentially.

Python
  • openpit.Engine.builder().full_sync()
  • openpit.Engine.builder().no_sync()
  • openpit.Engine.builder().account_sync()

Prefer no_sync() when you do not explicitly work with multiple threads yourself - it has zero synchronization overhead and is the right default for embeddings that drive the engine from a single thread (synchronous code or one asyncio loop). Use full_sync() only when you actually share the engine across threads concurrently. Use account_sync() for sharded sequential workloads where each account is pinned to one processing chain.

Rust
  • Engine::builder::<OrderOperation, (), ()>().full_sync()
  • Engine::builder::<OrderOperation, (), ()>().no_sync()
  • Engine::builder::<OrderOperation, (), ()>().account_sync()

The choice depends on the embedding; the SDK does not impose a default.

Account Blocking by Engine

The engine maintains an internal blocked-accounts registry next to the policy chain. When a policy emits a reject with scope = account or signals a kill switch from apply execution report, the engine records the affected account and short-circuits every subsequent pre-trade request for that account before any policy runs.

The block is owned by the engine, not by the policy. A policy must not keep its own "this account is blocked" flag and must not re-check the condition on every order - returning the reject once is the entire contract. Once a block is recorded, the engine guarantees the policy is not invoked again for that account until the block is cleared.

When the engine records a block

  • A start stage callback returns rejects that include scope = account.
  • A main stage callback returns rejects that include scope = account.
  • apply execution report returns a non-empty list of AccountBlock values (a kill switch).

Caution: returning a Reject with scope = account from any pre-trade stage triggers an irreversible account block, cleared only by rebuilding the engine. A policy that intends to reject only the current order, for example for a transient condition that may resolve on its own, must use scope = order instead.

Only the first AccountBlock recorded for an account is kept. Later recordings for the same account are no-ops - first cause wins. When apply execution report is dispatched across policies, the engine records only the first AccountBlock from the first policy that returned a non-empty list. Remaining blocks, whether from the same policy or from later policies, are still returned to the caller in PostTradeResult account blocks but do not affect the registry.

What gets blocked

  • If the offending order or report exposes an account id, only that account is blocked, across every settlement asset and instrument.
  • If the order or report cannot produce an account id, the engine activates a global block and rejects every subsequent pre-trade request, regardless of account.

What the caller observes

  • For blocks triggered by start-stage or main-stage rejects, future rejects on that account carry code = AccountBlocked and replay the policy, reason, and details from the original reject.
  • For blocks triggered by apply execution report, future rejects replay the code, reason, and details from the AccountBlock the policy emitted (for example PnlKillSwitchTriggered).
  • While any block is active and an order arrives without an account id, the engine rejects it with MissingRequiredField and the order scope.

When a block is cleared

A blocked account stays blocked until the engine is rebuilt. There is no implicit unblock: rerunning the original check, replaying the order, retrying the request, or letting time pass does not lift the block.

Built-in Policies

The current built-in policies are all start-stage policies.

OrderValidationPolicy

Validates basic order structure before more expensive checks. Does not validate price, side, instrument, or account.

What it controls:

  • Rejects zero quantity trade amount
  • Rejects zero volume trade amount

Required fields to populate:

  • Order: trade amount
  • Execution Report: none

Rejects:

  • Missing trade amount Code: MissingRequiredField, Scope: order Reason: failed to access required field
  • Zero quantity Code: InvalidFieldValue, Scope: order Reason: order quantity must be non-zero
  • Zero volume Code: InvalidFieldValue, Scope: order Reason: order volume must be non-zero
Go
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildOrderValidation(),
	).
	Build()
Python
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_order_validation(),
    )
    .build()
)
Rust
use openpit::pretrade::policies::OrderValidationPolicy;
use openpit::{Engine, OrderOperation};

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

RateLimitPolicy

Counts start-stage attempts inside a time window. Flood semantics: rejected attempts count against the limit to prevent bypass by retrying.

What it controls (by axis):

  • Broker axis: global limit across all orders. Uses an approximate fixed-window counter.
  • Asset axis: limit per settlement asset. Uses an approximate fixed-window counter.
  • Account axis: limit per account. Uses a precise sliding-window log.
  • Account+Asset axis: limit per account and settlement asset pair. Uses a precise sliding-window log.

Caution: RateLimitAccountBarrier and RateLimitAccountAssetBarrier reject with scope = account on breach. The engine records the affected account in its blocked-accounts registry on the first breach, which turns what would otherwise be a window-bound rate limit into a permanent account block cleared only by rebuilding the engine. Choose these axes only when that escalation is intended; use the broker or asset axes (both reject with scope = order) when a transient cap is enough.

All configured axes are incremented and checked on every call. Checks run in order: broker -> asset -> account -> account+asset.

Limit parameters (RateLimit):

Field Type Description
max orders integer maximum attempts in the window
window duration length of the time window

Required fields to populate:

  • Order: instrument (when asset or account+asset axes are configured), account id (when account or account+asset axes are configured)
  • Execution Report: none

Rejects:

  • Code: RateLimitExceeded, Scope: order Reason: rate limit exceeded: broker barrier
  • Code: RateLimitExceeded, Scope: order Reason: rate limit exceeded: asset barrier
  • Code: RateLimitExceeded, Scope: account Reason: rate limit exceeded: account barrier
  • Code: RateLimitExceeded, Scope: account Reason: rate limit exceeded: account+asset barrier
  • Code: MissingRequiredField, Scope: order (when a required field cannot be read)
Go
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildRateLimit().
			BrokerBarrier(
				policies.RateLimitBrokerBarrier{
					Limit: policies.RateLimit{
						MaxOrders: 100,
						Window:    time.Second,
					},
				},
			),
	).
	Build()
Python
import datetime
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_rate_limit()
        .broker_barrier(
            openpit.pretrade.policies.RateLimitBrokerBarrier(
                limit=openpit.pretrade.policies.RateLimit(
                    max_orders=100,
                    window=datetime.timedelta(seconds=1),
                ),
            ),
        )
    )
    .build()
)
Rust
use std::time::Duration;

use openpit::pretrade::policies::{
    RateLimit,
    RateLimitBrokerBarrier,
    RateLimitPolicy,
};
use openpit::{
    Engine, OrderOperation, WithExecutionReportOperation, WithFinancialImpact,
};

type Report = WithExecutionReportOperation<WithFinancialImpact<()>>;
let builder = Engine::builder::<OrderOperation, Report, ()>().no_sync();
let policy = RateLimitPolicy::new(
    Some(RateLimitBrokerBarrier {
        limit: RateLimit {
            max_orders: 100,
            window: Duration::from_secs(1),
        },
    }),
    [],  // asset barriers
    [],  // account barriers
    [],  // account+asset barriers
    builder.storage_builder(),
)?;
let engine = builder.pre_trade(policy).build()?;

OrderSizeLimitPolicy

Limits the size of a single order by quantity and notional value. Prevents fat-finger errors.

What it controls (by axis):

  • Broker axis: global hard cap applied to all orders. Additive - checked in addition to other axes.
  • Asset axis: limit per settlement asset.
  • Account+Asset axis: limit per account and settlement asset pair.

There is no per-account (without asset) axis.

Override rule for the asset chain: if an account+asset barrier is configured for a given pair, it overrides the asset barrier for that account. If no account+asset barrier exists but an asset barrier does, the asset barrier applies. If neither is configured, the axis does not check the order. The broker axis is always additive regardless of the asset chain outcome.

When both an asset-chain axis and the broker axis reject the same order, the asset-chain reject is returned first.

Limit parameters (OrderSizeLimit):

Field Type Description
max quantity Quantity maximum quantity per order
max notional Volume maximum notional value per order

For a volume-based trade amount, notional is taken directly. For a quantity-based trade amount, notional is derived as |price| * quantity. price must be provided when this conversion is needed.

Required fields to populate:

  • Order: instrument, trade amount, account id (when account+asset axes are configured), price (when quantity-to-notional conversion is needed)
  • Execution Report: none

Rejects:

  • Quantity exceeded Code: OrderQtyExceedsLimit Reason: order quantity exceeded
  • Notional exceeded Code: OrderNotionalExceedsLimit Reason: order notional exceeded
  • Both exceeded Code: OrderExceedsLimit Reason: order size exceeded
  • Missing price or failed size translation Code: OrderValueCalculationFailed Reason: order value calculation failed
  • Missing required field Code: MissingRequiredField
Go
usd, err := param.NewAsset("USD")
maxQty, err := param.NewQuantityFromString("100")
maxNotional, err := param.NewVolumeFromString("50000")

engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildOrderSizeLimit().
			AssetBarriers(
				policies.OrderSizeAssetBarrier{
					SettlementAsset: usd,
					Limit: policies.OrderSizeLimit{
						MaxQuantity: maxQty,
						MaxNotional: maxNotional,
					},
				},
			).
			BrokerBarrier(
				policies.OrderSizeBrokerBarrier{
					Limit: policies.OrderSizeLimit{
						MaxQuantity: maxQty,
						MaxNotional: maxNotional,
					},
				},
			),
	).
	Build()
Python
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_order_size_limit()
        .asset_barriers(
            openpit.pretrade.policies.OrderSizeAssetBarrier(
                limit=openpit.pretrade.policies.OrderSizeLimit(
                    max_quantity=openpit.param.Quantity(100),
                    max_notional=openpit.param.Volume(50000),
                ),
                settlement_asset="USD",
            ),
        )
        .broker_barrier(
            openpit.pretrade.policies.OrderSizeBrokerBarrier(
                limit=openpit.pretrade.policies.OrderSizeLimit(
                    max_quantity=openpit.param.Quantity(10000),
                    max_notional=openpit.param.Volume(5000000),
                ),
            ),
        )
    )
    .build()
)
Rust
use openpit::param::{Asset, Quantity, Volume};
use openpit::pretrade::policies::{
    OrderSizeAssetBarrier,
    OrderSizeLimit,
    OrderSizeLimitPolicy,
};
use openpit::{Engine, OrderOperation};

let policy = OrderSizeLimitPolicy::new(
    None,
    [OrderSizeAssetBarrier {
        limit: OrderSizeLimit {
            max_quantity: Quantity::from_str("100")?,
            max_notional: Volume::from_str("50000")?,
        },
        settlement_asset: Asset::new("USD")?,
    }],
    [],
)?;

let engine = Engine::builder::<OrderOperation, (), ()>()
    .no_sync()
    .pre_trade(policy)
    .build()?;

PnlBoundsKillSwitchPolicy

Tracks accumulated realized P&L and blocks new orders when it moves outside configured bounds. After a kill switch triggers, the engine blocks the affected account id from every subsequent pre-trade request across all settlement assets - see Account Blocking by Engine for how the block is recorded and cleared.

What it controls (by axis):

  • Broker barrier: P&L bounds applied to all accounts with a given settlement asset. If no broker barrier is configured for an order's settlement asset and no account barrier matches, the order passes without P&L tracking.
  • Account+Asset barrier: P&L bounds for a specific account and settlement asset pair. Accepts an initial pnl to resume tracking from a previously accumulated value.

Both barriers are checked on every order; the broker barrier is checked first.

Barrier parameters (PnlBoundsBrokerBarrier):

Field Type Required Description
settlement asset Asset yes settlement asset for P&L tracking
lower bound optional P&L no loss limit (typically negative)
upper bound optional P&L no profit-taking limit (typically positive)

At least one bound must be set. The constructor does not validate bound signs or the order of lower bound <= upper bound.

P&L accumulation uses the account id from the execution report (not from the original order). Each report contributes pnl + fee to the accumulated total. P&L overflow causes a permanent block.

Post-trade behavior:

  • Accepts two valid ways to pass trading result:
    • Separate: pnl is net trading P&L before fees, fee is provided separately. The engine applies fee as a negative P&L contribution.
    • Combined: pnl already includes fees, fee returns None or zero. In that case the fee is not counted twice.

Required fields to populate:

  • Order: instrument, account id
  • Execution Report: instrument, account id, pnl, fee

Rejects:

  • First detection via broker barrier Code: PnlKillSwitchTriggered, Scope: account Reason: pnl kill switch triggered: broker barrier
  • First detection via account+asset barrier Code: PnlKillSwitchTriggered, Scope: account Reason: pnl kill switch triggered: account+asset barrier
  • All subsequent checks after kill switch has triggered Code: PnlKillSwitchTriggered, Scope: account Reason: pnl kill switch triggered: account blocked
  • Missing required field Code: MissingRequiredField
Go
usd, err := param.NewAsset("USD")
lowerBound, err := param.NewPnlFromString("-1000")
upperBound, err := param.NewPnlFromString("500")

engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildPnlBoundsKillswitch().
			BrokerBarriers(
				policies.PnlBoundsBrokerBarrier{
					SettlementAsset: usd,
					LowerBound:      optional.Some(lowerBound),
					UpperBound:      optional.Some(upperBound),
				},
			),
	).
	Build()
Python
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_pnl_bounds_killswitch()
        .broker_barriers(
            openpit.pretrade.policies.PnlBoundsBrokerBarrier(
                settlement_asset="USD",
                lower_bound=openpit.param.Pnl(-1000),
                upper_bound=openpit.param.Pnl(500),
            ),
        )
    )
    .build()
)
Rust
use openpit::param::{Asset, Pnl};
use openpit::pretrade::policies::{
    PnlBoundsBrokerBarrier,
    PnlBoundsKillSwitchPolicy,
};
use openpit::{
    Engine, OrderOperation, WithExecutionReportOperation, WithFinancialImpact,
};

type Report = WithExecutionReportOperation<WithFinancialImpact<()>>;
let builder = Engine::builder::<OrderOperation, Report, ()>().no_sync();
let policy = PnlBoundsKillSwitchPolicy::new(
    [PnlBoundsBrokerBarrier {
        settlement_asset: Asset::new("USD")?,
        lower_bound: Some(Pnl::from_str("-1000")?),
        upper_bound: Some(Pnl::from_str("500")?),
    }],
    [],
    builder.storage_builder(),
)?;
let engine = builder.pre_trade(policy).build()?;

Custom Policy API

Custom policy interfaces, callbacks, and language-specific examples: Policy API.

Related Pages

Clone this wiki locally