Skip to main content

Position history reads

These reads return the closed position lifecycles of an account.

They use the same POST /info endpoint, envelope and conventions as the base page. This page lists the lifecycle types.

Summary​

There is one row for each position that was opened and then closed. A row folds every fill of that life into one record: peak size, average entry, average close, realized PnL, fees and funding.

warning

This is not a trade log. One row covers a whole life, not one execution. For one record for each execution, with price, size, fee and order id, use user_fills and user_fills with a time window. If you port code that reads the per-trade history of a different exchange, use user_fills, not this read.

info

An open position is never returned. A life enters this history only when it closes. An open position is not lost. clearinghouse_state serves the current position from the clearinghouse state of the node. The two reads complete each other: clearinghouse_state for what you hold now, and position history for what you already closed.

Query types​

Closed position lifecycles, newest first​

{ "type": "user_position_history", "address": "0x<addr>", "limit": 100 }
ArgTypeRequiredDescription
addresshex addressyesAccount address
limituint32noRows returned. Default 500, clamped to 1 … 5000
start_timeuint64noWindow start (ms, inclusive). Filters on closed_at. If absent, the lower bound is open
end_timeuint64noWindow end (ms, inclusive). Filters on closed_at. If absent, the upper bound is open
warning

The request field is address, not user. A request with user gets 400:

{ "error": "missing field: address" }

An address that cannot be parsed gets the same rejection, which names the value:

{ "error": "invalid user address: nothex" }

These two reads answer a validation error as a bare string. error holds the message directly. There is no error.code key and no error.message key. Every other /info read answers {"error":{"code":…, "message":…}}. A client that reads error.code gets undefined here, and can report the rejection as a success. Handle a string error before you index into it.

There is no filter for each market. This read is scoped to the account only. coin is not an accepted argument. Filter by coin on the client after the read.

An account with no closed positions returns "positions": [] with 200. It is not an error and not a 404.

Response:

warning

type is on the envelope here, not inside data. Both position-history reads answer {"type": …, "data": {"address", "positions"}}. Every other /info read puts type inside data. A client that reads data.type to route the reply gets undefined on these two. Read the outer type.

{
"type": "user_position_history",
"data": {
"address": "0x662971350e886a0a5631d3e9133d33f767f80611",
"positions": [
{
"coin": "SOL",
"side": "short",
"max_sz": null,
"closed_sz": "0.80",
"avg_entry_px": null,
"avg_close_px": "74.75000000",
"closed_pnl": "-0.8960000000",
"fee_paid": "0.020930",
"realized_pnl": "-0.9169300000",
"funding_paid": "0",
"net_pnl": "-0.9169300000",
"opened_at": 1786162051867,
"closed_at": 1786162051867,
"open_block": 6831775,
"close_block": 6831775,
"entry_complete": false,
"close_complete": false,
"funding_complete": false
}
]
}
}

Rows are in newest-first order by closed_at.

Closed position lifecycles in a time window​

{ "type": "user_position_history_by_time", "address": "0x<addr>", "start_time": 1700000000000, "end_time": 1700003600000 }

This read has the same arguments and the same row shape as user_position_history. There is one difference: rows are in oldest-first order.

Both types accept the same window. The order is the only difference, and it decides which rows stay after a limit cut. See paging.

Window filter​

start_time and end_time filter on closed_at, never on opened_at.

A lifecycle is a point event at the time it closes. A position that was opened before the window and closed inside it is therefore returned. It has its true opened_at, which is outside the window that you asked for. This is deliberate. The alternative hides the rows that a period-PnL report needs.

A position opened inside the window but not closed yet is absent, because it is not closed. It appears when it closes, in the window that holds its closed_at.

Paging​

limit caps the rows returned. Compare the row count that you got with the limit that you asked for:

  • If len(positions) < limit, you have every row in the window.
  • If len(positions) == limit, there can be more. Rows were dropped.

The type decides which rows are dropped. user_position_history keeps the newest rows and drops the oldest. user_position_history_by_time keeps the oldest rows and drops the newest.

To get the dropped rows, narrow the window with start_time and end_time and read again, or increase limit (up to 5000). To walk a long history, move the closed_at window. Use the closed_at of the last row as the next boundary.

Row fields​

