All packages
polymarket-pnl-substreams

polymarket-pnl-substreams

StreamingFast
v0.1.0/0 downloads/Repository

Package ref

polymarket-pnl-substreams@v0.1.0

Run package

CLI

Run db_out from the command line.

substreams run polymarket-pnl-substreams@v0.1.0 db_out -e polygon
Authenticate by running substreams auth or directly on thegraph.market (see docs).

README

Polymarket PnL Substreams

Source: streamingfast/substreams-chain-modules · prediction-markets/polymarket-pnl-substreams

Per-address Polymarket positions, mark-to-market PnL, volume and whale detection on Polygon, derived from streamingfast/polymarket-fills-substreams (unified CLOB v1 + v2 fills), plus CTF position splits, merges and payout redemptions from colindickson/polymarket-ctf — including a market registry. Ships a PostgreSQL db_out sink (Database Changes) for the trades, whale_alerts, user_positions, user_pnl and markets tables.

This supersedes PaulieB14/polymarket-pnl — same feature set, but its map_order_fills never actually produced fill data (a checksummed-vs- lowercase address mismatch against its eth_common:index_events filter), so nothing downstream of it ever ran on real rows.

PnL model: mark-to-market, not average cost

user_positions.total_pnl = net_cash_flow + token_amount * latest_price. This is exact — no averaging assumption — and once token_amount reaches 0 it is the fully realized PnL. It does mean realized and unrealized PnL are not reported as two separate numbers.

The reason is structural, not a shortcut: average-cost accounting needs a module that reads its own prior output (to know the running average before writing the next delta), and Substreams' store engine does not support that — accumulation for updatePolicy: add/set stores happens natively in the runtime, never inside your own module, so a module can feed a store or read a store but never both for the same store. Every store this package writes (store_user_positions, store_user_cash_flow, store_user_volume, store_market_volume, store_latest_prices) is a plain commutative accumulation for exactly this reason, keeping the module graph acyclic.

CTF position tracking

colindickson/polymarket-ctf's PositionSplit, PositionsMerge and PayoutRedemption events carry a condition_id and an index set, not a token ID. Recovering the token ID means implementing Gnosis CTF's position- ID derivation — which, for the general case (nested/combinatorial positions), is an elliptic-curve (BN254) point encode/decode. That path is not implemented here — it's genuinely unverifiable without a live reference, which is exactly the kind of "looks plausible, quietly wrong" arithmetic this package's sibling (polymarket-fills-substreams, replacing a broken lowercase/checksum address filter) exists to avoid repeating.

What is implemented, in src/ctf_position.rs, is the simpler top-level case (parent_collection_id == 0), ported from Polymarket's own go-ctf-utils reference implementation rather than the on-chain Solidity, and verified two ways:

  1. Matches that repo's own test vectors exactly (cargo test, see positionid_test.go for the source vectors).
  2. A derived token ID from a real, live PositionSplit event was checked against real fills data pulled earlier in the same validation pass — it matched a takerAssetId that actually traded on the exchange for the same market, byte for byte.

A sample of 89,328 real PositionSplit/PositionsMerge events (Polygon blocks 93,000,000–93,003,000) found zero with a non-zero parent_collection_id, and Polymarket's own go-ctf-utils has no support for a non-zero one either — so the top-level-only scope is not expected to miss anything in practice. Any event with a non-zero parent_collection_id is skipped (not guessed) defensively, in case that ever changes.

