---
title: "Policies"
description: "OpenPit exposes two policy stages:"
---

<!-- markdownlint-disable MD010 MD033 -->
# 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**: usually applies to all orders with a given settlement asset.
  `OrderSizeLimitPolicy` is the exception: it resolves quantity by underlying
  asset and notional by settlement asset; see
  [OrderSizeLimitPolicy](#ordersizelimitpolicy).
- **Account axis**: applies to all orders from a given account.
- **Account+Asset axis**: usually applies to orders from a specific account and
  settlement asset pair. `OrderSizeLimitPolicy` applies the same underlying /
  settlement split within the account; see
  [OrderSizeLimitPolicy](#ordersizelimitpolicy).

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](Threading-Contract.md) for the per-mode contract).

<!-- markdownlint-disable MD013 MD060 -->
| 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. |
<!-- markdownlint-enable MD013 MD060 -->

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 `account id`, 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:

<details><summary>Go</summary>

- `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.

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
const shards = 256

type task struct {
    accountID string
    order     model.Order
}

// One channel per shard, one goroutine draining each channel: the same
// account always lands on the same worker, so AccountSync holds.
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)
}

// fnv32 hashes the account ID; the modulo pins each account to one shard.
dispatch := func(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.

</details>

<details><summary>Python</summary>

- `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.

</details>

<details><summary>JavaScript</summary>

- `Engine.builder()`

The WebAssembly binding is single-threaded and exposes no user-selectable sync
mode. One engine instance runs synchronously on the calling thread; use one
engine per worker or isolate when you need parallelism.

</details>

<details><summary>C++</summary>

- `openpit::EngineBuilder(openpit::SyncPolicy::Full)`
- `openpit::EngineBuilder(openpit::SyncPolicy::None)`
- `openpit::EngineBuilder(openpit::SyncPolicy::Account)`

The sync policy is a constructor argument on `openpit::EngineBuilder`; the SDK
does not impose a default. Prefer `SyncPolicy::None` for a single-threaded
embedding with zero synchronization overhead. Use `SyncPolicy::Full` only when
the engine handle is actually shared across threads concurrently, and
`SyncPolicy::Account` for sharded sequential workloads where each account is
pinned to one processing chain.

</details>

<details><summary>Rust</summary>

