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_ids 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; :lost0.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.