FieldTypeDescription
coinstringThe market symbol that the position traded on
side"long" / "short"Direction of the life
max_szDecimal string | nullPeak size that the position reached, in base units. null when entry_complete is false
closed_szDecimal stringSize closed over the life, in base units. The field was once closed_qty. That name is gone
avg_entry_pxDecimal string | nullSize-weighted average entry price, in decimal USDC. null when entry_complete is false
avg_close_pxDecimal string | nullSize-weighted average close price, in decimal USDC
closed_pnlDecimal stringRealized PnL before fees, in decimal USDC (signed). The lot-matched number of the chain. See the warning below
fee_paidDecimal stringTotal trading fees over the life, in decimal USDC
realized_pnlDecimal stringclosed_pnl − fee_paid, in decimal USDC (signed)
funding_paidDecimal stringNet funding over the life, in decimal USDC (signed). "0" means unknown when funding_complete is false
net_pnlDecimal stringrealized_pnl + funding_paid, in decimal USDC (signed)
opened_atuint64Open timestamp (consensus ms)
closed_atuint64Close timestamp (consensus ms). The window filters on this field
open_blockuint64The committed block height of the first fill observed for this life
close_blockuint64The committed block height at which the life closed
entry_completeboolfalse means that the opening fill was never observed. The read withholds the entry-side numbers
close_completeboolfalse means that the close-side numbers are floors, not totals
funding_completeboolfalse means that funding_paid is unknown and net_pnl excludes funding

Identities​

realized_pnl = closed_pnl − fee_paid
net_pnl = realized_pnl + funding_paid

The server computes both from the fields next to them, so the numbers in one row always agree.

Lot-matched closed_pnl​

warning

Do not calculate closed_pnl again from the average prices. It is not (avg_close_px − avg_entry_px) × closed_sz. A check of that kind produces false mismatches.

closed_pnl is the number of the chain. The chain matches it lot by lot, as each closing fill consumes specific opening lots. The two averages summarize the same life. They lose the lot pairing, so the product of the averages does not give the matched result. Trust closed_pnl. Use the averages for display.

Completeness flags​

entry_complete, close_complete and funding_complete say if the numbers next to them are trustworthy. Each row has its own flags. A row with a false flag is degraded, not wrong. The fields that could mislead come back null. The read does not return a partial average as if it were complete.

Read the flag before you read the number.

entry_complete​

It is false when the opening fill was never observed. There are three causes: the open is below the history retention floor, the open happened before a restart of the history service, or the open is inside a recorded archive gap.

When it is false, max_sz and avg_entry_px are null. An average over part of a life is worse than no average. The read serves no number instead of a plausible wrong one. null here means "not known", never "zero".

close_complete​

It is false in two cases:

  1. The leg went flat with no closing fill observed. The close was rebuilt from the newest fill that was seen.
  2. entry_complete is false. The same cut that hid the open can hide a close, so no close-side number can claim to be complete over a known loss.

When it is false, treat closed_sz, closed_pnl and fee_paid as floors: at least this much, and possibly more.

funding_complete​

It is false when the funding total cannot be trusted. funding_paid is then "0" with the meaning unknown, not "no funding was paid". net_pnl equals realized_pnl, because it excludes funding fully.

If either boundary flag reads false, the span that the funding total sums over is shorter. That also clears funding_complete.

A false funding_complete is common, and it does not mean a data fault. The funding stream lags the fill stream in normal operation. The funding of a recently closed position is often still catching up.

The boundary flags decide if it can recover.

  • If entry_complete and close_complete are both true, this is the lag case. Read the row again later, and funding_complete becomes true.
  • If either boundary flag is false, funding_complete never becomes true. The boundary loss shortens the span that the total sums over, so the funding figure stays unknown permanently. Do not poll such a row for it.

Effect of a restart​

warning

A position that was open across a restart of the history service gives a degraded row permanently.

The service accumulates open positions in memory. A restart loses that memory. When the fills resume, the service sees a position that is already non-zero and cannot know where it started. It starts a new accumulation at the first fill that it observes, and marks entry_complete: false.

All the results are permanent. The row never recovers, because the fills that would fix it are already behind the read:

  • entry_complete is false, so close_complete is also false.
  • funding_complete is also false, because a broken boundary shortens the span that funding sums over. funding_paid stays "0" with the meaning unknown, and net_pnl excludes funding permanently on this row. Do not poll it for the flag to clear. It will not clear.
  • max_sz and avg_entry_px are null.
  • open_block and opened_at show the first fill observed after the restart, not the true open. When one fill both starts the accumulation and closes the position, open_block == close_block and opened_at == closed_at. The row seems to open and close in one block.

The example row above is this case: equal blocks, equal timestamps, and both entry fields null.

This is expected behaviour, not a bug. A null entry price on such a row is the system correctly reporting that it cannot know the entry.

You can still use avg_close_px, closed_pnl, fee_paid and closed_sz. They all come from real observed fills. Read them as floors that cover the observed part of the life, not as totals for the whole life.

See also​