PaperEx

Generic, exchange-agnostic paper trading and simulated-execution engine for Elixir. Pure functional core: portfolio state, execution attempts, positions, orders, fills, and a single state-transition function. No HTTP, no database, no Polymarket dependency.

This is the first paper-trading package on Hex.pm. It was extracted from a battle-tested paper-trading layer in a Polymarket prediction-market trading bot and generalized so the same engine works with any exchange via an adapter layer.

What this package is

What this package is NOT

Two simulation modes — design constraint

The package preserves a deliberate split, carried over from the production source it was extracted from:

  1. Research / idea simulation. Idealized fills, simplified exits. Answers "was the signal directionally good?".
  2. Live-mirror simulation. Realistic execution constraints — entry caps, slippage, missed fills, skipped attempts. Answers "what would live execution have done?".

The engine implements both with the same code path and a single config knob (:mode). What changes between modes is the strictness of the guardrails and the adapter you plug in. Execution attempts are first-class so a missed live-mirror order is recorded with the same shape as a successful research fill.

Quick start

alias PaperEx.{Engine, MarketSnapshot, Order, Portfolio}
# 1. Implement (or use) a PaperEx.Adapter — see test/support for a
# minimal example.
defmodule MyAdapter do
@behaviour PaperEx.Adapter
alias PaperEx.{Fill, Instrument, MarketSnapshot}
@impl true
def normalize_instrument(%{id: id}), do: {:ok, Instrument.new(id: id)}
@impl true
def normalize_snapshot(%{bids: bids, asks: asks}, %Instrument{id: id}) do
{:ok, MarketSnapshot.new(instrument_id: id, bids: bids, asks: asks)}
end
@impl true
def simulate_fill(%Order{side: :buy, size: size}, %MarketSnapshot{asks: [{p, _} | _]}, _opts) do
{:ok, :filled, [Fill.new(size: size, price: p, side: :buy)]}
end
def simulate_fill(_o, _s, _opts), do: {:ok, :missed, :no_liquidity}
end
# 2. Build a portfolio, an order, and a snapshot.
portfolio = Portfolio.new(starting_balance: 1_000.0)
order =
Order.new(
market_id: "tok-1",
side: :buy,
order_type: :market,
size: 10,
id: "order-1"
)
snapshot =
MarketSnapshot.new(
instrument_id: "tok-1",
asks: [{0.55, 100}],
bids: [{0.53, 100}]
)
# 3. Apply the order. The engine returns `{updated_portfolio, execution}`.
{portfolio, execution} =
Engine.apply_order(portfolio, order, snapshot, adapter: MyAdapter)
execution.status # => :filled
execution.fill_price # => 0.55
portfolio.current_balance # => 994.5
hd(portfolio.positions).shares # => 10

Core concepts

Order

A buy/sell request keyed by :market_id, with side (:buy | :sell), type (:limit | :market), size, optional price for limits, optional strategy tag, and a free-form metadata map. See PaperEx.Order.

Execution

A first-class record of an order attempt. :filled, :missed, :skipped, :pending, :cancelled, :closed. Carries the list of PaperEx.Fill structs and a short bounded reason atom when applicable. See PaperEx.Execution.

Position

Open or closed paper position with entry, optional exit, side, shares, strategy tag, and metadata. P&L is realized on close. See PaperEx.Position.

Portfolio

Top-level container for starting balance, current balance, peak balance, open positions, history, and the execution-attempt ledger. See PaperEx.Portfolio.

Fill

Atomic fill at one price level. Multiple fills compose one execution when an order walks several book levels. See PaperEx.Fill.

Instrument & MarketSnapshot

Normalized market id and order-book snapshot the engine and adapter exchange. The engine never fetches these — adapters translate exchange-specific data into them. See PaperEx.Instrument and PaperEx.MarketSnapshot.

Adapter behaviour

Exchange-specific translation layer:

@callback normalize_instrument(payload) :: {:ok, Instrument.t()} | {:error, term()}
@callback normalize_snapshot(payload, Instrument.t()) :: {:ok, MarketSnapshot.t()} | {:error, term()}
@callback normalize_fill(payload, Instrument.t()) :: {:ok, Fill.t()} | {:error, term()} # optional
@callback simulate_fill(Order.t(), MarketSnapshot.t(), opts) :: ... # optional
@callback fee(Fill.t(), opts) :: number() # optional
@callback reason_codes() :: [atom()] # optional

See PaperEx.Adapter. A worked minimal example lives in test/support/paper_ex/mock_adapter.ex.

Bounded reason codes

The generic engine emits a closed set of reason atoms — see PaperEx.ReasonCodes.engine_codes/0. Adapters may add their own namespaced atoms. Long error detail belongs in Execution.metadata, not in :reason.

Engine guardrails

Engine.apply_order/4 accepts an opts keyword list:

Pending remainders

For :limit orders that partially cross the book, opt in to remainder tracking, then resolve later either by cancelling or by re-simulating against a fresh snapshot:

# 1. Open with remainder tracking.
{portfolio, filled_ex} =
Engine.apply_order(portfolio, limit_order, initial_snapshot,
adapter: MyAdapter,
pending_remainder: true
)
# portfolio.executions now contains [filled_ex, pending_ex]
# where pending_ex.status == :pending, pending_ex.id == {:pending_remainder, order.id}
# 2a. Cancel the remainder explicitly.
{:ok, portfolio, cancelled_ex} =
Engine.cancel_pending(portfolio, limit_order.id, reason: :cancelled_by_caller)
# 2b. Or advance the remainder against a fresh snapshot. Each
# matching pending is re-simulated; fills are applied and pending
# entries shrink (partial) or close (full).
{:ok, portfolio, results} =
Engine.advance_pending(portfolio, fresh_snapshot,
adapter: MyAdapter
)
# results == [{pending_id, {:filled, ex}}
# | {pending_id, {:partial, ex, new_pending}}
# | {pending_id, {:still_pending, pending}}
# | {pending_id, {:skipped, reason, ex}}]

Both cancel_pending/3 and advance_pending/3 are pure functions — no GenServer, no processes. Callers persist the portfolio however they want (a host like PolymarketBot.Paper.PortfolioServer can call advance_pending/3 after every snapshot refresh to progress resting limit orders without strategy-specific glue).

To inspect which pending remainders are currently open (for a status view, dashboard, or reconciliation), use Engine.open_pending_remainders/1 — it accepts a Portfolio or a raw execution list and returns the open :pending executions in ledger order. It is the single source of truth for the open-pending rule that cancel_pending/3 and advance_pending/3 use internally.

Status — 0.4.0

Implemented:

Not implemented yet:

Installation

After publication:

def deps do
[
{:paper_ex, "~> 0.4.0"}
]
end

License

MIT.