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_polymarketpackage. - 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:
- Research / idea simulation. Idealized fills, simplified exits. Answers "was the signal directionally good?".
- 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 # => 10Core 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()] # optionalSee 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 implementingPaperEx.Adapter(required for simulation).:adapter_opts— passed through to adapter callbacks.:max_order_notional— cap onsize * price. Default:infinity.:max_position_size— cap on resulting position size.:allow_shorts— whenfalse(default), bare sells with no existing long are skipped.:allow_negative_cash— whenfalse(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— whentrue, partial fills of:limitorders also append a:pendingExecutionfor the unfilled quantity.:marketorders never produce pending entries. Defaultfalse(drop the remainder). Pending remainders can later be resolved withEngine.advance_pending/3or cancelled withEngine.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.Adapterbehaviour 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_remainderpolicy for:limitpartial fills, plus pureEngine.cancel_pending/3,Engine.advance_pending/3, andEngine.open_pending_remainders/1helpers. - 212 tests covering happy paths, every guardrail skip, limit miss,
no-liquidity miss, adapter-absent skip, fees on buy/close,
multi-fill VWAP,
:modemetadata round-trip, pending-remainder on/off behavior, market FAK never producing pending, cancel-pending lifecycle, the fulladvance_pending/3state-transition table (partial / full / still-pending / skipped variants), and theopen_pending_remainders/1rule (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_polymarketpackage).
Installation
After publication:
def deps do
[
{:paper_ex, "~> 0.4.0"}
]
endLicense
MIT.