PaperExPolymarket.LiveMirror (PaperExPolymarket v0.3.2)

Copy Markdown View Source

Polymarket-specific live-mirror helpers built on top of PaperEx.Engine.

Live mirroring answers a different question than research-mode simulation: "what likely would have happened to this order under the real exchange's fill, sizing, and exit rules?". The paper_ex engine already records :filled / :missed / :skipped outcomes; live-mirror also needs to reconcile against actual Polymarket fills when they arrive, and to track resting orders explicitly at the Polymarket level.

All cash and position accounting is delegated to PaperEx.Engine (apply_order/4 for intents, apply_fill/4 for observed fills) — this module never reimplements portfolio math, so short cover/flip, partial-close P&L, and fee handling stay identical to the engine.

This module wraps four live-mirror flows:

  • simulate_intent/4 — record what the engine would do against the current book. Stamps :mode = :live_mirror on the execution metadata. Use this when you have an intent and a snapshot but no actual fill yet.
  • mirror_actual_fill/4 — record an observed exchange fill (from the Data API /activity endpoint or the RTDS WebSocket) as a :filled execution on the mirror portfolio, applied through PaperEx.Engine.apply_fill/4. Use this when reconciling the mirror against real exchange behavior.
  • record_pending/3 — record a :pending execution for a resting order and later move it to :filled or :cancelled via resolve_pending/4. This is a Polymarket-level, caller-driven pending marker; it is separate from the generic engine's own :pending_remainder lifecycle (PaperEx.Engine.advance_pending/3, cancel_pending/3, open_pending_remainders/1) and does not feed into it.
  • resolve_pending/4 — resolve a record_pending/3 entry to :filled or :cancelled. Idempotent: a pending that has already been resolved cannot be resolved again (returns {:error, :pending_already_resolved}), so a duplicate event can't double-book cash or positions.

Mirror portfolios exist before the first fill

Callers should seed a mirror Portfolio at startup, not lazily on the first fill. The execution-attempt ledger is the authoritative record of what was attempted, regardless of whether any of those attempts succeeded. Constructing the portfolio late means losing misses and skips that happened before the first fill — which is exactly the information live-mirror exists to expose.

Token resolution before reconciliation

The functions that consume a raw Polymarket trade event take an already-resolved PaperEx.Instrument, not a raw token id or condition id: mirror_actual_fill/4 as a positional argument, and resolve_pending/4 via the :instrument opt when a :trade_event is supplied. (simulate_intent/4 works off the snapshot, and record_pending/3 needs only the order.) Callers should resolve the instrument once (via PaperExPolymarket.Adapter.normalize_instrument/1 or PaperExPolymarket.Market.from_clob_market/2) and hold it for the duration of the order's lifecycle.

Summary

Functions

Record an actual exchange trade event as a :filled execution on portfolio. Cash and positions are updated by PaperEx.Engine.apply_fill/4, so the observed fill goes through the same accounting a simulated fill would (open / average-in / reduce-cover / flip).

Record a :pending execution for a resting order. Cash and positions are not moved (the engine cannot know whether the order will fill or be cancelled).

Resolve a previously-recorded :pending execution.

Run order against snapshot through PaperEx.Engine with the Polymarket adapter and :mode = :live_mirror recorded on the execution metadata.

Functions

mirror_actual_fill(portfolio, order, trade_event, inst)

Record an actual exchange trade event as a :filled execution on portfolio. Cash and positions are updated by PaperEx.Engine.apply_fill/4, so the observed fill goes through the same accounting a simulated fill would (open / average-in / reduce-cover / flip).

trade_event is a Polymarket Data-API activity payload or an RTDS trade payload. The event's asset token id must match instrument.id or the call returns {portfolio, {:error, reason}} (e.g. {:error, :instrument_mismatch}) with no state change.

record_pending(portfolio, order, opts \\ [])

Record a :pending execution for a resting order. Cash and positions are not moved (the engine cannot know whether the order will fill or be cancelled).

Returns {updated_portfolio, execution}. The returned execution carries a caller-provided :id (defaulting to order.id) so a later resolve_pending/4 can find it.

Polymarket-specific :reason and :metadata defaults:

  • :reason:resting_gtc (a GTC order placed but not yet filled).
  • :metadata.mode:live_mirror.
  • :metadata.placed_atorder.placed_at.

resolve_pending(portfolio, execution_id, atom, opts)

@spec resolve_pending(PaperEx.Portfolio.t(), term(), :filled | :cancelled, keyword()) ::
  {PaperEx.Portfolio.t(), PaperEx.Execution.t()}
  | {PaperEx.Portfolio.t(), {:error, atom()}}

Resolve a previously-recorded :pending execution.

outcome is one of :filled or :cancelled.

For :filled, opts must include :order (the resting order, whose :id must match the pending's :order_id) and either a :trade_event map (plus :instrument) or a :fill PaperEx.Fill struct. The fill is applied through PaperEx.Engine.apply_fill/4; the original :pending execution is left on the ledger and a new :filled execution is appended.

For :cancelled, opts[:reason] is the bounded reason atom (:cancelled_by_caller, :cancelled_by_exchange, etc.).

Idempotent: once a pending has been resolved, resolving it again returns {portfolio, {:error, :pending_already_resolved}} with no state change. An unknown id returns {:error, :pending_not_found}.

simulate_intent(portfolio, order, snap, opts \\ [])

Run order against snapshot through PaperEx.Engine with the Polymarket adapter and :mode = :live_mirror recorded on the execution metadata.

Returns {updated_portfolio, execution} exactly like PaperEx.Engine.apply_order/4.