Skip to content

Policy API

Eugene Palchukovsky edited this page Aug 1, 2026 · 18 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.
  • A registered mutation's commit and rollback callbacks are finalizers and have no right to fail; a failure arms an engine kill switch whose reach follows the registering policy. See Account Blocking - Mutation Finalizer Contract.

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

Context Lifetime

Every stage hook receives a context as its first argument. How long that context stays usable is language-specific:

  • Go: the context itself is non-owning and callback-scoped - valid only for that call; do not retain it or use it after the hook returns. An AccountControl obtained from it remains valid through the reservation's commit or rollback, or through finalization of a drop-copy operation, and may be captured by a mutation callback for deferred blocking; do not use it after that transaction completes. See Custom Go Types.
  • Python: the context owns its state, so holding a reference is safe; the AccountControl it exposes still records a block only within its own pre-trade transaction, from the callback through that request's commit or rollback. A block recorded after that transaction completes is unspecified.
  • JavaScript: the context owns its state and may be retained; once the owning operation is finalized, its AccountControl and drop-copy mutation recorder throw LifecycleError rather than writing into a finished transaction.
  • C++: the context is non-owning and callback-scoped, and is neither copyable nor movable - valid only for that call. See Custom Cpp Types.
  • Rust: the hook borrows the context, so the compiler already bounds its use to the call.

For drop copy, account-control operations requested through these contexts are applied in order before the accepted operation is returned. They are not retained as caller rollback callbacks and are not undone by a later operation rollback.

Drop-Copy Policy Hooks

Drop copy runs the same start and main policy hooks in registration order. The context flag tells a policy that ordinary risk or compliance rejects are non-enforcing because the order already happened. A policy should branch on the flag only when necessary to preserve bookkeeping - for example, Spot Funds forces TrackOnly. Pure admission policies such as order validation and order size limits do no work in drop-copy mode.

The context surfaces use each language's native naming:

Language Detect drop copy Register a start-stage mutation
Go ctx.IsDropCopy() ctx.RecordDropCopyStartMutation(commit, rollback)
Python ctx.is_drop_copy ctx.record_drop_copy_start_mutation(mutation)
JavaScript ctx.isDropCopy ctx.recordDropCopyStartMutation(mutation)
C++ ctx.IsDropCopy() ctx.RecordDropCopyStartMutation(commit, rollback)
Rust ctx.is_drop_copy() ctx.record_drop_copy_start_mutation(mutation)

Start-stage hooks have no ordinary mutation return channel, so stateful custom policies use this recorder only during an active drop-copy callback. Apply tentative state before registering a start-stage or main-stage mutation. Both recorders take the same infallible commit and rollback pair as an ordinary main-stage mutation: the commit callback runs when the caller commits the returned operation, and the rollback callback runs when the caller rolls it back or releases it unfinalized. If evaluation fails, the engine runs every collected rollback in reverse registration order before returning the rejects, and no operation is produced. Main-stage policies keep using their ordinary mutation channel.

There is no public record drop-copy account block hook. A custom policy asks for a block in the normal way by returning an account-scoped reject. Drop copy does not use that reject to deny the historical order. Ordered account-control operations are applied before the accepted operation is returned and remain in effect even if the caller later rolls the operation back. To abort the bookkeeping because the policy cannot calculate or apply the historical effect, return a standardized code for which the binding's evaluation-failure classifier is true; see Reject Codes.

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.

Calling Back Into the Engine From a Callback

A callback must not re-enter the engine that invoked it. The call is already inside that engine's storage access, so re-entering it aliases state the sync mode cannot protect.

In JavaScript the rule is enforced across engine methods, retained accounts and configure facades, requests, reservations, and drop-copy operations. They throw LifecycleError when the same engine is re-entered synchronously from one of its own policy or mutation callbacks. The guard also covers the implicit rollback that runs when a Reservation or DropCopyOperation is released without commit or rollback, so a mutation rollback closure cannot reach back into its own engine during release. Driving a different engine instance from a callback remains allowed. Match on the error class rather than its message.

In Python and C++, a nested engine or market-data call made from inside a callback does not disturb the exception owned by the operation that encloses it. Each operation owns its own callback-exception slot. A policy may catch a nested lookup failure without losing the enclosing operation's failure, and a failing account-group lookup is reported by the call that asked for it rather than being read as "this account has no group". Python keeps the first callback exception raised within one operation; remaining callbacks still run, but a later exception does not replace the first.

When a Callback Itself Fails

