Skip to content

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 (notional to spend in the settlement currency). See Order Sizing.
  • Limit and market orders — priced orders out of the box; market orders priced live from a market-data 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.
  • Full holdings lifecycle — reserve on accept, consume on fill, release on cancel or partial, all handled automatically.
  • 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 is calculation metadata, not part of the accumulator's 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. See Self-Computed PnL Kill Switch.
  • Negative and zero prices — handled correctly, including the rare cases where a sell reserves settlement instead of acquiring it.
  • 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 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 — identical behaviour and examples across Go, Python, JavaScript, C++, and Rust.

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 is purely informational - it never enters the solvency calculation and never gates any order. 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.

The account currency is optional account metadata managed through engine.accounts(). Resolution cascades from the explicit account currency to group_of(account), then to DEFAULT_ACCOUNT_GROUP, then to no currency. Setting, changing, or clearing an account currency does not validate existing holdings and does not recompute already stored average entry price or PnL. The currency is used only to calculate comparable PnL contributions; it is not a key for account PnL or a barrier axis. If you change that metadata after positions exist, you own the migration risk until a future control or recompute tool is available.

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 only where it has a contribution to denominate: a fill carrying a non-zero fee, or one that reduces, closes, or reverses the position. An opening or same-direction fill with no fee or an explicitly zero fee contributes and publishes a computable zero, so it leaves the account PnL running.
  • 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 it does not need FX. A reducing or closing fill 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, it uses 1 / mark.
  • 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.
  • Missing initial position PnL or cost basis halts the affected position. 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.

Account adjustments expose two PnL paths. An account-PnL operation carries only a numeric PnL or a halt reason; it has no asset. 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.

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 notional amount to spend in the settlement currency, e.g. "buy 2000 USD worth of AAPL". The policy derives the rest from the price.

How each is reserved:

Side By quantity By volume
Buy Holds price × quantity of the settlement asset. Holds the volume directly — exactly the notional you named — as settlement.
Sell Reserves quantity of the underlying asset. Derives quantity = volume / price, then reserves that quantity of the underlying.

Every sell — quantity or volume — requires a resolvable price. A volume sell derives its quantity from the order's limit price or a live quote; a quantity sell uses the order's limit price or a live quote for the settlement lock and incoming projection. A sell with no order price and no market-data price is rejected with MarkPriceUnavailable before any reservation is attempted.

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:
  2. settlement (USD): available drops by 2000, held rises by 2000.
  3. base (AAPL): incoming rises by 10.
  4. 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:
  5. settlement (USD): 2000 consumed from held; available credited with the fill proceeds (net of any price improvement).
  6. base (AAPL): available credited with 10; incoming reduced by 10.
  7. Cancel / partial — when an order ends with an unfilled remainder, that remainder is released from held back to available, and the corresponding incoming on the acquiring leg is released. A cancel after filling 4 of 10 units:
  8. settlement (USD): 6 × 200 = 1200 released from held back to available.
  9. base (AAPL): incoming reduced by 6 (the unfilled remainder).

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

Seeding Balances

The policy never invents funds. Initial balances are loaded exclusively through the account adjustment 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: net spendable is available plus held, so a held of -2000 against an available of 2000 leaves nothing spendable.

Every adjustment returns a per-asset outcome carrying both the applied delta and the resulting absolute value. Persisting those deltas is how an embedding keeps its own books in step with the engine — see Balance Reconciliation.

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.

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()
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()
JavaScript
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();
C++
#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();
}
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();

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 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 added to the quoted price when computing the worst-case commitment. 1500 bps means the policy holds 15% more than the quote implies. The value must not exceed 10000 bps (100%).
  • 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; resolution order is account - group - instrument - global.

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

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),
); 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()
Python
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"))

# 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()
JavaScript
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" }));

// 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();
C++
#include <cassert>

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"))) ==
       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();
}
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")?))?;

// 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();

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. This does not authorize live repricing of a historical market order. If the order does not carry a usable historical price, Spot Funds returns an evaluation failure 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:

Tier Scope Overrides
Global Every account -
Account group All accounts in the group Global
Account The specific account Global and group

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 for code examples.

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, but with the funds ledger, FX handling, and fee accounting that Spot Funds already owns. See Two Ways to Watch PnL for how the two controls differ.

The barrier axis is the account. There is no currency, position, asset, or settlement dimension: each account has exactly one PnL state, and the effective barrier watches only that state. Account currency remains calculation metadata for converting fill and fee contributions.

Configuring Barriers

A barrier 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 exclusive: 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 through a three-tier cascade, most specific wins:

