Skip to content

Policy API

Eugene Palchukovsky edited this page Jun 27, 2026 · 13 revisions

Policy API

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

Behavioral contract first, then language-specific examples.

Stage Contracts

  • 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 request step succeeds.

For account-adjustment batch policy hooks, see Account Adjustments.

Concurrency and Policy State

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 stage or execute request while another thread reads or mutates fields used by the same policy callbacks.
  • 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 adjustments.

Language Interfaces

Go Interface

The Go SDK exposes:

  • unified interface pretrade.Policy - all stage hooks and the account-adjustment callback in one interface;
  • for custom order/report types, pretrade.ClientPreTradePolicy[Order, Report] - the same four callbacks, but order and report arrive as the typed project struct (account-adjustment still uses model.AccountAdjustment);
  • adapters with payload validation: pretrade.NewSafeClientPreTradePolicy;
  • adapters without validation, for SDK-controlled paths: pretrade.NewUnsafeFastClientPreTradePolicy;
  • built-in native policies are registered via the Builtin builder method.

Python Interface

Python exposes a unified policy class over record-style openpit.Order and openpit.ExecutionReport:

  • unified class: openpit.pretrade.Policy - all stage hooks and account-adjustment callback with default no-op implementations

Business outcomes are returned, not raised:

  • start stage returns Iterable[PolicyReject]
  • main stage returns PolicyPreTradeResult
  • account adjustment returns PolicyDecision | Iterable[PolicyReject] | tuple[Mutation, ...] | None

Policies can register:

  • Mutation(commit=callable, rollback=callable)

C++ Interface

C++ has no single policy base class. A policy is any handler object that exposes a Name() plus zero or more stage hooks, detected at compile time; an absent hook is registered as null and treated as "accept by default":

  • start stage: std::optional<Reject> CheckPreTradeStart(const Order&) const - return a Reject or std::nullopt
  • main stage: void PerformPreTradeCheck(const Context&, tx::Mutations&, Result&, PolicyDecision&) const - report zero or more rejects through PushReject(decision, ...), and optionally register mutations or push lock prices / outcomes into the collectors
  • post-trade: std::vector<accounts::AccountBlock> ApplyExecutionReport(const PostTradeContext&, const ExecutionReport&, PostTradeAdjustments&) const - return the account blocks to raise, optionally pushing group-tagged outcomes
  • account adjustment: PolicyDecision ApplyAccountAdjustment(const accountadjustment::Context&, param::AccountId, const accountadjustment::AccountAdjustment&, tx::Mutations&, AccountOutcomes&) const - validate one adjustment, optionally registering mutations and pushing outcomes

The handler is wrapped in an adapter that fixes the cast mode and the concrete order/report types:

  • StartPolicyAdapterWithSafeSlowArgType<Policy, Order, Report> and PolicyAdapterWithSafeSlowArgType<Policy, Order, Report> - recover the typed order/report from the context with a checked cast, turning a payload mismatch into a value reject;
  • the ...WithUnsafeFastArgType variants skip the check on SDK-controlled paths.

The adapter is registered through openpit::pretrade::CustomPolicy<Adapter> on the EngineBuilder. Hooks run under the engine and must never let an exception escape across the C ABI boundary; business outcomes are returned as Reject / PolicyDecision values, not thrown.

Rust Interface

Rust exposes a unified trait for custom policies and caller-defined order contracts:

  • unified trait: PreTradePolicy<Order, ExecutionReport, AccountAdjustment = ()>
    • all stage hooks and account-adjustment callback with default no-op implementations (only name is required)
  • start-stage callback receives: &PreTradeContext, &Order
  • main-stage callback receives: &PreTradeContext, &Order, &mut Mutations
  • account-adjustment callback receives: &AccountAdjustmentContext, AccountId, &A, &mut Mutations

Example: Custom Main-Stage Policy

Go
package main

import (
 "fmt"

 "go.openpit.dev/openpit/accountadjustment"
 "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) PolicyGroupID() model.PolicyGroupID {
    return model.DefaultPolicyGroupID
}

func (p *NotionalCapPolicy) CheckPreTradeStart(
 pretrade.Context,
 model.Order,
) []reject.Reject {
 return nil
}

