On this page
- Deprecated synchronous portfolio
- When to use the legacy endpoint
- Request
- Query parameters
- Response
- Aggregation semantics
- Error responses
- Worked example
- NAV time series (by address)
- Request
- Query parameters
- Response
- Cumulative PnL time series
- Request
- Query parameters
- Response
- Data-source policy
- Hyperliquid rollup across sub-providers
- Quota
Portfolio projection
GET /v1/portfolio is the canonical, cache-only read of the latest complete
Polymarket + Hyperliquid Balance checkpoint. It executes the existing unified
projection implementation and never calls a provider or node in the request
path. A cold or hard-expired wallet returns balanceHead: null; it never
fabricates $0 or publishes a partial total.
The same implementation exposes GET /v1/portfolio/performance,
POST /v1/portfolio/performance/heads/batch, and
POST /v1/portfolio/refresh. The public URL and response contract are both v1
(schemaVersion: 1). The underlying Balance/Performance projection remains the
latest implementation; internal ledger, event, and persistence versions are
not HTTP API versions. No /v2/portfolio* routes are published.
Deprecated synchronous portfolio
GET /v1/portfolio/legacy preserves the old provider fan-out response while
fund valuation and migration consumers move to dedicated positions, cash, and
account-state APIs. Do not use it for Total Assets, NAV, or PnL.
When to use the legacy endpoint
- Existing internal fund or migration code that still requires provider-native position/account-state payloads while it moves to a dedicated operational API.
- Do not add new consumers. The route requires an
admin-scoped key and may synchronously query providers. It is not a Total Assets, NAV, or PnL source.
For per-wallet trade history (fills, fees, etc.) use
/v1/me/fills — it shares the same wallet identity model.
Request
bashcurl -H "Authorization: Bearer art_live_<your-key>" \
"https://api.artery.questflow.ai/v1/portfolio/legacy?user=0x9d84cbc7eb19c4cbb24f3866c70a5fcab502d1dc&provider=polymarket,hyperliquid"Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
user | string | conditional | Default EVM address (0x + 40 hex chars) applied to every provider without an override. Required unless a per-provider address (hlUser / pmUser) covers each requested provider. Lowercased server-side before forwarding. |
hlUser | string | no | Per-provider override for Hyperliquid — the user's native HL account (Privy user.address). Use when the HL address differs from the Polymarket deposit-wallet. Falls back to user. |
pmUser | string | no | Per-provider override for Polymarket — the deposit-wallet address. Falls back to user. |
provider | string | no | Comma-separated subset of polymarket, hyperliquid. Omit to fan out to every supported provider. |
fresh | string | no | 1 or true bypasses the short-lived HL account-state cache. Use after a confirmed wallet event. |
Polymarket and Hyperliquid often use different addresses for the same
user (PM deposit-wallet vs HL native EOA). Pass pmUser / hlUser to query
each with its own address in a single call; each slice echoes the address
it resolved. If a per-provider override covers every requested provider,
user is optional.
Unknown provider names return 400 unknown_provider with the offending
values listed. The full set lives in the providers field of every
response so you can discover-then-filter.
Response
jsonc{
"schemaVersion": 1,
"complete": true,
"user": "0x9d84cbc7eb19c4cbb24f3866c70a5fcab502d1dc",
"providers": ["polymarket", "hyperliquid"],
"slices": [
{
"provider": "polymarket",
"address": "0x9d84cbc7eb19c4cbb24f3866c70a5fcab502d1dc",
"native": [
// Verbatim Polymarket position array — one row per market the
// wallet has any size on. Keys match Polymarket's own API.
],
"valueUsd": 1234.56,
"availableUsd": 250.0,
"navUsd": 1484.56,
"note": null,
},
{
"provider": "hyperliquid",
"address": "0x9d84cbc7eb19c4cbb24f3866c70a5fcab502d1dc",
"native": {
// Verbatim HL `clearinghouseState` (main perp dex) — assetPositions[],
// marginSummary, crossMarginSummary, withdrawable, time.
},
"valueUsd": 5678.9,
"availableUsd": 1200.0,
"navUsd": 5678.9,
"source": "node",
"asOf": "2026-08-06T15:54:43.808Z",
"buckets": [
{ "kind": "perp", "usd": 78.9 },
{ "kind": "spot", "usd": 5400.0 },
{ "kind": "vault", "usd": 200.0 },
{ "kind": "staking", "usd": 0 },
],
"accountMode": "unifiedAccount",
"note": null,
},
],
"summary": {
"totalNavUsd": 7163.46,
"totalValueUsd": 6913.46,
"totalAvailableUsd": 1450.0,
"providersReporting": 2,
"providersRequested": 2,
},
"warnings": [],
"fetchedAt": "2026-05-19T07:00:00.000Z",
}Aggregation semantics
slices[i].addressis the 0x address actually queried for that provider. WithpmUser/hlUseroverrides PM and HL can resolve to different addresses; without them both echouser.slices[i].nativeis the untransformed upstream payload. Polymarket returns an array (one row per position); Hyperliquid returns an object (the fullclearinghouseStateenvelope includingassetPositions[],marginSummary,crossMarginSummary,withdrawable, andtime). Consumers that want raw venue data don't have to make two more calls.slices[i].source/asOf(Hyperliquid) identify the authoritative upstream (nodeorofficial) and its validated L1 timestamp.native,availableUsd,valueUsd, and the bucket breakdown all come from this same acquisition; Artery never combines a raw state from one moment with a valuation from another. A stale node timestamp triggers official fallback.slices[i].valueUsdis the USDC-denominated wallet value when the provider exposes one, elsenull. Polymarket returns position notionals; Hyperliquid returns the complete account value — perp equity across every dex (main + HIP-3/builder) + spot holdings + vault deposits + staking — the same number/v1/me/statsreports. It is not just the main-dexmarginSummary.accountValueshown innative: a wallet whose funds sit in spot or anxyz:builder dex has near-zeronativeequity but a realvalueUsd.slices[i].availableUsdis idle/withdrawable cash NOT in open positions, USD. Hyperliquid =clearinghouseState.withdrawable; Polymarket = the deposit wallet's on-chain idle collateral (pUSD + USDC.e).nullwhen the provider didn't expose it. Note (Hyperliquid): this is a subset ofvalueUsd(a liquidity breakdown), not additive — seenavUsd.slices[i].navUsdis the clean, provider-consistent account NAV — the single number to use as "this provider's total assets" (identical to/v1/me/wallets/{id}/navnavUsdc). It resolves thevalueUsd/availableUsdasymmetry:- Hyperliquid:
navUsd = valueUsd—valueUsdis already the complete account value (cash included), soavailableUsdis not added. - Polymarket:
navUsd = valueUsd + availableUsd— positions plus idle cash (the two are disjoint).nullonly when the provider reported no value at all. UsenavUsd(notvalueUsd + availableUsd) for account totals — adding the two uniformly double-counts Hyperliquid.
- Hyperliquid:
slices[i].buckets(Hyperliquid) breaksvalueUsddown by sub-account:perp/spot/vault/staking. Use it to reconcile against the HL app — each bucket maps to an HL portfolio tab, and the sum equalsvalueUsd.slices[i].accountMode(Hyperliquid) echoes HL'suserAbstractionmode. OnunifiedAccount/portfolioMarginwallets the account is ONE pool on the spot clearinghouse (the spot USDCtotalis marked live, uPnL included), sovalueUsd = Σ spot value + vault + staking— equal to HL's own account value. Theperpbucket shows the per-dex accountValue as a view carved out of thespotbucket (buckets still sum tovalueUsd). Legacy modes (default,disabled,dexAbstraction) keep separate ledgers —valueUsd = perp + spot + vault + staking.slices[i].spotBalances(Hyperliquid) — per-coin spot balances (coin,total,hold,usd). An unpriced non-zero balance makes the snapshot fail rather than silently understating NAV. Powers a Balances tab from the same call. Raw shape also available viaGET /v1/hyperliquid/info/spot-clearinghouse-state.slices[i].perpByDex(Hyperliquid) — per-dex perp equity view (dex=''for the main perp, HIP-3 dexs by name) withaccountValueUsd+unrealizedPnlUsd. Powers a reserve breakdown; raw per-dex state viaGET /v1/hyperliquid/info/clearinghouse-state?dex=.summary.totalNavUsdis the cross-provider Total Assets — Σ per-slicenavUsd. This is the correct grand total (no double-count); prefer it over any hand-rolled sum oftotalValueUsd/totalAvailableUsd.summary.totalValueUsdsums slices'valueUsd(provider-specific holdings value). Kept as a breakdown; slices withvalueUsd: nullare excluded but still counted inslices.providersReportingis the count of slices that contributed. Not the grand total — usetotalNavUsd.summary.totalAvailableUsdsums idle/withdrawable cash (availableUsd) across providers — HLwithdrawable+ PM idle collateral. A liquidity breakdown; do not add it tototalValueUsdfor a grand total (that double-counts HL) — usetotalNavUsd.schemaVersion=1/complete=trueidentify the authoritative snapshot contract.providersReportingalways equalsprovidersRequestedon 200.warnings[]is retained for response compatibility and is empty on a successful snapshot. Any critical provider failure returns 502 instead of a partial response.fetchedAtis when the server completed the fan-out. Both legs are fetched in parallel viaPromise.allSettled; the slower one bounds the total response time.
Error responses
| HTTP | error.code | When |
|---|---|---|
| 400 | validation_failed | user missing or not a 0x-40-hex address. |
| 400 | unknown_provider | provider= contains a name not in the supported set. |
| 401 | unauthenticated | Missing / invalid Authorization header. |
| 403 | insufficient_scope | Key lacks the read scope. |
| 502 | portfolio_snapshot_incomplete | A requested provider could not produce a complete, finite snapshot. |
| 429 | quota_exceeded | Over the portfolio.read SKU quota — see X-Quota-Sku, X-Quota-Limit, X-Quota-Remaining, and X-Quota-Reset. |
A 502 response identifies only the affected provider and a stable code:
json{
"code": "portfolio_snapshot_incomplete",
"message": "Unable to produce a complete portfolio snapshot",
"failures": [{ "provider": "hyperliquid", "code": "provider_snapshot_unavailable" }]
}Raw upstream errors, transport details, and URLs are logged server-side and are never included in the public response.
Worked example
bash# A wallet active on both Polymarket + HL perps
curl -H "Authorization: Bearer art_live_<your-key>" \
"https://api.artery.questflow.ai/v1/portfolio/legacy?user=0xabc...&provider=polymarket,hyperliquid"jsonc{
"user": "0xabc...",
"providers": ["polymarket", "hyperliquid"],
"slices": [
{
"provider": "polymarket",
"address": "0xabc...",
"native": [
{
"conditionId": "0xCONDITION_ID",
"outcome": "YES",
"size": "150.00",
"avgPrice": "0.62",
"currentValue": "93.00",
},
],
"valueUsd": 93.0,
"availableUsd": 40.0,
"navUsd": 133.0,
"note": null,
},
{
"provider": "hyperliquid",
"address": "0xabc...",
"native": {
"assetPositions": [
{
"type": "oneWay",
"position": {
"coin": "BTC",
"szi": "0.5",
"entryPx": "65000",
"positionValue": "33850.5",
"unrealizedPnl": "350.78",
},
},
],
"marginSummary": { "accountValue": "12450.78", "totalNtlPos": "33850.5" },
"withdrawable": "8400.21",
"time": 1779163200000,
},
// unifiedAccount: the 12100.00 margining the BTC position is held on the
// spot balance, so it's excluded from `spot` (available 0) and counted
// once via `perp` = the full accountValue. Nothing counts twice.
"valueUsd": 12450.78,
"availableUsd": 8400.21,
"navUsd": 12450.78,
"buckets": [
{ "kind": "perp", "usd": 12450.78 },
{ "kind": "spot", "usd": 0 },
{ "kind": "vault", "usd": 0 },
{ "kind": "staking", "usd": 0 },
],
"accountMode": "unifiedAccount",
"note": null,
},
],
"summary": {
"totalNavUsd": 12583.78,
"totalValueUsd": 12543.78,
"totalAvailableUsd": 8440.21,
"providersReporting": 2,
},
"warnings": [],
"fetchedAt": "2026-05-19T07:00:00.000Z",
}Note how valueUsd (12450.78) is not native.marginSummary.accountValue
plus spot — on a unifiedAccount wallet the perp margin already sits on the
spot balance, so the buckets sum to the same total the HL app shows.
When the same wallet has no Polymarket activity:
jsonc{
"user": "0xabc...",
"providers": ["polymarket", "hyperliquid"],
"slices": [
{ "provider": "polymarket", "address": "0xabc...", "native": [], "valueUsd": null, "availableUsd": 40.0, "navUsd": 40.0, "note": null },
{ "provider": "hyperliquid", "address": "0xabc...", "native": { ... }, "valueUsd": 12450.78, "availableUsd": 8400.21, "navUsd": 12450.78, "note": null }
],
"summary": { "totalNavUsd": 12490.78, "totalValueUsd": 12450.78, "totalAvailableUsd": 8440.21, "providersReporting": 1 },
"warnings": [],
"fetchedAt": "2026-05-19T07:00:00.000Z"
}valueUsd: null + empty native[] is the canonical "no positions" shape —
not an error. summary ignores the null and the total still makes sense.
NAV time series (by address)
GET /v1/portfolio/nav is a deprecated compatibility view over
GET /v1/portfolio/performance. It performs no provider or node calls. A
complete Performance checkpoint remains authoritative. If that cross-provider
checkpoint is unavailable, each provider may independently return its validated
durable last-good NAV; this never creates a partial aggregate. New consumers
must read /v1/portfolio/performance directly for aggregate decisions.
Only a provider address already bound to the caller's organization may create a
short-lived refresh hint, and that hint targets only the requested NAV window.
Unbound addresses are cache-only and omit internal attempt/retry scheduling
metadata. PM observation fallback is capped at 2,000 recent points; ALL
therefore reports partial history rather than claiming an unbounded archive.
HL NAV snapshots are also capped at 2,000 points; a legacy oversized ALL
snapshot is truncated and explicitly downgraded to partial coverage.
Request
bashcurl -H "Authorization: Bearer art_live_<your-key>" \
"https://api.artery.questflow.ai/v1/portfolio/nav?user=0xabc...&window=1M"Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
user | string | conditional | Default 0x address, applied to every provider without an override. Required unless hlUser / pmUser cover each requested provider. |
hlUser | string | no | Hyperliquid native-address override. Falls back to user. |
pmUser | string | no | Polymarket deposit-wallet override. Falls back to user. |
provider | string | no | A single provider (hyperliquid or polymarket) for one series. Omit to fan out to all. |
window | string | no | Curve window: 1D, 1W, 1M, ALL. Default ALL. |
Response
jsonc{
"window": "ALL",
"providers": ["hyperliquid"],
"series": [
{
"provider": "hyperliquid",
"address": "0x1234abcd0000000000000000000000000000abcd",
"source": "performance_projection",
"quality": "authoritative", // | "degraded"
"points": [
{ "t": 1782259200, "navUsdc": 10250.42, "netDepositsUsdc": 10000 },
{ "t": 1782345600, "navUsdc": 10480.13, "netDepositsUsdc": 10000 },
],
"availability": {
"dataStatus": "fresh", // | cached_fresh | cached_stale | unavailable
"reason": null,
"observedAt": "2026-07-08T09:59:00.000Z",
"lastSuccessAt": "2026-07-08T09:59:00.000Z",
"refreshAttemptedAt": "2026-07-08T09:59:00.000Z",
"nextRetryAt": null,
"nextRefreshAt": "2026-07-08T10:04:00.000Z",
"source": "performance_projection",
},
// "note": "..." // optional caveat (e.g. PM deposit steps as PnL steps)
},
],
"warnings": [], // per-provider upstream failures (never a 5xx)
"asOf": "2026-07-08T10:00:00.000Z",
}When Performance is unavailable, source may be hl_grid_primary or
pm_nav_observations. cached_stale points are display-only: show
lastSuccessAt and never use them as a fresh total, ranking input, or merged
portfolio value.
t= unix seconds, ascending.navUsdc= account value (USD) att.netDepositsUsdcis the checkpointed cumulative cash-flow basis for the same provider cut.sourceis alwaysperformance_projectionin this compatibility response.qualityisauthoritativeonly when the Performance window is ready; otherwise the provider envelope is degraded and empty.
Cumulative PnL time series
GET /v1/portfolio/pnl is a deprecated compatibility view over
GET /v1/portfolio/performance. It performs no provider or node calls. NAV and
PnL are derived from one immutable Performance epoch. New consumers must read
the canonical Performance contract directly.
Request
bashcurl -H "Authorization: Bearer art_live_<your-key>" \
"https://api.artery.questflow.ai/v1/portfolio/pnl?user=0xabc...&window=1M"Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
user | string | conditional | Default 0x address, applied to every provider without an override. Required unless hlUser / pmUser cover each requested provider. |
hlUser | string | no | Hyperliquid native-address override. Falls back to user. |
pmUser | string | no | Polymarket deposit-wallet override. Falls back to user. |
provider | string | no | A single provider (hyperliquid or polymarket) for a non-merged series. Omit to fan out to all providers and return merged. |
window | string | no | Curve window: 1D, 1W, 1M, ALL. Default ALL. Ignored when windows is set. |
windows | string | no | Batched multi-window. Comma-separated subset of 1D,1W,1M,ALL (e.g. 1D,1W,1M,ALL). Returns one byWindow map per series in one client request. HL reuses its widest trustworthy local series when possible; PM preserves each official native window. Takes precedence over window. |
Response
With window (single):
jsonc{
"window": "1M",
"providers": ["polymarket", "hyperliquid"],
"series": [
{
"provider": "polymarket",
"address": "0xabc...",
"bound": true,
"source": "performance_projection",
"points": [
{ "t": 1779076800, "pnl": 0 },
{ "t": 1779080400, "pnl": 12.5 },
{ "t": 1779084000, "pnl": 9.8 },
],
},
{
"provider": "hyperliquid",
"address": "0xdef...",
"bound": false,
"source": "performance_projection",
"points": [
{ "t": 1779076800, "pnl": 0 },
{ "t": 1779080400, "pnl": -40.0 },
{ "t": 1779084000, "pnl": 120.3 },
],
},
],
"merged": [
{ "t": 1779076800, "pnl": 0 },
{ "t": 1779080400, "pnl": -27.5 },
{ "t": 1779084000, "pnl": 130.1 },
],
"warnings": [],
"asOf": "2026-05-19T07:00:00.000Z",
}With windows=1D,ALL (batched) — each series carries a byWindow map. HL and
snapshot slices are window-anchored; PM slices remain account-cumulative.
Additive mergedByWindow is safely normalized per window. Legacy merged
keeps its cross-provider, all-or-nothing semantics:
jsonc{
"windows": ["1D", "ALL"],
"providers": ["hyperliquid"],
"series": [
{
"provider": "hyperliquid",
"address": "0xdef...",
"bound": true,
"byWindow": {
"1D": {
"source": "performance_projection",
"status": "ready",
"points": [
{ "t": 1779076800, "pnl": 0 },
{ "t": 1779080400, "pnl": 12.3 },
],
},
"ALL": {
"source": "performance_projection",
"status": "ready",
"points": [
{ "t": 1777000000, "pnl": 0 },
{ "t": 1779080400, "pnl": -40.0 },
],
},
},
},
],
"merged": null,
"mergedByWindow": {
"1D": [
{ "t": 1779076800, "pnl": 0 },
{ "t": 1779080400, "pnl": 12.3 },
],
"ALL": [
{ "t": 1777000000, "pnl": 0 },
{ "t": 1779080400, "pnl": -40.0 },
],
},
"warnings": [],
"asOf": "2026-05-19T07:00:00.000Z",
}Data-source policy
series[i].sourceisperformance_projectionfor both providers.- All provider and merged points are selected from one epoch/generation and
share its finalized
dataThroughcut. status: unavailableand empty points mean checkpoint coverage is not proven; they never mean zero PnL.canonical/canonicalByWindowremains the compatibility safety envelope. The canonical endpoint is/v1/portfolio/performance.warnings[]carries projection coverage failures;asOfis the adapter completion timestamp, not a provider observation time.
Hyperliquid rollup across sub-providers
A wallet can be bound under any of the three Hyperliquid deployments
(hyperliquid_perp, hyperliquid_hip4, hyperliquid_dex). When an org has
multiple HL bindings for the same EOA, the customer-facing PnL surfaces
(/v1/me/pnl and the portal tables) treat them as a single
"Hyperliquid" family:
- Filter family-wide.
/v1/me/pnl?provider=hyperliquidexpands to all three HL sub-providers server-side;applied.providerechoes back the family label'hyperliquid'so the chip selection round-trips intact. - Per-sub-provider breakdown still available. Pass
?groupBy=provider(without aprovider=filter) to see one row per fine-grained provider — useful when you actually want to attribute PnL back to perp vs hip4 vs dex. The portal collapses these visually into one "Hyperliquid" row; raw API consumers see the unrollup'd data. - Storage stays fine-grained.
customer_wallets.providerkeeps the sub-provider enum; binding viaprovider=hyperliquidcanonicalizes tohyperliquid_perpon the way in.resolveOrgByAddressmatches fills across every HL sub-provider, so a single binding covers perp + hip4 + dex without duplicate rows.
The deprecated /v1/portfolio/legacy endpoint above is already family-flat — it returns
one slices[i] per family (polymarket, hyperliquid), so there is no
sub-provider exposure here. The rollup notes apply to
/v1/me/pnl and the portal
PnL pages.
Quota
This endpoint is metered under the portfolio.read SKU. Each successful
response counts as 1 unit. Per-tier daily caps live in the
rate limits page.