PaperEx.Strategy behaviour (PaperEx v0.8.0)

Copy Markdown View Source

Behaviour for paper-trading strategies: pure decision functions over market + portfolio state.

A strategy receives a context per instrument per tick and returns the orders it wants placed. It expresses intent only — the host application's runner owns execution policy (guardrail caps, kill switches, pacing, notification). Strategies must not perform side effects in decide/2: no network calls, no process state, no clock reads. Everything a decision needs is in the context; everything it learns lives in the returned state.

This behaviour was promoted from the polymarket_bot reference app once multiple strategy families (resting-bid, copy-trade, scanner) proved the abstraction — per the workspace watch-list rule of not extracting frameworks early.

Callbacks

  • init/1 — turn keyword opts into strategy state once at runner startup. Validate hard here; a misconfigured strategy should refuse to start, not trade garbage.

  • decide/2 — given a context and state, return a decision. Two forms, distinguished by tuple arity:

    • {orders, new_state} — place orders (the common case). Return {[], state} to do nothing.
    • {orders, cancels, new_state} — first cancel the resting pending remainders named in cancels, then place orders. A cancel reference is the :order_id of a resting pending — read it from PaperEx.Engine.open_pending_remainders/1 (each returned Execution carries the :order_id to cancel by). This is what lets a strategy move a resting quote — cancel the stale one, place a fresh one — rather than only ever adding orders. A market maker re-quoting around a moving midpoint is the motivating case.

    Orders are PaperEx.Order structs; set your own ids, sizes and prices — runners may prefix or suffix ids for uniqueness but stamp no other defaults, so a cancelling strategy must cancel by the :order_id it reads back from open_pending_remainders/1, not one it reconstructs. A runner that does not support the 3-tuple form may treat it as {orders, new_state} and ignore the cancels, so strategies that need cancels must be paired with a runner that honors them — and should declare cancels?/0 so hosts can gate incompatible execution paths (e.g. live mirroring).

  • cancels?/0 (optional) — return true to declare that this strategy uses the 3-tuple cancel form. Hosts use it to refuse unsafe pairings up front (a runner that mirrors orders to a live venue but cannot yet cancel them there must not run a re-quoting strategy). Defaults to false when not exported.

Context shape

%{
  instrument_id: term(),
  snapshot: PaperEx.MarketSnapshot.t(),
  portfolio: PaperEx.Portfolio.t()
}

Runners may include additional keys (the Polymarket reference app, for example, also passes :token_id — the same value as :instrument_id — and copy-trade runners pass observed activity). Strategies should pattern-match only on the keys they need and tolerate extras.

The portfolio is the current state including open positions and pending remainders — use PaperEx.Engine.open_pending_remainders/1 to avoid stacking duplicate resting orders.

Summary

Types

A reference to a resting pending remainder to cancel: the :order_id of the order that opened it (as carried on the Execution returned by PaperEx.Engine.open_pending_remainders/1).

What decide/2 returns. The 2-tuple places orders; the 3-tuple cancels the named resting pendings first, then places orders.

What explain/2 returns: the would-be action, or why the strategy passed.

Callbacks

Optional: declare that the strategy's decide/2 may return the 3-tuple cancel form. Hosts gate incompatible execution paths on it. Absent ⇒ treated as false.

Optional: explain the strategy's decision for a context — the same inputs decide/2 sees — as {:act, summary} or {:skip, reason}. Reasons should be normalized categories (no per-token numbers) so hosts can aggregate them into a histogram. Must mirror decide/2's gating. Absent ⇒ the host falls back to a generic label.

Functions

Safe wrapper over explain/2: the module's explanation, or a generic {:skip, "no explain/2"} if it doesn't implement the optional callback. Never raises — diagnostics must not break a tick.

True if module implements this behaviour (both callbacks exported). Loads the module first, so it is safe to call before the module has been referenced.

Types

cancel_ref()

@type cancel_ref() :: term()

A reference to a resting pending remainder to cancel: the :order_id of the order that opened it (as carried on the Execution returned by PaperEx.Engine.open_pending_remainders/1).

context()

@type context() :: %{
  :instrument_id => term(),
  :snapshot => PaperEx.MarketSnapshot.t(),
  :portfolio => PaperEx.Portfolio.t(),
  optional(atom()) => term()
}

decision()

@type decision() ::
  {[PaperEx.Order.t()], state()}
  | {[PaperEx.Order.t()], [cancel_ref()], state()}

What decide/2 returns. The 2-tuple places orders; the 3-tuple cancels the named resting pendings first, then places orders.

explanation()

@type explanation() :: {:act, String.t()} | {:skip, String.t()}

What explain/2 returns: the would-be action, or why the strategy passed.

state()

@type state() :: term()

Callbacks

cancels?()

(optional)
@callback cancels?() :: boolean()

Optional: declare that the strategy's decide/2 may return the 3-tuple cancel form. Hosts gate incompatible execution paths on it. Absent ⇒ treated as false.

decide(context, state)

@callback decide(context(), state()) :: decision()

explain(context, state)

(optional)
@callback explain(context(), state()) :: explanation()

Optional: explain the strategy's decision for a context — the same inputs decide/2 sees — as {:act, summary} or {:skip, reason}. Reasons should be normalized categories (no per-token numbers) so hosts can aggregate them into a histogram. Must mirror decide/2's gating. Absent ⇒ the host falls back to a generic label.

init(keyword)

@callback init(keyword()) :: {:ok, state()} | {:error, term()}

Functions

explain(module, ctx, state)

@spec explain(module(), context(), state()) :: explanation()

Safe wrapper over explain/2: the module's explanation, or a generic {:skip, "no explain/2"} if it doesn't implement the optional callback. Never raises — diagnostics must not break a tick.

implemented_by?(module)

@spec implemented_by?(module()) :: boolean()

True if module implements this behaviour (both callbacks exported). Loads the module first, so it is safe to call before the module has been referenced.