PayoutRedemption has no per-token amount in the event (Gnosis redeemPositions() burns the caller's entire balance of each redeemed token), so map_redemption_legs reads the running position from store_user_positions to know how much to close — reading a store it does not also feed, which is the only way Substreams allows this without a module-graph cycle (see substreams.yaml module docs for the full explanation). The closing amount then lands in the separate store_user_redemption_adj; db_out sums both stores for the displayed user_positions.token_amount.

Splits/merges attribute cost/proceeds evenly across the resulting partition entries (see even_shares in src/lib.rs) — this makes a split-then-immediately-merge round trip net to exactly zero cash flow, regardless of how many outcomes the partition covers.

Modules

ModuleKindOutput
map_trade_legsmappolymarket.pnl.v1.TradeLegs — per-fill-leg position/cash-flow deltas, volume, price, trades, whale alerts
map_ctf_legsmappolymarket.pnl.v1.TradeLegs — CTF split (mint) / merge (burn) legs
map_redemption_legsmappolymarket.pnl.v1.TradeLegs — CTF redemption (close) legs, reads store_user_positions
map_marketsmappolymarket.pnl.v1.MarketDeltas — condition metadata + resolution
store_user_positionsstore (add, bigint)<user>:<token_id> → net open position from fills + splits/merges (not redemptions)
store_user_redemption_adjstore (add, bigint)<user>:<token_id> → redemption-closing adjustment
store_user_cash_flowstore (add, bigint)<user>:<token_id> and <user>:ALL → signed cumulative cash flow
store_user_volumestore (add, bigint)<user> → lifetime notional traded (fills only)
store_market_volumestore (add, bigint)<token_id> → lifetime notional traded (fills only)
store_latest_pricesstore (set, proto)<token_id> → most recent trade price (fills only)
db_outmapsf.substreams.sink.database.v1.DatabaseChanges

Whale detection

Any fill leg, CTF split/mint, or redemption with a collateral amount ≥ 10,000 USDC (10_000_000_000 raw atomic units) is written to whale_alerts. Merges are not checked (their split-time leg already was, and a round-trip split-then-merge isn't a new whale-sized flow).

Run it

substreams build
export SUBSTREAMS_SINK_DSN="postgres://user:pass@localhost:5432/polymarket?sslmode=disable"
substreams sink postgres setup polymarket-pnl-substreams-v0.1.0.spkg
substreams sink postgres polymarket-pnl-substreams-v0.1.0.spkg

Built with Substreams Skills (substreams-dev, substreams-sql).

Modules

Execution graph

22 modules
map

map_ctf_legs

Position/cash-flow legs from CTF PositionSplit (mint) and PositionsMerge (burn). token_id is derived per partition entry via Polymarket's own go-ctf-utils position-ID algorithm (keccak + quadratic-residue walk, no elliptic-curve step — verified against that repo's real test vectors). Scoped to parent_collection_id == 0 (see pnl.proto). PayoutRedemption is handled separately by map_redemption_legs to avoid a store self-reference.

from #33605403
map

map_redemption_legs

Position/cash-flow legs from CTF PayoutRedemption. The event has no per-token amount (Gnosis redeemPositions() burns the caller's entire balance), so the amount closed is read from store_user_positions — the module that store_user_positions cannot also depend on this one for, hence the separate store_user_redemption_adj below.

from #33605403
map

map_trade_legs

Per-OrderFilled-leg position and cash-flow deltas (maker + taker), volume, price and whale-alert extraction. No store inputs — every downstream store is a plain commutative "add" (or "set" for latest price), so nothing here needs to read prior state and the module graph stays acyclic. See pnl.proto for the mark-to-market PnL model this enables.

from #33605403
store

store_market_volume

<token_id> -> lifetime notional traded against this outcome token, raw USDC atomic units.

from #33605403

Store value

bigint

Update policy

add

store

store_user_cash_flow

"<user>:<token_id>" -> cumulative signed cash flow (negative = paid, positive = received), raw USDC atomic units. "<user>:ALL" -> the same, summed across every token for that user. Fed by fills, CTF splits/ merges and redemptions.

from #33605403

Store value

bigint

Update policy

add

store

store_user_positions

"<user>:<token_id>" -> net open position, raw token units (signed), from fills and CTF splits/merges only (NOT redemptions — see store_user_redemption_adj and map_redemption_legs).

from #33605403

Store value

bigint

Update policy

add

store

store_user_redemption_adj

<user>:<token_id> -> redemption-closing adjustment; db_out sums this with store_user_positions for the displayed token_amount.

from #33605403

Store value

bigint

Update policy

add

store

store_user_volume

<user> -> lifetime notional traded, raw USDC atomic units.

from #33605403

Store value

bigint

Update policy

add

Show 11 dependencies
map

pmctf:eth_common:all_events

`all_events` gives you all the events in a block (from successful transactions), with basic block hash/number/timestamp and transaction hash

Output

sf.substreams.ethereum.v1.Events

blockIndex

pmctf:eth_common:index_events

`index_events` sets the following keys on the block: * Event signatures evt_sig:0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef (signature in hex, prefixed by 0x) * Event address evt_addr:0x0123456789abcdef0123456789abcdef01234567 (address in hex, prefixed by 0x)

Output

sf.substreams.index.v1.Keys

map

pmfills:eth_common:all_events

`all_events` gives you all the events in a block (from successful transactions), with basic block hash/number/timestamp and transaction hash

Output

sf.substreams.ethereum.v1.Events

blockIndex

pmfills:eth_common:index_events

`index_events` sets the following keys on the block: * Event signatures evt_sig:0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef (signature in hex, prefixed by 0x) * Event address evt_addr:0x0123456789abcdef0123456789abcdef01234567 (address in hex, prefixed by 0x)

Output

sf.substreams.index.v1.Keys

map

pmfills:map_v1_events

Single-pass native decode of the CLOB v1 contracts: CTF Exchange (0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e, deployed block 33605403) and Neg Risk CTF Exchange (0xc5d563a36ae78145c45a50134d48a1215220f80a, deployed block 50505492). Both deploy blocks were verified against Polygon RPC via eth_getCode binary search, not inherited from prior (incorrect) documentation.

from #33605403
map

pmfills:map_v2b_fills

Native decode of Neg Risk CTF Exchange V2 (0xe2222d279d744050d28e00520010520000310f59, deployed block 85058176 — verified via eth_getCode binary search). OrderFilled/OrdersMatched only; ABI shape (topic0, indexed-param count, non-indexed data width) empirically confirmed against real eth_getLogs output from this address, matching CTF Exchange V2 exactly. This contract is named in the substreams-dev landing-page FAQ draft but was not read by any published package before this module.

from #85058176
map

pmfills:pmx_v2:eth_common:all_events

`all_events` gives you all the events in a block (from successful transactions), with basic block hash/number/timestamp and transaction hash

Output

sf.substreams.ethereum.v1.Events

blockIndex

pmfills:pmx_v2:eth_common:index_events

`index_events` sets the following keys on the block: * Event signatures evt_sig:0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef (signature in hex, prefixed by 0x) * Event address evt_addr:0x0123456789abcdef0123456789abcdef01234567 (address in hex, prefixed by 0x)

Output

sf.substreams.index.v1.Keys