Skip to content

Non-Mutating Dry-Run

Probe the pre-trade pipeline without changing any engine state.

The normal pre-trade pipeline mutates by design. The start stage spends rate-limit budget on every call, even rejected ones. The main stage creates reservations and holds. There is no "undo path" that restores the engine to its exact pre-call state: rollback cancels the reservation but the budget is already spent.

A dry-run is a completely separate, explicitly non-mutating path in the engine. It runs the same policy checks against the current engine state and reports what the equivalent real call would produce - verdict, would-be lock, and would-be account adjustments - while touching nothing. No rate-limit budget is spent, no reservation or hold is applied, and no account block is recorded. Repeating a dry-run is idempotent: a second call with the same order and state produces the same answer.

What a Dry-Run Returns

The engine returns an inert report object (not a transaction). There is nothing to commit or roll back.

Accessor Meaning
is pass true when all stages would have admitted the order
rejects The rejects the pipeline would have collected, or empty/nil
lock The PreTradeLock the main stage would have produced
account adjustments Per-asset held/available outcomes the main stage would have reported
account block The account block a real call would have recorded in the engine's blocked-accounts registry

lock and account adjustments are empty when the start stage would have rejected (the main stage never runs in that case, exactly as in the real pipeline). account block is reported here but is not recorded in the engine.

Entry Points

Two entry points mirror the real pre-trade API:

  • start pre trade dry run - runs only the start stage in dry-run mode. Use this when you want a fast admission check without computing a lock or adjustments.
  • execute pre trade dry run - runs both stages in dry-run mode. Returns the full report including would-be lock and adjustments.

Examples

Read the Dry-Run Verdict

Go
report, err := engine.ExecutePreTradeDryRun(order)
if err != nil {
  panic(err)
}
defer report.Close()

if report.IsPass() {
  fmt.Println("order would be admitted")
} else {
  for _, r := range report.Rejects() {
    fmt.Printf(
      "would reject by %s [%d]: %s (%s)\n",
      r.Policy,
      r.Code,
      r.Reason,
      r.Details,
    )
  }
}
Python
report = engine.execute_pre_trade_dry_run(order=order)
if report.is_pass:
    print("order would be admitted")
else:
    for reject in report.rejects:
        print(
            f"would reject by {reject.policy} "
            f"[{reject.code}]: {reject.reason}: {reject.details}"
        )
C++
const openpit::pretrade::DryRunReport report =
    engine.ExecutePreTradeDryRun(order);
if (report.Passed()) {
  std::cout << "order would be admitted\n";
} else {
  for (const openpit::pretrade::Reject& reject : report.Rejects()) {
    std::cout << "would reject by " << reject.policy << " ["
              << static_cast<int>(reject.code) << "]: " << reject.reason
              << " (" << reject.details << ")\n";
  }
}
Rust
let report = engine.execute_pre_trade_dry_run(order);
if report.is_pass() {
    println!("order would be admitted");
} else {
    for reject in report.rejects().unwrap().iter() {
        eprintln!(
            "would reject by {} [{}]: {} ({})",
            reject.policy, reject.code, reject.reason, reject.details
        );
    }
}

Use the Dry-Run Before a Real Call

A common pattern: probe first, then commit only when the probe passes.

Go
// Probe without spending any budget or creating any reservation.
probe, err := engine.ExecutePreTradeDryRun(order)
if err != nil {
  panic(err)
}
defer probe.Close()

if !probe.IsPass() {
  return // would have been rejected - skip the real call
}

// The real call now runs with fresh state; the probe had no effect.
reservation, rejects, err := engine.ExecutePreTrade(order)
if err != nil {
  panic(err)
}
if rejects != nil {
  return
}
defer reservation.Close()
reservation.Commit()
Python
# Probe without spending any budget or creating any reservation.
probe = engine.execute_pre_trade_dry_run(order=order)
if not probe.is_pass:
    pass  # would have been rejected - skip the real call
else:
    # The real call now runs with fresh state; the probe had no effect.
    result = engine.execute_pre_trade(order=order)
    if result:
        result.reservation.commit()
C++
// Probe without spending any budget or creating any reservation.
const openpit::pretrade::DryRunReport probe =
    engine.ExecutePreTradeDryRun(order);
if (!probe.Passed()) {
  return;  // would have been rejected - skip the real call
}

// The real call now runs with fresh state; the probe had no effect.
openpit::pretrade::ExecuteResult result = engine.ExecutePreTrade(order);
if (result.Passed()) {
  result.reservation->Commit();
}
Rust
// Probe without spending any budget or creating any reservation.
let probe = engine.execute_pre_trade_dry_run(order.clone());
if probe.is_pass() {
    // The real call now runs with fresh state; the probe had no effect.
    engine.execute_pre_trade(order)?.commit();
}

Custom Policy Dry-Run Hooks

Side-effect-free policies get dry-run support for free. The engine calls the dry-run hooks instead of the normal ones on the dry-run path; the default implementations simply delegate to the normal hooks.

A policy whose normal hook produces an immediate side effect - spending budget, applying a hold - must override the dry-run variant to be read-only.

