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

  • A pure functional core for paper trading. Plain structs and a single state-transition function — no GenServers, no processes, no side effects.
  • A bounded vocabulary for what happened to an order: :filled, :missed, :skipped, :pending, :cancelled, :closed — peer values, never collapsed into "no trade".
  • An adapter behaviour for exchange-specific data normalization. The engine never fetches a quote, an order book, or a fill; the caller hands it normalized data and the engine simulates.

What this package is NOT

  • Not a complete trading platform.
  • Not Polymarket-specific. Zero references to token IDs, condition IDs, Gamma, CLOB, or RTDS. A Polymarket adapter lives in a separate paper_ex_polymarket package.
  • Not stateful. Plain structs; persist however you like (Ecto, ETS, file, GenServer state).
  • Owns no HTTP, WebSocket, or database code.

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:

  • :adapter — module implementing PaperEx.Adapter (required for simulation).
  • :adapter_opts — passed through to adapter callbacks.
  • :max_order_notional — cap on size * price. Default :infinity.
  • :max_position_size — cap on resulting position size.
  • :allow_shorts — when false (default), bare sells with no existing long are skipped.
  • :allow_negative_cash — when false (default), buys that would overdraw cash are skipped.
  • :duplicate_policy:allow (default) or :reject.
  • :mode:research | :live_mirror, recorded on execution metadata for downstream filtering.

  • :pending_remainder — when true, partial fills of :limit orders also append a :pending Execution for the unfilled quantity. :market orders never produce pending entries. Default false (drop the remainder). Pending remainders can later be resolved with Engine.advance_pending/3 or cancelled with Engine.cancel_pending/3.

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:

  • Project metadata and Hex packaging readiness.
  • Domain structs: Order, Execution, Position, Portfolio, Fill, Instrument, MarketSnapshot, ReasonCodes.
  • PaperEx.Adapter behaviour with required and optional callbacks.
  • PaperEx.Engine.apply_order/4 — pure state transition with guardrails, fee passthrough, VWAP fills, position open/merge/close, history append, ledger append.
  • Opt-in :pending_remainder policy for :limit partial fills, plus pure Engine.cancel_pending/3, Engine.advance_pending/3, and Engine.open_pending_remainders/1 helpers.
  • 212 tests covering happy paths, every guardrail skip, limit miss, no-liquidity miss, adapter-absent skip, fees on buy/close, multi-fill VWAP, :mode metadata round-trip, pending-remainder on/off behavior, market FAK never producing pending, cancel-pending lifecycle, the full advance_pending/3 state-transition table (partial / full / still-pending / skipped variants), and the open_pending_remainders/1 rule (supersession, resolution, cancellation, ledger order, portfolio vs list input).

Not implemented yet:

  • An optional GenServer wrapper for long-running paper portfolios.
  • P&L mark-to-market based on snapshot updates alone.
  • A first-party Polymarket adapter (lives in the separate paper_ex_polymarket package).

Installation

After publication:

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

License

MIT.