Current section
Files
Jump to
Current section
Files
README.md
# 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
- 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
```elixir
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:
```elixir
@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:
```elixir
# 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:
```elixir
def deps do
[
{:paper_ex, "~> 0.4.0"}
]
end
```
## License
MIT.