- `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.

</details>

## 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 ordinary 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 an ordinary pre-trade request on that account until the block is cleared.
Drop copy still invokes the policy because it records an order that already
happened.

### 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).
- In Go, an `ApplyExecutionReport` policy callback panics; the binding recovers
  it as a `SystemUnavailable` account block.
- A mutation commit or rollback callback reports a failure. That finalizer had no
  right to fail, so the engine arms a `SystemUnavailable` kill switch rather than
  dropping the failure; see
  [Account Blocking - Mutation Finalizer Contract](Account-Blocking.md#mutation-finalizer-contract).

**Caution:** returning a `Reject` with `scope = account` from any pre-trade
stage triggers an engine-recorded account block. 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. An engine-recorded
block can be lifted explicitly via `accounts().unblock(account)` without
rebuilding the engine; see [Account Blocking](Account-Blocking.md).

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 an **execution report** cannot produce an `account id`, the engine
  activates an engine-wide block and rejects every subsequent pre-trade request,
  regardless of account. The report describes exposure that already exists and
  the engine cannot attribute it, so it stops everything.
- If a **pre-trade order** cannot produce an `account id`, nothing is recorded.
  The order is still rejected, but a rejected request created no exposure, so it
  never justifies stopping every account.
- If a **mutation finalizer** fails, the reach follows the mutation's
  provenance, not the pipeline: a built-in policy's mutation blocks the
  pipeline's account, while a custom policy's mutation - which every mutation
  registered through a binding is - blocks every account. See
  [Account Blocking - Mutation Finalizer Contract](Account-Blocking.md#mutation-finalizer-contract).
- Drop copy never reaches this decision: an order whose `account id` cannot be
  read is rejected with `MissingRequiredField` before any policy runs, because
  the account is the engine's routing and account-control key. No block of
  either kind is recorded.

### Drop-copy exception

Existing account and account-group blocks do not gate drop copy. Account-scoped
rejects remain non-enforcing for the historical order, but on the accepted path
the engine applies their ordered account-control operations before returning the
operation. Those effects persist across caller rollback, so later ordinary
pre-trade requests are stopped. The accepted operation reports both the first
block requested by that call and a snapshot of the effective state at apply
return, which may already be blocked by an older account or group block. A fatal
evaluation failure returns rejects instead of an operation and abandons the
recorded account-control operations.

### 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 it is explicitly unblocked or 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. A block recorded by the engine can also be lifted explicitly without
a rebuild by calling `accounts().unblock(account)` on the engine's `accounts`
handle. An engine-wide block is cleared by its own call, `unblock all`, which
leaves accounts and account groups blocked individually intact; see
[Account Blocking](Account-Blocking.md) for the full admin API and the per-language
names.

## Built-in Policies

### SpotFundsPolicy

It tracks per-account spendable funds and rejects orders an account cannot
afford. Each `(account, asset)` slot holds three buckets - `available`, `held`,
and `incoming`. A reserve moves the outflow amount from `available` to `held`
and simultaneously records the expected inflow on the acquiring leg as
`incoming` (buy: base asset quantity; priced sell: settlement proceeds). A fill
consumes `held` and credits the acquired asset to `available`, reducing
`incoming` by the filled amount. A final report releases the reservation
remainder the caller supplies on it, from both `held` and `incoming`; the engine
derives no remainder of its own. The `incoming` bucket never enters the
solvency check; it gates orders only through a configured position limit.
Balances are seeded only through the [account adjustment](Account-Adjustments.md)
pipeline.

For configuration depth - market-order pricing, slippage, per-instrument
overrides, and the holdings lifecycle - see the dedicated [Spot Funds](Spot-Funds.md)
page. To keep your own ledger aligned with the funds the policy moves, see
[Balance Reconciliation](Balance-Reconciliation.md).

**What it controls:**

- Rejects orders whose cost exceeds `available` plus `held` funds
- Prices market orders from a [market data](Market-Data.md) bundle when configured
- Optionally blocks an account when its single engine-computed account PnL
  moves outside a configured bound or becomes halted. The barrier axis stays the
  account, but every barrier carries a currency. For an account with a known
  effective currency, an account barrier in another currency is a
  configuration fault: it blocks that account with `PnlKillSwitchTriggered`
  and reason `pnl barrier currency mismatch`, without falling through to group
  or global. Group and global barriers in another currency are skipped; if no
  account barrier exists and neither fallback matches, the account has no
  control at all. Without an effective currency, no level is filtered by
  currency and the first in-scope barrier is effective. Bounds are never
  FX-converted. When missing FX halts the account PnL while an effective barrier
  applies, the post-trade report is processed and the account is then blocked;
  subsequent pre-trade requests are rejected - see
  [Spot Funds - Self-Computed PnL Kill Switch](Spot-Funds.md#self-computed-pnl-kill-switch)
- Optionally caps how far an order may push an account's worst-case position
  per asset, long or short, working orders included; a breach rejects the
  order without blocking the account - see
  [Spot Funds - Position Limit](Spot-Funds.md#position-limit)

Required fields to populate:

- `Order`: `instrument`, `account id`, `side`, `trade amount`, optional `price`
- `Execution Report`: `instrument`, `account id`, `side`, fill details
- `Account Adjustment`: `balance operation`, `amount`, `bounds`

Rejects:

- Insufficient spendable funds
  Code: `InsufficientFunds`, Scope: `order`
- Projected position beyond a configured position limit
  Code: `PositionLimitExceeded`, Scope: `order`
- Market order with no market-data bundle configured
  Code: `UnsupportedOrderType`, Scope: `order`
- Market order priced from a stale or missing quote field; or a sell order with
  no order price and no market-data price
  Code: `MarkPriceUnavailable`, Scope: `order`
- Execution report (fill or cancel) arrives without its lock price
  Code: `MissingRequiredField`, Scope: `account`

<details><summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(policies.BuildSpotFunds()).
	Build()
```

</details>

<details><summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```python
import openpit
import openpit.pretrade.policies

# Limit-only spot funds, registered first in the policy list.
engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(openpit.pretrade.policies.build_spot_funds())
    .build()
)
```

</details>

<details><summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import { buildSpotFunds } from "@openpit/engine/pretrade/policies";

