Packages

Generic, exchange-agnostic paper trading and simulated-execution engine for Elixir. Pure functional core: portfolio state, execution attempts, positions, and orders. No HTTP, no database, no Polymarket dependency.

Current section

Files

Jump to
paper_ex lib paper_ex strategy.ex
Raw

lib/paper_ex/strategy.ex

defmodule PaperEx.Strategy do
@moduledoc """
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 `c: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.
"""
alias PaperEx.{MarketSnapshot, Order, Portfolio}
@type state :: term()
@type context :: %{
:instrument_id => term(),
:snapshot => MarketSnapshot.t(),
:portfolio => Portfolio.t(),
optional(atom()) => term()
}
@typedoc """
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 cancel_ref :: term()
@typedoc """
What `c:decide/2` returns. The 2-tuple places orders; the 3-tuple
cancels the named resting pendings first, then places orders.
"""
@type decision ::
{[Order.t()], state()}
| {[Order.t()], [cancel_ref()], state()}
@callback init(keyword()) :: {:ok, state()} | {:error, term()}
@callback decide(context(), state()) :: decision()
@doc """
Optional: declare that the strategy's `c:decide/2` may return the
3-tuple cancel form. Hosts gate incompatible execution paths on
it. Absent ⇒ treated as `false`.
"""
@callback cancels?() :: boolean()
@typedoc "What `c:explain/2` returns: the would-be action, or why the strategy passed."
@type explanation :: {:act, String.t()} | {:skip, String.t()}
@doc """
Optional: explain the strategy's decision for a context — the same
inputs `c: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 `c:decide/2`'s
gating. Absent ⇒ the host falls back to a generic label.
"""
@callback explain(context(), state()) :: explanation()
@optional_callbacks cancels?: 0, explain: 2
@doc """
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.
"""
@spec implemented_by?(module()) :: boolean()
def implemented_by?(module) when is_atom(module) do
Code.ensure_loaded?(module) and
function_exported?(module, :init, 1) and
function_exported?(module, :decide, 2)
end
def implemented_by?(_), do: false
@doc """
Safe wrapper over `c: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.
"""
@spec explain(module(), context(), state()) :: explanation()
def explain(module, ctx, state) do
if Code.ensure_loaded?(module) and function_exported?(module, :explain, 2) do
module.explain(ctx, state)
else
{:skip, "no explain/2"}
end
rescue
e -> {:skip, "explain raised: #{Exception.message(e)}"}
catch
_, _ -> {:skip, "explain crashed"}
end
end