Skip to main content

Solana Perps Trader Cookbook

Ready-to-run recipes for the questions traders, copy-traders and strategy builders actually ask, built on the Perp DEX cubes. Every query here was validated against the live endpoint; swap the example wallet/market for your own. Field semantics live on the Phoenix Perpetuals API page.

Two rules apply to almost every recipe:

  • TraderIsAmm: false — the venue's AMM backstop trades every market; leave it in and it tops every leaderboard.
  • Latest-state snapshots use limitBy + orderBy: {descending: Block_Time} — the newest row per key is the current state. Don't pre-filter Closed: false or Size: {ne: 0}: that skips past closing rows and resurrects stale positions. Take the latest row, then drop flats (Size == 0) client-side.

Copy trading

Follow a trader's every fill, live

Stream each execution of a wallet you follow — the signal feed a copy-trading bot subscribes to, including the position each fill produced:

subscription {
Solana {
PerpetualFills(
where: {
Fill: {
Trader: { is: "DUGirckBgoaW3zoEPhTVVo68pZpXrTKuJrsLBLWcZQo2" }
Liquidation: false
}
}
) {
Block { Time }
Fill {
Asset { Symbol }
Side
ExecutionPrice
Amount { Filled Quote }
Position { Size EntryPrice }
}
}
}
}

Position { Size, EntryPrice } after each fill tells you their resulting exposure — you see reduces and flips, not just entries. Note that Trader is the venue's position account (a PDA), which you learn from any of their fills or positions.

A trader's current open book

Latest state per market for one wallet — what they hold right now:

query {
Solana {
PerpetualPositions(
limitBy: { by: Position_Asset_Id, count: 1 }
orderBy: { descending: Block_Time }
limit: { count: 100 }
where: {
Position: { Trader: { is: "DUGirckBgoaW3zoEPhTVVo68pZpXrTKuJrsLBLWcZQo2" } }
}
) {
Block { Time }
Position {
Asset { Symbol }
Position { EntryPrice Size }
MarkPrice
}
}
}
}

Rows with Size: 0 are markets they've fully closed — drop them and the rest is the live book, with entry prices.

Who is worth copying — the report card

Realized PnL, close count, win rate and liquidation count per trader, in one aggregation:

query {
Solana {
PerpetualPositions(
limit: { count: 20 }
orderBy: { descendingByField: "realized" }
where: { Position: { TraderIsAmm: false, Closed: true } }
) {
Position { Trader }
realized: sum(of: Position_RealizedPnl)
closes: count
wins: count(if: { Position: { RealizedPnl: { gt: 0 } } })
losses: count(if: { Position: { RealizedPnl: { lt: 0 } } })
liquidated: count(if: { Position: { Liquidation: true } })
}
}
}

Win rate is wins / closes; a high realized with liquidated > 0 tells you how they treat risk. Add a Block: { Time: { since: … } } filter to score a recent window instead of all time.

Positions & PnL

Top unrealized positions and traders

Unrealized PnL is (mark − entry) × size over each trader's latest open position. One request returns both the position snapshot and fresh marks:

query {
Solana {
openPositions: PerpetualPositions(
limitBy: { by: [Position_Trader, Position_Asset_Id], count: 1 }
orderBy: { descending: Block_Time }
limit: { count: 3000 }
where: { Position: { TraderIsAmm: false } }
) {
Block { Time }
Position {
Trader
Asset { Id Symbol }
Position { EntryPrice Size }
MarkPrice
}
}
marks: PerpetualPrices(
limitBy: { by: Price_Asset_Id, count: 1 }
orderBy: { descending: Block_Time }
limit: { count: 200 }
) {
Price { Asset { Id Symbol } Mark }
}
}
}

Then a few lines client-side:

marks = {m["Price"]["Asset"]["Id"]: m["Price"]["Mark"] for m in d["marks"]}
open_pos = []
for r in d["openPositions"]:
p = r["Position"]; size = p["Position"]["Size"]
if size == 0:
continue # flat = closed
mark = marks.get(p["Asset"]["Id"]) or p["MarkPrice"]
upnl = (mark - p["Position"]["EntryPrice"]) * size # signed Size handles shorts
open_pos.append((p["Trader"], p["Asset"]["Symbol"], size, upnl))

top_positions = sorted(open_pos, key=lambda x: x[3], reverse=True)

Sum per Trader for a whale-exposure leaderboard. Prefer the marks alias over the position row's own MarkPrice — the latter is denormalized and can be 0.

Funding a trader has paid or received

Funding settlements are their own rows — Funding non-zero, size unchanged:

query {
Solana {
PerpetualPositions(
limit: { count: 100 }
orderBy: { descending: Block_Time }
where: {
Position: {
Trader: { is: "DUGirckBgoaW3zoEPhTVVo68pZpXrTKuJrsLBLWcZQo2" }
Funding: { ne: 0 }
}
}
) {
Block { Time }
Position { Asset { Symbol } Funding }
}
}
}

Positive = received, negative = paid. Replace the field list with total: sum(of: Position_Funding) for the net carry cost of holding their positions.

Market signals

Whale fills

Every fill above a notional threshold — as history or a live tape:

