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 & realized PnL - tracked per
(account, asset)as one average entry price and one cumulative realized PnL for that holdings slot. Both values are denominated in the account currency, not the instrument quote or settlement currency. - Self-computed PnL kill switch - optionally block an account when its engine-computed realized PnL, on the account-currency axis, moves outside a configured bound. 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, 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 can derive, per (account, asset), an
average entry price and a running realized PnL 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. Each holdings slot has
one average entry price and one cumulative realized PnL. Both values are stored
and emitted in the account currency, not in the instrument quote or settlement
currency.
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 realized
PnL. If you change that metadata after positions exist, you own the migration
risk until a future control or recompute tool is available.
Tracking is evaluated only on position-touching fills, where
underlying_flow != 0:
- If no account currency resolves, the quantity mutation still applies and the average entry price and realized PnL reset to not tracked. This never blocks or rejects the fill for PnL accounting.
- If the instrument quote equals the account currency, no FX market data is needed and tracking continues from the fill values directly.
- 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 accounts with that stale quote.
- If FX is missing - the quote is unavailable, the instrument is unknown, or there is no market-data handle - the quantity mutation still applies and the slot's average entry price and realized PnL reset to not tracked. This also never blocks or rejects the fill for PnL accounting.
- A non-flat untracked slot does not auto-resume tracking, because its cost basis has been lost. Use an explicit force-set to re-arm it.
Account adjustments can force-set the average entry price and realized PnL fields of the balance-operation. The caller supplies those values already denominated in the account currency; Spot Funds does not apply FX to them. The next position-touching fill re-checks the account currency and FX availability before continuing tracking.
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)),
}),
),
})
rejects, _, err := engine.ApplyAccountAdjustment(accountID, []model.AccountAdjustment{seed})
if err != nil {
panic(err)
}
if rejects.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()
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 = "USD";
seed.operation = aa::Operation::OfBalance(std::move(balanceOp));
aa::Amount seedAmount;
seedAmount.balance =
AdjustmentAmount::OfAbsolute(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("AAPL", "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<()>>,
>;
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: (),
operation: AccountAdjustmentBalanceOperation {
asset: Asset::new("USD")?,
average_entry_price: None,
realized_pnl: 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()
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("AAPL", "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 = "USD";
seed.operation = aa::Operation::OfBalance(std::move(balanceOp));
aa::Amount seedAmount;
seedAmount.balance =
AdjustmentAmount::OfAbsolute(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<()>>,
>;
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: (),
operation: AccountAdjustmentBalanceOperation {
asset: Asset::new("USD")?,
average_entry_price: None,
realized_pnl: 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.
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 - no externally supplied PnL figure is trusted. When accumulated realized PnL for an account moves outside a configured bound, 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 currency - the same currency Spot Funds denominates its average entry price and realized PnL in (see What It Controls). There is no per-asset or per-settlement axis: an account carries one accumulated realized-PnL figure per account currency, and one barrier watches it.
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,
for one account currency. 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 with a matching account currency | - |
| Account group | Accounts in the group with a matching account currency | Global |
| Account | The specific account and account currency | Global and group |
Only account barriers accept an initial pnl seed, consumed once at
construction to resume from a previously accumulated value. Global and
account-group barriers carry no seed.
The example below registers Spot Funds with a global loss barrier of -1000 USD
and a tighter per-account barrier that also seeds 0 accumulated PnL. 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.
Go
usd, _ := param.NewAsset("USD")
account := param.NewAccountIDFromUint64(99224416)
lower, _ := param.NewPnlFromString("-1000")
accountLower, _ := param.NewPnlFromString("-250")
seed, _ := param.NewPnlFromString("0")
// 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().
GlobalBarriers(policies.SpotFundsPnlBoundsBarrier{
AccountCurrency: usd,
LowerBound: optional.Some(lower),
}).
AccountBarriers(policies.SpotFundsPnlBoundsAccountBarrier{
AccountID: account,
Barrier: policies.SpotFundsPnlBoundsBarrier{
AccountCurrency: usd,
LowerBound: optional.Some(accountLower),
},
InitialPnl: seed,
}),
).
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_barriers(
openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
account_currency=openpit.param.Asset("USD"),
lower_bound=openpit.param.Pnl(-1000),
),
)
.account_barriers(
openpit.pretrade.policies.SpotFundsPnlBoundsAccountBarrier(
account_id=account_id,
barrier=openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
account_currency=openpit.param.Asset("USD"),
lower_bound=openpit.param.Pnl(-250),
),
initial_pnl=openpit.param.Pnl(0),
),
)
)
.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("USD");
global.lowerBound = Pnl::FromString("-1000");
policies::SpotFundsPnlBoundsBarrier accountBarrier("USD");
accountBarrier.lowerBound = Pnl::FromString("-250");
openpit::EngineBuilder builder(openpit::SyncPolicy::None);
builder.Add(policies::SpotFundsPnlBoundsKillSwitchPolicy{}
.GlobalBarrier(std::move(global))
.AccountBarrier(policies::SpotFundsPnlBoundsAccountBarrier(
accountId, std::move(accountBarrier),
Pnl::FromString("0"))));
openpit::Engine engine = builder.Build();
Rust
use openpit::param::{AccountId, Asset, Pnl};
use openpit::pretrade::policies::{
SpotFundsPnlBoundsAccountBarrier, SpotFundsPnlBoundsBarrier, SpotFundsPolicy,
SpotFundsPricingSource, SpotFundsSettings,
};
use openpit::{
Engine, FullSync, OrderOperation, SpotFundsMarketData, WithAccountAdjustmentAmount,
WithAccountAdjustmentBalanceOperation, WithAccountAdjustmentBounds,
WithExecutionReportFillDetails, WithExecutionReportOperation,
};
type SpotReport = WithExecutionReportOperation<WithExecutionReportFillDetails<()>>;
type SpotAdjustment = WithAccountAdjustmentAmount<
WithAccountAdjustmentBounds<WithAccountAdjustmentBalanceOperation<()>>,
>;
let account = AccountId::from_u64(99224416);
let usd = Asset::new("USD")?;
// The bounds cascade lives in SpotFundsSettings. A global loss barrier of
// -1000 USD, plus a tighter per-account barrier that seeds 0 accumulated PnL.
let mut settings = SpotFundsSettings::new(0, SpotFundsPricingSource::Mark, [])?;
settings.set_pnl_global_barriers([SpotFundsPnlBoundsBarrier {
account_currency: usd.clone(),
lower_bound: Some(Pnl::from_str("-1000")?),
upper_bound: None,
}])?;
let account_barrier = SpotFundsPnlBoundsAccountBarrier {
barrier: SpotFundsPnlBoundsBarrier {
account_currency: usd,
lower_bound: Some(Pnl::from_str("-250")?),
upper_bound: None,
},
account_id: account,
initial_pnl: Pnl::from_str("0")?,
};
let settings = settings.with_initial_pnl_account_barriers([account_barrier])?;
let builder = Engine::builder::<OrderOperation, SpotReport, SpotAdjustment>().full_sync();
let policy = SpotFundsPolicy::<FullSync, FullSync>::new(
settings,
None::<SpotFundsMarketData<FullSync>>,
builder.storage_builder(),
);
let engine = builder.pre_trade(policy).build()?;
What Is Tracked, What Is Not¶
The kill switch tracks only accounts a barrier resolves for. An account with no resolving barrier is not under PnL control: its realized PnL is not accumulated, nothing blocks on PnL, and no FX is required for its fills - the pre-existing accounting behavior described in What It Controls is preserved unchanged.
For an account that is under PnL control, each position-touching fill contributes to the accumulator:
- The fill's realized PnL, in the account currency. When the settlement
currency differs from the account currency, Spot Funds converts through the
market-data
markofInstrument(or the inverse1 / mark), the same FX path used for average-entry and realized-PnL accounting. - An optional fee on the execution report, given as a
{amount, currency}pair. The fee has two independent effects, described next.
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 the account is under PnL control. 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 contribution - only under PnL control. When a barrier resolves, the fee reduces realized PnL (a fee is a cost). The fee amount is converted from the fee currency to the account currency through the same FX path, then netted into the fill's realized-PnL contribution before the accumulator is updated and the bounds are checked.
If the fee is already denominated in the account currency, no FX is needed for its PnL contribution; the debit against the fee-asset balance never needs FX.
Runtime Reconfiguration¶
Barriers are runtime-tunable through the Configurator. Replacing barriers retunes bounds only - it never resets the accumulator. The live accumulated PnL is re-evaluated against the new bounds on the next fill; if the current value is already outside a tightened bound, the next fill that leaves the accumulated value outside the bound trips the switch.
To move the accumulator itself - for example to reconcile against an external
ledger after a restart - use the dedicated force-set call, which is an absolute
assignment (upsert) of the live accumulated PnL for one (account, account
currency) entry. As with the standalone kill switch, force-setting past a bound
can move an account into a blocked state but never out of one; only an explicit
admin unblock clears a latched block.
Go
usd, _ := param.NewAsset("USD")
account := param.NewAccountIDFromUint64(99224416)
newLower, _ := param.NewPnlFromString("-500")
forced, _ := param.NewPnlFromString("-120")
// Retune the account-currency PnL barriers; live accumulated PnL is untouched.
err := engine.Configure().SpotFundsPnlBoundsKillSwitch(
policies.SpotFundsPolicyName,
[]policies.SpotFundsPnlBoundsBarrier{
{AccountCurrency: usd, LowerBound: optional.Some(newLower)},
},
nil,
nil,
)
if err != nil {
panic(err)
}
// Force-set the live accumulated PnL for one (account, account currency).
if err := engine.Configure().SetSpotFundsAccountPnl(
policies.SpotFundsPolicyName,
account,
usd,
forced,
); err != nil {
panic(err)
}
Python
import openpit
import openpit.pretrade.policies
account_id = openpit.param.AccountId.from_int(99224416)
# Retune the account-currency PnL barriers; live accumulated PnL is untouched.
engine.configure().spot_funds_pnl_bounds_killswitch(
openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
global_barriers=[
openpit.pretrade.policies.SpotFundsPnlBoundsBarrier(
account_currency=openpit.param.Asset("USD"),
lower_bound=openpit.param.Pnl(-500),
),
],
)
# Force-set the live accumulated PnL for one (account, account currency).
engine.configure().set_spot_funds_account_pnl(
openpit.pretrade.policies.SpotFundsPnlBoundsKillswitchBuilder.NAME,
account=account_id,
account_currency=openpit.param.Asset("USD"),
pnl=openpit.param.Pnl(-120),
)
C++
namespace policies = openpit::pretrade::policies;
using openpit::param::AccountId;
using openpit::param::Pnl;
const AccountId accountId = AccountId::FromUint64(99224416);
// Retune the account-currency PnL barriers; live accumulated PnL is untouched.
policies::SpotFundsPnlBoundsBarrier global("USD");
global.lowerBound = Pnl::FromString("-500");
engine.Configure().SpotFundsPnlBoundsKillSwitch(
policies::SpotFundsPolicyName,
std::vector<policies::SpotFundsPnlBoundsBarrier>{std::move(global)});
// Force-set the live accumulated PnL for one (account, account currency).
engine.Configure().SetSpotFundsAccountPnl(
policies::SpotFundsPolicyName, accountId, "USD", Pnl::FromString("-120"));
Rust
use openpit::param::{Asset, Pnl};
use openpit::pretrade::policies::{
SpotFundsConfigError, SpotFundsPnlBoundsBarrier, SpotFundsPolicy,
};
use openpit::FullSync;
let name = SpotFundsPolicy::<FullSync, FullSync>::NAME;
let usd = Asset::new("USD")?;
let new_lower = Pnl::from_str("-500")?;
// Retune the account-currency PnL barriers; live accumulated PnL is untouched.
engine
.configure()
.spot_funds::<SpotFundsConfigError>(name, |settings| {
settings.set_pnl_global_barriers([SpotFundsPnlBoundsBarrier {
account_currency: usd.clone(),
lower_bound: Some(new_lower),
upper_bound: None,
}])
})?;
// Force-set the live accumulated PnL for one (account, account currency).
engine
.configure()
.set_spot_funds_account_pnl(name, account, Asset::new("USD")?, Pnl::from_str("-120")?)?;
When It Blocks the Account¶
Under PnL control, a fill blocks the account - across every settlement asset and instrument, exactly as described in Account Blocking by Engine - in three cases:
| Trigger | Reject code |
|---|---|
| Accumulated realized PnL is below the lower bound or above the upper bound. | PnlKillSwitchTriggered |
| Arithmetic overflow while computing or accumulating the PnL. | OrderValueCalculationFailed |
| A required FX rate is unavailable for the fill's PnL computation (including a fee's PnL conversion). | OrderValueCalculationFailed |
The FX fail-safe is deliberate: once an account is under PnL control, the engine must be able to state the accumulated PnL in the account currency to honor the limit. If a rate it needs is missing it blocks rather than silently skipping the check. Assets already denominated in the account currency never require FX, so they never trip this fail-safe. An account not under PnL control never needs FX and is never blocked for a missing rate.
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 currency | 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 | Converts to account currency; blocks with OrderValueCalculationFailed if a required rate is missing |
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 |
Required amount exceeds spendable funds for the asset. |
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. |
MissingRequiredField |
account |
An execution report (fill or cancel) arrives without the pre-trade lock price needed to reconcile its settlement legs. Applies to both buys and sells. |
PnlKillSwitchTriggered |
account |
Accumulated realized PnL for an account under PnL control is outside a configured bound. |
OrderValueCalculationFailed |
account |
An account under PnL control hits arithmetic overflow, or a required FX rate for the fill's PnL computation (including fee conversion) is unavailable. |
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.