PaperEx.Engine (PaperEx v0.8.0)

Copy Markdown View Source

Pure functional execution engine for PaperEx.

The primary entry point is apply_order/4. Given a portfolio, an order, a market snapshot, and a config, it returns an updated portfolio and a record of what happened. Alongside it the engine exposes advance_pending/3 and cancel_pending/3 (to resolve resting :limit remainders), resolve_market/4 (to settle every position on a market at its final payout), and open_pending_remainders/1 (to materialize resting remainders as open positions). Every one is a pure function over plain structs — there is no GenServer, no IO, and no persistence.

Two simulation modes — same engine

Per PaperEx's two-modes design constraint, the engine supports both research and live-mirror semantics by routing through different configuration, not different code paths. The same apply_order/4 emits an Execution with :filled, :missed, :skipped, :pending, :cancelled, or :closed regardless of mode; what changes between modes is the strictness of the guardrails and which adapter is plugged in (a research-mode adapter can simplify fills; a live-mirror adapter can honor exchange-specific FOK/GTC rules).

Apply-order contract

{portfolio, execution} = PaperEx.Engine.apply_order(portfolio, order, snapshot, config)

portfolio is always updated — at minimum its :executions ledger gets the new Execution appended. Cash and positions only change when the execution status is :filled. Misses, skips, and cancellations record the attempt but do not move state.

Configuration

The fourth argument is a keyword list, all keys optional:

  • :adapter — module implementing PaperEx.Adapter. Required for simulation. If absent, the engine treats the order as :skipped with reason :adapter_rejected.
  • :adapter_opts — keyword list passed to adapter callbacks (simulate_fill/3, fee/2).
  • :max_order_notional — cap on size * price of any single order. Defaults to :infinity.
  • :max_position_size — cap on shares held in any one position after the order. Defaults to :infinity.
  • :allow_shorts — when false (default), sells are limited to the size of an existing position; opening a short position yields :skipped / :shorts_disallowed.
  • :allow_negative_cash — when false (default), buys that would drive current_balance below 0 yield :skipped / :insufficient_cash. When true, the engine fills anyway and records the negative balance (useful for live-mirror research where you want to see the would-be overdraw).
  • :duplicate_policy:allow (default) or :reject. With :reject a second open in the same {market_id, strategy} is :skipped / :duplicate_position.
  • :mode:research or :live_mirror. Stored on the resulting Execution.metadata for downstream filtering; it does not itself change the fill arithmetic (the guardrail config and adapter do).
  • :pending_remainderfalse (default) drops the unfilled portion of a partial fill and lets non-crossing limits evaporate, matching pre-0.2 behavior. true gives :limit orders GTC semantics: a partial fill rests its remainder, and a pure miss (:limit_not_crossed / :no_liquidity) rests at full size — a :pending Execution is appended whose metadata records remaining_size, filled_size, market_id, side, limit_price, and strategy, ready for advance_pending/3. Only :limit orders rest; :market orders are FAK and never produce pendings. The filled portion (if any) is still applied regardless of this flag.

Future phases may add per-portfolio defaults via PaperEx.Portfolio.metadata. Today, all config is passed per-call.

Pending remainder lifecycle

When pending_remainder: true and a :limit order partially crosses, apply_order/4 returns the filled Execution (so the return shape stays {portfolio, execution}), and additionally appends a :pending Execution to portfolio.executions. The pending entry has:

  • :id{:pending_remainder, order.id}, so callers can find it later via cancel_pending/3 or advance_pending/3.
  • :order_idorder.id.
  • :status:pending.
  • :metadata%{remaining_size, filled_size, market_id, side, limit_price, order_type, strategy, mode}. strategy and mode may be nil. :side, :limit_price, and :order_type are recorded so advance_pending/3 can reconstruct the order intent against a fresh snapshot.