func (p *NotionalCapPolicy) PerformPreTradeCheck(
 _ pretrade.Context,
 order model.Order,
 _ tx.Mutations,
 _ pretrade.Result,
) []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(
 pretrade.PostTradeContext,
 model.ExecutionReport,
 pretrade.PostTradeAdjustments,
) []reject.AccountBlock {
 return nil
}

func (p *NotionalCapPolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) []reject.Reject {
 return nil
}
Python
import typing

import openpit


class NotionalCapPolicy(openpit.pretrade.Policy):
    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.Context,
        order: openpit.Order,
    ) -> openpit.pretrade.PolicyPreTradeResult:
        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.PolicyPreTradeResult.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.PolicyPreTradeResult.accept()

    @typing.override
    def apply_execution_report(
        self,
        ctx: openpit.pretrade.PostTradeContext,
        report: openpit.ExecutionReport,
    ) -> openpit.pretrade.PostTradeResult | None:
        _ = ctx, report
        return None
C++
// Computes settlement notional from a per-unit price and an instrument
// quantity (notional = price * quantity), crossing the exact-decimal C ABI so
// the result is bit-for-bit identical across language bindings. Returns
// nullopt when the engine reports the multiplication as a value error, which
// the caller turns into an explicit reject rather than an exception.
[[nodiscard]] std::optional<Volume> CalculateNotional(const Price& price,
                                                      const Quantity& quantity) {
  OpenPitParamVolume raw{};
  OpenPitParamError* error = nullptr;
  if (!openpit_param_price_calculate_volume(price.Raw(), quantity.Raw(), &raw,
                                            &error)) {
    if (error != nullptr) {
      openpit_destroy_param_error(error);
    }
    return std::nullopt;
  }
  return Volume::FromRaw(raw);
}

class NotionalCapPolicy {
 public:
  // Policy-local config: reject any order above this absolute notional.
  explicit NotionalCapPolicy(Volume maxAbsNotional)
      : m_maxAbsNotional(maxAbsNotional) {}

  [[nodiscard]] std::string_view Name() const noexcept {
    return "NotionalCapPolicy";
  }

  void PerformPreTradeCheck(const openpit::model::Order& order,
                            const Context& context,
                            openpit::tx::Mutations& mutations, Result& result,
                            PolicyDecision& decision) const {
    static_cast<void>(context);
    static_cast<void>(mutations);
    static_cast<void>(result);
    if (!order.operation.has_value()) {
      PushReject(decision,
                 Reject(std::string(Name()), RejectScope::Order,
                        RejectCode::MissingRequiredField,
                        "required order field missing", "operation is not set"));
      return;
    }
    const openpit::model::OrderOperation& operation = *order.operation;

    // Translate the public order surface into one number that this policy can
    // reason about: requested notional.
    if (!operation.tradeAmount.has_value()) {
      PushReject(decision, Reject(std::string(Name()), RejectScope::Order,
                                  RejectCode::MissingRequiredField,
                                  "required order field missing",
                                  "trade_amount is not set"));
      return;
    }
    const openpit::model::TradeAmount& tradeAmount = *operation.tradeAmount;

    // A volume trade amount is already the notional; a quantity trade amount
    // must be priced into a notional (notional = price * quantity).
    std::optional<Volume> requestedNotional = tradeAmount.AsVolume();
    if (!requestedNotional.has_value()) {
      const std::optional<Quantity> quantity = tradeAmount.AsQuantity();
      if (!operation.price.has_value()) {
        PushReject(decision,
                   Reject(std::string(Name()), RejectScope::Order,
                          RejectCode::OrderValueCalculationFailed,
                          "order value calculation failed",
                          "price not provided for evaluating notional"));
        return;
      }
      requestedNotional = CalculateNotional(*operation.price, *quantity);
      if (!requestedNotional.has_value()) {
        PushReject(decision,
                   Reject(std::string(Name()), RejectScope::Order,
                          RejectCode::OrderValueCalculationFailed,
                          "order value calculation failed",
                          "price and quantity could not be used to evaluate "
                          "notional"));
        return;
      }
    }

    if (*requestedNotional > m_maxAbsNotional) {
      // Business validation failures should become explicit rejects.
      PushReject(decision,
                 Reject(std::string(Name()), RejectScope::Order,
                        RejectCode::RiskLimitExceeded, "strategy cap exceeded",
                        "requested notional " + requestedNotional->ToString() +
                            ", max allowed: " + m_maxAbsNotional.ToString()));
      return;
    }

    // This policy only validates. It does not reserve mutable state.
  }