A callback that raises or throws is an evaluation failure, not a verdict, and drop copy handles it exactly like an ordinary pre-trade call: the engine compensates the mutations it collected and reports the failure through the binding's own channel. Python rethrows the original callback exception. C++ rethrows the original exception too, once cleanup has finished. JavaScript throws PolicyCallbackError with the original value as cause, whatever its class. An operation that an engine defect abandoned reports that InternalError instead of wrapping it.

A mutation finalizer is the one callback that has no right to fail. When one fails during a caller-driven commit or rollback it still reaches the same binding-native channel as the equivalent reservation callback, and the void call still returns normally - but the failure is not dropped: the engine arms a kill switch, and for a mutation registered by a custom policy that block covers every account, not only the pipeline's. Every mutation registered through a binding is a custom-policy mutation. Implicit rollback on release performs the same cleanup without intentionally throwing, because no caller is left to receive an error; the kill switch is then the only channel. The full contract, including the cause the engine records and how a policy author should avoid it, is in Account Blocking - Mutation Finalizer Contract.

Go does not let a panic cross the cgo boundary. A panic from either pre-trade hook - including a drop-copy hook - from either dry-run hook, or from the account-adjustment hook becomes an order-level SystemUnavailable reject whose details contain the recovered value. A panic from ApplyExecutionReport becomes a SystemUnavailable account block that the engine records for that account. A panic from policy teardown has no result channel left, so it is contained and reported to the process-wide handler installed with openpit.SetTeardownFailureHandler; without one it leaves no trace.

A native Rust callback that panics unwinds to the caller unchanged; the SDK adds no containment around policy or mutation callbacks in any pipeline, including drop copy. Rust policy and mutation callbacks must therefore not panic. The foreign bindings above convert their own callback failures before this boundary, so their documented channels are unaffected.

A Rust panic at the JavaScript WebAssembly boundary surfaces as InternalError, not a bare trap. It permanently poisons the loaded module instance: every later call that would reach core state returns the same InternalError instead of running. That covers the whole state-bearing surface - Engine, Request, Reservation, Accounts, Configurator, AccountControl, Context, MarketDataService, and ReferenceBook. The instance never recovers: discard every handle from it and reload the module before continuing.

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.

The post-trade hook returns account blocks and may push group-tagged account-adjustment and account-PnL outcomes into pretrade.PostTradeAdjustments and pretrade.PostTradePnls. The three channels are independent.

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 PolicyAccountAdjustmentResult; its independent rejects, mutations, account_blocks, and account_adjustments channels may be omitted or empty for a no-op

Policies can register:

  • Mutation(commit=callable, rollback=callable)

JavaScript / TypeScript Interface

JavaScript exposes one structural policy interface over the submitted order and execution-report model types:

  • unified interface: Policy<OrderModel, ExecutionReportModel>;
  • start stage: checkPreTradeStart(ctx, order);
  • optional read-only start dry-run: checkPreTradeStartDryRun(ctx, order);
  • main stage: performPreTradeCheck(ctx, order);
  • optional read-only main dry-run: performPreTradeCheckDryRun(ctx, order);
  • post-trade: applyExecutionReport(ctx, report);
  • account adjustment: applyAccountAdjustment(ctx, accountId, adjustment).

Business outcomes are returned as PolicyReject, PolicyPreTradeResult, PolicyAccountAdjustmentResult, PolicyDecision, and mutation values. A thrown callback exception is an API failure and is re-thrown to the caller. Each policy receives a fresh clone of the submitted order or report, including host-specific fields; account adjustments use the concrete OpenPit AccountAdjustment model.

C++ Interface

C++ has no single policy base class. A direct CustomPolicy<Handler> receives its name in the constructor, and the handler exposes one or more stage hooks detected at compile time; any other 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
  • optional read-only start dry-run: std::optional<Reject> CheckPreTradeStartDryRun(const Order&) const
  • 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
  • optional read-only main dry-run: void PerformPreTradeCheckDryRun(const Context&, tx::Mutations&, Result&, PolicyDecision&) const
  • post-trade: std::vector<accounts::AccountBlock> ApplyExecutionReport(const PostTradeContext&, const ExecutionReport&, PostTradeAdjustments&, PostTradePnls&) const - return the account blocks raised, optionally pushing group-tagged adjustment and account-PnL outcomes into the two collectors
  • account adjustment: PolicyAccountAdjustmentResult ApplyAccountAdjustment(const accountadjustment::Context&, param::AccountId, const accountadjustment::AccountAdjustment&, tx::Mutations&, AccountOutcomes&) const - validate one adjustment, optionally registering mutations and pushing outcomes; return accepted account blocks through the result's accountBlocks

