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