  [[nodiscard]] std::vector<openpit::accounts::AccountBlock> ApplyExecutionReport(
      const openpit::pretrade::PostTradeContext& context,
      const openpit::ExecutionReport& report,
      openpit::pretrade::PostTradeAdjustments& adjustments) const {
    static_cast<void>(context);
    static_cast<void>(report);
    static_cast<void>(adjustments);
    return {};
  }

 private:
  Volume m_maxAbsNotional;
};
Rust
use openpit::param::{TradeAmount, Volume};
use openpit::pretrade::{
    PolicyPreTradeResult, PostTradeContext, PreTradeContext, PreTradePolicy, Reject,
    RejectCode, RejectScope, Rejects,
};
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, A, Sync> PreTradePolicy<O, R, A, Sync> for NotionalCapPolicy
where
    O: HasTradeAmount + HasOrderPrice,
    Sync: openpit::SyncMode,
{
    fn name(&self) -> &str {
        "NotionalCapPolicy"
    }

    fn perform_pre_trade_check(
        &self,
        _ctx: &PreTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        order: &O,
        _mutations: &mut Mutations,
    ) -> Result<Option<PolicyPreTradeResult>, Rejects> {
        // 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) => {
                return Err(Rejects::from(Reject::new(
                    <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                    RejectScope::Order,
                    RejectCode::MissingRequiredField,
                    "required order field missing",
                    error.to_string(),
                )));
            }
        };
        let price = match order.price() {
            Ok(price) => price,
            Err(error) => {
                return Err(Rejects::from(Reject::new(
                    <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                    RejectScope::Order,
                    RejectCode::MissingRequiredField,
                    "required order field missing",
                    error.to_string(),
                )));
            }
        };
        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(_) => {
                        return Err(Rejects::from(Reject::new(
                            <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                            RejectScope::Order,
                            RejectCode::OrderValueCalculationFailed,
                            "order value calculation failed",
                            "price and quantity could not be used to evaluate notional",
                        )));
                    }
                }
            }
            (TradeAmount::Quantity(_), None) => {
                return Err(Rejects::from(Reject::new(
                    <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                    RejectScope::Order,
                    RejectCode::OrderValueCalculationFailed,
                    "order value calculation failed",
                    "price not provided for evaluating cash flow/notional/volume",
                )));
            }
            _ => {
                return Err(Rejects::from(Reject::new(
                    <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                    RejectScope::Order,
                    RejectCode::UnsupportedOrderType,
                    "unsupported order type",
                    "custom trade amount variant is not supported by this policy",
                )));
            }
        };

        if requested_notional > self.max_abs_notional {
            // Business validation failures should become explicit rejects.
            return Err(Rejects::from(Reject::new(
                <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                RejectScope::Order,
                RejectCode::RiskLimitExceeded,
                "strategy cap exceeded",
                format!(
                    "requested notional {}, max allowed: {}",
                    requested_notional, self.max_abs_notional
                ),
            )));
        }
        Ok(None)
    }

    fn apply_execution_report(
        &self,
        _ctx: &PostTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _report: &R,
    ) -> Option<openpit::PostTradeResult> {
        None
    }
}

Rollback on Main-Stage Error

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

Example: Rollback Safety Pattern

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/accountadjustment"
 "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) PolicyGroupID() model.PolicyGroupID {
 return model.DefaultPolicyGroupID
}

func (p *ReserveThenValidatePolicy) CheckPreTradeStart(
 pretrade.Context,
 model.Order,
) []reject.Reject {
 return nil
}

func (p *ReserveThenValidatePolicy) PerformPreTradeCheck(
 _ pretrade.Context,
 _ model.Order,
 mutations tx.Mutations,
 _ pretrade.Result,
) []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(
 pretrade.PostTradeContext,
 model.ExecutionReport,
 pretrade.PostTradeAdjustments,
) []reject.AccountBlock {
 return nil
}