Tier Scope Overrides
Global Every account -
Account group Every account in the group Global
Account The specific account Global and group

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 bounds only; 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 and a tighter per-account barrier. 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 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.

Go
account := param.NewAccountIDFromUint64(99224416)
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{
                LowerBound: optional.Some(lower),
            }).
            AccountBarriers(policies.SpotFundsPnlBoundsAccountBarrier{
                AccountID: account,
                Barrier: policies.SpotFundsPnlBoundsBarrier{
                    LowerBound: optional.Some(accountLower),
                },
            }),
    ).
    Build()
if err != nil {
    panic(err)
}
defer engine.Stop()
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(
                lower_bound=openpit.param.Pnl(-1000),
            ),
        )
        .account_barriers(
            openpit.pretrade.policies.SpotFundsPnlBoundsAccountBarrier(
                account_id=account_id,
                barrier=openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
                    lower_bound=openpit.param.Pnl(-250),
                ),
            ),
        )
    )
    .build()
)
JavaScript
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("-1000", undefined))
      .accountBarriers([
        new SpotFundsPnlBoundsAccountBarrier(
          accountId,
          new SpotFundsPnlBoundsBarrier("-250", undefined),
        ),
      ]),
  )
  .build();
C++
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;
global.lowerBound = Pnl::FromString("-1000");

policies::SpotFundsPnlBoundsBarrier accountBarrier;
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();
Rust
use openpit::param::{AccountId, 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);

