@openpit/engine

@openpit/engine - OpenPit (Pre-trade Integrity Toolkit) for JavaScript

Verify Release npm node License

@openpit/engine is an embeddable pre-trade risk SDK for integrating policy-driven risk checks into trading systems from JavaScript and TypeScript. It is a WebAssembly build of the OpenPit engine that runs the same way in Node, browsers, Deno, Bun, and edge runtimes (Cloudflare Workers) behind a single import, with no native add-on to compile and no await in the common path.

For an overview and links to all resources, see the project website openpit.dev. For the generated API reference, see the JS API docs. For full project documentation, see the repository README. For conceptual and architectural pages, see the project wiki.

Before the 1.0 release OpenPit follows a relaxed Semantic Versioning:

  • PATCH releases carry bug fixes and small internal corrections.
  • MINOR releases may introduce new features and may also change the public interface.

Breaking API changes can appear in minor releases before 1.0. Pick version constraints that tolerate API evolution during the pre-stable phase.

npm install @openpit/engine

The package ships two platform builds behind one import. Both instantiate the wasm synchronously at load, so there is no await in the common path:

  • Node reads the sibling .wasm from disk (smallest footprint, fastest cold start).
  • Browser / edge uses base64-inlined wasm - no fetch, no fs, no extra asset to host. It works on any CDN with zero configuration.

CDN / no build step (browser, Deno) via esm.sh:

<script type="module">
import { Engine } from "https://esm.sh/@openpit/engine";
// or: https://cdn.jsdelivr.net/npm/@openpit/engine/+esm
</script>

Deno, via the npm specifier:

import { Engine as DenoEngine } from "npm:@openpit/engine";

End users should install the published npm package; a source build is needed only for local development on the binding itself. The full toolchain (Rust with the wasm32-unknown-unknown target, wasm-bindgen, wasm-opt, and the Node dev dependencies) is provisioned by:

just install

Note that just install provisions the complete build toolchain for the whole repository. If you do not need the full build, read the recipe in justfile and install only the parts you need for this package: the wasm32-unknown-unknown target, a matching wasm-bindgen-cli, and the local npm dependencies. Then:

cd bindings/js
npm install
npm run build

Prices, quantities, and money cross the boundary as decimal strings - the only lossless form for full- and variable-scale instruments. This mirrors what major exchange APIs do on the wire, and it sidesteps the IEEE-754 rounding that silently corrupts trailing digits (0.1 + 0.2 === 0.30000000000000004).

The DecimalInput type accepted on input is:

type DecimalInput = string | number | bigint;
  • string is the recommended, lossless form ("100.50", "0.00847000").
  • bigint is safe for exact integer values.
  • number is an IEEE-754 double and is accepted only as a convenience for small exact integers. Never use number for fractional money.

Value types are constructed from DecimalInput and serialize back to a decimal string; they never return a raw number for money.

import { Price } from "@openpit/engine/param";

const price = Price.fromString("100.50");
price.toString(); // "100.50"
price.toJSON(); // "100.50" (so JSON.stringify is lossless)

// Quantize to an instrument tick with an explicit rounding strategy.
Price.fromStringRounded("1.005", 2, "default").toString(); // "1.00"

The canonical rounding strategies are "midpointNearestEven", "midpointAwayFromZero", "up", and "down". Four ergonomic aliases are also available: "default"/"banker" map to midpoint nearest-even and "conservativeProfit"/"conservativeLoss" map to round-down. The plain strings are accepted directly; the package also exports a RoundingStrategies constant (and matching RoundingStrategy type) for autocomplete, alongside RejectCode, RejectScope, and SpotFundsPricingSource value sets for the stable strings accepted by this package. The param subpath also exports the five FillType wire values and the nine ParamKind names used by cross-language error reporting.

You do not have to construct wrapper classes to feed the engine. Scalar inputs accept a plain value (accountId: 99224416, side: "BUY", price: "185.00"), and every group accepts a plain object literal - so an order or an execution report can be written inline, as in the Usage example. The wrapper classes (Price, AccountId, OrderOperation, ...) remain available as a typed alternative and are interchangeable with the plain forms.