func (p *ReserveThenValidatePolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) []reject.Reject {
 return nil
}
Python
import typing

import openpit


class ReserveThenValidatePolicy(openpit.pretrade.Policy):
    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.Context,
        order: openpit.Order,
    ) -> openpit.pretrade.PolicyPreTradeResult:
        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.PolicyPreTradeResult.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.PolicyPreTradeResult.accept(mutations=[rollback])

    @typing.override
    def apply_execution_report(
        self,
        ctx: openpit.pretrade.PostTradeContext,
        report: openpit.ExecutionReport,
    ) -> openpit.pretrade.PostTradeResult | None:
        _ = ctx, report
        return None
C++
class ReserveThenValidatePolicy {
 public:
  ReserveThenValidatePolicy() = default;

  [[nodiscard]] std::string_view Name() const noexcept {
    return "ReserveThenValidatePolicy";
  }

  void PerformPreTradeCheck(const openpit::model::Order& order,
                            const Context& context,
                            openpit::tx::Mutations& mutations, Result& result,
                            PolicyDecision& decision) const {
    static_cast<void>(order);
    static_cast<void>(context);
    static_cast<void>(mutations);
    static_cast<void>(result);

    // Pretend that this request needs a temporary reservation of 100. We apply
    // it eagerly because downstream logic wants to observe the tentative state
    // immediately.
    const Volume prevReserved = m_reserved;
    const Volume nextReserved = Volume::FromString("100");
    m_reserved = nextReserved;

    if (m_reserved > m_limit) {
      // The decision is rejected, so the engine will not apply this request:
      // restore the previous state before returning the reject.
      m_reserved = prevReserved;
      PushReject(decision,
                 Reject(std::string(Name()), RejectScope::Order,
                        RejectCode::RiskLimitExceeded,
                        "temporary reservation exceeds limit",
                        "reserved " + nextReserved.ToString() +
                            ", limit: " + m_limit.ToString()));
    }
  }

  [[nodiscard]] std::vector<openpit::accounts::AccountBlock> ApplyExecutionReport(
      const openpit::pretrade::PostTradeContext& context,
      const openpit::ExecutionReport& report,
      openpit::pretrade::PostTradeAdjustments& adjustments) const {
    static_cast<void>(context);
    static_cast<void>(report);
    static_cast<void>(adjustments);
    return {};
  }

 private:
  mutable Volume m_reserved = Volume::FromString("0");
  Volume m_limit = Volume::FromString("50");
};
Rust
use std::cell::RefCell;
use std::rc::Rc;

use openpit::param::Volume;
use openpit::pretrade::{
    PolicyPreTradeResult, PostTradeContext, PreTradeContext, PreTradePolicy, Reject,
    RejectCode, RejectScope, Rejects,
};
use openpit::{Mutation, Mutations};

struct ReserveThenValidatePolicy {
    reserved: Rc<RefCell<Volume>>,
    next: Volume,
    limit: Volume,
}

impl<O, R, A, Sync> PreTradePolicy<O, R, A, Sync> for ReserveThenValidatePolicy
where
    Sync: openpit::SyncMode,
{
    fn name(&self) -> &str {
        "ReserveThenValidatePolicy"
    }

    fn perform_pre_trade_check(
        &self,
        _ctx: &PreTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _order: &O,
        mutations: &mut Mutations,
    ) -> Result<Option<PolicyPreTradeResult>, Rejects> {
        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 Err(Rejects::from(Reject::new(
                <Self as PreTradePolicy<O, R, A, Sync>>::name(self),
                RejectScope::Order,
                RejectCode::RiskLimitExceeded,
                "temporary reservation exceeds limit",
                format!("reserved {}, limit: {}", next, self.limit),
            )));
        }
        Ok(None)
    }

    fn apply_execution_report(
        &self,
        _ctx: &PostTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _report: &R,
    ) -> Option<openpit::PostTradeResult> {
        None
    }
}

Custom Order and Execution Report Models

Go Custom Models