// Limit-only spot funds, registered first in the policy list.
const engine = Engine.builder().builtin(buildSpotFunds()).build();
```

</details>

<details><summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/policies_test.cpp -->
```cpp
namespace policies = openpit::pretrade::policies;

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
// Limit-only mode: no market-data service handle.
builder.Add(policies::SpotFundsPolicy{});
const openpit::Engine engine = builder.Build();
```

</details>

<details><summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use openpit::pretrade::policies::{SpotFundsPolicy, SpotFundsSettings};
use openpit::{
    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<openpit::AccountAdjustmentAmount>,
    >,
>;

let builder = Engine::builder::<OrderOperation, SpotReport, SpotAdjustment>().full_sync();
// Limit-only mode: no market-data bundle.
let policy = SpotFundsPolicy::<FullSync, FullSync>::new(
    SpotFundsSettings::new(0, SpotFundsPricingSource::Mark, [])?,
    None::<SpotFundsMarketData<FullSync>>,
    builder.storage_builder(),
);
let engine = builder.pre_trade(policy).build()?;
```

</details>

### OrderValidationPolicy

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

This is a live-admission policy and is intentionally inert for drop copy. A
completed historical order is not revalidated, and missing validation-only
fields do not abort its bookkeeping.

**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`

<details><summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildOrderValidation(),
	).
	Build()
```

</details>

<details><summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```python
import openpit
import openpit.pretrade.policies

engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_order_validation(),
    )
    .build()
)
```

</details>

<details><summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import { buildOrderValidation } from "@openpit/engine/pretrade/policies";

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

</details>

<details><summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/policies_test.cpp -->
```cpp
namespace policies = openpit::pretrade::policies;

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::OrderValidationPolicy{});
const openpit::Engine engine = builder.Build();
```

</details>

<details><summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use openpit::pretrade::policies::OrderValidationPolicy;
use openpit::{Engine, OrderOperation};

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

</details>

### RateLimitPolicy

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

Drop copy spends the same budget at the same start-stage point as an ordinary
request. The attempt remains counted even if a later policy cannot evaluate the
historical order or the caller rolls back the returned operation; rate-limit
counters are not deferred or rolled back specially for drop copy.

**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.

Every axis rejects with `scope = order`, including the account and
account+asset axes. A breach refuses the current request and nothing more: the
counter and its configured window decide when the account is admitted again,
and no rate-limit axis records a block in the blocked-accounts registry.
Choose an axis by what it should meter, not by how far its refusal reaches.

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: `order`
  Reason: `rate limit exceeded: account barrier`
- Code: `RateLimitExceeded`, Scope: `order`
  Reason: `rate limit exceeded: account+asset barrier`
- Code: `MissingRequiredField`, Scope: `order`
  (when a required field cannot be read)

<details><summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildRateLimit().
			BrokerBarrier(
				policies.RateLimitBrokerBarrier{
					Limit: policies.RateLimit{
						MaxOrders: 100,
						Window:    time.Second,
					},
				},
			),
	).
	Build()
```

</details>

<details><summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```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()
)
```

</details>

<details><summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import {
  buildRateLimit,
  RateLimit,
  RateLimitBrokerBarrier,
} from "@openpit/engine/pretrade/policies";

// windowMs is the rolling-window length in milliseconds (1 second here).
const engine = Engine.builder()
  .builtin(
    buildRateLimit().brokerBarrier(
      new RateLimitBrokerBarrier(new RateLimit(100, 1000)),
    ),
  )
  .build();
```

</details>

<details><summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/policies_test.cpp -->
```cpp
namespace policies = openpit::pretrade::policies;

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::RateLimitPolicy{}.BrokerBarrier(
    policies::RateLimitBrokerBarrier(policies::RateLimit(
        /*maxOrders=*/100, /*windowNanoseconds=*/1'000'000'000))));
const openpit::Engine engine = builder.Build();
```

</details>

<details><summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use std::time::Duration;

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