subscription {
Solana {
PerpetualFills(where: { Fill: { Amount: { Quote: { gt: 5000 } } } }) {
Block { Time }
Fill {
Asset { Symbol }
Side
ExecutionPrice
Amount { Filled Quote }
Trader
Liquidation
}
}
}
}

As a query, add orderBy: { descending: Block_Time } and a limit for the recent whale prints.

OHLC candles from the mark price

Strategy builders and backtesters: bucket PerpetualPrices into intervals and take argMin/argMax aggregates —

query {
Solana {
PerpetualPrices(
where: { Price: { Asset: { Symbol: { is: "BTC" } } } }
orderBy: { ascendingByField: "Block_Time" }
limit: { count: 96 }
) {
Block { Time(interval: { in: minutes, count: 15 }) }
Price {
open: Mark(minimum: Block_Time)
high: Mark(maximum: Price_Mark)
low: Mark(minimum: Price_Mark)
close: Mark(maximum: Block_Time)
}
}
}
}

Mark(minimum: Block_Time) reads "the Mark at the earliest time in the bucket" — open; Mark(maximum: Price_Mark) is the bucket's high. Price rows are emitted on trading activity, so an interval with no trades produces no candle (no zero-filled bars).

Open interest, basis and fee revenue over time

One query per market gives an OI series, the perp-vs-spot basis, and — because TakerFees/MakerFees are cumulative counters — per-bucket fee revenue as end-minus-start:

query {
Solana {
PerpetualMarketSummaries(
where: { MarketSummary: { Asset: { Symbol: { is: "SOL" } } } }
orderBy: { ascendingByField: "Block_Time" }
limit: { count: 168 }
) {
Block { Time(interval: { in: hours, count: 1 }) }
MarketSummary {
oi: OpenInterest(maximum: Block_Time)
mark: Mark(maximum: Block_Time)
spot: SpotIndex(maximum: Block_Time)
takerFeesEnd: TakerFees(maximum: Block_Time)
takerFeesStart: TakerFees(minimum: Block_Time)
}
}
}
}

Basis = mark − spot; hourly taker fees = takerFeesEnd − takerFeesStart. Rising OI with a widening basis is the classic crowded-longs signal.

Order-flow pressure — taker buys vs sells

Conditional sums split taker volume by side per bucket:

query {
Solana {
PerpetualFills(
where: { Fill: { Asset: { Symbol: { is: "SOL" } } } }
orderBy: { ascendingByField: "Block_Time" }
limit: { count: 168 }
) {
Block { Time(interval: { in: hours, count: 1 }) }
buyVol: sum(of: Fill_Amount_Quote, if: { Fill: { Side: { is: "bid" } } })
sellVol: sum(of: Fill_Amount_Quote, if: { Fill: { Side: { is: "ask" } } })
trades: count
}
}
}

(buyVol − sellVol) / (buyVol + sellVol) is a ready order-flow-imbalance series.

Risk

Biggest liquidations

Rank forced closes by what they took:

query {
Solana {
PerpetualPositions(
limit: { count: 20 }
orderBy: { descendingByField: "lost" }
where: { Position: { Type: { is: "Liquidation" } } }
) {
Position { Trader Asset { Symbol } }
lost: sum(of: Position_LiquidatedQuote)
events: count
}
}
}

For the live feed version and the multi-row anatomy of a liquidation, see the liquidation section of the Phoenix page.


Every query above becomes a live stream by switching to subscription and removing limit/orderBy/limitBy — except the snapshot and interval recipes, which are inherently query-shaped. Run them over Kafka instead with the solana.perpetual.proto topic when you need the full firehose.

Frequently Asked Questions

How do I copy-trade a Solana perps wallet with an API?

Subscribe to PerpetualFills filtered to that wallet's Trader address over wss://streaming.bitquery.io/graphql. Each fill arrives with side, execution price, size and the trader's resulting position, which is the signal feed a copy-trading bot acts on. Pair it with a PerpetualPositions snapshot to know their current open book before you start mirroring.

How do I find profitable perp traders to follow?

Aggregate closed PerpetualPositions per trader: sum RealizedPnl for total profit, count closes, and use conditional counts for wins, losses and liquidations. Sorting by the summed PnL gives a leaderboard, and wins divided by closes gives each trader's win rate — all in a single GraphQL query.

How do I calculate unrealized PnL for open positions?

Take each trader's latest position state per market using limitBy on trader and asset, drop rows whose size is zero, then compute mark minus entry times signed size. Fetch fresh mark prices in the same request with a second aliased query on PerpetualPrices. The signed size makes the same formula work for longs and shorts.

Can I build OHLC candles for perpetual markets?

Yes. Bucket PerpetualPrices with a Block Time interval and use argMin/argMax aggregates: Mark at the earliest time is the open, Mark at the latest time is the close, and the maximum and minimum of Mark are the high and low. Intervals with no trading produce no candle because price rows are only emitted on trading activity.

How do I track whale trades on Solana perps?

Filter PerpetualFills on Amount Quote above your notional threshold. As a query with descending Block Time it returns recent large prints; as a subscription it becomes a live whale tape including whether each fill was a liquidation.

How do I measure buy and sell pressure on a perp market?

Aggregate PerpetualFills per time bucket with two conditional sums of Amount Quote, one where the taker side is bid and one where it is ask. The difference over the total is a ready order-flow-imbalance series for the market.