Go uses ClientEngine and typed policy interfaces to work with project-specific order and report types:

  • Embed model.Order into a custom struct to add project-specific fields.
  • Embed model.ExecutionReport into a custom struct to add project-specific fields.
  • Implement pretrade.ClientPreTradePolicy[Order, Report] - all four callbacks receive the typed project struct, not the generic model.Order; account adjustment uses model.AccountAdjustment regardless of client type.
  • 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/accountadjustment"
 "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"
)

// 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) PolicyGroupID() model.PolicyGroupID { return model.DefaultPolicyGroupID }

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) PerformPreTradeCheck(
 pretrade.Context,
 StrategyOrder,
 tx.Mutations,
 pretrade.Result,
) []reject.Reject {
 return nil
}

func (p *StrategyTagPolicy) ApplyExecutionReport(
 pretrade.PostTradeContext,
 StrategyReport,
 pretrade.PostTradeAdjustments,
) []reject.AccountBlock {
 return nil
}

func (p *StrategyTagPolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) []reject.Reject {
 return nil
}

func main() {
 engine, err := openpit.NewClientPreTradeEngineBuilder[
  StrategyOrder, StrategyReport,
 ]().
  FullSync().
  PreTrade(&StrategyTagPolicy{}).
  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

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 alongside the standard report fields.
        self.venue_exec_id = venue_exec_id


class StrategyTagPolicy(openpit.pretrade.Policy):
    @property
    def name(self) -> str:
        return "StrategyTagPolicy"

    def check_pre_trade_start(
        self,
        ctx: openpit.pretrade.Context,
        order: openpit.Order,
    ) -> list[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=(
                        "strategy tag "
                        f"{strategy_order.strategy_tag!r}"
                        " is not allowed"
                    ),
                    scope=openpit.pretrade.RejectScope.ORDER,
                )
            ]
        return []


engine = (
    openpit.Engine.builder()
    .no_sync()
    .pre_trade(StrategyTagPolicy())
    .build()
)

