Packages
polymarket_sdk
0.2.0
Higher-level Elixir SDK for Polymarket: Gamma market discovery, Data API, CTF/pUSD helpers, and convenience facades. Depends on polymarket_clob for the low-level CLOB surface.
Current section
Files
Jump to
Current section
Files
lib/polymarket/activity.ex
defmodule Polymarket.Activity do
@moduledoc """
Pure normalization and join helpers for Polymarket activity data.
This module operates on plain maps. It does not fetch from the Data API
or the CLOB; the caller pipes raw activity events (typically from
`Polymarket.Data.get_activity/2`) and CLOB fills (from
`PolymarketClob.API.Account.get_trades/3`) into these helpers.
All input keys are looked up in **both** camelCase string form (the
shape Polymarket's Data API returns) and snake_case string form (the
shape the CLOB and several internal callers use). Atom keys are also
accepted as a convenience for in-Elixir construction.
## Match semantics
`cost_for_order/3`, `revenue_for_order/3`, and `redemption_for_market/2`
**sum** USDC sizes across every matching event. This is a deliberate
divergence from the bot's `extract_activity_cost/3` which returns the
first match: GTC orders that partially fill across multiple takers
produce multiple activity rows, and summing is the correct total.
All three return `nil` when no event matches, distinguishing
"no activity yet" from "events that summed to 0". The bot uses `0.0`
for the same case; the SDK chose `nil` to keep "missing data" and
"actual zero" semantically separate.
## Activity ↔ fills join
Polymarket's `/activity` endpoint identifies each TRADE by
`transactionHash`, not `orderHash`. To find the activity row for a
given CLOB order id, we resolve `tx_hash` from the fills list first
and then match against `activity.transactionHash`. When no fills list
is available, `cost_for_order/3` and `revenue_for_order/3` fall back
to a best-effort match against legacy `orderHash` / `taker_order_id`
fields. Pass an empty list `[]` for fills if all you have is the order
id and you want the fallback path.
"""
alias Polymarket.Activity.Event
@type raw_event :: map()
@type raw_fill :: map()
@type order_id :: String.t()
@type condition_id :: String.t()
# ── Normalization ──
@doc """
Normalizes a list of raw `/activity` event maps into typed
`Polymarket.Activity.Event` structs.
Unknown event types are still emitted with `type: :unknown` and the
full raw map preserved under `:raw`, so a caller iterating over a
large activity feed never loses events it did not anticipate.
"""
@spec normalize([raw_event()]) :: [Event.t()]
def normalize(events) when is_list(events) do
Enum.map(events, &normalize_one/1)
end
defp normalize_one(event) when is_map(event) do
%Event{
type: parse_type(field(event, "type")),
side: parse_side(field(event, "side")),
tx_hash: field(event, "transactionHash") || field(event, "transaction_hash"),
condition_id: field(event, "conditionId") || field(event, "condition_id"),
token_id: field(event, "asset") || field(event, "token_id"),
usdc_size: parse_float(field(event, "usdcSize") || field(event, "usdc_size")),
shares: parse_float(field(event, "size")),
price: parse_float(field(event, "price")),
timestamp: parse_integer(field(event, "timestamp")),
raw: event
}
end
# ── Cost / revenue / redemption ──
@doc """
Sums fee-inclusive USDC cost across every TRADE BUY event matching
`order_id`. Returns `nil` when no event matches.
Activity is matched to an order id by:
1. Finding fills whose `order_id` or `taker_order_id` equals the
requested `order_id` and collecting their `tx_hash`/`transaction_hash`.
2. Selecting activity events whose `transactionHash` is in that set
AND whose `type == "TRADE"` AND `side == "BUY"`.
When step 1 yields nothing (e.g. caller did not supply fills), a
fallback matches activity rows directly by `orderHash` or
`taker_order_id` equal to `order_id`. The fallback is best-effort
only — Polymarket has historically populated `transactionHash` more
reliably than `orderHash` on `/activity`.
"""
@spec cost_for_order([raw_event()], order_id(), [raw_fill()]) :: float() | nil
def cost_for_order(activities, order_id, fills)
when is_list(activities) and is_binary(order_id) and is_list(fills) do
sum_usdc_for_trade(activities, order_id, fills, "BUY")
end
@doc """
Sums fee-inclusive USDC revenue across every TRADE SELL event
matching `order_id`. Returns `nil` when no event matches.
Same join semantics as `cost_for_order/3`.
"""
@spec revenue_for_order([raw_event()], order_id(), [raw_fill()]) :: float() | nil
def revenue_for_order(activities, order_id, fills)
when is_list(activities) and is_binary(order_id) and is_list(fills) do
sum_usdc_for_trade(activities, order_id, fills, "SELL")
end
@doc """
Sums REDEEM payouts for `condition_id`. Returns `nil` when no event
matches.
Activity rows are matched directly on `conditionId` / `condition_id`;
no fills join is needed because REDEEM events do not carry a CLOB
order id.
"""
@spec redemption_for_market([raw_event()], condition_id()) :: float() | nil
def redemption_for_market(activities, condition_id)
when is_list(activities) and is_binary(condition_id) do
matches =
for event <- activities,
field(event, "type") == "REDEEM",
field(event, "conditionId") == condition_id or
field(event, "condition_id") == condition_id do
parse_float(field(event, "usdcSize") || field(event, "usdc_size")) || 0.0
end
sum_or_nil(matches)
end
# ── Private ──
defp sum_usdc_for_trade(activities, order_id, fills, expected_side) do
tx_hashes = tx_hashes_for_order(fills, order_id)
matches =
if tx_hashes != [] do
for event <- activities,
field(event, "type") == "TRADE",
field(event, "side") == expected_side,
event_tx_hash = field(event, "transactionHash") || field(event, "transaction_hash"),
event_tx_hash in tx_hashes do
parse_float(field(event, "usdcSize") || field(event, "usdc_size")) || 0.0
end
else
# Legacy fallback for callers without a fills list. Match activity
# rows by `orderHash` / `taker_order_id` directly. Best-effort:
# Polymarket's /activity does not always populate orderHash.
for event <- activities,
field(event, "type") == "TRADE",
field(event, "side") == expected_side,
field(event, "orderHash") == order_id or
field(event, "order_hash") == order_id or
field(event, "taker_order_id") == order_id do
parse_float(field(event, "usdcSize") || field(event, "usdc_size")) || 0.0
end
end
sum_or_nil(matches)
end
defp tx_hashes_for_order(fills, order_id) do
Enum.flat_map(fills, fn fill ->
if field(fill, "order_id") == order_id or
field(fill, "taker_order_id") == order_id or
field(fill, "orderID") == order_id do
case field(fill, "tx_hash") || field(fill, "transaction_hash") ||
field(fill, "transactionHash") do
nil -> []
hash -> [hash]
end
else
[]
end
end)
end
defp sum_or_nil([]), do: nil
defp sum_or_nil(values), do: Enum.sum(values)
defp parse_type("TRADE"), do: :trade
defp parse_type("REDEEM"), do: :redeem
defp parse_type(_), do: :unknown
defp parse_side("BUY"), do: :buy
defp parse_side("SELL"), do: :sell
defp parse_side(_), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value * 1.0
# Binary inputs must parse to a number with no trailing characters.
# `Float.parse("12abc")` returns `{12.0, "abc"}`,
# which the lenient `{n, _}` pattern would accept as 12.0. That would
# silently turn a partial numeric string into a confident 12.0
# contribution to a P&L sum. Require empty rest so partial parses
# surface as `nil` and the caller can distinguish "missing data" from
# "real value." Mirrors the strictness already used in `parse_integer/1`.
defp parse_float(value) when is_binary(value) do
case Float.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp parse_float(_), do: nil
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(value) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp parse_integer(_), do: nil
# Defensive map accessor: tries the string key first (the shape every
# production caller currently uses), then the corresponding atom key
# for in-Elixir test fixtures.
defp field(map, key) when is_map(map) and is_binary(key) do
case Map.fetch(map, key) do
{:ok, value} ->
value
:error ->
atom_key = safe_existing_atom(key)
if atom_key, do: Map.get(map, atom_key), else: nil
end
end
defp field(_, _), do: nil
defp safe_existing_atom(key) do
String.to_existing_atom(key)
rescue
ArgumentError -> nil
end
end