9.5 KiB
Phoenix Perps read-only integration
PhoenixPerpsService, PhoenixPerpsAccount and PhoenixPerpsPosition live in
com.r35157.libs.phoenix.perps; the REST implementation is in impl.ref.
They contain no Evelyn/UI types or signing/trading operations. NenjimHub creates
one adapter without a portfolio cache as phoenix.perps.default and injects it into both Evelyn
Perps processes. Each process owns its own wallet list, refresh worker and snapshot.
See Evelyn for consumer behavior and
the canonical system overview
for configuration and deployment status.
REST reads and account model
The implementation uses these official endpoints at https://perp-api.phoenix.trade:
GET /v1/trader/state/{authority}?traderPdaIndex=0returns the wallet authority's version-1 snapshot and subaccounts. The account model currently uses trader PDA index 0 for user accounts. Subaccount 0 is cross-margin; other returned subaccounts are isolated.getAccounts()returns actual balances separately from positions;getOpenPositions()remains a convenience flattening of those accounts. Keep funded empty Cross accounts in the overview and include only nonzerobasePositionLotsas position rows. An omittedpositionsfield is documented as optional and means empty; null or a wrong type is rejected.GET /v1/view/exchange/marketssupplies each symbol'sbaseLotsDecimals,tickSize, leverage tiers and taker fee.GET /v1/market/{symbol}/mark-pricesuppliesmarkPrice.pricein USD. The API permits an absent mark price; a held position without a valid positive price fails the lookup rather than appearing as zero.
An empty trader state needs only the first request. Otherwise metadata is fetched once
and each active symbol's price once per wallet lookup, including when that symbol is
held in several subaccounts. Request starts are spaced at least one second apart across both environments to avoid
bursts of per-market reads. Each HTTP request has a 10-second timeout and uses the
Hub's existing HTTP client (no redirects by default). On HTTP 429 the adapter respects
Retry-After (seconds or HTTP date; 60 seconds if missing/invalid). Further calls
fail fast during that shared cooldown, letting Jupiter and other wallet attempts
continue; the normal refresh cycle retries later. There are no automatic
retries, new API keys or authentication/signing flows. Public reads worked during
verification, despite generated API security annotations referring to bearer auth.
HTTP failures, including 401/403/404/429, are errors rather than empty portfolios.
The shared pacing follows Phoenix's rate-limit guidance;
it does not guarantee a fixed quota.
Units, precision and identity
The official SDK's baseLotsToBaseUnits
uses lots / 10 ** baseLotsDecimals; the exponent may be negative. The adapter uses:
quantity = BigDecimal(basePositionLots).scaleByPowerOfTen(-baseLotsDecimals)
sizeUsd = abs(quantity) * markPrice.price
All lot counts use BigInteger; conversion and multiplication use BigDecimal
without a rounding context. Jackson parses JSON price numbers directly as decimal,
without a double intermediate. For example, 2,144 SOL lots at baseLotsDecimals=2
are 21.44 SOL; at a mark price of 104.45 USD, notional is 2,239.408 USD. Entry price,
collateral, virtual quote lots and margin are not substitutes for current notional.
Rounding to cents occurs only in the EMC display formatter. Margin and estimated
exit fees additionally follow the venue rounding described below.
Within each wallet's Phoenix venue, identity is traderPdaIndex:subaccountIndex:symbol.
Sequence numbers do not affect identity. Rows sort by PDA index, subaccount index
and symbol; labels use SYMBOL-PERP. Isolated rows remain directly beneath the venue.
Cross positions belong beneath their explicit Cross account group, which supersedes
the former no-subaccount-level restriction from issue #99. Positive
quantity means Long and negative means Short. Thus two SOL subaccounts remain two
rows even though their labels match.
Authority/PDA identity, snapshot version, arrays, symbols, duplicate subaccounts or positions, integer lots, metadata and prices are validated. A malformed lookup fails as a whole; it never publishes a partially decoded portfolio. The shared Jackson mapper is copied so strict parsing does not change other integrations.
Refresh behavior and limitations
Each Evelyn process performs an initial background refresh, then waits 60 seconds following each completed cycle. Jupiter and Phoenix are attempted independently for each configured wallet. Successful results replace only that wallet/venue; failures log wallet and venue and retain its last successful result. A valid empty result replaces that venue's positions and account balances, preserving its group. Funded Cross accounts remain visible when they no longer hold positions. Snapshot reads never fetch external data; EMC's three-second polling remains unchanged.
Positions and prices are separate REST reads and can belong to different slots.
This is a periodic current-notional estimate, not an atomic exchange valuation.
A cycle's duration includes all sequential reads and request pacing before the
60-second pause (32 active markets need at least about 33 seconds for their reads). Failed
reads can leave old data visible indefinitely. The venue now shows (retained) after
a failed refresh, or (unavailable) before any successful read; cell tooltips include
the last-success time. Initial not-yet-read scopes show (loading) and cannot contribute
fabricated zero totals.
Trader PDA indices other than 0, streaming and position management are out of scope.
Valuations, funding and margin
Monetary arithmetic uses exact BigDecimal/BigInteger values. Quote lots are
1e-6 USD. The official SDK computation
and PnL definition
separate price PnL from funding and fees:
price uPnL = quantity × markPrice + virtualQuotePositionLots / 1e6
funding = unsettledFundingQuoteLots / 1e6 (signed; positive adds equity)
equity = collateral + sum(price uPnL + funding)
exit fee = ceil_to_micro_USD(abs(quantity) × markPrice × takerFee × REST takerFeeOverrideMultiplier)
Value = equity - sum(estimated exit fees)
The REST override is already a decimal multiplier (observed 0.9), not the raw
on-chain i8 representation. Missing override, fee, funding or virtual quote data
makes affected valuations unavailable, rather than defaulting to zero. Settled
trading fees and accumulatedFundingQuoteLots are not subtracted again; collateral
already reflects settlement. Equity is undiscounted: no positive-uPnL risk discount
or spot-collateral haircut is substituted for owned capital.
collateral is quote collateral in micro-USD. Each nonzero spotCollaterals.balance
currently makes collateral/equity/Value unavailable: this version does not have a
verified spot index-price valuation. It never quietly ignores SOL or another
collateral asset. Zero spot balances do not change quote-only valuations. Missing
collateral is also unavailable. Independent price PnL/exposure remain computable.
For a Cross position the Collateral column is its initial margin requirement, not a share of the account balance. The implementation follows the SDK margin math and integer price conversion:
markTicks = floor(markPrice × 10^(6-baseLotsDecimals) / tickSize)
grossQuoteLots = abs(basePositionLots) × markTicks × tickSize
L = first-tier leverage, or truncated linear interpolation of leverage between
the surrounding maxSizeBaseLots bounds; beyond the final bound use 1
initialMarginUsd = ceil(grossQuoteLots / L) / 1e6
Interpolation truncates the complete positive leverage, including when leverage is decreasing. For 150 lots between (100 lots, 10×) and (200 lots, 5×), L is 7, not 7.5 or 8. At 100 USD and exponent 2, margin is 21.428572 USD. Exposure retains the exact original REST mark, preserving issue #99's current-notional meaning.
Cross account leverage is gross exposure / positive undiscounted equity before estimated exit fees. Cross position leverage and ROI are unavailable because the venue's displayed conventions have not been verified; the UI explains this and shows dollar PnL. Isolated leverage/ROI use actual collateral. Cross position Value is inapplicable; Cross account Value counts all shared capital once, including free collateral when its last position closes. Other aggregate ratios are blank. No account margin-utilization percentage is claimed; orders/risk adjustments are not included in such a metric.
Value is an estimate at separately read reference prices, not an executable quote.
Slippage, price impact, swaps, network fees, account rent, future funding and
unrelated wallet balances are excluded. No profit cap is applied to Phoenix.
Unsupported/missing values and any higher totals requiring them show —.