-
Notifications
You must be signed in to change notification settings - Fork 1
Policy API
Pit exposes custom policy hooks for two stages:
-
Start stage: cheap checks that must run for every request -
Main stage: deeper checks that can emit one or more rejects and register reversible mutations
This page describes behavior first, then shows language-specific examples for the SDKs that expose the relevant custom-policy surface directly.
- Start stage returns one reject outcome or pass-through.
- Main stage can collect multiple rejects and register reversible mutations.
- Main-stage context provides read-only access to request data.
- Main-stage mutations are committed only when the full
execute requeststep succeeds.
For account-adjustment batch policy hooks, see Account Adjustments.
Custom policy state must not be read or mutated in parallel with engine calls on the same engine instance.
- Unsafe pattern: one thread executes
start stageorexecute requestwhile another thread reads or mutates fields that the same policy callbacks use. - If shared access is unavoidable, synchronization is fully owned by the host application (locks, serialized access, actor loop, etc.).
- Preferred pattern: keep policy state mutations inside engine calls and feed
external corrections through
apply account adjustmentsonly.
Go SDK keeps policy integration idiomatic and thin:
- standard start-stage interface:
pretrade.CheckStartPolicy - standard main-stage interface:
pretrade.Policy - for custom order/report types:
pretrade.ClientCheckPreTradeStartPolicy[Order, Report]pretrade.ClientPreTradePolicy[Order, Report]
- adapters with payload validation:
pretrade.NewSafeClientCheckPreTradeStartPolicypretrade.NewSafeClientPreTradePolicy - adapters without validation (for SDK-controlled paths):
pretrade.NewUnsafeFastClientCheckPreTradeStartPolicypretrade.NewUnsafeFastClientPreTradePolicy - built-in native policies satisfy
pretrade.BuiltinPolicyand are registered via theBuiltin*builder methods (for exampleBuiltinCheckPreTradeStartPolicy)
Python exposes matching high-level interfaces over record-style
openpit.Order and openpit.ExecutionReport:
- start-stage class:
openpit.pretrade.CheckPreTradeStartPolicy - main-stage class:
openpit.pretrade.PreTradePolicy
Business outcomes are returned, not raised:
- start stage returns
PolicyReject | None - main stage returns
PolicyDecision
Main-stage Python policies can register:
Mutation(commit=callable, rollback=callable)
Rust exposes first-class traits for custom policies and caller-defined order contracts:
- start-stage trait:
CheckPreTradeStartPolicy<O, R> - main-stage trait:
PreTradePolicy<O, R> - start-stage callback receives:
&PreTradeContext&O
- main-stage callback receives:
&PreTradeContext&OMutationsVec<Reject>
Go
package main
import (
"fmt"
"go.openpit.dev/openpit/model"
"go.openpit.dev/openpit/param"
"go.openpit.dev/openpit/pretrade"
"go.openpit.dev/openpit/reject"
"go.openpit.dev/openpit/tx"
)
type NotionalCapPolicy struct {
// Policy-local config: reject any order above this absolute notional.
MaxAbsNotional param.Volume
}
func (p *NotionalCapPolicy) Close() {}
func (p *NotionalCapPolicy) Name() string { return "NotionalCapPolicy" }
func (p *NotionalCapPolicy) PerformPreTradeCheck(
_ pretrade.Context,
order model.Order,
_ tx.Mutations,
) []reject.Reject {
operation, ok := order.Operation().Get()
if !ok {
return reject.NewSingleItemList(
reject.CodeMissingRequiredField,
p.Name(),
"required order field missing",
"operation is not set",
reject.ScopeOrder,
)
}
// Translate the public order surface into one number that this policy
// can reason about: requested notional.
tradeAmount, ok := operation.TradeAmount().Get()
if !ok {
return reject.NewSingleItemList(
reject.CodeMissingRequiredField,
p.Name(),
"required order field missing",
"trade_amount is not set",
reject.ScopeOrder,
)
}
var requestedNotional param.Volume
if tradeAmount.IsVolume() {
requestedNotional = tradeAmount.MustVolume()
} else {
price, ok := operation.Price().Get()
if !ok {
return reject.NewSingleItemList(
reject.CodeOrderValueCalculationFailed,
p.Name(),
"order value calculation failed",
"price not provided for evaluating notional",
reject.ScopeOrder,
)
}
notional, err := price.CalculateVolume(tradeAmount.MustQuantity())
if err != nil {
return reject.NewSingleItemList(
reject.CodeOrderValueCalculationFailed,
p.Name(),
"order value calculation failed",
"price and quantity could not be used to evaluate notional",
reject.ScopeOrder,
)
}
requestedNotional = notional
}
if requestedNotional.Compare(p.MaxAbsNotional) > 0 {
// Business validation failures should become explicit rejects.
return reject.NewSingleItemList(
reject.CodeRiskLimitExceeded,
p.Name(),
"strategy cap exceeded",
fmt.Sprintf(
"requested notional %v, max allowed: %v",
requestedNotional, p.MaxAbsNotional,
),
reject.ScopeOrder,
)
}
// This policy only validates. It does not reserve mutable state.
return nil
}
func (p *NotionalCapPolicy) ApplyExecutionReport(model.ExecutionReport) bool {
return false
}Python
import typing
import openpit
class NotionalCapPolicy(openpit.pretrade.PreTradePolicy):
@typing.override
def __init__(self, max_abs_notional: openpit.param.Volume) -> None:
# Policy-local config: reject any order above this absolute notional.
self._max_abs_notional = max_abs_notional
@property
@typing.override
def name(self) -> str:
return "NotionalCapPolicy"
@typing.override
def perform_pre_trade_check(
self,
ctx: openpit.pretrade.PreTradeContext,
order: openpit.Order,
) -> openpit.pretrade.PolicyDecision:
assert order.operation is not None
# Translate the public order surface into one number that this policy
# can reason about: requested notional.
trade_amount = order.operation.trade_amount
if trade_amount.is_volume:
requested_notional = trade_amount.as_volume
else:
assert trade_amount.is_quantity
assert order.operation.price is not None
requested_notional = order.operation.price.calculate_volume(
trade_amount.as_quantity
)
if requested_notional > self._max_abs_notional:
# Business validation failures should become explicit rejects,
# not exceptions.
return openpit.pretrade.PolicyDecision.reject(
rejects=[
openpit.pretrade.PolicyReject(
code=openpit.pretrade.RejectCode.RISK_LIMIT_EXCEEDED,
reason="strategy cap exceeded",
details=(
"requested notional "
f"{requested_notional}, "
f"max allowed: {self._max_abs_notional}"
),
scope=openpit.pretrade.RejectScope.ORDER,
)
]
)
# This policy only validates. It does not reserve mutable state.
return openpit.pretrade.PolicyDecision.accept()
@typing.override
def apply_execution_report(
self,
report: openpit.ExecutionReport,
) -> bool:
_ = report
return FalseRust
use openpit::param::{TradeAmount, Volume};
use openpit::pretrade::{PreTradeContext, PreTradePolicy, Reject, RejectCode, RejectScope};
use openpit::Mutations;
use openpit::{HasOrderPrice, HasTradeAmount};
struct NotionalCapPolicy {
// Policy-local config: reject any order above this absolute notional.
max_abs_notional: Volume,
}
impl<O, R> PreTradePolicy<O, R> for NotionalCapPolicy
where
O: HasTradeAmount + HasOrderPrice,
{
fn name(&self) -> &str {
"NotionalCapPolicy"
}
fn perform_pre_trade_check(
&self,
_ctx: &PreTradeContext,
order: &O,
_mutations: &mut Mutations,
rejects: &mut Vec<Reject>,
) {
// Translate the public order surface into one number that this policy
// can reason about: requested notional.
let trade_amount = match order.trade_amount() {
Ok(trade_amount) => trade_amount,
Err(error) => {
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::MissingRequiredField,
"required order field missing",
error.to_string(),
));
return;
}
};
let price = match order.price() {
Ok(price) => price,
Err(error) => {
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::MissingRequiredField,
"required order field missing",
error.to_string(),
));
return;
}
};
let requested_notional = match (trade_amount, price) {
(TradeAmount::Volume(volume), _) => volume,
(TradeAmount::Quantity(quantity), Some(price)) => {
match price.calculate_volume(quantity) {
Ok(v) => v,
Err(_) => {
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::OrderValueCalculationFailed,
"order value calculation failed",
"price and quantity could not be used to evaluate notional",
));
return;
}
}
}
(TradeAmount::Quantity(_), None) => {
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::OrderValueCalculationFailed,
"order value calculation failed",
"price not provided for evaluating cash flow/notional/volume",
));
return;
}
_ => {
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::UnsupportedOrderType,
"unsupported order type",
"custom trade amount variant is not supported by this policy",
));
return;
}
};
if requested_notional > self.max_abs_notional {
// Business validation failures should become explicit rejects.
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::RiskLimitExceeded,
"strategy cap exceeded",
format!(
"requested notional {}, max allowed: {}",
requested_notional, self.max_abs_notional
),
));
}
}
fn apply_execution_report(&self, _report: &R) -> bool {
false
}
}If at least one main-stage policy rejects, the engine does not return a reservation and rolls back all registered mutations in reverse order.
Rollback order is deterministic:
- registration order for commit
- reverse registration order for rollback
This pattern is useful when one policy updates intermediate in-memory state and the same policy decides that the request must be rejected.
Go
package main
import (
"fmt"
"go.openpit.dev/openpit/model"
"go.openpit.dev/openpit/param"
"go.openpit.dev/openpit/pretrade"
"go.openpit.dev/openpit/reject"
"go.openpit.dev/openpit/tx"
)
type ReserveThenValidatePolicy struct {
reserved param.Volume
limit param.Volume
}
func (p *ReserveThenValidatePolicy) Close() {}
func (p *ReserveThenValidatePolicy) Name() string { return "ReserveThenValidatePolicy" }
func (p *ReserveThenValidatePolicy) PerformPreTradeCheck(
_ pretrade.Context,
_ model.Order,
mutations tx.Mutations,
) []reject.Reject {
// Pretend that this request needs a temporary reservation of 100.
// We apply it eagerly because downstream logic wants to observe the
// tentative state immediately.
prevReserved := p.reserved
nextReserved, _ := param.NewVolumeFromString("100")
p.reserved = nextReserved
_ = mutations.Push(
func() {
// Commit is empty: state was applied eagerly.
},
func() {
p.reserved = prevReserved
},
)
if p.reserved.Compare(p.limit) > 0 {
// Return the reject after the rollback mutation is registered.
// The engine will restore the previous state automatically.
return reject.NewSingleItemList(
reject.CodeRiskLimitExceeded,
p.Name(),
"temporary reservation exceeds limit",
fmt.Sprintf("reserved %v, limit: %v", nextReserved, p.limit),
reject.ScopeOrder,
)
}
return nil
}
func (p *ReserveThenValidatePolicy) ApplyExecutionReport(model.ExecutionReport) bool {
return false
}Python
import typing
import openpit
class ReserveThenValidatePolicy(openpit.pretrade.PreTradePolicy):
@typing.override
def __init__(self) -> None:
self._reserved = openpit.param.Volume(0.0)
self._limit = openpit.param.Volume(50.0)
@property
@typing.override
def name(self) -> str:
return "ReserveThenValidatePolicy"
@typing.override
def perform_pre_trade_check(
self,
ctx: openpit.pretrade.PreTradeContext,
order: openpit.Order,
) -> openpit.pretrade.PolicyDecision:
assert order.operation is not None
# Pretend that this request needs a temporary reservation of 100.
# We apply it eagerly because downstream logic wants to observe the
# tentative state immediately.
prev_reserved = self._reserved
next_reserved = openpit.param.Volume(100.0)
self._reserved = next_reserved
rollback = openpit.Mutation(
commit=lambda: None, # Commit is empty: state was applied eagerly.
rollback=lambda: setattr(self, "_reserved", prev_reserved),
)
if next_reserved > self._limit:
# Return the reject together with the rollback mutation.
# The engine will restore the previous state automatically.
return openpit.pretrade.PolicyDecision.reject(
rejects=[
openpit.pretrade.PolicyReject(
code=openpit.pretrade.RejectCode.RISK_LIMIT_EXCEEDED,
reason="temporary reservation exceeds limit",
details=(
f"reserved {next_reserved}, "
f"limit: {self._limit}"
),
scope=openpit.pretrade.RejectScope.ORDER,
)
],
mutations=[rollback],
)
return openpit.pretrade.PolicyDecision.accept(mutations=[rollback])
@typing.override
def apply_execution_report(
self,
report: openpit.ExecutionReport,
) -> bool:
_ = report
return FalseRust
use std::cell::RefCell;
use std::rc::Rc;
use openpit::param::Volume;
use openpit::pretrade::{PreTradeContext, PreTradePolicy, Reject, RejectCode, RejectScope};
use openpit::{Mutation, Mutations};
struct ReserveThenValidatePolicy {
reserved: Rc<RefCell<Volume>>,
next: Volume,
limit: Volume,
}
impl<O, R> PreTradePolicy<O, R> for ReserveThenValidatePolicy {
fn name(&self) -> &str {
"ReserveThenValidatePolicy"
}
fn perform_pre_trade_check(
&self,
_ctx: &PreTradeContext,
_order: &O,
mutations: &mut Mutations,
rejects: &mut Vec<Reject>,
) {
// Pretend that this request needs a temporary reservation of 100.
// We apply it eagerly because downstream logic wants to observe the
// tentative state immediately.
let prev = *self.reserved.borrow();
let rollback_reserved = Rc::clone(&self.reserved);
let next = self.next;
*self.reserved.borrow_mut() = next;
mutations.push(Mutation::new(
|| {
// Commit is empty: state was applied eagerly.
},
move || {
*rollback_reserved.borrow_mut() = prev;
},
));
if next > self.limit {
// Return the reject after the rollback mutation is registered.
// The engine will restore the previous state automatically.
rejects.push(Reject::new(
<Self as PreTradePolicy<O, R>>::name(self),
RejectScope::Order,
RejectCode::RiskLimitExceeded,
"temporary reservation exceeds limit",
format!("reserved {}, limit: {}", next, self.limit),
));
}
}
fn apply_execution_report(&self, _report: &R) -> bool {
false
}
}Go uses ClientEngine and typed policy interfaces to work with project-specific
order and report types:
- Embed
model.Orderinto a custom struct to add project-specific fields. - Embed
model.ExecutionReportinto a custom struct to add project-specific fields. - Implement
pretrade.ClientCheckPreTradeStartPolicy[Order, Report]orpretrade.ClientPreTradePolicy[Order, Report]— callbacks receive the typed project struct, not the genericmodel.Order. - Build the engine with
NewClientPreTradeEngineBuilder[Order, Report](), which returns a*ClientEngine[Order, Report, ...]. The client engine wraps each submitted value in a cgo handle and routes it to the typed policy callbacks.
Go
package main
import (
"fmt"
"log"
"go.openpit.dev/openpit"
"go.openpit.dev/openpit/model"
"go.openpit.dev/openpit/pretrade"
"go.openpit.dev/openpit/reject"
)
// StrategyOrder carries project-specific metadata alongside the standard order.
type StrategyOrder struct {
model.Order
StrategyTag string
}
// StrategyReport carries project-specific metadata alongside the standard report.
type StrategyReport struct {
model.ExecutionReport
VenueExecID string
}
// StrategyTagPolicy rejects orders from blocked strategy tags.
type StrategyTagPolicy struct{}
func (p *StrategyTagPolicy) Close() {}
func (p *StrategyTagPolicy) Name() string { return "StrategyTagPolicy" }
func (p *StrategyTagPolicy) CheckPreTradeStart(
_ pretrade.Context,
order StrategyOrder,
) []reject.Reject {
if order.StrategyTag == "blocked" {
return reject.NewSingleItemList(
reject.CodeComplianceRestriction,
p.Name(),
"strategy blocked",
fmt.Sprintf("strategy tag %q is not allowed", order.StrategyTag),
reject.ScopeOrder,
)
}
return nil
}
func (p *StrategyTagPolicy) ApplyExecutionReport(StrategyReport) bool {
return false
}
func main() {
builder, err := openpit.NewClientPreTradeEngineBuilder[StrategyOrder, StrategyReport]()
if err != nil {
log.Fatal(err)
}
builder.CheckPreTradeStartPolicy(&StrategyTagPolicy{})
engine, err := builder.Build()
if err != nil {
log.Fatal(err)
}
defer engine.Stop()
order := StrategyOrder{Order: model.NewOrder(), StrategyTag: "alpha"}
request, rejects, err := engine.StartPreTrade(order)
if err != nil {
log.Fatal(err)
}
if rejects != nil {
for _, r := range rejects {
fmt.Printf("rejected by %s: %s\n", r.Policy, r.Reason)
}
return
}
defer request.Close()
reservation, rejects, err := request.Execute()
if err != nil {
log.Fatal(err)
}
if rejects != nil {
for _, r := range rejects {
fmt.Printf("rejected by %s: %s\n", r.Policy, r.Reason)
}
return
}
defer reservation.Close()
reservation.Commit()
}Python custom models inherit from openpit.Order or openpit.ExecutionReport.
The original subclass instance reaches policy callbacks unchanged. Policies
access project-specific attributes by casting the received base type.
Python
import typing
import openpit
class StrategyOrder(openpit.Order):
def __init__(
self,
*,
operation: openpit.OrderOperation,
strategy_tag: str,
) -> None:
super().__init__(operation=operation)
# Project-specific metadata carried alongside the standard order fields.
self.strategy_tag = strategy_tag
class StrategyReport(openpit.ExecutionReport):
def __init__(
self,
*,
operation: openpit.ExecutionReportOperation,
financial_impact: openpit.FinancialImpact,
venue_exec_id: str,
) -> None:
super().__init__(operation=operation, financial_impact=financial_impact)
# Project-specific metadata carried alongside the standard report fields.
self.venue_exec_id = venue_exec_id
class StrategyTagPolicy(openpit.pretrade.CheckPreTradeStartPolicy):
@property
def name(self) -> str:
return "StrategyTagPolicy"
def check_pre_trade_start(
self,
ctx: openpit.pretrade.PreTradeContext,
order: openpit.Order,
) -> typing.Iterable[openpit.pretrade.PolicyReject]:
# The original subclass instance reaches the callback unchanged.
strategy_order = typing.cast(StrategyOrder, order)
if strategy_order.strategy_tag == "blocked":
return [
openpit.pretrade.PolicyReject(
code=openpit.pretrade.RejectCode.COMPLIANCE_RESTRICTION,
reason="strategy blocked",
details=(
f"strategy tag {strategy_order.strategy_tag!r} is not allowed"
),
scope=openpit.pretrade.RejectScope.ORDER,
)
]
return []
def apply_execution_report(self, report: openpit.ExecutionReport) -> bool:
_ = report
return False
engine = (
openpit.Engine.builder()
.check_pre_trade_start_policy(policy=StrategyTagPolicy())
.build()
)
order = StrategyOrder(
operation=openpit.OrderOperation(
instrument=openpit.Instrument("AAPL", "USD"),
account_id=openpit.param.AccountId.from_u64(99224416),
side=openpit.param.Side.BUY,
trade_amount=openpit.param.TradeAmount.quantity(10),
price=openpit.param.Price(25),
),
strategy_tag="alpha",
)
start_result = engine.start_pre_trade(order=order)
if not start_result:
messages = ", ".join(
f"{r.policy} [{r.code}]: {r.reason}: {r.details}"
for r in start_result.rejects
)
raise RuntimeError(messages)
execute_result = start_result.request.execute()
if not execute_result:
messages = ", ".join(
f"{r.policy} [{r.code}]: {r.reason}: {r.details}"
for r in execute_result.rejects
)
raise RuntimeError(messages)
execute_result.reservation.commit()Rust uses capability traits (Has*) and can compose OrderOperation with
project-only fields plus Deref to inherit required capabilities.
See Custom Rust Types for full derive setup, manual trait implementations, and wrapper composition patterns.
- Policies: built-in controls and policy catalog
- Pre-trade Pipeline: request and reservation semantics
- Account Adjustments: batch rollback semantics
- Custom Rust Types: Rust model composition patterns
- Custom Go Types: Go ClientEngine and typed model composition