Ordinary setters and input coercions borrow or clone wrapper values, so passing a Price, AccountId, model group, or similar value does not invalidate the caller's handle. Their .clone() methods are available when an independent copy is useful, but are not required just to assign a value. Only lifecycle handles and staged builders (Request, Reservation, Mutation, and consumed engine builders) intentionally become unusable after their terminal operation.

The engine evaluates an order through a deterministic pre-trade pipeline:

  • engine.startPreTrade(order) runs lightweight start-stage checks and returns a single-use Request.
  • request.execute() runs main-stage check policies and returns a single-use Reservation.
  • reservation.commit() applies the reserved state; reservation.rollback() reverts it. Exactly one of the two must be called, exactly once.
  • engine.executePreTrade(order) is a shortcut that collapses start and main into one step, still returning a Reservation to commit or roll back.
  • engine.startPreTradeDryRun(order) / engine.executePreTradeDryRun(order) run the same checks on a read-only path and report what would have happened without spending rate-limit budget, creating reservations, or recording account blocks.
  • engine.applyExecutionReport(report) updates post-trade policy state.
  • engine.configure() retunes registered built-ins at runtime, including spot-funds limit modes and the generic / spot-funds P&L accumulators.

Start-stage checks aggregate rejects from all registered policies. Main-stage checks aggregate rejects and run rollback mutations in reverse order when any reject is produced.

The five built-in policies are:

You can also write project-specific policies against the public policy API: Custom policies.

The WebAssembly engine is single-threaded and always uses no-op locking. The builder exposes no sync-mode selection, and the engine and its handles are !Send. There is no async engine and no off-thread execution: every policy callback runs synchronously on the calling thread. Use one engine instance per worker/isolate when you need parallelism.

import { Engine } from "@openpit/engine";
import { TradeAmount } from "@openpit/engine/param";
import {
type OrderInit,
type ExecutionReportInit,
} from "@openpit/engine/model";
import { buildOrderValidation } from "@openpit/engine/pretrade/policies";

// 1. Build the engine once, at platform initialization.
const engine = Engine.builder().builtin(buildOrderValidation()).build();

// 2. Assemble an order as a plain object. Scalars accept plain values (the
// account id as a number, the price as a decimal string); the order itself
// is an object literal - no wrapper classes to construct. The OrderInit
// annotation is optional; it just lets the literal sit in its own variable.
const order: OrderInit = {
operation: {
underlyingAsset: "AAPL",
settlementAsset: "USD",
accountId: 99224416,
side: "BUY",
tradeAmount: TradeAmount.quantity("100"),
price: "185.00",
},
};

// 3. Start stage: lightweight checks, no state change yet.
const start = engine.startPreTrade(order);
if (!start.ok) {
const reasons = start.rejects
.map((r) => `${r.policy} [${r.code}]: ${r.reason}`)
.join(", ");
throw new Error(reasons);
}

// 4. Main stage: full pre-trade and risk control.
const request = start.request;
if (request === undefined) {
throw new Error("accepted start result is missing its request");
}
const execute = request.execute();
if (!execute.ok) {
const reasons = execute.rejects
.map((r) => `${r.policy} [${r.code}]: ${r.reason}`)
.join(", ");
throw new Error(reasons);
}

// 5. Commit once the venue accepts the order; roll back otherwise.
const reservation = execute.reservation;
if (reservation === undefined) {
throw new Error("accepted execute result is missing its reservation");
}
try {
// sendOrderToVenue(order);
reservation.commit();
} catch (err) {
reservation.rollback();
throw err;
}

// 6. Feed the venue's execution report back into post-trade policy state, again
// as a plain object literal. P&L and fee cross as decimal strings.
const report: ExecutionReportInit = {
operation: {
underlyingAsset: "AAPL",
settlementAsset: "USD",
accountId: 99224416,
side: "BUY",
},
financialImpact: { pnl: "-50", fee: "3.4" },
};

const result = engine.applyExecutionReport(report);
// A non-empty `accountBlocks` means a kill switch has fired for the account.
if (result.accountBlocks.length > 0) {
// Halt routing for the blocked account.
}