Callers resolve a pending remainder by:

  • advance_pending/3 — re-simulate the remainder against a fresh snapshot. Fills the portion that now crosses, leaves anything that still doesn't cross pending. See that function's docs for the full state-transition table.
  • cancel_pending/3 — append a :cancelled Execution to the ledger.
  • A fresh apply_order/4 for the remaining size (a new Order, not a reactivation of the pending one).

Position exits

Positions close two ways, both computing realized P&L:

  • Selling — an opposite-side apply_order/4 against an open long reduces or fully closes it (and with allow_shorts: true may flip past zero into a short). A partial sell books a :closed child Position to history for the closed shares, carrying that slice's realized P&L, while the parent stays :open at its original entry basis with reduced :shares. A full close transitions the position itself to :closed.
  • Market resolutionresolve_market/4 settles every position on a market at its final payout (the prediction-market exit: winning shares pay out, losing shares expire worthless) and cancels any open pending remainders on that market.

What this engine does NOT do

  • Fee math beyond what the adapter returns from PaperEx.Adapter.fee/2 (plus the explicit :win_fee_rate on resolution).
  • Mark-to-market updates triggered by snapshot changes alone — see PaperEx.Valuation for on-demand valuation against caller-supplied snapshots.

Bounded reason codes

Skipped/missed/cancelled executions carry a :reason atom from PaperEx.ReasonCodes.engine_codes/0, or an adapter-specific atom declared in the adapter's PaperEx.Adapter.reason_codes/0. The engine never inserts long error strings into the :reason slot — long detail belongs in Execution.metadata.

Summary

Types

Per-pending outcome returned by advance_pending/3.

Functions

Advance every open :pending remainder that matches snapshot.instrument_id against the fresh book.

Apply a KNOWN (observed) fill to portfolio — the live-mirror counterpart to apply_order/4.

Apply order to portfolio against snapshot, returning {updated_portfolio, execution}.

Append a :cancelled Execution to the ledger that resolves the pending remainder for order_id.

Return the currently-open pending remainders in a portfolio or execution ledger, in ledger order.

Resolve a market to its final outcome, closing every open position on it and cancelling every open pending remainder.

Types

advance_outcome()

@type advance_outcome() ::
  {:filled, PaperEx.Execution.t()}
  | {:partial, PaperEx.Execution.t(), PaperEx.Execution.t()}
  | {:still_pending, PaperEx.Execution.t()}
  | {:skipped, atom() | String.t(), PaperEx.Execution.t()}

Per-pending outcome returned by advance_pending/3.

config()

@type config() :: keyword()

Functions

advance_pending(portfolio, snapshot, opts \\ [])

@spec advance_pending(PaperEx.Portfolio.t(), PaperEx.MarketSnapshot.t(), config()) ::
  {:ok, PaperEx.Portfolio.t(), [{term(), advance_outcome()}]}

Advance every open :pending remainder that matches snapshot.instrument_id against the fresh book.

Pure function. For each eligible pending execution this:

  1. reconstructs the remaining order intent from pending.metadata (:side, :limit_price, :order_type, :remaining_size, :market_id, :strategy);
  2. simulates the remainder against snapshot via the configured adapter;
  3. applies any new fill to the portfolio;
  4. updates / resolves the pending entry by appending new ledger executions — the original pending is never mutated.

Return shape

{:ok, updated_portfolio, results}

where results is a list of {pending_execution_id, outcome} tuples preserving the order pending entries appeared in the ledger. outcome is one of:

  • {:filled, filled_execution} — the remainder filled fully. The appended filled_execution carries metadata.resolves: pending_id so the pending is considered closed by cancel_pending/3 and future advance_pending/3 calls.
  • {:partial, filled_execution, new_pending} — the remainder partially filled. The appended filled_execution covers the filled portion; new_pending has the same id ({:pending_remainder, order_id}) and an updated :remaining_size. The new pending supersedes the prior one via find_open_pending/2's most-recent-wins semantics.
  • {:still_pending, pending} — the book did not cross or had no liquidity. Nothing is appended; the existing pending stands. (Choosing not to append a :missed per non-crossing tick keeps the ledger compact under periodic Snapshotter refresh.)
  • {:skipped, reason, skipped_execution} — a guardrail rejected the advance. A :skipped execution is appended so the attempt is auditable; cash/positions are not moved; the pending stays open. Reasons: :shorts_disallowed, :insufficient_position, :insufficient_cash, :adapter_rejected, :pending_intent_missing (the metadata lacked side/limit_price — only possible for pendings created before this version), or any PaperEx.Adapter.simulate_fill/3 failure code.

