Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
## 0.8.0 - 2026-07-25
### Added
- **`decide/2` may now cancel resting orders.** The callback can
return `{orders, cancels, state}` (a 3-tuple) in addition to the
existing `{orders, state}`; `cancels` is a list of `:order_id`s of
resting pending remainders to cancel before the new `orders` are
placed. Backward compatible — the 2-tuple form is unchanged and
every existing strategy keeps working; a runner distinguishes the
two by tuple arity. This is what lets a strategy *move* a resting
quote (cancel the stale one, place a fresh one) instead of only
ever adding orders — the prerequisite for genuine two-sided market
making. New `PaperEx.Strategy.decision/0` and `cancel_ref/0` types.
- **Optional `cancels?/0` callback** — a strategy returns `true` to
declare it uses the cancel form, so a host can refuse unsafe
pairings up front (e.g. a runner that mirrors orders to a venue it
cannot yet cancel on). Optional, defaults to `false`, fully
backward compatible.
## 0.7.0 (unreleased)
### Added
- **`PaperEx.Strategy` behaviour** — the strategy contract
(`init/1` validation, pure `decide/2` over
`%{instrument_id, snapshot, portfolio}` with threaded state),
promoted from the `polymarket_bot` reference app once multiple
strategy families (resting-bid, copy-trade, scanner) proved the
abstraction. Exchange-agnostic: contexts may carry extra
host-specific keys; strategies match only what they need.
Includes `implemented_by?/1`. 4 new tests.
## 0.6.0 (unreleased)
### Changed
- **GTC rest-on-miss** (Codex review finding). Under
`pending_remainder: true`, a `:limit` order that misses entirely
(`:limit_not_crossed` or `:no_liquidity`) now **rests as a
full-size pending remainder** instead of evaporating — which is
what GTC means. Previously only *partial* fills rested, so a
resting-order strategy would re-submit the same intent every
tick. The `:missed` execution is still recorded (the attempt is
auditable); the new `:pending` sits alongside it and is advanced
/cancelled through the existing lifecycle. Without the flag, and
for `:market` orders, behavior is unchanged. 5 new tests.
## 0.5.0 (unreleased)
### Added
- `PaperEx.Engine.resolve_market/4` — prediction-market resolution.
Closes every open position on a market at its final outcome
(`:won` → `:payout_per_share`, default `1.0`; `:lost` → `0.0`),
computes realized P&L, credits/debits cash (synthetic shorts buy
back at the final price), applies an optional `:win_fee_rate` to
winning long payouts, cancels open pending remainders on the
market, and appends `:closed` / `:cancelled` executions with the
bounded reason `:market_resolved`. Returns
`{:ok, portfolio, %{closed: [...], cancelled_pending: [...]}}`.
- `PaperEx.Valuation` — pure mark-to-market valuation of open
positions against caller-supplied snapshots.
`open_position_value/3` prices longs at best bid and shorts at
best ask (`:conservative`, default) or both at `:midpoint`,
returns a net liquidation value with a per-position breakdown,
and refuses partial totals (`{:error, {:unpriced, ids}}`) rather
than silently omitting unpriced positions.
- `:market_resolved` added to `PaperEx.ReasonCodes.engine_codes/0`.
- 23 new tests covering long/short resolution for both outcomes,
win-fee semantics, custom payouts, pending cancellation on
resolution, market scoping, multi-strategy resolution, ledger
effects, outcome validation, and the full valuation matrix
(conservative/midpoint pricing, empty book sides, missing
snapshots, netting, portfolio input, closed-position exclusion).
### Changed
- Engine moduledoc documents the two position-exit paths (selling
and resolution); the "P&L on close not implemented" caveat is
gone.
- `resolve_market/4` validates `:payout_per_share` and
`:win_fee_rate` (non-negative numbers; `ArgumentError` otherwise)
— a negative value would silently corrupt cash/P&L (Codex review
finding).
- The exit-side P&L convention is now documented explicitly and
pinned by a test: `Position.pnl` is `(exit - entry) * shares`
minus exit-side fees only, on both the sell-close and resolution
paths. Opening fees hit cash at open time and are deliberately
not re-subtracted into `pnl`.
## 0.4.0 (unreleased)
### Added
- `PaperEx.Engine.open_pending_remainders/1` — public helper
returning the currently-open pending remainders of a
`PaperEx.Portfolio` or a raw `[PaperEx.Execution]` list, in
ledger order. This is now the single source of truth for the
open-pending rule (latest pending per `order_id`; cleared by a
later `:cancelled` for that id or a later `:filled` tagged
`metadata.resolves: {:pending_remainder, order_id}`; unrelated
fills do not clear).
### Changed
- `cancel_pending/3` and `advance_pending/3` internals now derive
open pendings through `open_pending_remainders/1` instead of a
private duplicate. Behavior is unchanged — the private
`find_open_pending/2` and `open_pendings_for/2` delegate to the
public helper.
## 0.3.0 (unreleased)
### Added
- `PaperEx.Engine.advance_pending/3` — pure function that walks
open `:pending` executions matching a fresh `MarketSnapshot`'s
`instrument_id` and tries to resolve each remainder. Returns
`{:ok, updated_portfolio, results}` where `results` is a list of
`{pending_execution_id, outcome}` tuples preserving ledger order.
Outcomes:
- `{:filled, filled_execution}` — full remainder filled.
- `{:partial, filled_execution, new_pending}` — partial fill;
`new_pending` has the same id with a reduced `remaining_size`.
- `{:still_pending, pending}` — no cross / no liquidity. No
ledger churn.
- `{:skipped, reason, skipped_execution}` — guardrail rejected
(`:shorts_disallowed`, `:insufficient_position`,
`:insufficient_cash`, `:adapter_rejected`,
`:pending_intent_missing`).
- 18 new `paper_ex` tests covering: empty / different-market
shortcuts; no-cross and no-liquidity → still pending; partial
advance (remaining_size reduced, position grown); full advance
(closes pending so `cancel_pending/3` reports `:pending_not_found`);
deterministic ordering across multiple pendings; sell-shorts
guard from a held=0 setup; `allow_shorts: true` opens a new
short; `:insufficient_position` from a drained long;
`:insufficient_cash` with strict and `allow_negative_cash: true`
configs; missing-adapter and legacy-pending (no `:side`)
fallbacks; `apply_order/4` return shape unchanged; ledger /
`find_open_pending/2` semantics after partial vs full advance.
### Changed
- `:pending` remainder metadata now carries `:side`, `:limit_price`,
and `:order_type` so `advance_pending/3` can reconstruct the
order intent without retaining the original `Order` struct. This
is additive — old callers that read other metadata keys are
unaffected. Pendings created before this version are advanced as
`{:skipped, :pending_intent_missing, …}`.
- `find_open_pending/2` (private) now also clears the open pending
on a later `:filled` execution tagged with
`metadata.resolves: {:pending_remainder, order_id}`. The
pre-existing behavior of NOT clearing on the initial filled-then-
pending sequence from `apply_order/4` is preserved (the initial
filled lands BEFORE the pending in the ledger and so its acc
precedes the pending).
- Engine moduledoc rewritten to document `advance_pending/3` and
the extended pending-metadata contract.
## 0.2.0 (unreleased)
### Added
- **Opt-in pending-remainder policy.** `PaperEx.Engine.apply_order/4`
now accepts `:pending_remainder` in the config keyword. When set
to `true` and a `:limit` order partially crosses, the engine
appends a `:pending` `PaperEx.Execution` to the portfolio's
ledger alongside the existing filled execution. The `:pending`
entry has id `{:pending_remainder, order.id}` and metadata with
`remaining_size`, `filled_size`, `market_id`, `strategy`, and
`mode`. `:market` orders are FAK and never produce pending
entries.
- `PaperEx.Engine.cancel_pending/3` — pure helper that resolves a
pending remainder by appending a `:cancelled` `Execution` to the
ledger. Accepts `:reason` and `:metadata` options. Returns
`{:ok, portfolio, cancelled_execution}` or
`{:error, :pending_not_found}`. Cash and positions are not moved
(pending never moved them either).
- 16 new tests covering: default behavior (pending not recorded
when policy off), pending recorded on partial limit fill with the
documented metadata shape, market FAK never records pending,
fully-filled limit orders never record pending, `cancel_pending`
honoring caller `:reason` and `:metadata`, idempotency boundary
(can't double-cancel), and return-shape stability
(`apply_order/4` still returns `{portfolio, execution}` and the
primary execution is the filled one).
### Changed
- `PaperEx.Engine.apply_order/4` adapter dispatch now wraps
`function_exported?/3` checks with `Code.ensure_loaded?/1` so
adapters living in dependency apps that the BEAM has not yet
auto-loaded resolve correctly.
- Engine moduledoc rewrites the "what this engine does NOT do"
section to reflect that pending remainders are now first-class
for `:limit` orders.
## 0.1.0 (unreleased)
Initial scaffold of the generic exchange-agnostic paper trading
engine. Pure functional core: structs, an adapter behaviour, and a
single state-transition function. No HTTP, no database, no Polymarket
dependency.
### Added
- `PaperEx` top-level module with `version/0` and a moduledoc that
records the two-modes design constraint (research vs live-mirror)
carried over from the production source.
- Core domain structs:
- `PaperEx.Order` — buy/sell request keyed by `market_id`, with side,
type (`:limit | :market`), size, price, optional strategy tag,
and a free-form metadata map.
- `PaperEx.Execution` — first-class attempt record. Status is one
of `:filled | :missed | :skipped | :pending | :cancelled | :closed`,
so misses and skips are peer values, not absorbed into "no trade".
Carries an optional list of `PaperEx.Fill` structs and
aggregates (`vwap/1`, `total_size/1`, `total_fees/1`).
- `PaperEx.Position` — open or closed paper position with entry,
optional exit, side, shares, strategy tag, and metadata.
- `PaperEx.Portfolio` — top-level container for starting balance,
current balance, peak balance, open positions, history, the
execution-attempt ledger, and stats.
- `PaperEx.Fill` — atomic fill at one price level, with notional
and cost helpers.
- `PaperEx.Instrument` — normalized exchange-agnostic market id
with display fields and optional tick size.
- `PaperEx.MarketSnapshot` — order-book snapshot for one
instrument with best-bid/ask, midpoint, and spread helpers.
- `PaperEx.ReasonCodes` — bounded set of generic engine reason
atoms.
- `PaperEx.Adapter` behaviour with required `normalize_instrument/1`
and `normalize_snapshot/2`, and optional `normalize_fill/2`,
`simulate_fill/3`, `fee/2`, and `reason_codes/0`.
- `PaperEx.Engine.apply_order/4` — pure state transition: pre-flight
guardrails, adapter-driven fill simulation, fee passthrough, VWAP
fills, open/merge/close position bookkeeping, history append,
ledger append. Configurable via a keyword list:
`:adapter`, `:adapter_opts`, `:max_order_notional`,
`:max_position_size`, `:allow_shorts`, `:allow_negative_cash`,
`:duplicate_policy`, `:mode`.
- `PaperEx.MockAdapter` in `test/support` — minimal worked example
implementing every callback (required and optional).
- 150+ tests covering domain struct validation, adapter behaviour
conformance, mock-adapter normalize/simulate/fee/reason paths,
engine happy paths (buy, sell, partial close, multi-level VWAP),
every guardrail skip (insufficient cash, insufficient position,
shorts disallowed, max order notional, max position size,
duplicate position), limit miss, no-liquidity miss, adapter-absent
skip, fees on buy/close, every code path records exactly one
execution, and `:mode` metadata round-trip.
### Deferred
- Resolving `:pending` resting orders across snapshots.
- An optional GenServer wrapper for long-running paper portfolios.
- Mark-to-market P&L updates triggered by snapshots alone.
- The first-party Polymarket adapter — lives in the separate
`paper_ex_polymarket` package per the workspace package map.