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}— placeorders(the common case). Return{[], state}to do nothing.{orders, cancels, new_state}— first cancel the resting pending remainders named incancels, then placeorders. A cancel reference is the:order_idof a resting pending — read it fromPaperEx.Engine.open_pending_remainders/1(each returnedExecutioncarries the:order_idto 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.Orderstructs; 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_idit reads back fromopen_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 declarecancels?/0so hosts can gate incompatible execution paths (e.g. live mirroring).cancels?/0(optional) — returntrueto 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 tofalsewhen 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
@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).
@type context() :: %{ :instrument_id => term(), :snapshot => PaperEx.MarketSnapshot.t(), :portfolio => PaperEx.Portfolio.t(), optional(atom()) => term() }
@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.
What explain/2 returns: the would-be action, or why the strategy passed.
@type state() :: term()
Callbacks
@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.
@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.
Functions
@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.
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.