order = StrategyOrder(
    operation=openpit.OrderOperation(
        instrument=openpit.Instrument("AAPL", "USD"),
        account_id=openpit.param.AccountId.from_int(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()

C++ Custom Models

C++ custom models derive from openpit::model::Order or openpit::model::ExecutionReport and add project-specific fields. A typed policy receives the concrete type through the SafeSlow adapter, which recovers it from the context order with a checked cast; the adapter then drives the engine builder through openpit::pretrade::CustomPolicy.

C++
// StrategyOrder carries project-specific metadata alongside the standard order.
struct StrategyOrder : public openpit::model::Order {
  std::string strategyTag;
};

// StrategyReport carries project-specific metadata alongside the standard
// report.
struct StrategyReport : public openpit::model::ExecutionReport {
  std::string venueExecId;
};

// StrategyTagPolicy rejects orders from blocked strategy tags.
class StrategyTagPolicy {
 public:
  [[nodiscard]] std::string_view Name() const noexcept {
    return "StrategyTagPolicy";
  }

  [[nodiscard]] std::optional<Reject> CheckPreTradeStart(
      const StrategyOrder& order) const {
    if (order.strategyTag == "blocked") {
      return Reject(std::string(Name()), RejectScope::Order,
                    RejectCode::ComplianceRestriction, "strategy blocked",
                    "strategy tag \"" + order.strategyTag + "\" is not allowed");
    }
    return std::nullopt;
  }

  [[nodiscard]] std::vector<openpit::accounts::AccountBlock> ApplyExecutionReport(
      const openpit::pretrade::PostTradeContext& context,
      const StrategyReport& report,
      openpit::pretrade::PostTradeAdjustments& adjustments) const {
    static_cast<void>(context);
    static_cast<void>(report);
    static_cast<void>(adjustments);
    return {};
  }
};

using StrategyStartAdapter =
    openpit::pretrade::StartPolicyAdapterWithSafeSlowArgType<StrategyTagPolicy,
                                                             StrategyOrder,
                                                             StrategyReport>;

CustomPolicy<StrategyStartAdapter> policy(
    "StrategyTagPolicy", StrategyStartAdapter{StrategyTagPolicy{}});

openpit::EngineBuilder builder(openpit::SyncPolicy::Full);
builder.Add(policy);
openpit::Engine engine = builder.Build();

StrategyOrder order;
openpit::model::OrderOperation op;
op.instrument = openpit::model::Instrument("AAPL", "USD");
op.accountId = openpit::param::AccountId::FromUint64(99224416);
op.side = openpit::model::Side::Buy;
op.tradeAmount =
    openpit::model::TradeAmount::OfQuantity(Quantity::FromString("10"));
op.price = Price::FromString("25");
order.operation = std::move(op);
order.strategyTag = "alpha";

openpit::pretrade::StartResult start = engine.StartPreTrade(order);
ASSERT_TRUE(start.Passed());

openpit::pretrade::ExecuteResult execute = start.request->Execute();
ASSERT_TRUE(execute.Passed());
execute.reservation->Commit();

Rust Custom Models

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.

Blocking an Account from a Policy

A policy can block an account (kill switch) directly from a callback through the context's account control handle. Once blocked, the engine rejects every later start stage for that account with ACCOUNT BLOCKED, without involving any policy start-check. Blocking is owned by the engine: the policy only asks the handle to record the block.

Context availability differs by stage:

  • account-adjustment context: account control is always present.
  • pre-trade context (start and main stage): account control is optional. It is present only when the engine exposes the account-block facility for the order's account; otherwise it is absent.

A policy may either block immediately, or capture the handle into a mutation rollback/commit closure to block on a deferred failure.

Language Surfaces

  • Python: ctx.account_control is an openpit.AccountControl | None in pre-trade callbacks and an openpit.AccountControl in the account-adjustment callback. Block with ctx.account_control.block(openpit.pretrade.AccountBlock(policy=..., code=openpit.pretrade.RejectCode.ACCOUNT_BLOCKED, reason=..., details=...)).
  • Rust: ctx.account_control is an Option<AccountControl<..>> in PreTradeContext and an AccountControl<..> in AccountAdjustmentContext. Block with account_control.block(AccountBlock::new(policy, code, reason, details)) using openpit::pretrade::{AccountBlock, RejectCode}.
  • Go: the account-adjustment context exposes the same facility; block through the context handle with an AccountBlock carrying reject.CodeAccountBlocked.
  • C++: context.AccountControl() is a std::optional<accounts::AccountControl> in the pre-trade context and always engaged in the account-adjustment context. Block with context.AccountControl()->Block(accounts::AccountBlock(RejectCode::AccountBlocked, policy, reason, details)).

Example: Block an Account from an Adjustment Callback

Python
import openpit


class BlockOnAdjustmentPolicy(openpit.pretrade.Policy):
    @property
    def name(self) -> str:
        return "BlockOnAdjustmentPolicy"

    def apply_account_adjustment(
        self,
        ctx: openpit.AccountAdjustmentContext,
        account_id: openpit.param.AccountId,
        adjustment: openpit.AccountAdjustment,
    ) -> None:
        del account_id, adjustment
        # The adjustment context always exposes the account-block facility.
        control: openpit.AccountControl = ctx.account_control
        control.block(
            openpit.pretrade.AccountBlock(
                policy=self.name,
                code=openpit.pretrade.RejectCode.ACCOUNT_BLOCKED,
                reason="blocked via account_control",
                details="custom policy blocked the account from a callback",
            )
        )
        return None


engine = (
    openpit.Engine.builder().no_sync().pre_trade(policy=BlockOnAdjustmentPolicy()).build()
)

# Driving an adjustment triggers the block.
engine.apply_account_adjustment(
    account_id=openpit.param.AccountId.from_int(99224416),
    adjustments=[
        openpit.AccountAdjustment(
            operation=openpit.AccountAdjustmentBalanceOperation(asset="USD")
        )
    ],
)

# A later order on the same account is rejected with ACCOUNT_BLOCKED, without
# any start-check involvement.
blocked = engine.start_pre_trade(
    order=openpit.Order(
        operation=openpit.OrderOperation(
            instrument=openpit.Instrument("AAPL", "USD"),
            account_id=openpit.param.AccountId.from_int(99224416),
            side=openpit.param.Side.BUY,
            trade_amount=openpit.param.TradeAmount.quantity(10),
            price=openpit.param.Price(25),
        ),
    )
)
assert not blocked.ok
assert blocked.rejects[0].code == openpit.pretrade.RejectCode.ACCOUNT_BLOCKED

In C++ the account-adjustment hook receives an accountadjustment::Context whose AccountControl() is always present. The policy records the kill switch through it; the outcome is the same as the other bindings: once the account is blocked, every later start stage for it is rejected with ACCOUNT_BLOCKED, without involving any policy start-check.

C++
// BlockOnAdjustmentPolicy blocks the adjusted account from its callback.
class BlockOnAdjustmentPolicy {
 public:
  [[nodiscard]] std::string_view Name() const noexcept {
    return "BlockOnAdjustmentPolicy";
  }

  [[nodiscard]] openpit::pretrade::PolicyDecision ApplyAccountAdjustment(
      const openpit::accountadjustment::Context& context,
      openpit::param::AccountId accountId,
      const openpit::accountadjustment::AccountAdjustment& adjustment,
      openpit::tx::Mutations& mutations,
      openpit::pretrade::AccountOutcomes& outcomes) const {
    static_cast<void>(accountId);
    static_cast<void>(adjustment);
    static_cast<void>(mutations);
    static_cast<void>(outcomes);
    // The adjustment context always exposes the account-block facility.
    context.AccountControl()->Block(openpit::accounts::AccountBlock(
        openpit::pretrade::RejectCode::AccountBlocked, std::string(Name()),
        "blocked via account control",
        "custom policy blocked the account from a callback"));
    return {};  // Accept the adjustment; the block is the side effect.
  }
};

openpit::EngineBuilder builder(openpit::SyncPolicy::None);
openpit::pretrade::CustomPolicy<BlockOnAdjustmentPolicy> policy(
    "BlockOnAdjustmentPolicy", BlockOnAdjustmentPolicy{});
builder.Add(policy);
openpit::Engine engine = builder.Build();

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

// Driving an adjustment triggers the block.
openpit::accountadjustment::BalanceOperation balanceOp;
balanceOp.asset = Asset("USD");
openpit::accountadjustment::AccountAdjustment adjustment;
adjustment.operation =
    openpit::accountadjustment::Operation::OfBalance(std::move(balanceOp));
const openpit::AdjustmentResult adjustmentResult = engine.ApplyAccountAdjustment(
    accountId,
    std::vector<openpit::accountadjustment::AccountAdjustment>{adjustment});

// A later order on the same account is rejected with ACCOUNT_BLOCKED, without
// any start-check involvement.
openpit::pretrade::StartResult blocked =
    engine.StartPreTrade(AccountOrder(99224416));

Post-Trade Context

The post-trade hook apply execution report receives a post-trade context as its first argument. It carries the realized outcome of an execution report back into policy state and exposes a lazy account-group accessor for the report's account.

  • Go: ApplyExecutionReport(ctx pretrade.PostTradeContext, report, adjustments).
  • Python: apply_execution_report(self, ctx: openpit.pretrade.PostTradeContext, report).
  • Rust: apply_execution_report(&self, ctx: &PostTradeContext<..>, report: &R).
  • C++: ApplyExecutionReport(const pretrade::PostTradeContext&, const R&, pretrade::PostTradeAdjustments&).

Unlike the pre-trade and account-adjustment contexts, the post-trade context carries no account control handle: a post-trade kill switch is reported through the hook's return value (AccountBlock / PostTradeResult), as described in Policies.

Reading the Account Group

The post-trade context exposes the report account's account group id through account_group() (Rust) / AccountGroup() (Go, returning an optional.Option) / account_group (Python). The pre-trade and account-adjustment contexts expose the same accessor for their bound account. The lookup is performed once and cached for the lifetime of the context, so a policy can branch on the account's group cheaply:

  • pre-trade context: the order's account; the accessor yields nothing when the order carries no account;
  • account-adjustment context: the account being adjusted;
  • post-trade context: the execution report's account.

This is the account group, distinct from the per-policy policy group id used by Pre-Trade Lock; see Account Groups.

Related Pages

Clone this wiki locally