// A global loss barrier of -1000, plus a tighter per-account barrier.
let global_barrier = SpotFundsPnlBoundsBarrier {
    lower_bound: Some(Pnl::from_str("-1000")?),
    upper_bound: None,
};
let account_barrier = SpotFundsPnlBoundsAccountBarrier {
    barrier: SpotFundsPnlBoundsBarrier {
        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()?;

What Is Tracked, What Is Not

Spot Funds maintains its account-PnL accumulator for every account whose report touches a position or carries a fee, whether or not a barrier resolves. A resolving barrier adds only the bounds check and account-blocking behavior. With no resolving barrier, PnL is still accumulated and emitted, and the same account currency and FX inputs are required, but neither a bound breach nor a halted PnL state can block the account.

Each position-touching fill is evaluated for two account-currency ledgers:

  • 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.
  • The account contribution is calculated independently and accumulated across positions. Both calculations convert through the market-data mark of Instrument (or the inverse 1 / mark) when the quote differs from account currency.
  • An optional fee on the execution report, given as a {amount, currency} pair. The fee has two independent effects, described next.

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 attached to an execution-report fill is a structured {amount, currency} value and acts on two ledgers at once:

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

Runtime Reconfiguration

Barriers are runtime-tunable through the Configurator. 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 their account blocks, but does not stop accumulation. 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 live accumulated PnL is checked as a level condition. Tightening a bound does not reset the accumulator; the next pre-trade check rejects an already out-of-bounds account even before another fill arrives.

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 every block caused by the correction. A numeric assignment beyond an effective bound is applied first, returns a PnlKillSwitchTriggered account block, and latches that block before the call returns. A Halted assignment under an effective barrier likewise returns a PnlKillSwitchTriggered account block. The Engine records that block before the successful correction call returns, so the next pre-trade check sees the engine-level account block. Without an effective barrier, the halt remains stored and the list is empty. Either path can move the account into a blocked state but never out of one; only an explicit admin unblock clears a latched block.

Go
account := param.NewAccountIDFromUint64(99224416)
newLower, _ := param.NewPnlFromString("-500")
forced, _ := param.NewPnlFromString("-600")
globalBarrier := policies.SpotFundsPnlBoundsBarrier{
    LowerBound: optional.Some(newLower),
}

// Retune the account PnL barrier; live accumulated PnL is untouched.
if err := engine.Configure().SpotFundsPnlBoundsKillSwitch(
    policies.SpotFundsPolicyName,
    optional.Some(&globalBarrier),
    nil,
    nil,
); err != nil {
    panic(err)
}

// Force-set the live accumulated PnL for one account.
result, err := engine.Configure().SetSpotFundsAccountPnl(
    policies.SpotFundsPolicyName,
    account,
    model.NewPnlState(forced),
)
if err != nil {
    panic(err)
}
if len(result.AccountBlocks) != 1 {
    panic("expected the force-set to trip the PnL barrier")
}
Python
import openpit
import openpit.pretrade.policies

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

# Retune the account PnL barrier; live accumulated PnL is untouched.
engine.configure().spot_funds_pnl_bounds_killswitch(
    openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
    global_barrier=openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
        lower_bound=openpit.param.Pnl(-500),
    ),
)

# Force-set the live accumulated PnL for one account.
result = engine.configure().set_spot_funds_account_pnl(
    openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
    account=account_id,
    state=openpit.param.Pnl(-600),
)
assert len(result.account_blocks) == 1
JavaScript
import { Engine } from "@openpit/engine";
import {
  buildSpotFundsPnlBoundsKillswitch,
  SpotFundsPnlBoundsAccountBarrier,
  SpotFundsPnlBoundsBarrier,
  SpotFundsPnlBoundsKillswitchBuilder,
} from "@openpit/engine/pretrade/policies";

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

// Retune the account PnL barriers; live accumulated PnL is untouched.
engine.configure().spotFundsPnlBoundsKillswitch(
  SpotFundsPnlBoundsKillswitchBuilder.NAME,
  {
    globalBarrier: new SpotFundsPnlBoundsBarrier("-500", undefined),
    accountBarriers: [
      new SpotFundsPnlBoundsAccountBarrier(
        accountId,
        new SpotFundsPnlBoundsBarrier("-250", "250"),
      ),
    ],
  },
);

// Force-set the live accumulated PnL for one account.
const result = engine.configure().setSpotFundsAccountPnl(
  SpotFundsPnlBoundsKillswitchBuilder.NAME,
  {
    account: accountId,
    state: "-600",
  },
);
if (result.accountBlocks.length !== 1) {
  throw new Error("expected the force-set to trip the PnL barrier");
}
C++
namespace policies = openpit::pretrade::policies;
using openpit::param::AccountId;
using openpit::param::Pnl;

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

// Retune the account PnL barrier; live accumulated PnL is untouched.
policies::SpotFundsPnlBoundsBarrier global;
global.lowerBound = Pnl::FromString("-500");
engine.Configure().SpotFundsPnlBoundsKillSwitch(
    policies::SpotFundsPolicyName,
    policies::SpotFundsPnlBoundsGlobalBarrierUpdate::Set(std::move(global)));

// Force-set the live accumulated PnL for one account.
const auto result = engine.Configure().SetSpotFundsAccountPnl(
    policies::SpotFundsPolicyName, accountId, Pnl::FromString("-600"));
assert(result.accountBlocks.size() == 1);
Rust
use openpit::param::Pnl;
use openpit::pretrade::policies::{
    SpotFundsConfigError, SpotFundsPnlBoundsBarrier, SpotFundsPolicy,
};
use openpit::FullSync;

let name = SpotFundsPolicy::<FullSync, FullSync>::NAME;
let new_lower = Pnl::from_str("-500")?;

// Retune the account PnL barrier; live accumulated PnL is untouched.
engine
    .configure()
    .spot_funds::<SpotFundsConfigError>(name, |settings| {
        settings.set_pnl_global_barrier(Some(SpotFundsPnlBoundsBarrier {
            lower_bound: Some(new_lower),
            upper_bound: None,
        }))
    })?;

// Force-set the live accumulated PnL for one account.
let result = engine
    .configure()
    .set_spot_funds_account_pnl(
        name,
        account,
        openpit::PnlState::Value(Pnl::from_str("-600")?),
    )?;
assert_eq!(result.account_blocks.len(), 1);

When It Blocks the Account

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:

Trigger Behavior
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.
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.

With no effective barrier, numeric updates and newly halted account-PnL states are stored and published without rejecting or blocking. 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.

Spot Funds PnL kill switch 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
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

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

Code Scope When
InsufficientFunds order The reservation exceeds spendable funds for the asset. Emitted only under Enforce; TrackOnly never emits it.
UnsupportedOrderType order Market order received while in limit-only mode.
MarkPriceUnavailable order Market order priced from a quote field that is missing or stale; or a sell order that has no order price and no market-data price to resolve.
OrderValueCalculationFailed order The order value could not be produced: the slippage and pricing cascade failed to derive an effective price, or the notional or derived quantity could not be computed from the resolved price and the trade amount.
MissingRequiredField order, account order: a required order field is absent. account: a required account-adjustment field is absent, or an execution report (fill or cancel) is missing a required field or the pre-trade lock price needed to reconcile its settlement legs. The lock price applies to both buys and sells.
InvalidFieldFormat account An account-adjustment field is present but cannot 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. order: while holding or projecting a reservation leg. account: while applying an account adjustment, reconciling an execution report, or rolling a reservation back. No funds limit mode suppresses it.
PnlKillSwitchTriggered account An effective barrier resolves for the account and its PnL 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.

Examples

A minimal, copy-paste-friendly integration of this policy in Go and Python covers the limit-only form end to end. For table-driven scenario testing, the spot_table tool is available in Go and Python, with bundled scenario tables.