Those are the direct CustomPolicy<Handler> signatures. A typed PolicyAdapter client policy additionally exposes Name() and prepends const ClientOrder& to the main-stage and main dry-run signatures, substitutes const ClientOrder& in the start and start dry-run signatures, and substitutes const ClientReport& in the post-trade signature. Missing dry-run hooks fall back to the corresponding normal hook. The adapter performs the selected checked or unchecked cast before invoking a hook. The name passed to CustomPolicy must match the client policy's Name() so engine registration and adapter-produced rejects carry one stable identity.

The handler is wrapped in an adapter that fixes the cast mode and the concrete order/report types. PolicyAdapter registers its main hook plus every optional start, dry-run, post-trade, and account-adjustment hook exposed by the same client-policy instance; StartPolicyAdapter is the start-only compatibility wrapper:

  • 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. CustomPolicy contains callback exceptions inside the SDK boundary; the invoking C++ engine method, drop copy included, rethrows the original exception after the call returns. Exceptions represent API failures; expected business outcomes are returned as Reject / PolicyDecision values.

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,
 pretrade.PostTradePnls,
) []reject.AccountBlock {
 return nil
}

func (p *NotionalCapPolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) (pretrade.PolicyAccountAdjustmentResult, []reject.Reject) {
 return pretrade.PolicyAccountAdjustmentResult{}, nil
}
Python
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
    def name(self) -> str:
        return "NotionalCapPolicy"

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

    def apply_execution_report(
        self,
        ctx: openpit.pretrade.PostTradeContext,
        report: openpit.ExecutionReport,
    ) -> openpit.pretrade.PostTradeResult | None:
        _ = ctx, report
        return None
JavaScript
import { Order } from "@openpit/engine/model";
import { Volume } from "@openpit/engine/param";
import {
  type Context,
  type Policy,
  type PolicyPreTradeResult,
  type PolicyReject,
} from "@openpit/engine/pretrade";

// Reject any order above this absolute notional. Implemented against the
// public `Policy` interface; the callbacks read the typed `Order` view.
function notionalCapPolicy(maxAbsNotional: Volume): Policy {
  const name = "NotionalCapPolicy";
  return {
    name,

    checkPreTradeStart(ctx: Context, order: Order): Iterable<PolicyReject> {
      void ctx;
      void order;
      return [];
    },

    performPreTradeCheck(
      ctx: Context,
      order: Order,
    ): PolicyPreTradeResult | null {
      void ctx;
      const operation = order.operation;
      if (operation === undefined) {
        return {
          rejects: [
            {
              code: "MissingRequiredField",
              reason: "required order field missing",
              details: "operation is not set",
              scope: "order",
            },
          ],
        };
      }

      // Translate the public order surface into one number this policy can
      // reason about: requested notional.
      const tradeAmount = operation.tradeAmount;
      if (tradeAmount === undefined) {
        return {
          rejects: [
            {
              code: "MissingRequiredField",
              reason: "required order field missing",
              details: "tradeAmount is not set",
              scope: "order",
            },
          ],
        };
      }

      let requestedNotional: Volume;
      if (tradeAmount.isVolume) {
        requestedNotional = tradeAmount.asVolume!;
      } else {
        const price = operation.price;
        if (price === undefined) {
          return {
            rejects: [
              {
                code: "OrderValueCalculationFailed",
                reason: "order value calculation failed",
                details: "price not provided for evaluating notional",
                scope: "order",
              },
            ],
          };
        }
        requestedNotional = price.calculateVolume(tradeAmount.asQuantity!);
      }

      if (requestedNotional.compare(maxAbsNotional) > 0) {
        // Business validation failures should become explicit rejects.
        return {
          rejects: [
            {
              code: "RiskLimitExceeded",
              reason: "strategy cap exceeded",
              details: `requested notional ${requestedNotional.toString()}, max allowed: ${maxAbsNotional.toString()}`,
              scope: "order",
            },
          ],
        };
      }

      // This policy only validates. It does not reserve mutable state.
      return null;
    },
  };
}
C++
#include <openpit/openpit.hpp>

