Packages
polymarket_strategies
0.1.0
Blueprint / example trading strategies for the paper_ex paper-trading engine — a reference set of textbook strategies (momentum, mean-reversion, favorite/longshot, market-making, stink-bid, resolution-snipe, basket arbitrage) implementing PaperEx.Strategy.
Current section
Files
Jump to
Current section
Files
lib/mean_reversion.ex
defmodule PolymarketStrategies.MeanReversion do
@moduledoc """
Mean reversion: buy when the mid-price has dropped at least
`:drop` below its own recent average, betting the overreaction
snaps back. This is the *measured* version of what `StinkBid`
gropes at passively — instead of resting a fixed discount and
hoping, it acts only when an actual dip below the trailing mean is
observed.
Like `Momentum`, the price window lives in the strategy's threaded
**state**, keyed per token (no extra process or feed; samples at
the runner's `:interval_ms` cadence; resets on restart).
## Per tick, per token
* Append the current midpoint (skipped on a one-sided book).
* With at least `:lookback` prior samples, take their mean
(excluding the current sample — the reference the price is
reverting *toward*).
* If the current midpoint ≤ `mean * (1 - :drop)`, not already
engaged, and the best ask ≤ `:max_price`, place a marketable
limit buy at the ask.
Pair with a separate exit-management strategy for the exit —
a take-profit near the mean is the natural counterpart.
## Options
* `:lookback` — prior samples to average, integer ≥ 2. Required.
* `:drop` — minimum fractional dip below the mean to act, in
`(0, 1)` (`0.10` = 10% below the recent average). Required.
* `:size` — shares per entry, > 0. Required.
* `:max_price` — price ceiling, default `0.95`.
"""
@behaviour PaperEx.Strategy
alias PaperEx.{Engine, MarketSnapshot, Order}
@impl true
def init(opts) do
lookback = Keyword.get(opts, :lookback)
drop = Keyword.get(opts, :drop)
size = Keyword.get(opts, :size)
max_price = Keyword.get(opts, :max_price, 0.95)
cond do
not (is_integer(lookback) and lookback >= 2) ->
{:error, {:invalid_lookback, lookback}}
not (is_number(drop) and drop > 0 and drop < 1) ->
{:error, {:invalid_drop, drop}}
not (is_number(size) and size > 0) ->
{:error, {:invalid_size, size}}
not (is_number(max_price) and max_price > 0 and max_price <= 1) ->
{:error, {:invalid_max_price, max_price}}
true ->
{:ok, %{lookback: lookback, drop: drop, size: size, max_price: max_price, history: %{}}}
end
end
@impl true
def decide(%{instrument_id: token_id, snapshot: snapshot, portfolio: portfolio}, state) do
case MarketSnapshot.midpoint(snapshot) do
nil ->
{[], state}
mid ->
prior = Map.get(state.history, token_id, [])
# Keep `lookback` prior samples plus the current one.
window = [mid | prior] |> Enum.take(state.lookback + 1)
state = %{state | history: Map.put(state.history, token_id, window)}
with true <- length(prior) >= state.lookback,
reference = mean(Enum.take(prior, state.lookback)),
true <- reference > 0,
true <- mid <= reference * (1 - state.drop),
false <- engaged?(portfolio, token_id),
{ask, _size} <- MarketSnapshot.best_ask(snapshot),
true <- ask <= state.max_price do
order =
Order.new(
market_id: token_id,
side: :buy,
order_type: :limit,
size: state.size,
price: ask,
strategy: "mean_reversion",
id: "rev-#{String.slice(to_string(token_id), 0, 16)}"
)
{[order], state}
else
_ -> {[], state}
end
end
end
defp mean([]), do: 0
defp mean(list), do: Enum.sum(list) / length(list)
defp engaged?(portfolio, token_id) do
has_position? =
Enum.any?(portfolio.positions, fn pos ->
pos.market_id == token_id and pos.strategy == "mean_reversion"
end)
has_pending? =
portfolio
|> Engine.open_pending_remainders()
|> Enum.any?(fn ex ->
ex.metadata[:market_id] == token_id and ex.metadata[:strategy] == "mean_reversion"
end)
has_position? or has_pending?
end
end