Normal hook Dry-run hook Default
check pre trade start check pre trade start dry run delegates to normal
perform pre trade check perform pre trade check dry run delegates to normal

Built-in policies with side effects already override their dry-run variants: the rate-limit policy reads the window counter without spending budget; the spot-funds policy computes the would-be reserve without applying any hold.

Example: Read-Only Custom Start-Stage Hook

Go The `DryRunPolicy` interface carries the two dry-run hooks. When a policy satisfies `DryRunPolicy`, the engine uses the dry-run methods instead of the normal ones for the dry-run path.
type MyCountingPolicy struct {
  count int
}

func (*MyCountingPolicy) Close()                              {}
func (*MyCountingPolicy) Name() string                       { return "MyCountingPolicy" }
func (*MyCountingPolicy) PolicyGroupID() model.PolicyGroupID { return model.DefaultPolicyGroupID }

func (p *MyCountingPolicy) CheckPreTradeStart(
  _ pretrade.Context,
  _ model.Order,
) []reject.Reject {
  p.count++ // side effect: spends the real counter budget
  return nil
}

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

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

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

// CheckPreTradeStartDryRun is the read-only variant: no counter increment.
func (*MyCountingPolicy) CheckPreTradeStartDryRun(
  _ pretrade.Context,
  _ model.Order,
) []reject.Reject {
  return nil // read current state without spending the budget
}

// PerformPreTradeCheckDryRun is the read-only variant.
func (*MyCountingPolicy) PerformPreTradeCheckDryRun(
  _ pretrade.Context,
  _ model.Order,
  _ tx.Mutations,
  _ pretrade.Result,
) []reject.Reject {
  return nil
}
Python Defining `check_pre_trade_start_dry_run` or `perform_pre_trade_check_dry_run` on the policy class opts that hook into read-only mode on the dry-run path. Methods that are absent fall back to the normal hooks.
class MyCountingPolicy(openpit.pretrade.Policy):
    def __init__(self) -> None:
        self._count = 0

    @property
    def name(self) -> str:
        return "MyCountingPolicy"

    def check_pre_trade_start(
        self,
        ctx: openpit.pretrade.Context,
        order: openpit.Order,
    ) -> openpit.pretrade.Rejects:
        self._count += 1  # side effect: spends the real counter budget
        return ()  # pass

    def check_pre_trade_start_dry_run(
        self,
        ctx: openpit.pretrade.Context,
        order: openpit.Order,
    ) -> openpit.pretrade.Rejects:
        return ()  # read-only: no increment
C++ Define `CheckPreTradeStartDryRun` or `PerformPreTradeCheckDryRun` on the handler type passed to `CustomPolicy`. Hooks that are absent fall back to their normal counterparts.
class MyCountingPolicy {
 public:
  explicit MyCountingPolicy(std::uint64_t* count) : m_count(count) {}

  [[nodiscard]] std::optional<openpit::pretrade::Reject> CheckPreTradeStart(
      const openpit::Order& order) const {
    static_cast<void>(order);
    ++*m_count;  // side effect: spends the real counter budget
    return std::nullopt;
  }

  void PerformPreTradeCheck(const openpit::pretrade::Context& context,
                            openpit::pretrade::PolicyDecision& decision) const {
    static_cast<void>(context);
    static_cast<void>(decision);
  }

  // CheckPreTradeStartDryRun is the read-only variant: no counter increment.
  [[nodiscard]] std::optional<openpit::pretrade::Reject>
  CheckPreTradeStartDryRun(const openpit::Order& order) const {
    static_cast<void>(order);
    return std::nullopt;  // read current state without spending the budget
  }

  // PerformPreTradeCheckDryRun is the read-only variant.
  void PerformPreTradeCheckDryRun(
      const openpit::pretrade::Context& context,
      openpit::pretrade::PolicyDecision& decision) const {
    static_cast<void>(context);
    static_cast<void>(decision);
  }

 private:
  std::uint64_t* m_count;
};
Rust Override `check_pre_trade_start_dry_run` and/or `perform_pre_trade_check_dry_run` on the `PreTradePolicy` trait. Methods that are not overridden delegate to the normal hooks automatically.
use openpit::pretrade::{
    PolicyPreTradeResult, PostTradeContext, PreTradeContext, PreTradePolicy, Rejects,
};
use openpit::Mutations;

struct MyCountingPolicy {
    count: std::cell::Cell<u64>,
}

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

    fn check_pre_trade_start(
        &self,
        _ctx: &PreTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _order: &O,
    ) -> Result<(), Rejects> {
        self.count.set(self.count.get() + 1); // side effect
        Ok(())
    }

    // Dry-run variant: read-only, no counter increment.
    fn check_pre_trade_start_dry_run(
        &self,
        _ctx: &PreTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _order: &O,
    ) -> Result<(), Rejects> {
        Ok(()) // inspect state without spending budget
    }

    fn perform_pre_trade_check(
        &self,
        _ctx: &PreTradeContext<<Sync as openpit::SyncMode>::StorageLockingPolicyFactory>,
        _order: &O,
        _mutations: &mut Mutations,
    ) -> Result<Option<PolicyPreTradeResult>, Rejects> {
        Ok(None)
    }

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