Pending entries whose metadata.market_id does not match snapshot.instrument_id are left untouched and absent from results.

Configuration

opts accepts the same engine config as apply_order/4 — most importantly :adapter, :adapter_opts, :allow_shorts, :allow_negative_cash. :mode is recorded on appended executions but does not affect arithmetic.

What this function does NOT do

  • It does not cancel pendings on :missed outcomes. Pendings stay open until a :filled resolves them or the caller explicitly calls cancel_pending/3.
  • It does not split a partial fill across multiple sub-orders. A single advance walks the book once, producing at most one filled execution and at most one updated pending.
  • It treats the snapshot as static. If two pending entries on the same instrument both fill against the same book, both get the full liquidity — the engine does not model order contention. This matches apply_order/4's existing semantics for two consecutive orders against one snapshot.

apply_fill(portfolio, order, fills, config \\ [])

@spec apply_fill(
  PaperEx.Portfolio.t(),
  PaperEx.Order.t(),
  [PaperEx.Fill.t()],
  config()
) ::
  {PaperEx.Portfolio.t(), PaperEx.Execution.t()}
  | {PaperEx.Portfolio.t(), {:error, :no_fills}}

Apply a KNOWN (observed) fill to portfolio — the live-mirror counterpart to apply_order/4.

Where apply_order/4 simulates a fill against a snapshot, apply_fill/4 takes the fills that actually happened (e.g. reconciled from a live exchange) and applies them with the same cash and position accounting, returning {portfolio, execution} with a :filled Execution. The position lifecycle — open, average-in, reduce/cover, and flip — is identical to a simulated fill, so callers never have to reimplement it.

fills is a non-empty list of PaperEx.Fill structs on the same side as order. Config keys:

  • :mode — stamped on the execution metadata (e.g. :live_mirror).
  • :execution_metadata — a map merged into the execution's :metadata (e.g. the source event or instrument id).

An empty fills list returns {portfolio, {:error, :no_fills}} with no state change.

apply_order(portfolio, order, snapshot, config \\ [])

Apply order to portfolio against snapshot, returning {updated_portfolio, execution}.

Always returns a tuple. The Execution is always recorded on the portfolio's :executions ledger. Cash and positions only change on :filled results.

cancel_pending(portfolio, order_id, opts \\ [])

@spec cancel_pending(PaperEx.Portfolio.t(), term(), keyword()) ::
  {:ok, PaperEx.Portfolio.t(), PaperEx.Execution.t()}
  | {:error, :pending_not_found}

Append a :cancelled Execution to the ledger that resolves the pending remainder for order_id.

Pure function: looks up the most recent :pending Execution whose :order_id equals order_id (and which still has no later :filled/:cancelled for the same order_id). Returns {:ok, updated_portfolio, cancelled_execution} on success, {:error, :pending_not_found} otherwise.

Cash and positions are not moved — :pending never moved them either.

Options:

  • :reason — bounded atom recorded as Execution.reason. Defaults to :cancelled_by_caller.
  • :metadata — additional metadata map to merge into the cancelled execution.

open_pending_remainders(executions)

@spec open_pending_remainders(PaperEx.Portfolio.t() | [PaperEx.Execution.t()]) :: [
  PaperEx.Execution.t()
]

Return the currently-open pending remainders in a portfolio or execution ledger, in ledger order.

This is the single source of truth for "which :pending executions are still open" — the same rule cancel_pending/3 and advance_pending/3 use internally, exposed so consumers (e.g. a portfolio summary) don't re-derive it.