// Computes settlement notional with the exact domain value types. A boundary
// failure is an API error and therefore propagates as `openpit::Error`.
[[nodiscard]] openpit::param::Volume CalculateNotional(
    const openpit::param::Price& price,
    const openpit::param::Quantity& quantity) {
  return price.CalculateVolume(quantity);
}

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

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

  void PerformPreTradeCheck(const openpit::model::Order& order,
                            const openpit::pretrade::Context& context,
                            openpit::tx::Mutations& mutations,
                            openpit::pretrade::Result& result,
                            openpit::pretrade::PolicyDecision& decision) const {
    static_cast<void>(context);
    static_cast<void>(mutations);
    static_cast<void>(result);
    if (!order.operation.has_value()) {
      openpit::pretrade::PushReject(
          decision,
          openpit::pretrade::Reject(
              std::string(Name()), openpit::pretrade::RejectScope::Order,
              openpit::pretrade::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()) {
      openpit::pretrade::PushReject(
          decision,
          openpit::pretrade::Reject(
              std::string(Name()), openpit::pretrade::RejectScope::Order,
              openpit::pretrade::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<openpit::param::Volume> requestedNotional =
        tradeAmount.AsVolume();
    if (!requestedNotional.has_value()) {
      const std::optional<openpit::param::Quantity> quantity =
          tradeAmount.AsQuantity();
      if (!operation.price.has_value()) {
        openpit::pretrade::PushReject(
            decision,
            openpit::pretrade::Reject(
                std::string(Name()), openpit::pretrade::RejectScope::Order,
                openpit::pretrade::RejectCode::OrderValueCalculationFailed,
                "order value calculation failed",
                "price not provided for evaluating notional"));
        return;
      }
      requestedNotional = CalculateNotional(*operation.price, *quantity);
    }

    if (*requestedNotional > m_maxAbsNotional) {
      // Business validation failures should become explicit rejects.
      openpit::pretrade::PushReject(
          decision,
          openpit::pretrade::Reject(
              std::string(Name()), openpit::pretrade::RejectScope::Order,
              openpit::pretrade::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,
      openpit::pretrade::PostTradePnls& pnls) const {
    static_cast<void>(context);
    static_cast<void>(report);
    static_cast<void>(adjustments);
    static_cast<void>(pnls);
    return {};
  }

 private:
  openpit::param::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,
 pretrade.PostTradePnls,
) []reject.AccountBlock {
 return nil
}

func (p *ReserveThenValidatePolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) (pretrade.PolicyAccountAdjustmentResult, []reject.Reject) {
 return pretrade.PolicyAccountAdjustmentResult{}, nil
}
Python
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
    def name(self) -> str:
        return "ReserveThenValidatePolicy"

    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])

    def apply_execution_report(
        self,
        ctx: openpit.pretrade.PostTradeContext,
        report: openpit.ExecutionReport,
    ) -> openpit.pretrade.PostTradeResult | None:
        _ = ctx, report
        return None
JavaScript
import { Order } from "@openpit/engine/model";
import { Volume } from "@openpit/engine/param";
import {
  type Context,
  type Policy,
  type PolicyPreTradeResult,
} from "@openpit/engine/pretrade";

// Updates intermediate in-memory state and may then reject the same request.
function reserveThenValidatePolicy(): Policy {
  // Policy-local state, captured by the hook closure.
  let reserved = Volume.fromString("0");
  const limit = Volume.fromString("50");

  return {
    name: "ReserveThenValidatePolicy",

    checkPreTradeStart() {
      return [];
    },

    performPreTradeCheck(
      ctx: Context,
      order: Order,
    ): PolicyPreTradeResult | null {
      void ctx;
      void order;

      // 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 prevReserved = reserved;
      const nextReserved = Volume.fromString("100");
      reserved = nextReserved;

      // Commit is empty: state was applied eagerly. Rollback restores the
      // previous value if any policy rejects; the engine runs it
      // automatically in reverse registration order.
      const rollback = {
        commit: () => {},
        rollback: () => {
          reserved = prevReserved;
        },
      };

      if (nextReserved.compare(limit) > 0) {
        // Return the reject together with the rollback mutation.
        return {
          rejects: [
            {
              code: "RiskLimitExceeded",
              reason: "temporary reservation exceeds limit",
              details: `reserved ${nextReserved.toString()}, limit: ${limit.toString()}`,
              scope: "order",
            },
          ],
          mutations: [rollback],
        };
      }

      return { mutations: [rollback] };
    },
  };
}
C++
#include <openpit/openpit.hpp>

#include <memory>
#include <string_view>

class ReserveThenValidatePolicy {
 public:
  ReserveThenValidatePolicy() = default;

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

  void PerformPreTradeCheck(const openpit::model::Order& order,
                            const openpit::pretrade::Context& context,
                            openpit::tx::Mutations& mutations,
                            openpit::pretrade::Result& result,
                            openpit::pretrade::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 openpit::param::Volume prevReserved = m_reserved;
    const openpit::param::Volume nextReserved =
        openpit::param::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;
      openpit::pretrade::PushReject(
          decision,
          openpit::pretrade::Reject(
              std::string(Name()), openpit::pretrade::RejectScope::Order,
              openpit::pretrade::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,
      openpit::pretrade::PostTradePnls& pnls) const {
    static_cast<void>(context);
    static_cast<void>(report);
    static_cast<void>(adjustments);
    static_cast<void>(pnls);
    return {};
  }

 private:
  mutable openpit::param::Volume m_reserved =
      openpit::param::Volume::FromString("0");
  openpit::param::Volume m_limit =
      openpit::param::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 (*StrategyTagPolicy) Close() {}

func (*StrategyTagPolicy) Name() string { return "StrategyTagPolicy" }

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

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

func (*StrategyTagPolicy) ApplyAccountAdjustment(
 accountadjustment.Context,
 param.AccountID,
 model.AccountAdjustment,
 tx.Mutations,
 pretrade.AccountOutcomes,
) (pretrade.PolicyAccountAdjustmentResult, []reject.Reject) {
 return pretrade.PolicyAccountAdjustmentResult{}, 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):
    def __init__(self) -> None:
        self.last_venue_exec_id: str | None = None

    @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 []

    def apply_execution_report(
        self,
        ctx: openpit.pretrade.PostTradeContext,
        report: openpit.ExecutionReport,
    ) -> openpit.pretrade.PostTradeResult | None:
        del ctx
        strategy_report = typing.cast(StrategyReport, report)
        self.last_venue_exec_id = strategy_report.venue_exec_id
        return None


policy = StrategyTagPolicy()
engine = (
    openpit.Engine.builder()
    .no_sync()
    .pre_trade(policy)
    .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()

order.strategy_tag = "blocked"
blocked = engine.start_pre_trade(order=order)
assert not blocked
assert blocked.rejects[0].code == openpit.pretrade.RejectCode.COMPLIANCE_RESTRICTION

report = StrategyReport(
    operation=openpit.ExecutionReportOperation(
        instrument=openpit.Instrument("AAPL", "USD"),
        account_id=openpit.param.AccountId.from_int(99224416),
        side=openpit.param.Side.BUY,
    ),
    financial_impact=openpit.FinancialImpact(
        pnl=openpit.param.Pnl(0),
        fee=openpit.param.Fee(0),
    ),
    venue_exec_id="venue-123",
)
engine.apply_execution_report(report=report)
assert policy.last_venue_exec_id == "venue-123"

JS Custom Models

JavaScript composes host metadata with the OpenPit wrapper types. The engine validates the standard Order / ExecutionReport fields separately, while each custom policy callback receives a fresh clone that preserves the submitted object's custom fields, prototype, symbols, and cycles. Parameterize Policy with the application model types to keep those fields typed in callbacks.

JavaScript
import { Engine } from "@openpit/engine";
import { ExecutionReport, Order } from "@openpit/engine/model";
import { TradeAmount } from "@openpit/engine/param";
import { type Policy } from "@openpit/engine/pretrade";

type StrategyOrder = Order & {
  strategyTag: string;
};

type StrategyReport = ExecutionReport & {
  venueExecId: string;
};

let appliedVenueExecId: string | undefined;
const strategyTagPolicy: Policy<StrategyOrder, StrategyReport> = {
  name: "StrategyTagPolicy",

  checkPreTradeStart(_ctx, order) {
    if (order.strategyTag === "blocked") {
      return [
        {
          code: "ComplianceRestriction",
          reason: "strategy blocked",
          details: `strategy tag ${order.strategyTag} is not allowed`,
          scope: "order",
        },
      ];
    }
    return [];
  },

  performPreTradeCheck() {
    return null;
  },

  applyExecutionReport(_ctx, report) {
    appliedVenueExecId = report.venueExecId;
    return null;
  },
};

const strategyOrder: StrategyOrder = Object.assign(new Order(), {
  strategyTag: "alpha",
});
strategyOrder.operation = {
  underlyingAsset: "AAPL",
  settlementAsset: "USD",
  accountId: 99_224_416n,
  side: "BUY",
  tradeAmount: TradeAmount.quantity("10"),
  price: "25",
};

const strategyReport: StrategyReport = Object.assign(new ExecutionReport(), {
  venueExecId: "venue-42",
});
strategyReport.operation = {
  underlyingAsset: "AAPL",
  settlementAsset: "USD",
  accountId: 99_224_416n,
  side: "BUY",
};
strategyReport.financialImpact = { pnl: "5", fee: "0.25" };

const engine = Engine.builder().preTrade(strategyTagPolicy).build();
const start = engine.startPreTrade(strategyOrder);
if (!start.ok) {
  throw new Error("strategy order must pass the start stage");
}

const request = start.request;
if (request === undefined) {
  throw new Error("accepted start result is missing its request");
}
const execute = request.execute();
if (!execute.ok) {
  throw new Error("strategy order must pass the main stage");
}
const reservation = execute.reservation;
if (reservation === undefined) {
  throw new Error("accepted execute result is missing its reservation");
}
reservation.commit();
engine.applyExecutionReport(strategyReport);

console.log(strategyOrder.strategyTag, appliedVenueExecId);

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++
#include <openpit/openpit.hpp>

#include <string>

// 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:
  explicit StrategyTagPolicy(std::shared_ptr<std::string> appliedVenueExecId)
      : m_appliedVenueExecId(std::move(appliedVenueExecId)) {}

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

  [[nodiscard]] std::optional<openpit::pretrade::Reject> CheckPreTradeStart(
      const StrategyOrder& order) const {
    if (order.strategyTag == "blocked") {
      return openpit::pretrade::Reject(
          std::string(Name()), openpit::pretrade::RejectScope::Order,
          openpit::pretrade::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,
      openpit::pretrade::PostTradePnls& pnls) const {
    static_cast<void>(context);
    static_cast<void>(adjustments);
    static_cast<void>(pnls);
    *m_appliedVenueExecId = report.venueExecId;
    return {};
  }

 private:
  std::shared_ptr<std::string> m_appliedVenueExecId;
};

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

const auto appliedVenueExecId = std::make_shared<std::string>();
openpit::pretrade::CustomPolicy<StrategyStartAdapter> policy(
    "StrategyTagPolicy",
    StrategyStartAdapter{StrategyTagPolicy{appliedVenueExecId}});

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(::openpit::param::Asset("AAPL"),
                                           ::openpit::param::Asset("USD"));
op.accountId = openpit::param::AccountId::FromUint64(99224416);
op.side = openpit::model::Side::Buy;
op.tradeAmount =
    openpit::model::TradeAmount::OfQuantity(
        openpit::param::Quantity::FromString("10"));
op.price = openpit::param::Price::FromString("25");
order.operation = std::move(op);
order.strategyTag = "alpha";

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

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

StrategyReport report;
openpit::model::ExecutionReportOperation reportOp;
reportOp.instrument = openpit::model::Instrument(
    ::openpit::param::Asset("AAPL"), ::openpit::param::Asset("USD"));
reportOp.accountId = openpit::param::AccountId::FromUint64(99224416);
reportOp.side = openpit::model::Side::Buy;
report.operation = std::move(reportOp);
report.venueExecId = "venue-42";

const openpit::PostTradeResult post = engine.ApplyExecutionReport(report);
assert(post.accountBlocks.empty());
assert(*appliedVenueExecId == "venue-42");

Rust Custom Models

Rust uses capability traits (Has*) and can compose OrderOperation with project-only fields plus Deref to inherit required capabilities.

Rust
use std::ops::Deref;

use openpit::{ExecutionReportOperation, OrderOperation};

struct StrategyOrder {
    inner: OrderOperation,
    strategy_tag: String,
}

impl Deref for StrategyOrder {
    type Target = OrderOperation;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

struct StrategyReport {
    inner: ExecutionReportOperation,
    venue_exec_id: String,
}

impl Deref for StrategyReport {
    type Target = ExecutionReportOperation;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

See Custom Rust Types for the available derive setup, manual trait implementations, and wrapper-composition patterns.

Blocking an Account from a Policy

An account-adjustment policy can accept a modification and report an account block in the same callback result. The engine commits the accepted batch, then records every reported block before apply account adjustments returns. Thus a caller that receives an account block already has a blocked account: every later start stage for it is rejected with ACCOUNT BLOCKED, without involving any policy start-check.

Pre-trade contexts still expose an optional account control handle when the engine provides the account-block facility for an order's account. A pre-trade policy may use that handle immediately or from a mutation commit closure. An account-adjustment policy reports its block through its return value instead; it does not mutate account blocking as an unreported callback side effect.

Language Surfaces

  • Go: return pretrade.PolicyAccountAdjustmentResult with AccountBlocks: []reject.AccountBlock{...} and no rejects.
  • Python: return openpit.pretrade.PolicyAccountAdjustmentResult(account_blocks=(openpit.pretrade.AccountBlock(...),)).
  • JavaScript: return a PolicyAccountAdjustmentResult object with an accountBlocks array.
  • C++: return openpit::pretrade::PolicyAccountAdjustmentResult and append an accounts::AccountBlock to accountBlocks.
  • Rust: return Ok(PolicyAccountAdjustmentResult { account_adjustments: Vec::new(), account_blocks: vec![AccountBlock::new(...)] }).

Example: Block an Account from an Adjustment Callback

Go
type BlockOnAdjustmentPolicy struct{}

func (*BlockOnAdjustmentPolicy) Close() {}

func (*BlockOnAdjustmentPolicy) Name() string {
 return "BlockOnAdjustmentPolicy"
}

func (*BlockOnAdjustmentPolicy) PolicyGroupID() model.PolicyGroupID {
 return model.DefaultPolicyGroupID
}

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

func (*BlockOnAdjustmentPolicy) PerformPreTradeCheck(
 pretrade.Context,
 model.Order,
 tx.Mutations,
 pretrade.Result,
) []reject.Reject {
 return nil
}

func (*BlockOnAdjustmentPolicy) ApplyExecutionReport(
 pretrade.PostTradeContext,
 model.ExecutionReport,
 pretrade.PostTradeAdjustments,
 pretrade.PostTradePnls,
) []reject.AccountBlock {
 return nil
}

func (p *BlockOnAdjustmentPolicy) ApplyAccountAdjustment(
 _ accountadjustment.Context,
 _ param.AccountID,
 _ model.AccountAdjustment,
 _ tx.Mutations,
 _ pretrade.AccountOutcomes,
) (pretrade.PolicyAccountAdjustmentResult, []reject.Reject) {
 return pretrade.PolicyAccountAdjustmentResult{
  AccountBlocks: []reject.AccountBlock{reject.NewAccountBlock(
   reject.CodeAccountBlocked,
   p.Name(),
   "blocked by account-adjustment policy",
   "custom policy reported an account block from a callback",
  )},
 }, nil
}
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,
    ) -> openpit.pretrade.PolicyAccountAdjustmentResult:
        del account_id, adjustment
        return openpit.pretrade.PolicyAccountAdjustmentResult(
            account_blocks=(
                openpit.pretrade.AccountBlock(
                    policy=self.name,
                    code=openpit.pretrade.RejectCode.ACCOUNT_BLOCKED,
                    reason="blocked by account-adjustment policy",
                    details="custom policy reported an account block from a callback",
                ),
            ),
        )


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

# The accepted adjustment reports a block that the engine has already recorded.
result = engine.apply_account_adjustment(
    account_id=openpit.param.AccountId.from_int(99224416),
    adjustments=[
        openpit.AccountAdjustment(
            operation=openpit.AccountAdjustmentBalanceOperation(asset="USD")
        )
    ],
)
assert result.ok
assert len(result.account_blocks) == 1

# 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
JavaScript
import { Engine } from "@openpit/engine";
import { type AccountAdjustmentContext } from "@openpit/engine/accountadjustment";
import { type AccountAdjustment } from "@openpit/engine/model";
import { AccountId, TradeAmount } from "@openpit/engine/param";
import {
  type Policy,
  type PolicyAccountAdjustmentResult,
} from "@openpit/engine/pretrade";
import { AccountBlock } from "@openpit/engine/reject";

const blockOnAdjustmentPolicy: Policy = {
  name: "BlockOnAdjustmentPolicy",

  checkPreTradeStart() {
    return [];
  },

  performPreTradeCheck() {
    return null;
  },

  applyAccountAdjustment(
    _ctx: AccountAdjustmentContext,
    _accountId: AccountId,
    _adjustment: AccountAdjustment,
  ): PolicyAccountAdjustmentResult {
    return {
      accountBlocks: [new AccountBlock(
        "BlockOnAdjustmentPolicy",
        "AccountBlocked",
        "blocked by account-adjustment policy",
        "custom policy reported an account block from a callback",
      )],
    };
  },
};

const engine = Engine.builder()
  .preTrade(blockOnAdjustmentPolicy)
  .build();

// The accepted adjustment reports a block that the engine has already recorded.
const adjustmentResult = engine.applyAccountAdjustment(99224416, [
  { operation: { asset: "USD" } },
]);
if (!adjustmentResult.ok || adjustmentResult.accountBlocks.length !== 1) {
  throw new Error("accepted adjustment must report one account block");
}

// A later order on the same account is rejected with AccountBlocked, without
// any start-check involvement.
const blocked = engine.startPreTrade({
  operation: {
    underlyingAsset: "AAPL",
    settlementAsset: "USD",
    accountId: 99224416,
    side: "BUY",
    tradeAmount: TradeAmount.quantity("10"),
    price: "25",
  },
});
if (blocked.ok) {
  throw new Error("order must be blocked");
}
if (blocked.rejects[0]!.code !== "AccountBlocked") {
  throw new Error("expected AccountBlocked");
}

In C++, the account-adjustment hook returns the block in its policy result. The outcome is the same as the other bindings: before the accepted batch result is returned, the engine records the block, and every later start stage for that account is rejected with ACCOUNT_BLOCKED without involving a policy start-check.

C++
#include <openpit/openpit.hpp>

#include <string>
#include <string_view>

// BlockOnAdjustmentPolicy accepts the adjustment and reports an account block.
class BlockOnAdjustmentPolicy {
 public:
  [[nodiscard]] std::string_view Name() const noexcept {
    return "BlockOnAdjustmentPolicy";
  }

  [[nodiscard]] openpit::pretrade::PolicyAccountAdjustmentResult
  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);
    static_cast<void>(context);
    openpit::pretrade::PolicyAccountAdjustmentResult result;
    result.accountBlocks.emplace_back(
        openpit::pretrade::RejectCode::AccountBlocked, std::string(Name()),
        "blocked by account-adjustment policy",
        "custom policy reported an account block from a callback");
    return result;
  }
};

// Builds the canonical single-leg order for `accountId`.
[[nodiscard]] openpit::model::Order AccountOrder(std::uint64_t accountId) {
  openpit::model::Order order;
  openpit::model::OrderOperation op;
  op.instrument = openpit::model::Instrument(::openpit::param::Asset("AAPL"),
                                             ::openpit::param::Asset("USD"));
  op.accountId = ::openpit::param::AccountId::FromUint64(accountId);
  op.side = openpit::model::Side::Buy;
  op.tradeAmount =
      openpit::model::TradeAmount::OfQuantity(
          openpit::param::Quantity::FromString("10"));
  op.price = openpit::param::Price::FromString("25");
  order.operation = std::move(op);
  return order;
}

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

// The accepted adjustment reports a block that the engine has already recorded.
openpit::accountadjustment::BalanceOperation balanceOp;
balanceOp.asset = ::openpit::param::Asset("USD");
openpit::accountadjustment::AccountAdjustment adjustment;
adjustment.operation =
    openpit::accountadjustment::Operation::OfBalance(std::move(balanceOp));
openpit::accountadjustment::Amount amount;
amount.balance = openpit::param::AdjustmentAmount::Absolute(
    openpit::param::PositionSize::FromString("0"));
adjustment.amount = std::move(amount);
const openpit::AdjustmentResult adjustmentResult = engine.ApplyAccountAdjustment(
    accountId,
    std::vector<openpit::accountadjustment::AccountAdjustment>{adjustment});
assert(adjustmentResult.Passed());
assert(adjustmentResult.accountBlocks.size() == 1);

// 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));
Rust
struct BlockOnAdjustmentPolicy;

impl<Order, ExecutionReport, AccountAdjustment, Sync>
    PreTradePolicy<Order, ExecutionReport, AccountAdjustment, Sync>
    for BlockOnAdjustmentPolicy
where
    Sync: openpit::SyncMode,
{
    fn name(&self) -> &str {
        "BlockOnAdjustmentPolicy"
    }

    fn apply_account_adjustment(
        &self,
        _ctx: &AccountAdjustmentContext<
            <Sync as openpit::SyncMode>::StorageLockingPolicyFactory,
        >,
        _account_id: AccountId,
        _adjustment: &AccountAdjustment,
        _mutations: &mut Mutations,
    ) -> Result<PolicyAccountAdjustmentResult, Rejects> {
        Ok(PolicyAccountAdjustmentResult {
            account_adjustments: Vec::new(),
            account_blocks: vec![AccountBlock::new(
                "BlockOnAdjustmentPolicy",
                RejectCode::AccountBlocked,
                "blocked by account-adjustment policy",
                "custom policy reported an account block from a callback",
            )],
        })
    }
}

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, pnls) []reject.AccountBlock; push account adjustments and account PnL into the two collectors.
  • Python: apply_execution_report(self, ctx: openpit.pretrade.PostTradeContext, report) -> PostTradeResult | None; return account_blocks, account_pnls, and account_adjustments in that result.
  • JavaScript: applyExecutionReport(ctx: PostTradeContext, report) -> PostTradeResult | null | undefined; return accountBlocks, accountPnls, and accountAdjustments in that result.
  • C++: std::vector<accounts::AccountBlock> ApplyExecutionReport(const pretrade::PostTradeContext&, const R&, pretrade::PostTradeAdjustments&, pretrade::PostTradePnls&) const.
  • Rust: apply_execution_report(&self, ctx: &PostTradeContext<..>, report: &R) -> Option<PostTradeResult>; return account_blocks, account_pnls, and account_adjustments in that result.

Unlike the pre-trade and account-adjustment contexts, the post-trade context carries no account control handle. Go and C++ report post-trade account blocks through the hook return and push account-adjustment and account-PnL outcomes into their collectors. Python, JavaScript, and Rust return all three independent channels as fields of PostTradeResult. The engine merges those per-policy results into the result returned to the caller, 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