🚧 SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
TradingEntry Price & PnL

Entry Price & PnL

Your position’s profit or loss is computed continuously from its entry price and a reference priceoracle_price for normal open/close and trigger-fill paths, mark_price_ema for liquidation eligibility — adjusted for cumulative funding paid or received since entry. This page documents exactly how.

Which price is “the price” here? On-chain, opens, closes, partial closes, and trigger fills all settle against market.oracle_price. Only liquidation eligibility is evaluated against mark_price_ema. The audit table in Mark Price → What each instruction prices against is the authoritative map.

Entry price

When a position opens, the program records:

  • entry_price — the oracle price at the moment of open (not the EMA, not the trigger price for limit orders)
  • cumulative_funding_at_entry — a snapshot of the market’s cumulative funding counter at open time
  • collateral — the USDC posted as initial margin
  • size — the notional position value (= collateral × leverage)
  • opened_at — Unix timestamp
  • last_margin_transfer_collateral — same as collateral at open (used for the 18.3% anti-manipulation check)

These fields are immutable after open. Adding or withdrawing collateral changes collateral and last_margin_transfer_collateral but leaves entry_price untouched. This means a position’s PnL is always measured against its original entry — subsequent margin ops don’t rewrite history.

Unrealized PnL formula

At any moment, the position’s unrealized PnL is computed against oracle_price (for opens, closes, and trigger fills) or mark_price_ema (for liquidation eligibility) — the formula is the same; only the reference price differs.

Long PnL  = size × (ref − entry) / entry
Short PnL = size × (entry − ref) / entry

Where ref is oracle_price in trade-path computations (open, close, partial close, trigger fill) and mark_price_ema in liquidation-eligibility checks.

Implementation: calculate_pnl in programs/obv-perps/src/math/pnl.rs.

Worked examples

Long on BUR (Brighton, market ID 3), entry at index 520, size 1,000 USDC:

  • Oracle rises to 540 → PnL = 1000 × (540 − 520) / 520 = +38.46 USDC
  • Oracle falls to 500 → PnL = 1000 × (500 − 520) / 520 = −38.46 USDC

Short on MUN (Manchester United, market ID 11), entry at 620, size 500 USDC:

  • Oracle falls to 580 → PnL = 500 × (620 − 580) / 620 = +32.26 USDC
  • Oracle rises to 650 → PnL = 500 × (620 − 650) / 620 = −24.19 USDC

PnL scales linearly with size (notional) and hyperbolically with entry price. A 10-point move on an entry of 100 is a 10% return; on an entry of 500 it’s only 2%.

Funding payment

Funding continuously adjusts the position’s PnL between intervals. At any moment:

funding_payment = size × (cumulative_funding_now − cumulative_funding_at_entry) / 10^6
  • Positive payment → the position owes funding (subtracted from effective collateral).
  • Negative payment → the position is owed funding (added to effective collateral).

Longs pay positive funding; shorts pay negative. So:

net_effective_collateral = collateral + unrealized_pnl − funding_payment   (for longs)
                         = collateral + unrealized_pnl + funding_payment   (for shorts)

The effective_collateral figure used in margin checks already factors this in — funding is settled against the position on every significant interaction (close, margin op, liquidation attempt).

See Funding Rate for how the cumulative counter is computed.

PnL at close

When a position closes (via close_position, trigger execution, or liquidation), the program:

  1. Applies pending funding. The difference between cumulative_funding_now and cumulative_funding_at_entry is settled as a final funding payment.
  2. Computes realized PnL against the current oracle_price.
  3. Deducts the taker fee (10 bps of notional) from the realized proceeds.
  4. Transfers collateral + realized_pnl − funding_payment − fee from the global pool_token SPL account to the trader’s wallet (gated only on pool_token.amount ≥ payout — closes always work as long as the pool can pay).
  5. Closes the UserPosition PDA, refunding rent to the trader.

The full accounting is visible in the transaction’s event log — every close emits a PositionClosed event with realized_pnl, funding_paid, fee, and final settlement amount. See sdk/src/events/ for event parsing.

Partial close

A close that specifies a size smaller than the full position size performs a proportional close:

  • close_collateral = collateral × close_size / size
  • close_pnl = unrealized_pnl × close_size / size
  • close_funding = funding_payment × close_size / size
  • Position is updated: size -= close_size, collateral -= close_collateral

entry_price and cumulative_funding_at_entry are preserved on the remaining portion. This is important: a partial close doesn’t rebase your cost basis. Your remaining 80% of the position still bears its original entry price, so future PnL reports stay comparable.

Fees

ActionFeeBasis
Market open10 bps (0.1%)Notional size
Market close10 bpsNotional size
Trigger execution (SL/TP/Limit Open)10 bpsNotional size
Liquidation (Layer 1, 2, 3)Liquidation reward covers — no separate fee
Add/withdraw collateral0
Place/cancel trigger order0(rent only)

Maker fees (5 bps) are reserved for a future limit-book implementation. At v1, all opens and closes are takers.

Displaying PnL in UIs

When an integrator reads a position account and wants to display its current PnL, the computation is:

import { calculatePnl, calculateFundingPayment } from "@sportsperp/sdk";
 
const position = await client.getPosition(marketId, user);
const market = await client.getMarket(marketId);
 
// For UI display, show PnL against the oracle (same price the program
// uses for opens/closes). If you want to show "liquidation eligibility"
// PnL specifically, pass market.markPriceEma instead.
const pnl = calculatePnl({
  direction: position.direction,
  size: position.size,
  entryPrice: position.entryPrice,
  referencePrice: market.oraclePrice,
});
 
const funding = calculateFundingPayment({
  size: position.size,
  cumulativeCurrent: market.cumulativeFunding[position.direction],
  cumulativeAtEntry: position.cumulativeFundingAtEntry,
  isLong: position.direction === Direction.Long,
});
 
const effective = position.collateral + pnl - funding;
const marginRatio = (effective * 10_000n) / position.size;  // bps

All four computations mirror the Rust program exactly (sdk/src/math/). Unit tests cross-check the two implementations to ensure the TS result matches an on-chain compute to the last integer.

Fixed-point precision

All PnL math is integer arithmetic at 10^6 scale. Division is performed last to minimize rounding error:

// Correct: multiplies first, divides last
pnl = size * (ref - entry) / entry
 
// Wrong: would accumulate rounding error
pnl = size * ((ref - entry) / entry)

The pnl return is an i64 — positive for profit, negative for loss — so a maximum absolute PnL of ~9.2 × 10^18 at the 10^6 scale (≈ 9.2 × 10^12 USDC) is representable without overflow. Nothing realistic can hit that ceiling.

Further reading

  • Mark Price — what PnL is actually computed against.
  • Funding Rate — the per-interval payment mechanics.
  • Margin — how PnL feeds the maintenance-margin check.