The Polymarket data-api /positions endpoint (https://data-api.polymarket.com/positions?user=<addr>) reports resolved-but-unredeemed positions in a way that breaks naive PnL aggregation: a resolved loss shows its loss in cashPnl (unrealized) with realizedPnl: 0, so a buy-and-hold-to-resolution strategy looks like “$0 realized, everything unrealized”. The fix is to split realized vs unrealized on the redeemable flag, not on realizedPnl. Drives PM’s /wallets command (crates/position_manager/src/self_portfolio.rs aggregate()).

For Agents

Endpoint: GET https://data-api.polymarket.com/positions?user=<addr>. Verified live against deposit wallet 0xAdAc17C0A3725D2CA2Df66E6DcBBAC82ebA0aEb2 on 2026-06-28. Fix landed in commit b26b53b (position-manager main), in aggregate(). Note: /positions returns only currently-HELD positions (size > 0), so it cannot see lifetime realized PnL.

The Gotcha — realizedPnl stays 0 until you actually REDEEM

Per-position fields from /positions, by lifecycle state (verified live):

StateredeemablecurPricecurrentValuecashPnlrealizedPnl
Open (unresolved)falsestrictly 0 < p < 1mid-valueunrealized P/Lpartial-sell realized
Resolved LOSS (held, not redeemed)true00-initialValue (the loss, as unrealized)0
Resolved WIN (held, not redeemed)true1claimable payoutthe win0

A resolved bet's outcome sits in cashPnl (unrealized) until redeemed

realizedPnl only becomes non-zero when the position is REDEEMED (claimed) or partially sold. A resolved losing bet reports currentValue: 0, cashPnl: -initialValue, realizedPnl: 0, redeemable: true, curPrice: 0. So for a strategy that buys and holds to resolution, summing every position’s cashPnl into unrealized produces a misleading uPnL while rPnL stays $0.

Concrete impact (verified, wallet 0xAdAc)

Naive aggregation (sum all cashPnl → unrealized) reported uPnL −0. The economically-correct split is uPnL −130.89 (18 resolved losses).

The Fix — classify on redeemable, not realizedPnl

In crates/position_manager/src/self_portfolio.rs aggregate() (commit b26b53b):

  • redeemable == true (resolved) → fold cashPnl into realized_pnl_usd.
  • redeemable == false (open) → cashPnl into unrealized_pnl_usd, and initialValue into cost_basis_usd (so pnl_pct reflects only open positions).
  • value_usd keeps summing all currentValue (resolved loss = 0, resolved win = claimable payout, open = mid-value).
  • realizedPnl is still added for all positions (captures partial-sell realized that happened before resolution).

"Realized" here means resolved-but-not-yet-redeemed

/positions only returns currently-held positions (size > 0). Once a position is redeemed it drops off the endpoint entirely. So the realized_pnl_usd PM computes is really resolved-but-not-yet-redeemed realized — NOT lifetime realized. True lifetime realized PnL would require /activity or /trades.

The pUSD/USDC cash balance in self_portfolio.rs is read via eth_call balanceOf (6 decimals) against contract 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB, using POLYGON_RPC_URL from env. Verified to return a sane balance (~$418 for 0xAdAc), confirming this is the correct collateral contract for this deployment — NOT the standard USDC.e 0x2791Bca1.... This matches the pUSD collateral constant in pmv2-polymarket-deposit-wallet-live-trading-solution-2026-06-26.