---
title: "Spot Funds"
description: "OpenPit's real-time solvency gate. A single registration turns the engine into a hard pre-trade control that sees every order, reserves the exact funds."
---

<!-- markdownlint-disable MD010 MD033 -->
# Spot Funds

**Never let an order spend money the account does not have.** Spot Funds is
OpenPit's real-time solvency gate. A single registration turns the engine into a
hard pre-trade control that sees every order, reserves the exact funds it would
consume, and rejects - deterministically, in microseconds - anything the account
cannot actually pay for. No order can bypass it.

It is the control most teams adopt first, because it eliminates the single most
expensive class of trading incident: the over-spend. Oversells, double-spends,
and "phantom buying power" simply cannot happen when every working order is
backed by funds that were verified and set aside before the order ever left the
building.

## Why It Matters

- **Zero over-spend, by construction.** Funds are reserved before an order is
  accepted and released the instant it is cancelled or filled. The account can
  never commit to more than it holds.
- **Penny-accurate accounting.** A per-`(account, asset)` ledger tracks
  spendable, reserved, and incoming funds across the full order lifecycle, with
  exact-decimal arithmetic - no floating-point drift.
- **Built for the hot path.** A pure in-memory control with no I/O on the
  critical path: solvency checks run inline with order submission at trading
  latency.
- **Reconcilable.** Every balance change emits a signed delta and the resulting
  absolute, so your books stay provably in step with the engine's.
- **Spot, long-only, no leverage.** A deliberately conservative model: an
  account spends only what it owns. The right control for cash trading desks,
  custody-backed venues, and any flow where credit risk is not on the table.

## What It Can Do

- **Solvency gating on every order** - buys are gated against the settlement
  asset, sells against the underlying asset.
