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_mirroron 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/activityendpoint or the RTDS WebSocket) as a:filledexecution on the mirror portfolio, applied throughPaperEx.Engine.apply_fill/4. Use this when reconciling the mirror against real exchange behavior.record_pending/3— record a:pendingexecution for a resting order and later move it to:filledor:cancelledviaresolve_pending/4. This is a Polymarket-level, caller-driven pending marker; it is separate from the generic engine's own:pending_remainderlifecycle (PaperEx.Engine.advance_pending/3,cancel_pending/3,open_pending_remainders/1) and does not feed into it.resolve_pending/4— resolve arecord_pending/3entry to:filledor: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
@spec mirror_actual_fill( PaperEx.Portfolio.t(), PaperEx.Order.t(), map(), PaperEx.Instrument.t() ) :: {PaperEx.Portfolio.t(), PaperEx.Execution.t()} | {PaperEx.Portfolio.t(), {:error, atom()}}
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.
@spec record_pending(PaperEx.Portfolio.t(), PaperEx.Order.t(), keyword()) :: {PaperEx.Portfolio.t(), PaperEx.Execution.t()}
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_at—order.placed_at.
@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}.
@spec simulate_intent( PaperEx.Portfolio.t(), PaperEx.Order.t(), PaperEx.MarketSnapshot.t(), keyword() ) :: {PaperEx.Portfolio.t(), PaperEx.Execution.t()}
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.