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 (
askfor buys,bidfor 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
heldadjustments 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 intoavailable.
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
MissingAccountCurrencyon 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
markofInstrument(quote, account_currency). If only the inverseInstrument(account_currency, quote)is registered, it uses1 / 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.
MissingFxmeans 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
MissingFxand 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:
- Reserve — a passing order moves the outflow amount from
availabletoheld, and simultaneously records the expected inflow asincomingon the acquiring leg. A buy of 10 units at a price of 200: - settlement (USD):
availabledrops by 2000,heldrises by 2000. - base (AAPL):
incomingrises by 10. - Fill — an execution report consumes the filled portion from
held(outflow leg) and credits the acquired asset toavailable(inflow leg). Simultaneously, the acquiring leg'sincomingis reduced by the filled quantity. A full fill of the example order: - settlement (USD): 2000 consumed from
held;availablecredited with the fill proceeds (net of any price improvement). - base (AAPL):
availablecredited with 10;incomingreduced by 10. - Cancel / partial — when an order ends with an unfilled remainder, that
remainder is released from
heldback toavailable, and the correspondingincomingon the acquiring leg is released. A cancel after filling 4 of 10 units: - settlement (USD): 6 × 200 = 1200 released from
heldback toavailable. - base (AAPL):
incomingreduced 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;BookTopprices a buy from theaskand a sell from thebid.BookTopdoes not fall back to mark — a market buy with noaskis rejected withMarkPriceUnavailable. - 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
markofInstrument(or the inverse1 / 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.
Related Pages¶
- Pre-Trade Lock — persist and replay the lock price that reconciles every fill and cancel; required reading for restart recovery.
- Account Adjustments — how to seed and adjust balances.
- Balance Reconciliation — keep your own books in step with engine outcomes.
- Market Data Pricing — feed live quotes for market-order pricing.
- Dynamic Policy Reconfiguration — retune barriers and force-set accumulated PnL at runtime.
- Account Blocking — lift a latched kill-switch block.
- Policies — the full built-in policy catalog, including the standalone PnlBoundsKillSwitchPolicy.