Accepts either a PaperEx.Portfolio (its :executions are used) or a raw list of PaperEx.Execution structs.

Open-pending rule

Walking the ledger in order, for each order_id:

  • a :pending execution opens (or re-opens) that order's remainder, superseding any earlier still-open pending for the same order_id — only the latest survives;
  • a later :cancelled execution for that order_id clears it;
  • a later :filled execution whose metadata.resolves is {:pending_remainder, order_id} clears it (this is the tag advance_pending/3 stamps on a fill that resolves a pending);
  • an unrelated :filled execution without a matching metadata.resolves does not clear it — notably the initial fill from apply_order/4, which is appended before its pending.

The returned list contains the latest open %Execution{status: :pending} per order_id, ordered by where that surviving pending sits in the ledger.

Examples

iex> PaperEx.Engine.open_pending_remainders([])
[]

A partial advance (pending v1filled resolving it → pending v2) returns only pending v2.

resolve_market(portfolio, market_id, outcome, opts \\ [])

@spec resolve_market(PaperEx.Portfolio.t(), term(), :won | :lost, keyword()) ::
  {:ok, PaperEx.Portfolio.t(),
   %{closed: [PaperEx.Position.t()], cancelled_pending: [PaperEx.Execution.t()]}}

Resolve a market to its final outcome, closing every open position on it and cancelling every open pending remainder.

This is the prediction-market exit path: when a market resolves, each share of the winning outcome pays out a fixed amount (default 1.0) and each share of the losing outcome pays 0.0. Selling into bids is no longer possible — the market is over — so this is the only correct way to close positions on a resolved market.

Pure function: {:ok, updated_portfolio, results}.

Arguments

  • market_id — every open position and open pending remainder whose market matches is affected. Strategy bucketing is irrelevant here: the market resolves for everyone.
  • outcome:won or :lost, from the perspective of the instrument the positions hold. On a binary prediction market each outcome token resolves independently: the YES token of a market that resolved YES is :won; the NO token of the same market is :lost. Callers resolve each token id separately.

Options

  • :payout_per_share — payout for a winning share. Default 1.0 (standard binary prediction market).
  • :win_fee_rate — fee rate applied to the gross payout of winning long positions (Polymarket-style fee on winnings). Default 0.0. Shorts and losing positions pay no resolution fee — there is no cash inflow to fee.
  • :mode — recorded on the appended executions' metadata.

Cash and P&L semantics

  • Long position, :won — cash increases by shares * payout - fee; pnl = (payout - entry) * shares - fee.
  • Long position, :lost — no cash movement (shares are worth nothing); pnl = -entry * shares.
  • Short position — the engine's shorts are synthetic: the sell credited cash when the short opened, so resolution buys back at the final price. Cash decreases by shares * exit_price; pnl = (entry - exit_price) * shares. A short on a :lost instrument buys back at 0.0 (profit); on a :won instrument at the payout (loss).

Position.pnl is exit-side P&L throughout this engine: (exit - entry) * shares minus exit-side fees only. Fees paid when the position opened were debited from cash at open time and are deliberately not re-subtracted here — the same convention the sell-close path in apply_order/4 uses. Total economics are always visible in current_balance; pnl answers "how did the price move against my entry, net of what closing cost me".

Option validation

:payout_per_share and :win_fee_rate must be non-negative numbers; anything else raises ArgumentError (matching the package's struct-constructor convention for programmer errors). A negative payout or fee would silently corrupt cash and P&L.

Ledger effects

For each closed position, a :closed Execution is appended with reason: :market_resolved and metadata carrying outcome, payout, and market_id. For each open pending remainder on the market, a :cancelled Execution is appended with reason: :market_resolved (the order can never fill now).

Return shape

{:ok, portfolio, %{closed: [Position.t()], cancelled_pending: [Execution.t()]}}

closed holds the now-:closed position structs (also appended to portfolio.history); cancelled_pending holds the appended :cancelled executions. Both lists are empty when nothing on the market was open — the portfolio is returned unchanged in that case.