PostgreSQL Database Changes for trades, whale_alerts, user_positions, user_pnl and markets (schema.sql).
Output
sf.substreams.sink.database.v1.DatabaseChanges
Package ref
polymarket-pnl-substreams@v0.1.0Run package
CLI
Run db_out from the command line.
substreams run polymarket-pnl-substreams@v0.1.0 db_out -e polygonsubstreams auth or directly on thegraph.market (see docs).README
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.
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.
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:
cargo test, see
positionid_test.go for the source vectors).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.
| Module | Kind | Output |
|---|---|---|
map_trade_legs | map | polymarket.pnl.v1.TradeLegs — per-fill-leg position/cash-flow deltas, volume, price, trades, whale alerts |
map_ctf_legs | map | polymarket.pnl.v1.TradeLegs — CTF split (mint) / merge (burn) legs |
map_redemption_legs | map | polymarket.pnl.v1.TradeLegs — CTF redemption (close) legs, reads store_user_positions |
map_markets | map | polymarket.pnl.v1.MarketDeltas — condition metadata + resolution |
store_user_positions | store (add, bigint) | <user>:<token_id> → net open position from fills + splits/merges (not redemptions) |
store_user_redemption_adj | store (add, bigint) | <user>:<token_id> → redemption-closing adjustment |
store_user_cash_flow | store (add, bigint) | <user>:<token_id> and <user>:ALL → signed cumulative cash flow |
store_user_volume | store (add, bigint) | <user> → lifetime notional traded (fills only) |
store_market_volume | store (add, bigint) | <token_id> → lifetime notional traded (fills only) |
store_latest_prices | store (set, proto) | <token_id> → most recent trade price (fills only) |
db_out | map | sf.substreams.sink.database.v1.DatabaseChanges |
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).
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
PostgreSQL Database Changes for trades, whale_alerts, user_positions, user_pnl and markets (schema.sql).
Output
sf.substreams.sink.database.v1.DatabaseChanges
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.
Inputs
Market registry rows from CTF ConditionPreparation (creates) and ConditionResolution (resolves). Metadata only — see pnl.proto scope note.
Inputs
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.
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.
Inputs
Store value
proto:polymarket.pnl.v1.LatestPriceUpdate policy
set
Inputs
<token_id> -> lifetime notional traded against this outcome token, raw USDC atomic units.
Store value
bigint
Update policy
add
Inputs
"<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.
Store value
bigint
Update policy
add
"<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).
Store value
bigint
Update policy
add
<user>:<token_id> -> redemption-closing adjustment; db_out sums this with store_user_positions for the displayed token_amount.
Store value
bigint
Update policy
add
Inputs
Store value
bigint
Update policy
add
Inputs
Output
sf.substreams.ethereum.v1.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
Extracts CTF-specific events (ConditionPreparation, ConditionResolution, PositionSplit, PositionsMerge, PayoutRedemption).
Output
sf.substreams.ethereum.v1.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
Unified OrderFilled/OrdersMatched/OrderCancelled across CLOB v1 and both v2 exchanges. V2's side+token_id encoding is normalized into maker_asset_id/taker_asset_id so v1 and v2 rows union without a schema mismatch.
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.
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.
Output
sf.substreams.ethereum.v1.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
Extracts exchange trading events (OrderFilled, OrdersMatched) from the CTF Exchange V2 contract.