# 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.
