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/resolution_snipe.ex
defmodule PolymarketStrategies.ResolutionSnipe do
@moduledoc """
Resolution sniping: buy an outcome the market already considers
nearly certain and collect the last few cents when it resolves
to $1.
Market discovery is the operator's job (pick `token_ids` /
`:snapshotter_token_ids` — e.g. extreme-odds markets with enough
volume); this strategy implements the **entry policy**: act only
when the book actually lets you in at a price that leaves edge.
## Behavior per tick, per token
* Take the best ask when `min_price <= ask <= max_price`
(defaults 0.90–0.97) **and** the ask shows at least `size`
shares of depth — a depth gate: extreme-odds books without
depth can't absorb an entry, so a "snipe" that moves the
price isn't one.
* One bite per token (open position or pending tagged
`"resolution_snipe"` suppresses).
* The order is a limit at the ask: a taker entry that can never
pay more than the ask you saw.
The exit is `PortfolioServer.resolve_market/4` when the market
resolves — that is the whole thesis of the trade.
## Time-to-resolution gate (recommended)
A snipe's return is a few cents; its *annualized* return depends
entirely on how soon the market resolves. Locking capital at 0.95
for eight months to make five cents is a poor trade; the same
snipe resolving next week is excellent. Ranking candidates by
annualized return is the natural discovery heuristic. Pass
`:deadlines` (per-token resolution times) and `:max_days` to
refuse snipes that are too far out. When `:deadlines` is omitted
the gate is off (capital-lockup risk is then on you).
## Options (validated in `init/1`)
* `:size` — shares per snipe, positive. Required.
* `:min_price` — only snipe above this (the "nearly certain"
threshold). Default `0.90`.
* `:max_price` — never pay above this (your edge floor:
buying at 0.99 risks a cent to win a cent). Default `0.97`.
* `:deadlines` — optional `%{token_id => %DateTime{}}`. When
given, only tokens present here are sniped, and only within
`:max_days` of resolution (uses `ctx.now`).
* `:max_days` — resolution-proximity window when `:deadlines`
is set. Default `21`.
"""
@behaviour PaperEx.Strategy
alias PaperEx.{Engine, MarketSnapshot, Order}
@impl true
def init(opts) do
size = Keyword.get(opts, :size)
min_price = Keyword.get(opts, :min_price, 0.90)
max_price = Keyword.get(opts, :max_price, 0.97)
deadlines = Keyword.get(opts, :deadlines)
max_days = Keyword.get(opts, :max_days, 21)
cond do
not (is_number(size) and size > 0) ->
{:error, {:invalid_size, size}}
not (is_number(min_price) and min_price > 0.5 and min_price < 1) ->
{:error, {:invalid_min_price, min_price}}
not (is_number(max_price) and max_price > min_price and max_price < 1) ->
{:error, {:invalid_max_price, max_price}}
not valid_deadlines?(deadlines) ->
{:error, {:invalid_deadlines, deadlines}}
not (is_number(max_days) and max_days > 0) ->
{:error, {:invalid_max_days, max_days}}
true ->
{:ok,
%{
size: size,
min_price: min_price,
max_price: max_price,
deadlines: deadlines,
max_days: max_days
}}
end
end
@impl true
def decide(%{instrument_id: token_id, snapshot: snapshot, portfolio: portfolio} = ctx, state) do
with true <- within_resolution_window?(state, token_id, Map.get(ctx, :now)),
{ask, ask_size} <- MarketSnapshot.best_ask(snapshot),
true <- ask >= state.min_price and ask <= state.max_price,
true <- ask_size >= state.size,
false <- engaged?(portfolio, token_id) do
order =
Order.new(
market_id: token_id,
side: :buy,
order_type: :limit,
size: state.size,
price: ask,
strategy: "resolution_snipe",
id: "snipe-#{String.slice(to_string(token_id), 0, 16)}"
)
{[order], state}
else
_ -> {[], state}
end
end
# No deadlines configured (nil) → gate off (snipe anything in
# band). Any map — *including an empty one* — turns the gate ON:
# the token must be known AND within the window. An unknown
# resolution time is a reason NOT to lock up capital, so an empty
# map refuses everything until it's populated (Codex review P3:
# `%{}` is "configured but knows no token", not "gate off").
@impl true
def explain(%{instrument_id: token_id, snapshot: snapshot, portfolio: portfolio} = ctx, state) do
if within_resolution_window?(state, token_id, Map.get(ctx, :now)) do
case MarketSnapshot.best_ask(snapshot) do
{ask, ask_size} ->
cond do
ask < state.min_price or ask > state.max_price -> {:skip, "ask outside snipe band"}
ask_size < state.size -> {:skip, "ask depth < size"}
engaged?(portfolio, token_id) -> {:skip, "already holding"}
true -> {:act, "buy @ ask (snipe)"}
end
_ ->
{:skip, "no ask"}
end
else
{:skip, "outside resolution window"}
end
end
defp within_resolution_window?(%{deadlines: nil}, _token_id, _now), do: true
defp within_resolution_window?(%{deadlines: deadlines, max_days: max_days}, token_id, now) do
with %DateTime{} = deadline <- Map.get(deadlines, token_id),
%DateTime{} <- now do
seconds_left = DateTime.diff(deadline, now, :second)
seconds_left > 0 and seconds_left <= max_days * 86_400
else
_ -> false
end
end
defp valid_deadlines?(nil), do: true
defp valid_deadlines?(deadlines) when deadlines == %{}, do: true
defp valid_deadlines?(deadlines) when is_map(deadlines) do
Enum.all?(deadlines, fn
{token_id, %DateTime{}} when is_binary(token_id) -> true
_ -> false
end)
end
defp valid_deadlines?(_), do: false
defp engaged?(portfolio, token_id) do
has_position? =
Enum.any?(portfolio.positions, fn pos ->
pos.market_id == token_id and pos.strategy == "resolution_snipe"
end)
has_pending? =
portfolio
|> Engine.open_pending_remainders()
|> Enum.any?(fn ex ->
ex.metadata[:market_id] == token_id and ex.metadata[:strategy] == "resolution_snipe"
end)
has_position? or has_pending?
end
end