Policy rejects from engine.startPreTrade() and request.execute() are not exceptions: they are returned on the StartResult and ExecuteResult (ok plus a rejects array).

Malformed JS shapes throw native TypeError; invalid values and numeric ranges throw RangeError subclasses such as ParamError, AssetError, and AccountIdError, all rooted at OpenpitValueError. Lifecycle and engine-state failures use named Error subclasses. Every error OpenPit constructs at the boundary is also branded, so instanceof OpenpitError remains the catch-all in both the Node and browser builds; branch on the native category, concrete class, or stable err.name:

import { ParamError, OpenpitError } from "@openpit/engine";
import { Price } from "@openpit/engine/param";

try {
Price.fromString("not a number");
} catch (err) {
if (err instanceof ParamError) {
console.error(err.code); // e.g. "InvalidFormat"
} else if (err instanceof OpenpitError) {
console.error(err.name, err.message);
}
}

The base class and every subclass are exported from the root @openpit/engine; AccountBlockError is also re-exported from @openpit/engine/reject. The subclasses are:

  • ParamError - invalid numeric input, arithmetic overflow, or a malformed value.
  • AssetError / AccountIdError - empty or invalid asset / account identifiers.
  • LifecycleError - single-use misuse: executing the same request twice, finalizing the same reservation twice, using a stale account-control handle, or reusing an engine builder that was already consumed.
  • EngineBuildError - building an engine with no policy registered, or with a duplicate policy name / group id or an invalid built-in configuration.
  • PolicyConfigureError - unknown policy, settings-type mismatch, rejected update, or a non-reentrant nested configuration call.
  • PolicyCallbackError - a custom JS callback threw. Its cause is the original thrown value; result carries the completed post-trade or account- adjustment reconciliation result when that operation produces one.
  • MarketDataError (and UnknownInstrument / QuoteUnavailable), RegistrationError / AlreadyRegistered / UnknownInstrumentId, AccountGroupRegistrationError, and AccountBlockError for the remaining market-data, registration, account-group, and admin-block failures.

Two stable code vocabularies appear, and they do not overlap:

  • err.code (an ErrorCode, exported from the root) is set only on ParamError, AssetError, and AccountIdError. It classifies a value failure - for example "InvalidFormat", "Overflow", "Negative", or "Other" when no finer code applies. ParamError.param identifies the affected value type and ParamError.input preserves malformed text where the core reports it. The other subclasses leave code undefined.
  • AccountBlockError.kind (an AccountBlockErrorKind) is "ReservedGroup", "AccountNotBlocked", or "GroupNotBlocked", telling the three admin-block failures apart.
  • Registration, account-group, engine-build, and policy-configuration failures expose their own stable kind plus structured fields such as instrumentId, accountId, policyName, policyGroupId, expected, and found; callers do not need to parse human-readable messages.
  • RejectCode (exported from @openpit/engine/reject) is a separate vocabulary carried on a business Reject, never on a thrown error. Business rejects use stable codes such as "OrderValueCalculationFailed" when a policy cannot evaluate order value without a price.

The package ships ESM and CommonJS builds for Node >=18 and browser/edge bundlers. Every environment resolves the root . or any subpath to the matching node/ or browser/ entry and module format:

Environment Resolves to Wasm
Node ESM (>=18) node/<entry>.js from disk
Node CommonJS (>=18) node/<entry>.cjs from disk
Bundlers (Vite, webpack, Rollup, esbuild) browser/<entry>.{js,cjs} inlined
Browsers via CDN (esm.sh, jsDelivr, unpkg) browser/<entry>.js inlined
Deno (npm: specifier or esm.sh) node / browser ESM entry disk / inlined
Bun node/<entry>.{js,cjs} from disk
Cloudflare Workers / edge browser/<entry>.{js,cjs} inlined

<entry> is index for the root import or the subpath name (for example param, marketdata, pretrade/policies). The inlined browser build needs no asset-loader configuration and makes no network request at import, so CDN and Workers usage is zero-config.

Apache-2.0. See LICENSE and OWNERS.