type Report = WithExecutionReportOperation<WithFinancialImpact<()>>;
let builder = Engine::builder::<OrderOperation, Report, ()>().no_sync();
let policy = RateLimitPolicy::new(
    RateLimitSettings::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()?;
```

</details>

### OrderSizeLimitPolicy

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

This is a live-admission policy and is intentionally inert for drop copy. It
does not recalculate a size verdict or require quantity, notional, price, or
instrument fields for an order that already executed.

**What it controls (by axis):**

- **Broker axis**: global hard caps applied to every order, in addition to the
  asset chains.
- **Asset axis**: quantity caps are keyed by the instrument's underlying asset;
  notional caps are keyed by its settlement asset.
- **Account+Asset axis**: the same two keys, additionally scoped by account.

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

Quantity and notional resolve independently. Quantity checks `(account,
underlying asset)` first and then `underlying asset`; notional checks `(account,
settlement asset)` first and then `settlement asset`. In either chain, a
matching barrier that omits the metric is skipped and lookup continues. The
first barrier that carries the metric supplies that asset-chain cap, so omitting
a cap never removes a cap configured farther down the chain.

The broker axis is additive, not another level of either lookup chain. Each cap
on the broker barrier remains in force for every order alongside the selected
asset-chain cap. An asset or account+asset barrier therefore cannot widen a cap
above the broker default for one asset.

When both an asset-chain axis and the broker axis reject the same order, only
the asset-chain reject is returned; the broker breach is not reported
separately. When both selected asset-chain metrics fail, their combined reject
names the asset that supplied each cap. Every barrier of this policy - broker,
asset, and account+asset - rejects with `scope = order` and never blocks the
account.

**Limit parameters** (`OrderSizeLimit`):

| Field          | Type                | Description                      |
|----------------|---------------------|----------------------------------|
| `max quantity` | Quantity (optional) | maximum quantity per order       |
| `max notional` | Volume (optional)   | maximum notional value per order |

The two caps are independent and at least one must be present in every limit.
A capless limit is rejected during construction and by every runtime settings
setter. An absent cap constrains nothing. A cap rejects an order whose value on
that metric is above it, so a cap of zero rejects every order with a positive
quantity or notional and admits one of exactly zero.

Duplicate keys within an axis are rejected: an asset may appear only once on
the asset axis, and an `(account, asset)` pair may appear only once on the
account+asset axis. Put both caps for one key in the same barrier.

For a quantity-based `trade amount`, quantity is taken directly and notional is
derived as `|price| * quantity`. For a volume-based `trade amount`, notional is
taken directly and quantity is derived from the volume and `|price|`. Only
metrics with an applicable asset-chain or broker limit are resolved, and
`price` must be provided only when one of those resolutions needs conversion.

Required fields to populate:

- `Order`: `instrument` (only when the asset or account+asset axis is
  configured), `account id` (only when the account+asset axis is configured),
  `trade amount` (only when an applicable limit exists), `price` (only when an
  applicable limit needs quantity/notional conversion)
- `Execution Report`: none

Rejects:

- Quantity exceeded
  Code: `OrderQtyExceedsLimit`
  Reason: `order quantity exceeded`
- Notional exceeded
  Code: `OrderNotionalExceedsLimit`
  Reason: `order notional exceeded`
- Both exceeded within the asset-chain check or within the broker-axis check
  Code: `OrderExceedsLimit`
  Reason: `order size exceeded`
- Missing `price` or failed size translation
  Code: `OrderValueCalculationFailed`
  Reason: `order value calculation failed`
- Missing required field
  Code: `MissingRequiredField`

<details><summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
aapl, err := param.NewAsset("AAPL")
usd, err := param.NewAsset("USD")
assetMaxQty, err := param.NewQuantityFromString("100")
assetMaxNotional, err := param.NewVolumeFromString("50000")
brokerMaxQty, err := param.NewQuantityFromString("500")
brokerMaxNotional, err := param.NewVolumeFromString("100000")

// Quantity is keyed by the underlying asset, notional by the
// settlement asset; broker caps apply on top.
engine, err := openpit.NewEngineBuilder().
	NoSync().
	Builtin(
		policies.BuildOrderSizeLimit().
			AssetBarriers(
				policies.OrderSizeAssetBarrier{
					Asset: aapl,
					Limit: policies.OrderSizeLimit{
						MaxQuantity: optional.Some(assetMaxQty),
						MaxNotional: optional.None[param.Volume](),
					},
				},
				policies.OrderSizeAssetBarrier{
					Asset: usd,
					Limit: policies.OrderSizeLimit{
						MaxQuantity: optional.None[param.Quantity](),
						MaxNotional: optional.Some(assetMaxNotional),
					},
				},
			).
			BrokerBarrier(
				policies.OrderSizeBrokerBarrier{
					Limit: policies.OrderSizeLimit{
						MaxQuantity: optional.Some(brokerMaxQty),
						MaxNotional: optional.Some(brokerMaxNotional),
					},
				},
			),
	).
	Build()
```

</details>

<details><summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```python
import openpit
import openpit.pretrade.policies

# Quantity is keyed by the underlying asset, notional by the
# settlement asset; broker caps apply on top.
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=None,
                ),
                asset="AAPL",
            ),
            openpit.pretrade.policies.OrderSizeAssetBarrier(
                limit=openpit.pretrade.policies.OrderSizeLimit(
                    max_quantity=None,
                    max_notional=openpit.param.Volume(50000),
                ),
                asset="USD",
            ),
        )
        .broker_barrier(
            openpit.pretrade.policies.OrderSizeBrokerBarrier(
                limit=openpit.pretrade.policies.OrderSizeLimit(
                    max_quantity=openpit.param.Quantity(500),
                    max_notional=openpit.param.Volume(100000),
                ),
            ),
        )
    )
    .build()
)
```

</details>

<details><summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import {
  buildOrderSizeLimit,
  OrderSizeAssetBarrier,
  OrderSizeBrokerBarrier,
  OrderSizeLimit,
} from "@openpit/engine/pretrade/policies";

// Quantities and notionals cross as decimal strings.
// Quantity is keyed by the underlying asset, notional by the
// settlement asset; broker caps apply on top.
const engine = Engine.builder()
  .builtin(
    buildOrderSizeLimit()
      .assetBarriers([
        new OrderSizeAssetBarrier(
          new OrderSizeLimit("100", undefined),
          "AAPL",
        ),
        new OrderSizeAssetBarrier(
          new OrderSizeLimit(undefined, "50000"),
          "USD",
        ),
      ])
      .brokerBarrier(
        new OrderSizeBrokerBarrier(new OrderSizeLimit("500", "100000")),
      ),
  )
  .build();
```

</details>

<details><summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/policies_test.cpp -->
```cpp
namespace policies = openpit::pretrade::policies;

const openpit::param::Quantity assetMaxQty = openpit::param::Quantity::FromString("100");
const openpit::param::Volume assetMaxNotional = openpit::param::Volume::FromString("50000");
const openpit::param::Quantity brokerMaxQty = openpit::param::Quantity::FromString("500");
const openpit::param::Volume brokerMaxNotional = openpit::param::Volume::FromString("100000");

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
// Quantity is keyed by the underlying asset, notional by the
// settlement asset; broker caps apply on top.
builder.Add(
    policies::OrderSizeLimitPolicy{}
        .AssetBarrier(policies::OrderSizeAssetBarrier(
            policies::OrderSizeLimit::Quantity(assetMaxQty),
            openpit::param::Asset("AAPL")))
        .AssetBarrier(policies::OrderSizeAssetBarrier(
            policies::OrderSizeLimit::Notional(assetMaxNotional),
            openpit::param::Asset("USD")))
        .BrokerBarrier(policies::OrderSizeBrokerBarrier(
            policies::OrderSizeLimit::Both(brokerMaxQty, brokerMaxNotional))));
const openpit::Engine engine = builder.Build();
```

</details>

<details><summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use openpit::param::{Asset, Quantity, Volume};
use openpit::pretrade::policies::{
    OrderSizeAssetBarrier,
    OrderSizeBrokerBarrier,
    OrderSizeLimit,
    OrderSizeLimitPolicy,
    OrderSizeLimitSettings,
};
use openpit::storage::NoLocking;
use openpit::{
    Engine, OrderOperation, WithExecutionReportOperation, WithFinancialImpact,
};

type Report = WithExecutionReportOperation<WithFinancialImpact<()>>;
let engine = Engine::builder::<OrderOperation, Report, ()>()
    .no_sync()
    // Quantity is keyed by the underlying asset, notional by the
    // settlement asset; broker caps apply on top.
    .pre_trade(OrderSizeLimitPolicy::<NoLocking>::new(
        OrderSizeLimitSettings::new(
            Some(OrderSizeBrokerBarrier {
                limit: OrderSizeLimit {
                    max_quantity: Some(Quantity::from_str("500")?),
                    max_notional: Some(Volume::from_str("100000")?),
                },
            }),
            [
                OrderSizeAssetBarrier {
                    limit: OrderSizeLimit {
                        max_quantity: Some(Quantity::from_str("100")?),
                        max_notional: None,
                    },
                    asset: Asset::new("AAPL")?,
                },
                OrderSizeAssetBarrier {
                    limit: OrderSizeLimit {
                        max_quantity: None,
                        max_notional: Some(Volume::from_str("50000")?),
                    },
                    asset: Asset::new("USD")?,
                },
            ],
            [],
        )?,
    ))
    .build()?;
```

</details>

### 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](#account-blocking-by-engine)
for how the block is recorded and cleared.

Unlike pure admission policies, this kill switch remains active during drop
copy. A triggered account-scoped verdict does not reject the historical order;
the engine records its effects and then blocks the account for later ordinary
pre-trade calls. Fields are required only after a configured barrier is found
to apply to that account and settlement asset.

**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`):

<!-- markdownlint-disable MD013 -->
| 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)|
<!-- markdownlint-enable MD013 -->

At least one bound must be set. The constructor does not validate bound signs or
the order of `lower bound` <= `upper bound`. Bounds are exclusive: equality
with a configured lower or upper bound is accepted; only values below the lower
bound or above the upper bound breach.

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 an account block (cleared like any engine-recorded block;
see [When a block is cleared](#when-a-block-is-cleared)).

**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`
- Later requests after a pre-trade detection, replayed from the account block
  the engine recorded from the triggering reject
  Code: `AccountBlocked`, Scope: `account`
  Reason and details: those of the triggering reject
- Later requests after an `apply execution report` detection, replayed from the
  `AccountBlock` the policy returned
  Code: `PnlKillSwitchTriggered`, Scope: `account`
  Reason: `pnl kill switch triggered`
- Missing required field
  Code: `MissingRequiredField`

<details><summary>Go</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```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()
```

</details>

<details><summary>Python</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```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=openpit.param.Asset("USD"),
                lower_bound=openpit.param.Pnl(-1000),
                upper_bound=openpit.param.Pnl(500),
            ),
        )
    )
    .build()
)
```

</details>

<details><summary>JavaScript</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import {
  buildPnlBoundsKillswitch,
  PnlBoundsBrokerBarrier,
} from "@openpit/engine/pretrade/policies";

// Bounds cross as signed decimal strings; at least one bound must be set.
const engine = Engine.builder()
  .builtin(
    buildPnlBoundsKillswitch().brokerBarriers([
      new PnlBoundsBrokerBarrier("USD", "-1000", "500"),
    ]),
  )
  .build();
```

</details>

<details><summary>C++</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/policies_test.cpp -->
```cpp
namespace policies = openpit::pretrade::policies;

policies::PnlBoundsBrokerBarrier barrier(openpit::param::Asset("USD"));
barrier.lowerBound = openpit::param::Pnl::FromString("-1000");
barrier.upperBound = openpit::param::Pnl::FromString("500");

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(
    policies::PnlBoundsKillSwitchPolicy{}.BrokerBarrier(std::move(barrier)));
const openpit::Engine engine = builder.Build();
```

</details>

<details><summary>Rust</summary>

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use openpit::param::{Asset, Pnl};
use openpit::pretrade::policies::{
    PnlBoundsBrokerBarrier,
    PnlBoundsKillSwitchPolicy,
    PnlBoundsKillSwitchSettings,
};
use openpit::{
    Engine, OrderOperation, WithExecutionReportOperation, WithFinancialImpact,
};

type Report = WithExecutionReportOperation<WithFinancialImpact<()>>;
let builder = Engine::builder::<OrderOperation, Report, ()>().no_sync();
let policy = PnlBoundsKillSwitchPolicy::new(
    PnlBoundsKillSwitchSettings::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()?;
```

</details>

> **Two PnL kill switches.** This standalone policy trusts an externally
> supplied realized-PnL figure and watches it on the **settlement-asset** axis,
> with a scalar fee added to the accumulated total and no FX involved.
> `SpotFundsPolicy` offers a second kill switch that instead computes one
> account-wide realized PnL from the fills it already reconciles, with a
> structured fee and a fail-closed FX halt. See
> [Spot Funds - Two Ways to Watch PnL](Spot-Funds.md#two-ways-to-watch-pnl) to
> choose between them.

## Custom Policy API

Custom policy interfaces, callbacks, and language-specific examples: [Policy API](Policy-API.md).

## Related Pages

- [Pre-trade Pipeline](Pre-trade-Pipeline.md): Request and reservation semantics
- [Policy API](Policy-API.md): Custom policy interfaces and examples
- [Dynamic Policy Reconfiguration](Dynamic-Policy-Reconfiguration.md): Retune
  built-in policies at runtime without rebuilding the engine
- [Domain Types](Domain-Types.md): Value types used by built-in and custom policies
- [Reject Codes](Reject-Codes.md): Standard business reject codes
- [Architecture](Architecture.md): Public integration model
- [Storage](Storage.md): Built-in synchronization-aware key-value storage