- **Two ways to size an order** - by **quantity** (units of the instrument) or
  by **volume** (a notional magnitude in the settlement currency, which the side
  and the price sign turn into an outflow or an inflow). See
  [Order Sizing](#order-sizing).
- **Limit and market orders** - priced orders out of the box; market orders
  priced live from a [market-data](Market-Data.md) feed with a configurable
  worst-case slippage cushion.
- **Configurable market-order pricing** - price from the quote mark or from the
  top of book (`ask` for buys, `bid` for sells), with per-instrument slippage
  overrides that can be further narrowed to a specific account or
  [account group](Account-Groups.md).
- **Full holdings lifecycle** - reserve on accept, consume on every fill, and
  release the reservation remainder the caller supplies on the order's final
  report.
- **Average entry price and position PnL** - tracked independently for each
  `(account, asset)` holdings slot and published with that asset.
- **Account PnL** - one engine-computed accumulator per account, published
  without an asset dimension. Account currency denominates that accumulator but
  is not part of its identity.
- **Self-computed PnL kill switch** - optionally block an account when its
  account-wide PnL breaches a configured bound or the account PnL becomes
  halted. Every barrier carries a currency. An account-specific currency
  mismatch blocks its account; group/global mismatches are skipped. Without an
  effective currency, the first in-scope barrier in the account -> group ->
  global cascade applies. See
  [Self-Computed PnL Kill Switch](#self-computed-pnl-kill-switch).
- **Position limits** - optionally cap how far an order may push an account's
  worst-case position in an asset, long or short, working orders included. See
  [Position Limit](#position-limit).
- **Negative and zero explicit prices** - negative prices are handled,
  including the rare cases where a sell *reserves* settlement instead of
  acquiring it. An ordinary Buy or Sell volume order at an explicit zero price
  is rejected with `OrderValueCalculationFailed`. Drop copy alone accepts it
  and records empty reservation and incoming legs plus a zero lock price. See
  [Order Sizing](#order-sizing).
- **Explicit commit / rollback** - reservations are two-phase, so a failed
  downstream submission never leaks held funds.
- **Manager-driven balances** - seed and adjust funds through the auditable
  [account-adjustment](Account-Adjustments.md) pipeline; the policy never invents
  funds.
- **Venue-side shortfall modelling** - manager-set `held` adjustments may go
  negative to reflect funds encumbered outside the engine.
- **Binding parity** - the policy's behavior is defined once, in the core, so
  it does not vary by how the SDK is consumed.

## What It Controls

For each `(account, asset)` pair the policy maintains a holdings slot with three
independent buckets:

- `available`: spendable funds not committed to any working order.
- `held`: funds reserved against working orders, no longer spendable.
- `incoming`: expected future inflow that has not yet settled into `available`.

A buy reserves settlement-asset funds; a sell reserves the underlying asset. The
amount a new order may commit is bounded by `available` net of any manager-set
`held` shortfall. When the required amount is not present the order is rejected
with `InsufficientFunds` and no state changes.

The `incoming` bucket is populated both by orders and by account adjustments.
When an order is reserved, the acquiring leg's expected inflow is recorded as
`incoming` in parallel with the outflow leg's `held` reservation: a buy records
the base asset quantity it will receive as `incoming`; a priced sell records the
settlement proceeds it will receive as `incoming`. This projection never enters
the solvency calculation; it gates an order only through a configured
[position limit](#position-limit). As the order fills or cancels, `incoming` is
reduced to match; it converges to zero once the order is fully settled.

Alongside the three buckets the policy derives an **average entry price** and a
running **position PnL** for each `(account, asset)` holdings slot on a
weighted-average-cost basis. The average price is set as a position is opened
or added to, and PnL is booked when it is reduced or closed. Position PnL is
published with the asset it belongs to and never participates directly in the
kill switch.

Separately, Spot Funds maintains one **account PnL** accumulator per account.
It aggregates the account-level contributions produced by reconciled fills and
fees and is published without an asset. This account-wide value is the only PnL
state evaluated by the Spot Funds kill switch. An account that has never
recorded a realized contribution has no account-PnL state yet; opening and
same-direction fills without a non-zero fee leave that state unset.

The account currency is optional account metadata managed through
`engine.accounts()`. Its effective value resolves through the explicit account
currency, the account's current group, `DEFAULT_ACCOUNT_GROUP`, then no
currency. The currency denominates every calculation. For PnL barriers it
validates an account barrier, whose mismatch is a configuration fault that
blocks the account, and filters group and global fallbacks, whose mismatches are
skipped. A known-currency account with no account barrier and no matching
fallback has no bounds control, but its PnL keeps accumulating and publishing.
Bounds are never FX-converted - see
[Configuring Barriers](#configuring-barriers). The currency is not an
account-PnL key: the account keeps exactly one accumulator whatever its
currency is.

**Warning:** direct currency writes on an account or account group are
unchecked and re-evaluate nothing. Stored PnL and cost basis accumulated under
a different effective currency lose their meaning; the SDK guarantees nothing
about them and does not convert, detect, or report the change. Barrier
selection itself stays deterministic: the cascade is re-resolved with the new
effective currency on the next policy access, and an existing block is neither
released nor re-recorded.

For an example of good practice in building a control plane on this SDK, see
[Pit Officer](http://officer.openpit.dev/).

Group membership can replace the effective account, group, or global fallback
PnL barrier, with or without a currency change. It can also move the effective
currency itself, when the old and the new group carry different ones. After a
successful `register_group` or `unregister_group`, the same call resolves the
effective currency on each side of the transition, compares the barriers those
two currencies select, and evaluates the current account-PnL state whenever
that effective barrier or the account barrier's currency-match status changed:
an unset ledger is implicit zero, `Halted` is a violation, and a numeric value
is compared with the new bounds. A halt, breach, or account-barrier currency
mismatch latches the account block before the membership call returns. If both
the effective barrier and the account barrier's currency-match status are
unchanged, the call does not recheck or reblock the account. Setting or clearing
a currency directly, on the account or on a group, never triggers this re-check;
see the warning above.

Position and account PnL are independent sticky states. If a required
contribution cannot be calculated, the affected state becomes `Halted` with an
explicit reason. Account PnL stays halted until an account-PnL adjustment or
the configurator replaces it with a numeric value or another halt reason.
Position PnL stays halted until a balance adjustment for that asset does the
same. A newly halted account or position publishes the halt reason once; later
outcomes omit the unchanged halt. The stored account halt still participates in
pre-trade and post-trade kill-switch checks.

A halt is fail-safe only when an effective PnL barrier resolves for the
account. With such a barrier, pre-trade checks reject an order while that
account PnL is halted. During post-trade, the report and all publishable
outcomes are applied first; the account is then blocked. With no effective
barrier, the same halt is still stored and published, but it does not reject or
block.

- If no account currency resolves, the holdings mutation still applies. The
  position ledger halts with `MissingAccountCurrency` on every fill that touches
  the position, because it stores a cost basis for each of them. The account
  line halts with the same reason where it has a contribution to denominate - a
  fill carrying a non-zero fee, or one that reduces, closes, or reverses the
  position. If owned-position arithmetic overflows before that classification,
  both PnL lines halt with `ArithmeticOverflow`, not
  `MissingAccountCurrency`. An opening or same-direction fill with no fee or an
  explicitly zero fee does not engage the account line and leaves an unset
  account ledger unset. Only position accounting halts on that fill, because it
  needs the currency to store cost basis.
- If the instrument quote equals the account currency, no FX market data is
  needed and tracking continues from the fill values directly.
- An opening or same-direction fill with no fee or an explicitly zero fee has no
  realized contribution, so the account ledger needs no FX and stays unset.
  Position accounting still needs FX when the quote differs from the account
  currency so it can write or update cost basis. Without that FX the fill
  applies, but position PnL halts immediately with `MissingFx`. A reducing or
  closing fill likewise needs FX whenever its realized contribution must be
  converted into the account currency.
- If the quote differs from the account currency, Spot Funds uses the
  market-data `mark` of `Instrument(quote, account_currency)`. If only the
  inverse `Instrument(account_currency, quote)` is registered, a non-zero mark
  is applied as `1 / mark`. A mark of exactly zero performs an exact zero
  conversion in either quote direction; an inverse zero is not divided or
  reciprocated. Negative marks are valid in both directions: a direct mark is a
  signed multiplier and a non-zero inverse mark uses its signed reciprocal.
  This rule is the same for prices, fees, and every other P&L component.
- Fresh and stale FX are both usable for accounting. A stale quote may surface
  as an expired-quote error that carries the stale quote, but Spot Funds still
  uses that last available quote. `MissingFx` means that no usable quote has
  ever been observed for the required conversion.
- If FX is missing - the quote is unavailable, the instrument is unknown, or
  there is no market-data handle - the holdings mutation still applies and any
  PnL state that needs that contribution emits `MissingFx` and halts. A halted
  account PnL blocks the account when a barrier resolves; without a barrier it
  remains a published non-blocking state.
- A position ledger without a cost basis halts with `MissingCostBasis` when an
  event needs that basis. `MissingInitialPnl` is the reserved lowest-priority
  catch-all when a required PnL contribution cannot be established and no more
  specific missing-input reason applies. A valid unset initial position PnL does
  not halt by itself. The account calculation independently evaluates whether it
  can derive the same economic contribution in account currency; it halts only
  when one of its own required inputs is unavailable.
- When one event makes several halt reasons applicable, the published reason is
  the most specific operator action, not the first one the evaluation hit. The
  fixed order is `ArithmeticOverflow`, `MissingAccountCurrency`,
  `MissingFx`, `MissingCostBasis`, `MissingInitialPnl`.

Account adjustments expose two PnL paths. An account-PnL operation carries
only a numeric PnL or a halt reason; it has no asset, and an adjustment that
carries one is handled as that operation alone - its asset-scoped balance,
`held`, and `incoming` fields are not applied and it publishes no per-asset
outcome. Position PnL is the optional realized-PnL state of an asset-scoped
balance operation, supplied as a numeric PnL or a halt reason. That balance
operation can correct the position PnL together with its account-currency
average entry price. Each supplied PnL state replaces only its exact state.
Spot Funds does not apply FX to manager-supplied values. When an asset-scoped
adjustment leaves the position flat - `available` plus `held` at zero - the
average entry price of that slot is cleared.

## Order Sizing

An order's size can be expressed two ways, and the policy reserves the correct
funds for either:

- **By quantity** (`TradeAmount::Quantity`) - a number of instrument units. This
  is the familiar "buy 10 AAPL" form.
- **By volume** (`TradeAmount::Volume`) - a non-negative notional magnitude in
  the settlement currency, e.g. "2000 USD worth of AAPL". It is not necessarily
  spent: the side and price sign determine which legs are outflows or inflows,
  and the policy derives the quantity and reservation legs from the price.

How each is reserved:

<!-- markdownlint-disable MD013 MD060 -->
| Side | By quantity | By volume |
|------|-------------|-----------|
| **Buy** | Holds `price × quantity` of the settlement asset. | At a positive price, holds the declared volume in settlement and projects the derived underlying quantity as incoming. At a negative price, holds no settlement outflow but still projects the derived quantity as incoming. An ordinary order at an explicit zero price is rejected with `OrderValueCalculationFailed`. |
| **Sell** | Reserves `quantity` of the underlying asset. | At a positive price, reserves the derived underlying quantity and projects `price × quantity` to settlement `incoming` - the declared volume, up to the rounding of the quantity division. At a negative price, reserves that quantity and the settlement outflow produced by the negative price. An ordinary order at an explicit zero price is rejected with `OrderValueCalculationFailed`. |
<!-- markdownlint-enable MD013 MD060 -->

An ordinary volume order with an explicit price derives quantity as
`volume / abs(price)`. A negative price therefore produces the same quantity
magnitude as its positive counterpart, while its sign still determines which
settlement leg is an outflow. An explicit zero price is rejected with
`OrderValueCalculationFailed` before reservation for both Buy and Sell. Drop
copy is the sole exception: a historical volume order carrying its own zero
price derives `Quantity::ZERO`, records empty reservation and incoming legs,
and records a zero lock price.

A market order is different, because its price is derived from market data
rather than supplied. When no order price is present and the policy carries a
market-data bundle, the order is rejected before any reservation is attempted if
that derivation cannot produce a price. `MarkPriceUnavailable` covers the
inputs that are absent: an unregistered instrument, no usable quote at all, a
quote that is stale, or a quote missing the field the pricing source needs.
`OrderValueCalculationFailed` covers the inputs that are present but
unusable: a pricing field of zero or below, and a price left at zero or below
once slippage is applied. In [limit-only mode](#limit-only-mode-default) there
is no bundle at all, so the same priceless order is rejected earlier with
`UnsupportedOrderType`. Drop copy never takes this path at all: a historical
order replayed without its own price is rejected with `MissingRequiredField`
rather than priced from the current market - see
[Funds Limit Mode](#funds-limit-mode).

Both forms compose with limit and market orders alike, and the sign of the price
is honoured throughout (a negative price flips which leg actually carries the
reservation).

## Holdings Lifecycle

The policy moves funds between buckets across the order lifecycle:

1. **Reserve** - a passing order moves the outflow amount from `available` to
   `held`, and simultaneously records the expected inflow as `incoming` on the
   acquiring leg. A buy of 10 units at a price of 200:
   - settlement (USD): `available` drops by 2000, `held` rises by 2000.
   - base (AAPL): `incoming` rises by 10.
2. **Fill** - an execution report consumes the filled portion from `held`
   (outflow leg) and credits the acquired asset to `available` (inflow leg).
   Simultaneously, the acquiring leg's `incoming` is reduced by the filled
   quantity. A full fill of the example order:
   - settlement (USD): 2000 consumed from `held`; `available` credited with the
     fill proceeds (net of any price improvement).
   - base (AAPL): `available` credited with 10; `incoming` reduced by 10.
3. **Cancel / partial** - `remaining_reserved_quantity` is required input on
   every execution report, like the lock. The caller calculates and supplies it
   as the remainder of its own pre-trade reservation. This value is not the
   venue-reported remaining order quantity (FIX `LeavesQty`). Never copy venue
   leaves into it: for a volume order, they may be a money amount. The remainder
   is always a quantity of the instrument's underlying asset, never a settlement
   or money amount, including for volume orders. On every report, supply the
   greater of zero and the reserved quantity minus all fill quantities recorded
   for the order so far, including the fill on this report. The release runs
   only on a report that marks the order final and carries a non-zero remainder:
   at a non-negative lock price, a buy releases lock price × remainder from
   settlement `held` back to `available` and reduces base `incoming` by the
   remainder; a sell releases the remainder from base `held` back to `available`
   and reduces settlement `incoming` by lock price × remainder. At a negative
   lock price, a buy only reduces base `incoming`; a sell also releases
   |lock price| × remainder from settlement `held` back to `available`, leaving
   settlement `incoming` unchanged (see [Order Sizing](#order-sizing)). An
   intermediate partial fill reconciles only what it filled and releases
   nothing. It supplies the remainder after that fill. A final report supplies
   the same remainder, whether cancelling a partially filled order or reporting
   it fully filled. The remainder is never negative and is zero only once the
   recorded fills reach or exceed the reserved quantity; a volume order reported
   fully filled can still carry a positive remainder that must be released.
   A cancel after filling 4 of 10 units supplies
   `remaining_reserved_quantity = 6`:
   - settlement (USD): 6 × 200 = 1200 released from `held` back to `available`.
   - base (AAPL): `incoming` reduced by 6 (the unfilled remainder).

   For a volume order, use the quantity sized at the lock price during
   reservation by the engine's decimal division (see
   [Order Sizing](#order-sizing)), not a quantity re-derived from unspent volume
   at fill prices. A buy of 1000 USD by volume at a lock price of 100 reserves
   10 AAPL. A partial fill of 6 supplies 4 and releases nothing; a cancel then
   supplies 4:
   - settlement (USD): 4 × 100 = 400 released from `held` back to `available`.
   - base (AAPL): `incoming` reduced by 4 (the unfilled remainder).

Each step emits one outcome entry per asset it moved, in a fixed order: a buy
emits settlement then underlying, a sell underlying then settlement. A leg that
moves neither `held` nor `incoming` is a clean no-op - no holdings slot is
created and no entry is emitted for it - so consume the entries a result
actually carries rather than expecting one per instrument leg.

Reservations are explicit: after `execute_pre_trade` accepts an order the caller
must `commit` the reservation once the order is accepted downstream, or
`rollback` if submission fails.

> **The lock price is mandatory for reconciliation.** The fill and cancel steps
> above do not re-price from a live quote - they reconcile against the exact
> price the order was *reserved* at, carried in the order's
> [pre-trade lock](Pre-Trade-Lock.md). You **must** read the lock off the
> reservation, persist it, and attach it to every execution report for the
> order until the final report. An execution report - for a **buy or a sell** -
> that arrives without its lock price cannot be reconciled and blocks the account
> with `MissingRequiredField`. This also governs restart recovery - see
> [Pre-Trade Lock → Surviving a Restart](Pre-Trade-Lock.md#surviving-a-restart).

## Seeding Balances

The policy never invents funds. Initial balances are loaded exclusively through
the [account adjustment](Account-Adjustments.md) pipeline. A missing holdings slot
is treated as zero, so an unseeded account cannot buy or sell anything.

Use an absolute balance adjustment to establish a starting balance and a delta
adjustment to move it. Manager-initiated `held` adjustments are allowed to go
negative to model a venue-side shortfall: a negative `held` is added to
`available` to give net spendable, so a `held` of -2000 against an `available`
of 2000 leaves nothing spendable. A positive `held` is ordinary reserved money
and does not add to what a new order may commit.

An adjustment may carry its own `balance`, `held`, and `incoming` bounds. Each
field is applied first and then checked against the bounds that arrived with it;
the bounds are inclusive, so landing exactly on one is accepted, and a field
outside them rejects the adjustment with `AccountAdjustmentBoundsExceeded`
before any of it is kept.

An asset-scoped adjustment returns a per-asset outcome carrying both the applied
`delta` and the resulting `absolute` value for each field it set. Persisting
those deltas is how an embedding keeps its own books in step with the engine -
see [Balance Reconciliation](Balance-Reconciliation.md). An adjustment that sets
none of `balance`, average entry price, realized PnL, `held`, or `incoming` is
a clean no-op and returns no outcome at all.

## Limit-Only Mode (Default)

By default the policy operates in limit-only mode: every order must carry a
`price`. An order without a price is a market order, and in limit-only mode it is
rejected with `UnsupportedOrderType`. This is the right default when the
embedding only submits priced orders.

<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
// Limit-only spot funds: register first in the policy list.
engine, err := openpit.NewEngineBuilder().
    FullSync().
    Builtin(policies.BuildSpotFunds()).
    Build()
if err != nil {
    panic(err)
}
defer engine.Stop()

accountID := param.NewAccountIDFromUint64(99224416)

// Seed 10000 USD of available funds through the account-adjustment pipeline.
usd, _ := param.NewAsset("USD")
total, _ := param.NewPositionSizeFromString("10000")
seed, _ := model.NewAccountAdjustmentFromValues(model.AccountAdjustmentValues{
    BalanceOperation: optional.Some(
        model.NewAccountAdjustmentBalanceOperationFromValues(
            model.AccountAdjustmentBalanceOperationValues{Asset: optional.Some(usd)},
        ),
    ),
    Amount: optional.Some(
        model.NewAccountAdjustmentAmountFromValues(model.AccountAdjustmentAmountValues{
            Balance: optional.Some(param.NewAbsoluteAdjustmentAmount(total)),
        }),
    ),
})
seedResult, err := engine.ApplyAccountAdjustment(accountID, []model.AccountAdjustment{seed})
if err != nil {
    panic(err)
}
if seedResult.BatchError.IsSet() {
    panic("unexpected rejects")
}

// Buy 10 AAPL @ 200 holds 2000 USD; available drops to 8000.
order := model.NewOrder()
op := order.EnsureOperationView()
aapl, _ := param.NewAsset("AAPL")
op.SetInstrument(param.NewInstrument(aapl, usd))
op.SetAccountID(accountID)
op.SetSide(param.SideBuy)
qty, _ := param.NewQuantityFromString("10")
price, _ := param.NewPriceFromString("200")
op.SetTradeAmount(param.NewQuantityTradeAmount(qty))
op.SetPrice(price)

reservation, execRejects, err := engine.ExecutePreTrade(order)
if err != nil {
    panic(err)
}
if execRejects != nil {
    panic("unexpected post-trade rejects")
}
reservation.CommitAndClose()
```

</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: register first in the policy list.
engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(openpit.pretrade.policies.build_spot_funds())
    .build()
)

account_id = openpit.param.AccountId.from_int(99224416)

# Seed 10000 USD of available funds through the account-adjustment pipeline.
seed = openpit.AccountAdjustment(
    operation=openpit.AccountAdjustmentBalanceOperation(asset="USD"),
    amount=openpit.AccountAdjustmentAmount(
        balance=openpit.param.AdjustmentAmount.absolute(
            openpit.param.PositionSize(10000)
        )
    ),
)
seed_result = engine.apply_account_adjustment(
    account_id=account_id, adjustments=[seed]
)
assert seed_result.ok

# Buy 10 AAPL @ 200 holds 2000 USD; available drops to 8000.
order = openpit.Order(
    operation=openpit.OrderOperation(
        instrument=openpit.Instrument("AAPL", "USD"),
        account_id=account_id,
        side=openpit.param.Side.BUY,
        trade_amount=openpit.param.TradeAmount.quantity("10"),
        price=openpit.param.Price("200"),
    ),
)
result = engine.execute_pre_trade(order=order)
assert result.ok
result.reservation.commit()
```

</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 { type AccountAdjustmentInit, type OrderInit } from "@openpit/engine/model";
import { AdjustmentAmount, TradeAmount } from "@openpit/engine/param";
import { buildSpotFunds } from "@openpit/engine/pretrade/policies";

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

const accountId = 99224416;

// Seed 10000 USD of available funds through the account-adjustment pipeline.
const seed: AccountAdjustmentInit = {
  operation: { asset: "USD" },
  amount: { balance: AdjustmentAmount.absolute("10000") },
};
const seedResult = engine.applyAccountAdjustment(accountId, [seed]);
if (!seedResult.ok) {
  throw new Error("unexpected rejects");
}

// Buy 10 AAPL @ 200 holds 2000 USD; available drops to 8000.
const order: OrderInit = {
  operation: {
    underlyingAsset: "AAPL",
    settlementAsset: "USD",
    accountId,
    side: "BUY",
    tradeAmount: TradeAmount.quantity("10"),
    price: "200",
  },
};
const result = engine.executePreTrade(order);
if (!result.ok) {
  throw new Error("unexpected post-trade rejects");
}
const reservation = result.reservation;
if (reservation === undefined) {
  throw new Error("accepted execute result is missing its reservation");
}
reservation.commit();
```

</details>

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

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/spot_funds_test.cpp -->
```cpp
#include <cassert>

namespace policies = openpit::pretrade::policies;
namespace aa = openpit::accountadjustment;
using openpit::param::AccountId;
using openpit::param::AdjustmentAmount;
using openpit::param::PositionSize;
using openpit::param::Price;
using openpit::param::Quantity;

// Limit-only spot funds: register first in the policy list.
openpit::EngineBuilder builder(openpit::SyncPolicy::None);
policies::SpotFundsPolicy{}.AddTo(builder);
openpit::Engine engine = builder.Build();

const AccountId accountId = AccountId::FromUint64(99224416);

// Seed 10000 USD of available funds through the account-adjustment pipeline.
aa::AccountAdjustment seed;
aa::BalanceOperation balanceOp;
balanceOp.asset = ::openpit::param::Asset("USD");
seed.operation = aa::Operation::OfBalance(std::move(balanceOp));
aa::Amount seedAmount;
seedAmount.balance =
    AdjustmentAmount::Absolute(PositionSize::FromString("10000"));
seed.amount = std::move(seedAmount);

const openpit::AdjustmentResult seedResult = engine.ApplyAccountAdjustment(
    accountId, std::vector<aa::AccountAdjustment>{seed});
assert(seedResult.Passed());

// Buy 10 AAPL @ 200 holds 2000 USD; available drops to 8000.
openpit::model::Order order = openpit::model::Order::Limit(
    openpit::model::Instrument(::openpit::param::Asset("AAPL"),
                               ::openpit::param::Asset("USD")),
    accountId, openpit::model::Side::Buy,
    openpit::model::TradeAmount::OfQuantity(Quantity::FromString("10")),
    Price::FromString("200"));

openpit::pretrade::ExecuteResult result = engine.ExecutePreTrade(order);
if (result.Passed()) {
  result.reservation->Commit();
}
```

</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::{
    AccountId, AdjustmentAmount, Asset, PositionSize, Price, Quantity, Side, TradeAmount,
};
use openpit::pretrade::policies::{SpotFundsPolicy, SpotFundsSettings};
use openpit::{
    AccountAdjustmentAmount, AccountAdjustmentBalanceOperation, AccountAdjustmentBounds,
    Engine, FullSync, Instrument, 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()?;

let account = AccountId::from_u64(99224416);

// Seed 10000 USD of available funds through the account-adjustment pipeline.
let seed = WithAccountAdjustmentAmount {
    inner: WithAccountAdjustmentBounds {
        inner: WithAccountAdjustmentBalanceOperation {
            inner: AccountAdjustmentAmount::default(),
            operation: AccountAdjustmentBalanceOperation {
                asset: Asset::new("USD")?,
                average_entry_price: None,
            },
        },
        bounds: AccountAdjustmentBounds::default(),
    },
    amount: AccountAdjustmentAmount {
        balance: Some(AdjustmentAmount::Absolute(PositionSize::from_str("10000")?)),
        held: None,
        incoming: None,
    },
};
engine.apply_account_adjustment(account, &[seed])?;

// Buy 10 AAPL @ 200 holds 2000 USD; available drops to 8000.
let order = OrderOperation {
    instrument: Instrument::new(Asset::new("AAPL")?, Asset::new("USD")?),
    account_id: account,
    side: Side::Buy,
    trade_amount: TradeAmount::Quantity(Quantity::from_str("10")?),
    price: Some(Price::from_str("200")?),
};
engine.execute_pre_trade(order)?.commit();
```

</details>

## Market Orders

To accept market orders (orders without a `price`), give the policy a market-data
bundle. The bundle prices each market order from a live
[market-data service](Market-Data-Pricing.md) and applies a worst-case slippage
cushion so the held amount is conservative.

The bundle has three parameters:

- **global slippage** in basis points: the buffer applied to the quoted price
  when computing the worst-case commitment. It always moves against the
  account - a buy prices at `quote × (1 + bps)`, a sell at `quote × (1 - bps)` -
  so 1500 bps means a buy holds 15% more than the quote implies. It must not
  exceed 10000 bps (100%); a larger one is a configuration error, at
  construction and at runtime alike, and leaves the prior value unchanged. Note
  that exactly 10000 bps prices every market sell at zero, which is rejected
  with `OrderValueCalculationFailed`.
- **pricing source**: `Mark` (default) prices from the quote's mark; `BookTop`
  prices a buy from the `ask` and a sell from the `bid`. `BookTop` does not fall
  back to mark - a market buy with no `ask` is rejected with
  `MarkPriceUnavailable`.
- **instrument overrides**: per-instrument slippage that replaces the global for
  a specific registered instrument. An override can be further scoped to a
  specific account or [account group](Account-Groups.md); resolution order is
  account - group - instrument - global. Every override is range-checked like
  the global value, and one that carries no slippage is ignored so the cascade
  falls through to the next tier.

A market order whose worst-case commitment exceeds `available` is rejected with
`InsufficientFunds`, exactly like a priced order.

The market-data publisher supplies each quote's source age. If it observed a
mark 20 ms before publishing it, it passes a 20 ms age; with a finite effective
TTL, that time has already consumed part of the quote's freshness budget before
spot funds reads it.

<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
// Obtain the market-data builder from the engine builder so the sync mode
// is derived automatically.
eb := openpit.NewEngineBuilder().FullSync()
// A shared market-data service feeds the policy's market-order pricing.
marketData, err := eb.MarketData(marketdata.InfiniteTTL()).Build()
if err != nil {
    panic(err)
}
defer marketData.Close()

aapl, _ := param.NewAsset("AAPL")
usd, _ := param.NewAsset("USD")
instrument := param.NewInstrument(aapl, usd)

aaplID, err := marketData.Register(instrument)
if err != nil {
    panic(err)
}
mark, _ := param.NewPriceFromString("200")
if err := marketData.Push(
    aaplID,
    marketdata.NewQuote().WithMark(mark),
    0,
); err != nil {
    panic(err)
}

// Spot funds with market orders enabled at 1500 bps worst-case slippage,
// priced from the quote mark.
engine, err := eb.
    Builtin(
        policies.BuildSpotFunds().
            WithMarketOrders(marketData, 1500).
            PricingSource(policies.SpotFundsPricingSourceMark),
    ).
    Build()
if err != nil {
    panic(err)
}
defer engine.Stop()

accountID := param.NewAccountIDFromUint64(99224416)
total, _ := param.NewPositionSizeFromString("10000")
seed, _ := model.NewAccountAdjustmentFromValues(model.AccountAdjustmentValues{
    BalanceOperation: optional.Some(
        model.NewAccountAdjustmentBalanceOperationFromValues(
            model.AccountAdjustmentBalanceOperationValues{Asset: optional.Some(usd)},
        ),
    ),
    Amount: optional.Some(
        model.NewAccountAdjustmentAmountFromValues(model.AccountAdjustmentAmountValues{
            Balance: optional.Some(param.NewAbsoluteAdjustmentAmount(total)),
        }),
    ),
})
if _, err := engine.ApplyAccountAdjustment(
    accountID, []model.AccountAdjustment{seed},
); err != nil {
    panic(err)
}

// Market buy (no price): priced at mark 200 + 15% = 230 per unit worst case.
order := model.NewOrder()
op := order.EnsureOperationView()
op.SetInstrument(instrument)
op.SetAccountID(accountID)
op.SetSide(param.SideBuy)
qty, _ := param.NewQuantityFromString("5")
op.SetTradeAmount(param.NewQuantityTradeAmount(qty))

reservation, execRejects, err := engine.ExecutePreTrade(order)
if err != nil {
    panic(err)
}
if execRejects != nil {
    panic("unexpected post-trade rejects")
}
reservation.CommitAndClose()
```

</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
from datetime import timedelta

import openpit
import openpit.marketdata
import openpit.pretrade.policies

builder = openpit.Engine.builder().no_sync()

# A shared market-data service feeds the policy's market-order pricing.
market_data = builder.market_data(openpit.marketdata.QuoteTtl.infinite()).build()
aapl = openpit.Instrument("AAPL", "USD")
aapl_id = market_data.register(aapl)
market_data.push(
    aapl_id,
    openpit.marketdata.Quote(mark="200"),
    timedelta(),
)

# Spot funds with market orders enabled at 1500 bps worst-case slippage.
engine = (
    builder.builtin(
        openpit.pretrade.policies.build_spot_funds().market_data(
            market_data,
            global_slippage_bps=1500,
            pricing_source=openpit.pretrade.policies.SpotFundsPricingSource.MARK,
        )
    ).build()
)

account_id = openpit.param.AccountId.from_int(99224416)
seed = openpit.AccountAdjustment(
    operation=openpit.AccountAdjustmentBalanceOperation(asset="USD"),
    amount=openpit.AccountAdjustmentAmount(
        balance=openpit.param.AdjustmentAmount.absolute(
            openpit.param.PositionSize(10000)
        )
    ),
)
engine.apply_account_adjustment(account_id=account_id, adjustments=[seed])

# Market buy (no price): priced at mark 200 + 15% = 230 per unit worst case.
order = openpit.Order(
    operation=openpit.OrderOperation(
        instrument=aapl,
        account_id=account_id,
        side=openpit.param.Side.BUY,
        trade_amount=openpit.param.TradeAmount.quantity("5"),
        price=None,
    ),
)
result = engine.execute_pre_trade(order=order)
assert result.ok
result.reservation.commit()
```

</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 { Quote, QuoteTtl } from "@openpit/engine/marketdata";
import { type AccountAdjustmentInit, type OrderInit } from "@openpit/engine/model";
import {
  AdjustmentAmount,
  Instrument,
  TradeAmount,
} from "@openpit/engine/param";
import { buildSpotFunds } from "@openpit/engine/pretrade/policies";

const builder = Engine.builder();

// A shared market-data service feeds the policy's market-order pricing.
const marketData = builder.marketData(QuoteTtl.infinite()).build();
const aapl = new Instrument("AAPL", "USD");
const aaplId = marketData.register(aapl);
marketData.push(aaplId, new Quote({ mark: "200" }), 0);

// Spot funds with market orders enabled at 1500 bps worst-case slippage,
// priced from the quote mark.
const engine = builder
  .builtin(buildSpotFunds().marketData(marketData, 1500, "Mark", undefined))
  .build();

const accountId = 99224416;
const seed: AccountAdjustmentInit = {
  operation: { asset: "USD" },
  amount: { balance: AdjustmentAmount.absolute("10000") },
};
engine.applyAccountAdjustment(accountId, [seed]);

// Market buy (no price): priced at mark 200 + 15% = 230 per unit worst case.
const order: OrderInit = {
  operation: {
    underlyingAsset: "AAPL",
    settlementAsset: "USD",
    accountId,
    side: "BUY",
    tradeAmount: TradeAmount.quantity("5"),
  },
};
const result = engine.executePreTrade(order);
if (!result.ok) {
  throw new Error("unexpected post-trade rejects");
}
const reservation = result.reservation;
if (reservation === undefined) {
  throw new Error("accepted execute result is missing its reservation");
}
reservation.commit();
```

</details>

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

<!-- markdownlint-disable-next-line MD013 -->
<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/spot_funds_test.cpp -->
```cpp
#include <cassert>
#include <chrono>

namespace md = openpit::marketdata;
namespace policies = openpit::pretrade::policies;
namespace aa = openpit::accountadjustment;
using openpit::param::AccountId;
using openpit::param::AdjustmentAmount;
using openpit::param::PositionSize;
using openpit::param::Price;
using openpit::param::Quantity;

// The engine builder fixes the sync mode; the market-data service is built to
// match so the policy can read live quotes for market-order pricing.
openpit::EngineBuilder builder(openpit::SyncPolicy::None);

// A shared market-data service feeds the policy's market-order pricing. It must
// outlive the engine, which prices each market order from its live quotes.
md::Service marketData =
    md::Builder::FromEngineSyncPolicy(md::QuoteTtl::Infinite(),
                                      openpit::SyncPolicy::None)
        .Build();
const openpit::model::Instrument aapl(::openpit::param::Asset("AAPL"),
                                      ::openpit::param::Asset("USD"));
const md::RegisterResult registration = marketData.Register(aapl);
assert(registration.status == md::RegisterStatus::Ok);
assert(registration.instrumentId.has_value());
const md::InstrumentId aaplId = registration.instrumentId.value();
assert(marketData.Push(aaplId, md::Quote().WithMark(Price::FromString("200")),
                       std::chrono::nanoseconds::zero()) ==
       md::RegisterStatus::Ok);

// Spot funds with market orders enabled at 1500 bps worst-case slippage,
// priced from the quote mark.
policies::SpotFundsPolicy{}
    .WithMarketOrders(marketData, 1500)
    .PricingSource(policies::SpotFundsPricingSource::Mark)
    .AddTo(builder);
openpit::Engine engine = builder.Build();

const AccountId accountId = AccountId::FromUint64(99224416);
aa::AccountAdjustment seed;
aa::BalanceOperation balanceOp;
balanceOp.asset = ::openpit::param::Asset("USD");
seed.operation = aa::Operation::OfBalance(std::move(balanceOp));
aa::Amount seedAmount;
seedAmount.balance =
    AdjustmentAmount::Absolute(PositionSize::FromString("10000"));
seed.amount = std::move(seedAmount);

assert(engine
           .ApplyAccountAdjustment(accountId,
                                   std::vector<aa::AccountAdjustment>{seed})
           .Passed());

// Market buy (no price): priced at mark 200 + 15% = 230 per unit worst case.
openpit::model::Order order = openpit::model::Order::Market(
    aapl, accountId, openpit::model::Side::Buy,
    openpit::model::TradeAmount::OfQuantity(Quantity::FromString("5")));

openpit::pretrade::ExecuteResult result = engine.ExecutePreTrade(order);
if (result.Passed()) {
  result.reservation->Commit();
}
```

</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::sync::Arc;

use openpit::param::{
    AccountId, AdjustmentAmount, Asset, PositionSize, Price, Quantity, Side, TradeAmount,
};
use openpit::pretrade::policies::{SpotFundsPolicy, SpotFundsSettings};
use openpit::{
    AccountAdjustmentAmount, AccountAdjustmentBalanceOperation, AccountAdjustmentBounds,
    Engine, FullSync, Instrument, OrderOperation, Quote, QuoteTtl, SpotFundsMarketData,
    SpotFundsPricingSource, WithAccountAdjustmentAmount, WithAccountAdjustmentBalanceOperation,
    WithAccountAdjustmentBounds, WithExecutionReportFillDetails, WithExecutionReportOperation,
};

type SpotReport = WithExecutionReportOperation<WithExecutionReportFillDetails<()>>;
type SpotAdjustment = WithAccountAdjustmentAmount<
    WithAccountAdjustmentBounds<
        WithAccountAdjustmentBalanceOperation<openpit::AccountAdjustmentAmount>,
    >,
>;

let builder = Engine::builder::<OrderOperation, SpotReport, SpotAdjustment>().full_sync();

// A shared market-data service feeds the policy's market-order pricing.
let market_data = builder.market_data(QuoteTtl::Infinite).build();
let aapl = Instrument::new(Asset::new("AAPL")?, Asset::new("USD")?);
let aapl_id = market_data.register(aapl.clone())?;
market_data.push(
    aapl_id,
    Quote::new().with_mark(Price::from_str("200")?),
    std::time::Duration::ZERO,
)?;

// Worst-case slippage of 1500 bps, priced from the quote mark.
let settings = SpotFundsSettings::new(1500, SpotFundsPricingSource::Mark, [])?;
let bundle = SpotFundsMarketData::new(Arc::clone(&market_data));
let policy = SpotFundsPolicy::<FullSync, FullSync>::new(
    settings,
    Some(bundle),
    builder.storage_builder(),
);
let engine = builder.pre_trade(policy).build()?;

let account = AccountId::from_u64(99224416);
let seed = WithAccountAdjustmentAmount {
    inner: WithAccountAdjustmentBounds {
        inner: WithAccountAdjustmentBalanceOperation {
            inner: AccountAdjustmentAmount::default(),
            operation: AccountAdjustmentBalanceOperation {
                asset: Asset::new("USD")?,
                average_entry_price: None,
            },
        },
        bounds: AccountAdjustmentBounds::default(),
    },
    amount: AccountAdjustmentAmount {
        balance: Some(AdjustmentAmount::Absolute(PositionSize::from_str("10000")?)),
        held: None,
        incoming: None,
    },
};
engine.apply_account_adjustment(account, &[seed])?;

// Market buy (no price): priced at mark 200 + 15% = 230 per unit worst case.
let order = OrderOperation {
    instrument: aapl,
    account_id: account,
    side: Side::Buy,
    trade_amount: TradeAmount::Quantity(Quantity::from_str("5")?),
    price: None,
};
engine.execute_pre_trade(order)?.commit();
```

</details>

## Funds Limit Mode

By default the policy operates in **Enforce** mode: a reservation that would
exceed `available` settlement funds is rejected with `InsufficientFunds` before
any state changes. Switching to **TrackOnly** disables that gate. Every order
passes the solvency check - the reservation is recorded as normal, `held`
grows, and `available` may go negative. The account is never blocked for
insufficiency; arithmetic overflow is still an error.

TrackOnly is useful for observation windows (recording what *would* have been
rejected without rejecting it), for desks where a settlement layer handles
shortfalls externally, or as a staged rollout before enforcement is enabled.

Drop copy always uses TrackOnly for Spot Funds, regardless of the configured
global, group, or account mode. The completed order is recorded even when it
makes `available` negative; `InsufficientFunds` does not reject it. A halted
account PnL does not stop it either: where a pre-trade request would be
rejected, drop copy records the order and reports the account as blocked
instead, so the historical event lands and the account is still flagged. This
does not authorize live repricing of a historical market order. If the order
does not carry its own price, Spot Funds rejects it with `MissingRequiredField`
instead of using the current mark. Apply then compensates the collected
mutations and returns those rejects instead of a drop-copy operation.

### Cascade

The mode is resolved at reservation time through three tiers, from lowest to
highest precedence:

<!-- markdownlint-disable MD013 MD060 -->
| Tier | Scope | Overrides |
|------|-------|-----------|
| Global | Every account | - |
| Account group | All accounts in the group | Global |
| Account | The specific account | Global and group |
<!-- markdownlint-enable MD013 MD060 -->

Setting a tier to `None` clears the override so the cascade falls through to
the next tier. All three tiers are configurable at runtime through the
`Configurator` - see
[Dynamic Policy Reconfiguration - Spot Funds: Limit Mode](Dynamic-Policy-Reconfiguration.md#spot-funds-global-limit-mode)
for code examples.

## Position Limit

A position limit caps an account's worst-case position in one asset on the side
each order moves it toward. It is set per `(account, asset)` pair as a
non-negative quantity `L`. The projection starts from the slot's recorded
`available + held`, a negative `held` included, and adds the order's own net
effect on the asset. Working orders count through what they reserved:

- An order that raises the position is checked on the long side, with the
  slot's open positive `incoming` added, and is rejected when that projection
  goes above `L`.
- An order that lowers the position is checked on the short side, with the
  slot's open positive `held` subtracted, and is rejected when that projection
  goes below `-L`.

The bound is inclusive and exact-decimal, and only the side the order moves
toward is checked - the limit is not a symmetric bound on the position. A zero
limit rejects every order whose projection would move past zero in the
direction it moves. A position already beyond `L`, for example after the limit
was lowered, can still be reduced while that projection stays within the
limit. A working market order counts with the amounts reserved when it was
accepted; a later quote does not revalue it.

The limit applies to every leg of an order whose asset carries one - the
underlying and the settlement asset alike. For an instrument whose underlying
equals its settlement asset, the order's net effect on that asset is judged
once. A position the policy has never seen is zero. The projection reads the
holdings slot as recorded, so an account adjustment that rewrites `held` or
`incoming` is authoritative for the next check.

A breach rejects the order with `PositionLimitExceeded` at `order` scope; the
account is not blocked. A projection that cannot be computed exactly within the
decimal range rejects with `ArithmeticOverflow` at `order` scope, and its
details name the stage and the cause. The funds check on an asset runs before
its position check, so an asset that fails both reports `InsufficientFunds`. A
dry run returns the same verdict. Drop copy, account adjustments, and execution
reports are never gated by the limit.

No binding's builder takes a limit; Rust can also call
`SpotFundsSettings::set_position_limit` before `SpotFundsPolicy::new`. On a
running engine, pin, replace, or clear a limit per `(account, asset)` pair
through the configurator; a change applies from the next order and does not
re-evaluate open reservations - see
[Dynamic Policy Reconfiguration - Spot Funds: Position Limit](Dynamic-Policy-Reconfiguration.md#spot-funds-position-limit).

## Self-Computed PnL Kill Switch

Spot Funds can also gate an account on its own **realized PnL**, computed by the
engine from the fills it already reconciles. Execution-report PnL is not an
authority: ordinary contributions are computed from reconciled fills and fees.
Explicit account-PnL corrections remain authoritative state replacements. When
an effective barrier is configured and the account-wide PnL moves outside its
bound, or its state becomes halted, the engine blocks that account exactly like
the standalone
[PnlBoundsKillSwitchPolicy](Policies.md#pnlboundskillswitchpolicy), but with the
funds ledger, FX handling, and fee accounting that Spot Funds already owns. See
[Two Ways to Watch PnL](#two-ways-to-watch-pnl) for how the two controls differ.

The barrier axis is the **account**: each account has exactly one PnL state, and
the effective barrier watches only that state. There is no position, asset, or
settlement dimension. A barrier does carry a **currency**, but that currency is
not a second axis - for the account tier it validates the barrier instead of
selecting it, and for the group and global fallbacks it filters which barrier
is reached; either way it never changes what the barrier watches.

### Configuring Barriers

A barrier carries a mandatory `currency` and sets an optional `lower bound` (a
loss limit, typically negative), an optional `upper bound` (a profit-taking
limit, typically positive), or both.
At least one bound must be set; a barrier with neither is a configuration error.
Bounds are inclusive: equality with a configured
lower or upper bound is accepted; only values below the lower bound or above
the upper bound breach. Barriers resolve per order and per candidate account
during a configuration call through a three-tier cascade, most specific wins:

<!-- markdownlint-disable MD013 MD060 -->
| Tier | Scope | Overrides |
|------|-------|-----------|
| Global | Every account | - |
| Account group | Every account in the group | Global |
| Account | The specific account | Global and group |
<!-- markdownlint-enable MD013 MD060 -->

When an account has a known [effective currency](#what-it-controls), that
currency handles the account tier differently from fallback tiers. An account
barrier always belongs to that account: if its currency differs, the mismatch
immediately triggers `PnlKillSwitchTriggered` and the cascade does not continue.
Account-group and global barriers with a different currency are **skipped**. If
there is no account barrier and neither fallback carries the account's
currency, no barrier is effective, so the account has no PnL control: bounds
are not evaluated, and neither a breach nor a halt blocks it. Its PnL is still
accumulated and published.

When an account has **no** effective currency, there is nothing to compare
against, so no level is skipped: the first barrier reached by the account ->
group -> global cascade is effective. The existing missing-currency behaviour
is unchanged - the PnL line halts with `MissingAccountCurrency` where it owes a
denominated value, and that halt blocks the account.

Bounds are plain numbers in the barrier's own currency and are **never
converted**. For an account with a known effective currency, the accumulator
and the selected barrier have the same denomination. Without an effective
currency, a stored numeric accumulator is compared as written, while an
operation that owes a denominated value halts the PnL line. A missing FX quote
can halt the accumulator, but it can never move a bound.

P&L control is optional: an ordinary Spot Funds policy starts with no barriers,
so it continues calculating and publishing PnL without evaluating bounds or
blocking on a halt. The dedicated P&L builder requires at least one barrier at
construction. A registered ordinary `SpotFundsPolicy` can instead receive its
first barrier at runtime. Barriers contain configuration only - a mandatory
currency and at least one lower or upper bound. Account PnL is seeded or
corrected through the separate account-PnL operation.

The example below registers Spot Funds with a global loss barrier of -1000 USD
and a tighter per-account barrier, also in USD. For accounts with a known
effective currency, the global barrier controls only USD accounts where no
account barrier takes precedence; the account barrier controls its named
account and blocks it if its currency does not match. Enabling the PnL kill
switch is a distinct builder entry point that produces the same
`SpotFundsPolicy` (registered under
the same name), so barriers and the funds ledger live in one policy.

That entry point is a **preset**, not a bare constructor. Besides the barriers
it pins mark pricing with zero slippage and no overrides, and it sets the
global [funds limit mode](#funds-limit-mode) to **TrackOnly**. Track-only mode
disables insufficient-funds rejects while the policy continues to reconcile
holdings and account PnL: a policy built this way watches PnL but does not gate
solvency, so no order is ever stopped for want of funds. To keep the funds gate
and watch PnL at the same time, build an ordinary `SpotFundsPolicy` and give
its settings the barriers instead - at construction or at runtime.

<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
account := param.NewAccountIDFromUint64(99224416)
currency, _ := param.NewAsset("USD")
lower, _ := param.NewPnlFromString("-1000")
accountLower, _ := param.NewPnlFromString("-250")

// The PnL kill switch is a distinct spot-funds builder entry point; it
// produces the same SpotFundsPolicy, registered under the same name.
engine, err := openpit.NewEngineBuilder().
    NoSync().
    Builtin(
        policies.BuildSpotFundsPnlBoundsKillSwitch().
            GlobalBarrier(policies.SpotFundsPnlBoundsBarrier{
                Currency:   currency,
                LowerBound: optional.Some(lower),
            }).
            AccountBarriers(policies.SpotFundsPnlBoundsAccountBarrier{
                AccountID: account,
                Barrier: policies.SpotFundsPnlBoundsBarrier{
                    Currency:   currency,
                    LowerBound: optional.Some(accountLower),
                },
            }),
    ).
    Build()
if err != nil {
    panic(err)
}
defer engine.Stop()
```

</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

account_id = openpit.param.AccountId.from_int(99224416)

# The PnL kill switch is a distinct spot-funds builder entry point; it
# produces the same SpotFundsPolicy, registered under the same name.
engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(
        openpit.pretrade.policies.build_spot_funds_pnl_bounds_killswitch()
        .global_barrier(
            openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
                currency=openpit.param.Asset("USD"),
                lower_bound=openpit.param.Pnl(-1000),
            ),
        )
        .account_barriers(
            openpit.pretrade.policies.SpotFundsPnlBoundsAccountBarrier(
                account_id=account_id,
                barrier=openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
                    currency=openpit.param.Asset("USD"),
                    lower_bound=openpit.param.Pnl(-250),
                ),
            ),
        )
    )
    .build()
)
```

</details>

<details><summary>JavaScript</summary>

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

const accountId = 99_224_416n;

// The PnL kill switch is a distinct spot-funds builder entry point; it
// produces the same SpotFundsPolicy, registered under the same name.
const engine = Engine.builder()
  .builtin(
    buildSpotFundsPnlBoundsKillswitch()
      .globalBarrier(new SpotFundsPnlBoundsBarrier("USD", "-1000", undefined))
      .accountBarriers([
        new SpotFundsPnlBoundsAccountBarrier(
          accountId,
          new SpotFundsPnlBoundsBarrier("USD", "-250", undefined),
        ),
      ]),
  )
  .build();
```

</details>

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

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

const AccountId accountId = AccountId::FromUint64(99224416);

// The PnL kill switch is a distinct spot-funds builder entry point; it
// produces the same SpotFundsPolicy, registered under the same name.
policies::SpotFundsPnlBoundsBarrier global(openpit::param::Asset("USD"));
global.lowerBound = Pnl::FromString("-1000");

policies::SpotFundsPnlBoundsBarrier accountBarrier(
    openpit::param::Asset("USD"));
accountBarrier.lowerBound = Pnl::FromString("-250");

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::SpotFundsPnlBoundsKillSwitchPolicy{}
                .GlobalBarrier(std::move(global))
                .AccountBarrier(policies::SpotFundsPnlBoundsAccountBarrier(
                    accountId, std::move(accountBarrier))));
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::{AccountId, Asset, Pnl};
use openpit::pretrade::policies::{
    SpotFundsPnlBoundsAccountBarrier, SpotFundsPnlBoundsBarrier, SpotFundsPolicy,
};
use openpit::{
    Engine, FullSync, OrderOperation, SpotFundsMarketData, WithAccountAdjustmentAmount,
    WithAccountAdjustmentBalanceOperation, WithAccountAdjustmentBounds,
    WithExecutionReportFillDetails, WithExecutionReportOperation,
};

type SpotReport = WithExecutionReportOperation<WithExecutionReportFillDetails<()>>;
type SpotAdjustment = WithAccountAdjustmentAmount<
    WithAccountAdjustmentBounds<
        WithAccountAdjustmentBalanceOperation<openpit::AccountAdjustmentAmount>,
    >,
>;

let account = AccountId::from_u64(99224416);
let usd = Asset::new("USD")?;

// A global loss barrier of -1000, plus a tighter per-account barrier.
let global_barrier = SpotFundsPnlBoundsBarrier {
    currency: usd.clone(),
    lower_bound: Some(Pnl::from_str("-1000")?),
    upper_bound: None,
};
let account_barrier = SpotFundsPnlBoundsAccountBarrier {
    barrier: SpotFundsPnlBoundsBarrier {
        currency: usd.clone(),
        lower_bound: Some(Pnl::from_str("-250")?),
        upper_bound: None,
    },
    account_id: account,
};

// The PnL kill switch is a distinct spot-funds builder entry point; it
// produces the same SpotFundsPolicy, registered under the same name.
let builder = Engine::builder::<OrderOperation, SpotReport, SpotAdjustment>().full_sync();
let policy = SpotFundsPolicy::<FullSync, FullSync>::pnl_bounds_kill_switch(
    Some(global_barrier),
    [],
    [account_barrier],
    None::<SpotFundsMarketData<FullSync>>,
    builder.storage_builder(),
)?;
let engine = builder.pre_trade(policy).build()?;
```

</details>

### What Is Tracked, What Is Not

Spot Funds engages the account-PnL accumulator only when a fill reduces, closes,
or reverses a position, or when a report carries a non-zero fee. An opening or
same-direction fill with no fee or an explicitly zero fee realizes nothing: it
publishes no account-PnL outcome and leaves an unset account ledger unset. A
zero-quantity fill without a non-zero fee likewise leaves the account line
untouched. If the policy cannot classify a non-zero fill because owned-position
arithmetic overflows, both PnL lines halt with `ArithmeticOverflow`; the account
line is not treated as unengaged. Whether a barrier resolves does not change
what is tracked. It adds only the bounds check and account-blocking behavior;
without one, PnL is still accumulated and emitted, but a breach or halt does
not block the account.

Position accounting and account PnL have separate engagement requirements:

- Position average entry price and position realized PnL belong to the
  `(account, underlying asset)` holdings slot. The same slot is used when that
  asset is traded against multiple quote currencies. An opening or
  same-direction fill needs the account currency to store a cost basis at all,
  and uses FX to denominate it when the quote differs. It publishes no realized
  PnL of its own and leaves a previously unset position ledger unset.
- The account contribution is calculated independently and accumulated across
  positions. Both calculations convert through the market-data `mark` when the
  instrument quote differs from the account currency, under the FX rules stated
  in [What It Controls](#what-it-controls).
- An optional **fee** on the execution report, given as a `{amount, currency}`
  pair, engages both lines when it is non-zero, whatever the fill does to the
  position: each line converts it independently, so a non-zero fee requires the
  account currency and whatever FX its own currency needs. The position line
  starts numerical tracking when its ledger is unset, including when the
  account has never held the report's underlying asset. A position ledger that
  is already halted remains halted and does not accept a partial numeric fee
  contribution. An exact zero fee is a complete no-op that contributes to
  neither line and requires nothing of either. [Fees](#fees) covers the fee's
  second, unconditional effect on the balance.

Position and account PnL are calculated independently, but an account PnL must
not silently omit a required contribution. A missing contribution - including
`MissingFx` - moves the account PnL to `Halted`. The report still publishes the
account outcome for that transition. If an effective barrier exists, post-trade
processing then blocks the account; otherwise the halt remains stored but
non-blocking. Later reports omit that unchanged account outcome, while
pre-trade and post-trade checks still act on the stored halt whenever a barrier
resolves.

A halted position publishes the halt when it changes and omits an unchanged PnL
field on later outcomes. Position PnL never enters the kill-switch check.
Account PnL re-arms through its account-adjustment operation or the
configurator; position PnL re-arms only through a balance adjustment for that
asset.

### Fees

A fee on an execution report is a structured `{amount, currency}` value and acts
on two ledgers at once. The amount is signed the way a cost is: a positive
amount debits the balance and reduces PnL, and a negative one is a rebate that
credits and increases them.

- **Fee-asset balance debit - unconditional.** The fee amount is debited from
  the account's holdings in the fee currency, whether or not a PnL barrier
  resolves. This debit surfaces as a balance leg in the returned
  outcome alongside the fill's underlying and settlement legs: as its own leg
  when the fee currency is distinct, or folded into the matching underlying or
  settlement leg when it shares that asset.
- **Realized-PnL contributions - independent calculations.** The fee reduces
  both ledgers. It is converted independently into account currency for
  position PnL and account PnL. One unavailable conversion halts only the
  ledger that requires it; no partial numeric result is retained for a halted
  ledger. If account PnL halts or breaches a bound, an effective barrier blocks
  the account after the report is processed.

If the fee already uses a ledger's currency, that ledger needs no FX. The debit
against the fee-asset balance never needs FX. A zero fee is a complete PnL
no-op and cannot halt either ledger.

The fee belongs to the report, not to its fill. A report that carries a fee and
no fill - a commission correction booked on its own - goes through the same
balance debit and the same two PnL conversions, and can halt or block on the
same inputs. It moves no position and needs no lock price.

### Runtime Reconfiguration

Barriers are runtime-tunable through the
[Configurator](Dynamic-Policy-Reconfiguration.md). An omitted axis is unchanged.
The singular global PATCH value uses each binding's native tri-state `Unchanged
| Clear | Set(barrier)`; account-group and account iterables replace their axes
wholesale, and an engaged empty iterable clears its axis. Clearing every axis
disables PnL bound checks and prevents new PnL-triggered blocks, but does not
stop accumulation or release existing engine blocks. Supplying a barrier to an
ordinary Spot Funds policy enables bound checks against its live accumulator.
Replacing barriers retunes bounds only - it **never resets the accumulator**.
The same configurator call evaluates candidates bounded by the barrier axes that
changed. A global change sweeps accounts visible in the account-PnL ledger or
holdings, explicit account-barrier targets, registered account-group membership,
and accounts with an explicit account currency. An active in-flight holdings
mutation also keeps its account in the holdings candidate set after the
provisional row is physically pruned while rollback can still restore it.
Finalized pruned history is excluded; this is not a historical seen registry. An
account/group-only change sweeps changed account targets and members of changed
groups. Each candidate is resolved through the cascade with its own effective
currency. A candidate with a known currency has no effective barrier only when
it has no account barrier and neither fallback matches that currency; it is not
blocked. A mismatching account barrier blocks its account instead of falling
through to a fallback. A candidate without an effective currency still selects
the first in-scope barrier. An unset ledger is evaluated as implicit zero
without storing or publishing a PnL outcome. An entirely unseen future account
is checked against the published barrier on its first policy access. If a
candidate's state is halted or beyond its new barrier, the Engine records an
individual account block before returning. The retune result contains
per-account outcomes - an account plus its block - only for blocks newly
recorded by that call. It is not the current set of blocked accounts: an account
already blocked for an earlier cause is absent because the Engine preserves its
first cause. Removing the last effective barrier reports no block and does not
release an existing one. Clearing an override can expose a fallback barrier;
that effective-barrier change is evaluated normally and can record a block. A
retune that only changes a barrier's currency is an ordinary effective-barrier
change: a mismatching account barrier blocks its account with
`PnlKillSwitchTriggered`, while account barriers that match are evaluated.
Known-currency accounts that a group or global barrier no longer matches lose
that fallback control without being unblocked. Accounts without an effective
currency remain under the first in-scope barrier and are evaluated when that
barrier changes. Successful account-group registration and removal follow the
same immediate rule when they change the account's effective barrier or its
account barrier's currency-match status, whether or not the membership also
moves its effective currency. The membership call evaluates the current state,
including implicit zero and `Halted`, and records a block before returning. An
unchanged effective barrier and unchanged account-barrier currency-match status
do not cause another evaluation or block attempt. Setting or clearing a currency
is not part of this contract: it is written blindly and re-checks nothing.

To move the accumulator itself - for example to reconcile against an external
ledger after a restart - use the dedicated force-set call, which replaces the
live state for one account with either a numeric PnL or an explicit halt reason.
It has no asset argument. An accepted force-set call returns a configuration
result whose `account_blocks` list contains the block reported by the policy
whenever the assigned state violates an effective barrier. Those entries are
bare blocks rather than the per-account outcomes a retune returns: the call
already names its account, so the entries do not repeat it. A numeric
assignment beyond an effective bound is applied first and reports
`PnlKillSwitchTriggered`; a `Halted` assignment under an effective barrier
reports the same block. The Engine attempts to latch that block before the call
returns. If the account already had an individual block, its first stored cause
remains unchanged, but the result still contains the policy-reported block.
The Engine processes that block request before the successful correction call
returns, so the next pre-trade check sees the newly latched or pre-existing
engine-level account block. Without an effective barrier, the halt remains
stored and the list is empty. A force-set can move the account into a blocked
state but never out of one; only an explicit [admin unblock](Account-Blocking.md)
clears a latched block.

The examples below start with a global lower bound of `-1000` in USD, and the
retune keeps that currency. Neither account has an effective currency of its
own, so the barrier applies to both by scope. They first store
`-600` for one account, which is inside that bound. Tightening the bound to
`-500` immediately returns one outcome naming that stored account and carrying
its block. Force-setting a second account to `-600` then returns a bare block
under the current bound.

<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
retunedAccount := param.NewAccountIDFromUint64(99224416)
forcedAccount := param.NewAccountIDFromUint64(99224417)
currency, _ := param.NewAsset("USD")
newLower, _ := param.NewPnlFromString("-500")
outside, _ := param.NewPnlFromString("-600")
globalBarrier := policies.SpotFundsPnlBoundsBarrier{
    Currency:   currency,
    LowerBound: optional.Some(newLower),
}

// Seed live PnL inside the current -1000 barrier.
seed, err := engine.Configure().SetSpotFundsAccountPnl(
    policies.SpotFundsPolicyName,
    retunedAccount,
    model.NewPnlState(outside),
)
if err != nil {
    panic(err)
}
if len(seed.AccountBlocks) != 0 {
    panic("expected the initial force-set to stay within the old barrier")
}

// Tightening the barrier checks the known account and records the block now.
retune, err := engine.Configure().SpotFundsPnlBoundsKillSwitch(
    policies.SpotFundsPolicyName,
    optional.Some(&globalBarrier),
    nil,
    nil,
)
if err != nil {
    panic(err)
}
if len(retune.AccountBlocks) != 1 {
    panic("expected the tightened barrier to block the stored account PnL")
}
if retune.AccountBlocks[0].AccountID != retunedAccount {
    panic("expected the retune outcome to identify the stored account")
}

// A force-set beyond the current barrier also returns its recorded block.
forced, err := engine.Configure().SetSpotFundsAccountPnl(
    policies.SpotFundsPolicyName,
    forcedAccount,
    model.NewPnlState(outside),
)
if err != nil {
    panic(err)
}
if len(forced.AccountBlocks) != 1 {
    panic("expected the force-set to trip the PnL barrier")
}
```

</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

retuned_account = openpit.param.AccountId.from_int(99224416)
forced_account = openpit.param.AccountId.from_int(99224417)

# Seed live PnL inside the current -1000 barrier.
seed = engine.configure().set_spot_funds_account_pnl(
    openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
    account=retuned_account,
    state=openpit.param.Pnl(-600),
)
assert not seed.account_blocks

# Tightening the barrier checks the known account and records the block now.
retune = engine.configure().spot_funds_pnl_bounds_killswitch(
    openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
    global_barrier=openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
        currency=openpit.param.Asset("USD"),
        lower_bound=openpit.param.Pnl(-500),
    ),
)
assert len(retune.account_blocks) == 1
assert retune.account_blocks[0].account_id == retuned_account

# A force-set beyond the current barrier also returns its recorded block.
forced = engine.configure().set_spot_funds_account_pnl(
    openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
    account=forced_account,
    state=openpit.param.Pnl(-600),
)
assert len(forced.account_blocks) == 1
```

</details>

<details><summary>JavaScript</summary>

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

const retunedAccount = 99_224_416n;
const forcedAccount = 99_224_417n;
const engine = Engine.builder()
  .builtin(
    buildSpotFundsPnlBoundsKillswitch().globalBarrier(
      new SpotFundsPnlBoundsBarrier("USD", "-1000", undefined),
    ),
  )
  .build();

// Seed live PnL inside the current -1000 barrier.
const seed = engine.configure().setSpotFundsAccountPnl(
  SpotFundsPnlBoundsKillswitchBuilder.NAME,
  {
    account: retunedAccount,
    state: "-600",
  },
);
if (seed.accountBlocks.length !== 0) {
  throw new Error(
    "expected the initial force-set to stay within the old barrier",
  );
}

// Tightening the barrier checks the known account and records the block now.
const retune = engine.configure().spotFundsPnlBoundsKillswitch(
  SpotFundsPnlBoundsKillswitchBuilder.NAME,
  {
    globalBarrier: new SpotFundsPnlBoundsBarrier("USD", "-500", undefined),
  },
);
if (retune.accountBlocks.length !== 1) {
  throw new Error(
    "expected the tightened barrier to block stored account PnL",
  );
}
if (retune.accountBlocks[0]!.accountId.value !== retunedAccount) {
  throw new Error("expected the retune outcome to identify the stored account");
}

// A force-set beyond the current barrier also returns its recorded block.
const forced = engine.configure().setSpotFundsAccountPnl(
  SpotFundsPnlBoundsKillswitchBuilder.NAME,
  {
    account: forcedAccount,
    state: "-600",
  },
);
if (forced.accountBlocks.length !== 1) {
  throw new Error("expected the force-set to trip the PnL barrier");
}
```

</details>

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

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

const AccountId retunedAccount = AccountId::FromUint64(99224416);
const AccountId forcedAccount = AccountId::FromUint64(99224417);

// Seed live PnL inside the current -1000 barrier.
const auto seed = engine.Configure().SetSpotFundsAccountPnl(
    policies::SpotFundsPolicyName, retunedAccount, Pnl::FromString("-600"));
assert(seed.accountBlocks.empty());

// Tightening the barrier checks the known account and records the block now.
policies::SpotFundsPnlBoundsBarrier global(openpit::param::Asset("USD"));
global.lowerBound = Pnl::FromString("-500");
const auto retune = engine.Configure().SpotFundsPnlBoundsKillSwitch(
    policies::SpotFundsPolicyName,
    policies::SpotFundsPnlBoundsGlobalBarrierUpdate::Set(std::move(global)));
assert(retune.accountBlocks.size() == 1);
assert(retune.accountBlocks[0].accountId == retunedAccount);

// A force-set beyond the current barrier also returns its recorded block.
const auto forced = engine.Configure().SetSpotFundsAccountPnl(
    policies::SpotFundsPolicyName, forcedAccount, Pnl::FromString("-600"));
assert(forced.accountBlocks.size() == 1);
```

</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::{AccountId, Asset, Pnl};
use openpit::pretrade::policies::{
    SpotFundsConfigError, SpotFundsPnlBoundsBarrier, SpotFundsPolicy,
};
use openpit::FullSync;

let retuned_account = AccountId::from_u64(99224416);
let forced_account = AccountId::from_u64(99224417);
let usd = Asset::new("USD")?;
let name = SpotFundsPolicy::<FullSync, FullSync>::NAME;
let new_lower = Pnl::from_str("-500")?;
let outside = Pnl::from_str("-600")?;

// Seed live PnL inside the current -1000 barrier.
let seed = engine.configure().set_spot_funds_account_pnl(
    name,
    retuned_account,
    openpit::PnlState::Value(outside),
)?;
assert!(seed.account_blocks.is_empty());

// Tightening the barrier checks the known account and records the block now.
let retune = engine
    .configure()
    .spot_funds::<SpotFundsConfigError>(name, |settings| {
        settings.set_pnl_global_barrier(Some(SpotFundsPnlBoundsBarrier {
            currency: usd.clone(),
            lower_bound: Some(new_lower),
            upper_bound: None,
        }))
    })?;
assert_eq!(retune.account_blocks.len(), 1);
assert_eq!(retune.account_blocks[0].account_id, retuned_account);

// A force-set beyond the current barrier also returns its recorded block.
let forced = engine
    .configure()
    .set_spot_funds_account_pnl(
        name,
        forced_account,
        openpit::PnlState::Value(outside),
    )?;
assert_eq!(forced.account_blocks.len(), 1);
```

</details>

### When It Blocks the Account

A barrier resolves through the account -> group -> global cascade, as
[Configuring Barriers](#configuring-barriers) describes. With a known effective
currency, an account-specific mismatch blocks the account, while group/global
mismatches are skipped. Without one, the first in-scope barrier resolves.
When a PnL barrier resolves, pre-trade checks reject an account whose PnL is
already `Halted` or numerically outside the effective bounds. Post-trade also
checks the resulting account-PnL state after applying a contribution. It
applies the report and publishes its
outcomes before checking the resulting numeric or halted account state and
blocking the whole account across every asset and instrument. Later pre-trade
requests are rejected by that latched engine block, exactly as described in
[Account Blocking by Engine](Policies.md#account-blocking-by-engine):

<!-- markdownlint-disable MD013 MD060 -->
| Trigger | Behavior |
|---------|----------|
| An account-specific barrier carries a currency different from the account's effective currency. | Block the account with `PnlKillSwitchTriggered` on the first policy check or barrier retune; do not fall through to group or global bounds. |
| Numeric account PnL is below the lower bound or above the upper bound after a post-trade contribution. | Publish the outcome, then block the account with `PnlKillSwitchTriggered`; the engine block rejects subsequent pre-trade requests. |
| A runtime barrier update selects an account from the changed axes, changes its effective barrier, and finds its current state - including implicit zero for an unset ledger - outside the new bounds or `Halted`. | Record and return `PnlKillSwitchTriggered` in the same configurator call; accounts not yet visible to the policy are checked on their next policy access. |
| An administrative account-PnL force-set writes a value outside the effective bounds. | Apply the correction, return and latch `PnlKillSwitchTriggered` before the call returns. |
| Account PnL is `Halted`, including `MissingFx`, while an effective account-PnL barrier resolves. | Reject directly on pre-trade with `PnlKillSwitchTriggered`; on post-trade, apply the report and publish any outcomes it produces before blocking with the same code. Without a barrier, retain and publish the halt without rejecting or blocking. |
<!-- markdownlint-enable MD013 MD060 -->

With no effective barrier, numeric updates and newly halted account-PnL states
are stored and published without rejecting or blocking. That includes an
account with no account-specific barrier whose effective currency matches no
group or global fallback: it is tracked, never gated. An unchanged halt
remains stored but is not emitted again. Position PnL halts never participate in
the kill-switch decision. Always consume account-adjustment and account-PnL
outcomes even when the same result contains an account block.

## Two Ways to Watch PnL

OpenPit ships two PnL kill switches. They look alike but sit at different trust
boundaries; choose by who computes realized PnL.

<!-- markdownlint-disable MD013 MD060 -->
| | Spot Funds PnL kill switch | [PnlBoundsKillSwitchPolicy](Policies.md#pnlboundskillswitchpolicy) |
|---|---|---|
| Part of | `SpotFundsPolicy` (shares the funds ledger) | Standalone policy |
| Realized PnL source | Engine-computed from reconciled fills | Externally supplied on the report |
| Barrier axis | Account | Settlement asset |
| Barrier scopes | Global, account group, account | Broker (per settlement asset), account+asset |
| Barrier currency | Mandatory; an account-specific mismatch blocks, group/global mismatches are skipped, and without an effective currency the first in-scope barrier resolves | The settlement asset is the axis itself, not a filter on it |
| Fee | Structured `{amount, currency}`, debited from the fee asset and netted into PnL via FX | Scalar on the report, added to the accumulated total |
| FX | Position and account contributions convert independently into account currency; only an account-PnL halt can drive this kill switch | Not involved |
<!-- markdownlint-enable MD013 MD060 -->

Reach for the Spot Funds kill switch when the engine already reconciles your
fills and you want realized PnL derived from the same source of truth as your
funds. Reach for `PnlBoundsKillSwitchPolicy` when an upstream system is the
authority on realized PnL and hands you a single settlement-asset figure to
watch.

## Rejects

Scope tells you where the code lands, not only what it names. An `order`-scoped
code refuses one pre-trade request and changes nothing. An `account`-scoped one
raised while reconciling an execution report arrives as an account block rather
than a reject, because the venue's report is already fact: the policy applies
what it can, publishes the outcomes, and blocks the account.

<!-- markdownlint-disable MD013 MD060 -->
| Code | Scope | When |
|------|-------|------|
| `InsufficientFunds` | `order` | The reservation exceeds spendable funds for the asset. Emitted only under [Enforce](#funds-limit-mode); TrackOnly never emits it. |
| `PositionLimitExceeded` | `order` | The order's worst-case projected position in an asset, on the side the order moves toward, goes beyond its configured [position limit](#position-limit). The account is not blocked. |
| `UnsupportedOrderType` | `order` | Market order received while in limit-only mode. |
| `MarkPriceUnavailable` | `order` | An order - buy or sell - carries no price and no usable market-data price resolves for it: the instrument is not registered, no quote is available, the quote is stale, or the field the pricing source requires (`mark`, or `ask` / `bid` under `BookTop`) is absent from the quote. |
| `OrderValueCalculationFailed` | `order` | The order value could not be produced: the slippage and pricing cascade failed to derive an effective price, the notional or derived quantity overflowed for the price and trade amount, or an ordinary Buy or Sell [volume order](#order-sizing) carried an explicit zero price. Drop copy is the sole zero-price exception and records zero-sized legs instead of rejecting. |
| `MissingRequiredField` | `order`, `account` | `order`: a required order field - instrument, account ID, side, trade amount, or price - is absent, or a [drop-copy](#funds-limit-mode) order carries no price of its own. `account`: the account-adjustment balance asset is absent, or an execution report (fill or cancel) is missing a required field or the [pre-trade lock](Pre-Trade-Lock.md) price needed to reconcile its settlement legs. The lock price applies to both buys and sells. |
| `InvalidFieldFormat` | `account` | Any account-adjustment field other than the balance asset could not be read as the type the policy requires. |
| `AccountAdjustmentBoundsExceeded` | `account` | An account adjustment would move `balance`, `held`, or `incoming` outside the bounds carried by the same request. |
| `ArithmeticOverflow` | `order`, `account` | Decimal arithmetic left the representable range or could not be computed exactly. `order`: while holding or projecting a reservation leg, or when a [position-limit](#position-limit) projection cannot be computed exactly. `account`: while applying an account adjustment, reconciling an execution report, or rolling a reservation back. No funds limit mode suppresses it. |
| `PnlKillSwitchTriggered` | `account` | An account barrier has a currency different from the account's effective currency (reason `pnl barrier currency mismatch`), or an effective barrier resolves and its [PnL](#self-computed-pnl-kill-switch) is outside a configured bound or its PnL state is halted. |
| `Other` | `account` | The pre-trade lock carries more than one price for the policy group - two `SpotFundsPolicy` instances share a policy group ID. |
<!-- markdownlint-enable MD013 MD060 -->

## Examples

A minimal, copy-paste-friendly integration of this policy in [Go](https://github.com/openpitkit/pit/tree/main/examples/go/spot_funds)
and [Python](https://github.com/openpitkit/pit/tree/main/examples/python/spot_funds)
covers the limit-only form end to end. For table-driven scenario testing, the
`spot_table` tool is available in [Go](https://github.com/openpitkit/pit/tree/main/examples/go/spot_table)
and [Python](https://github.com/openpitkit/pit/tree/main/examples/python/spot_table),
with bundled [scenario tables](https://github.com/openpitkit/pit/tree/main/examples/tables/spot).

## Related Pages

- [Pre-Trade Lock](Pre-Trade-Lock.md) - persist and replay the lock price that
  reconciles every fill and cancel; required reading for restart recovery.
- [Account Adjustments](Account-Adjustments.md) - how to seed and adjust balances.
- [Balance Reconciliation](Balance-Reconciliation.md) - keep your own books in step
  with engine outcomes.
- [Market Data Pricing](Market-Data-Pricing.md) - feed live quotes for market-order
  pricing.
- [Dynamic Policy Reconfiguration](Dynamic-Policy-Reconfiguration.md) - retune
  barriers and position limits, and force-set accumulated PnL at runtime.
- [Account Blocking](Account-Blocking.md) - lift a latched kill-switch block.
- [Policies](Policies.md) - the full built-in policy catalog, including the
  standalone [PnlBoundsKillSwitchPolicy](Policies.md#pnlboundskillswitchpolicy).
