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 implementingPaperEx.Adapter. Required for simulation. If absent, the engine treats the order as:skippedwith reason:adapter_rejected.:adapter_opts— keyword list passed to adapter callbacks (simulate_fill/3,fee/2).:max_order_notional— cap onsize * priceof 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— whenfalse(default), sells are limited to the size of an existing position; opening a short position yields:skipped/:shorts_disallowed.:allow_negative_cash— whenfalse(default), buys that would drivecurrent_balancebelow0yield:skipped/:insufficient_cash. Whentrue, 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:rejecta second open in the same{market_id, strategy}is:skipped/:duplicate_position.:mode—:researchor:live_mirror. Stored on the resultingExecution.metadatafor downstream filtering; it does not itself change the fill arithmetic (the guardrail config and adapter do).:pending_remainder—false(default) drops the unfilled portion of a partial fill and lets non-crossing limits evaporate, matching pre-0.2 behavior.truegives:limitorders GTC semantics: a partial fill rests its remainder, and a pure miss (:limit_not_crossed/:no_liquidity) rests at full size — a:pendingExecutionis appended whose metadata recordsremaining_size,filled_size,market_id,side,limit_price, andstrategy, ready foradvance_pending/3. Only:limitorders rest;:marketorders 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 viacancel_pending/3oradvance_pending/3.:order_id—order.id.:status—:pending.:metadata—%{remaining_size, filled_size, market_id, side, limit_price, order_type, strategy, mode}.strategyandmodemay benil.:side,:limit_price, and:order_typeare recorded soadvance_pending/3can 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:cancelledExecutionto the ledger.- A fresh
apply_order/4for the remaining size (a newOrder, not a reactivation of the pending one).
Position exits
Positions close two ways, both computing realized P&L:
- Selling — an opposite-side
apply_order/4against an open long reduces or fully closes it (and withallow_shorts: truemay flip past zero into a short). A partial sell books a:closedchildPositiontohistoryfor the closed shares, carrying that slice's realized P&L, while the parent stays:openat its original entry basis with reduced:shares. A full close transitions the position itself to:closed. - Market resolution —
resolve_market/4settles 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_rateon resolution). - Mark-to-market updates triggered by snapshot changes alone —
see
PaperEx.Valuationfor 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
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
@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.
@type config() :: keyword()
Functions
@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:
- reconstructs the remaining order intent from
pending.metadata(:side,:limit_price,:order_type,:remaining_size,:market_id,:strategy); - simulates the remainder against
snapshotvia the configured adapter; - applies any new fill to the portfolio;
- 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 appendedfilled_executioncarriesmetadata.resolves: pending_idso the pending is considered closed bycancel_pending/3and futureadvance_pending/3calls.{:partial, filled_execution, new_pending}— the remainder partially filled. The appendedfilled_executioncovers the filled portion;new_pendinghas the same id ({:pending_remainder, order_id}) and an updated:remaining_size. The new pending supersedes the prior one viafind_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:missedper non-crossing tick keeps the ledger compact under periodic Snapshotter refresh.){:skipped, reason, skipped_execution}— a guardrail rejected the advance. A:skippedexecution 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 anyPaperEx.Adapter.simulate_fill/3failure 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
:missedoutcomes. Pendings stay open until a:filledresolves them or the caller explicitly callscancel_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.
@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.
@spec apply_order( PaperEx.Portfolio.t(), PaperEx.Order.t(), PaperEx.MarketSnapshot.t(), config() ) :: {PaperEx.Portfolio.t(), PaperEx.Execution.t()}
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.
@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 asExecution.reason. Defaults to:cancelled_by_caller.:metadata— additional metadata map to merge into the cancelled execution.
@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
:pendingexecution opens (or re-opens) that order's remainder, superseding any earlier still-open pending for the sameorder_id— only the latest survives; - a later
:cancelledexecution for thatorder_idclears it; - a later
:filledexecution whosemetadata.resolvesis{:pending_remainder, order_id}clears it (this is the tagadvance_pending/3stamps on a fill that resolves a pending); - an unrelated
:filledexecution without a matchingmetadata.resolvesdoes not clear it — notably the initial fill fromapply_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 v1 → filled resolving it →
pending v2) returns only pending v2.
@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—:wonor: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. Default1.0(standard binary prediction market).:win_fee_rate— fee rate applied to the gross payout of winning long positions (Polymarket-style fee on winnings). Default0.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 byshares * 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:lostinstrument buys back at0.0(profit); on a:woninstrument 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.