Packages

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
polymarket_strategies lib market_maker.ex
Raw

lib/market_maker.ex

defmodule PolymarketStrategies.MarketMaker do
@moduledoc """
Two-sided market maker — quote a bid below and an ask above the
midpoint, capture the spread, and **re-quote as the market moves**
by cancelling stale quotes and posting fresh ones.
This is the strategy that motivated the cancel/replace extension to
`PaperEx.Strategy` (`decide/2`'s 3-tuple form). Unlike `StinkBid`,
which rests one frozen bid, a market maker must continuously track
the mid — that requires *moving* a resting order, not just adding
more. Each tick it reads its own resting bid/ask from the portfolio
(`Engine.open_pending_remainders/1`, filtered to its `"market_maker"`
tag), compares them to the freshly-computed desired quotes, and
cancels + replaces only the side(s) whose price has drifted past
`:requote_threshold` (so it doesn't churn on every tick).
## Inventory and skew (long-only by default)
Because the engine is long-only unless `:allow_shorts`, the maker
sells only what it holds: the **ask is sized to current inventory**
(its own `"market_maker"` long on this token), and it stops posting
a **bid once inventory reaches `:max_inventory`** — a hard risk
bound. Inventory is mean-reverted toward `:target_inventory` via a
price **skew**: `skew = (inventory - target) * skew_factor` shifts
*both* quotes down when long (keener to sell, less keen to buy) and
up when short of target. A large skew can push the ask below the
best bid — i.e. cross and actively dump inventory — which is the
correct behavior when over-inventoried.
Set `:allow_short_selling` (with the runner's `engine_opts`
`allow_shorts: true`) for symmetric two-sided quoting that can go
short; off by default.
## Pairing
Run it on a token with `max_orders_per_tick >= 2` (it posts up to a
bid and an ask per tick; the per-tick cap counts *placements* only —
cancels are separate). Exits/inventory unwinding are handled by the
maker itself (the ask) and ultimately by `resolve_market/4`; a
separate exit-management strategy is **not** appropriate here (it
would fight the maker's own ask).
## Scope (intentional limitation)
**Single-token.** Inventory and quoting are per token id. On a
prediction market YES and NO are *separate* tokens, so this maker
does not net a YES long against a NO long: at `:max_inventory` on
one side it simply stops bidding rather than hedging via the
complement. Net YES/NO inventory (an optional `linked_token`) is a
deliberate future enhancement, flagged by the design review, not
handled here.
## Options (validated in `init/1`)
* `:half_spread` — distance from mid for each quote, `0 < x < 0.5`.
Required.
* `:size` — shares per quote, > 0. Required.
* `:max_inventory` — stop bidding at/above this long inventory,
> 0. Required (the risk bound).
* `:requote_threshold` — re-quote a side only when the desired
price moved more than this from the resting one. Default `0.01`.
* `:skew_factor` — price skew per share of inventory imbalance,
≥ 0. Default `0.0` (no skew).
* `:target_inventory` — inventory the skew pulls toward. Default 0.
* `:min_price` / `:max_price` — clamp quotes into a sane band.
Defaults `0.02` / `0.98`.
* `:allow_short_selling` — quote a full-size ask even without
inventory (needs engine `allow_shorts: true`). Default `false`.
"""
@behaviour PaperEx.Strategy
alias PaperEx.{Engine, MarketSnapshot, Order}
# Re-quoting requires the cancel/replace contract — declare it so a
# host (the StrategyRunner) refuses to mirror this strategy to a
# live venue it can't yet cancel on.
@impl true
def cancels?, do: true
# Polymarket prices are on a 1-cent tick; a bid one tick below the
# ask is the most aggressive non-crossing quote.
@tick 0.01
@size_epsilon 1.0e-9
@impl true
def init(opts) do
half_spread = Keyword.get(opts, :half_spread)
size = Keyword.get(opts, :size)
max_inventory = Keyword.get(opts, :max_inventory)
requote_threshold = Keyword.get(opts, :requote_threshold, 0.01)
skew_factor = Keyword.get(opts, :skew_factor, 0.0)
target_inventory = Keyword.get(opts, :target_inventory, 0)
min_price = Keyword.get(opts, :min_price, 0.02)
max_price = Keyword.get(opts, :max_price, 0.98)
allow_short_selling = Keyword.get(opts, :allow_short_selling, false)
cond do
not (is_number(half_spread) and half_spread > 0 and half_spread < 0.5) ->
{:error, {:invalid_half_spread, half_spread}}
not (is_number(size) and size > 0) ->
{:error, {:invalid_size, size}}
not (is_number(max_inventory) and max_inventory > 0) ->
{:error, {:invalid_max_inventory, max_inventory}}
not (is_number(requote_threshold) and requote_threshold >= 0) ->
{:error, {:invalid_requote_threshold, requote_threshold}}
not (is_number(skew_factor) and skew_factor >= 0) ->
{:error, {:invalid_skew_factor, skew_factor}}
not is_number(target_inventory) ->
{:error, {:invalid_target_inventory, target_inventory}}
not (is_number(min_price) and is_number(max_price) and min_price > 0 and
max_price < 1 and min_price < max_price) ->
{:error, {:invalid_price_bounds, {min_price, max_price}}}
not is_boolean(allow_short_selling) ->
{:error, {:invalid_allow_short_selling, allow_short_selling}}
true ->
{:ok,
%{
half_spread: half_spread,
size: size,
max_inventory: max_inventory,
requote_threshold: requote_threshold,
skew_factor: skew_factor,
target_inventory: target_inventory,
min_price: min_price,
max_price: max_price,
allow_short_selling: allow_short_selling
}}
end
end
@impl true
def decide(%{instrument_id: token_id, snapshot: snapshot, portfolio: portfolio}, state) do
case MarketSnapshot.midpoint(snapshot) do
# No midpoint (one-sided/empty book): can't quote sanely. Leave
# existing quotes in place rather than churn on a broken book.
nil ->
{[], [], state}
mid ->
inventory = inventory(portfolio, token_id)
skew = (inventory - state.target_inventory) * state.skew_factor
desired_ask = quote_price(mid + state.half_spread - skew, state)
# Keep the bid passive — never let it cross the best ask (a
# maker shouldn't pay the spread to accumulate). The ask is
# left free to cross when heavily skewed: that's deliberate
# inventory de-risking, not a bug (Codex P2).
desired_bid =
(mid - state.half_spread - skew)
|> quote_price(state)
|> no_cross_bid(snapshot, state)
{resting_bids, resting_asks} = resting_quotes(portfolio, token_id)
bid_room = state.max_inventory - inventory
bid_size = min(state.size, bid_room)
ask_size = if state.allow_short_selling, do: state.size, else: min(state.size, inventory)
{bid_orders, bid_cancels} =
quote_side(:buy, bid_size > 0, desired_bid, bid_size, resting_bids, token_id, state)
{ask_orders, ask_cancels} =
quote_side(:sell, ask_size > 0, desired_ask, ask_size, resting_asks, token_id, state)
{bid_orders ++ ask_orders, bid_cancels ++ ask_cancels, state}
end
end
@impl true
def explain(%{snapshot: snapshot}, _state) do
# A maker quotes (acts) whenever it has a sane two-sided book; the
# reason it shows no fills is that its resting quotes aren't crossed,
# not that it declines to act. So this is coarse by design.
case MarketSnapshot.midpoint(snapshot) do
nil -> {:skip, "no midpoint (one-sided/empty book)"}
_ -> {:act, "quoting two-sided"}
end
end
# Decide one side: place / leave / cancel against the resting quotes.
defp quote_side(_side, false, _price, _size, resting, _token_id, _state) do
# Don't want this side (inventory full for bids, nothing to sell
# for asks): cancel everything resting there.
{[], Enum.map(resting, & &1.order_id)}
end
defp quote_side(side, true, price, size, resting, token_id, state) do
case resting do
[] ->
{[build_quote(side, price, size, token_id)], []}
[only] ->
resting_price = only.metadata[:limit_price] || 0.0
resting_size = only.metadata[:remaining_size] || 0.0
# Leave it only if BOTH price and remaining size still match
# intent. Checking size too means a partially-filled quote is
# refreshed: a bid whose room shrank (inventory grew) is
# re-sized down so it can't fill past max_inventory, and an
# ask worn down to dust is re-posted at the right size rather
# than lingering forever (Codex P2).
if abs(resting_price - price) <= state.requote_threshold and
abs(resting_size - size) <= @size_epsilon do
{[], []}
else
{[build_quote(side, price, size, token_id)], [only.order_id]}
end
many ->
# Anomalous duplicates on one side — collapse to one fresh
# quote, cancelling all of them.
{[build_quote(side, price, size, token_id)], Enum.map(many, & &1.order_id)}
end
end
defp build_quote(side, price, size, token_id) do
Order.new(
market_id: token_id,
side: side,
order_type: :limit,
size: size,
price: price,
strategy: "market_maker",
id: "mm-#{side}-#{String.slice(to_string(token_id), 0, 16)}"
)
end
defp inventory(portfolio, token_id) do
portfolio.positions
|> Enum.filter(fn pos ->
pos.market_id == token_id and pos.strategy == "market_maker" and
pos.side == :buy and pos.status == :open
end)
|> Enum.reduce(0, fn pos, acc -> acc + pos.shares end)
end
defp resting_quotes(portfolio, token_id) do
mm =
portfolio
|> Engine.open_pending_remainders()
|> Enum.filter(fn ex ->
ex.metadata[:market_id] == token_id and ex.metadata[:strategy] == "market_maker"
end)
{Enum.filter(mm, &(&1.metadata[:side] == :buy)),
Enum.filter(mm, &(&1.metadata[:side] == :sell))}
end
defp no_cross_bid(bid, snapshot, state) do
case MarketSnapshot.best_ask(snapshot) do
{ask, _size} -> min(bid, quote_price(ask - @tick, state))
nil -> bid
end
end
defp quote_price(raw, state) do
# Coerce to float first: skew/target can be integers, and
# Float.round/2 requires a float.
(raw * 1.0)
|> max(state.min_price)
|> min(state.max_price)
|> Float.round(2)
end
end