258 lines
16 KiB
Markdown
258 lines
16 KiB
Markdown
# Phoenix Perps read-only integration
|
||
|
||
`PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition`, `PhoenixAccountRisk` and `PhoenixTwapOrder` 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.
|
||
The adapter also receives the existing `SolanaBlockChain` for local trader PDA
|
||
derivation only; risk lookup does not use its RPC or signing operations.
|
||
See [Evelyn](Evelyn.md#12-perps-service-og-mission-control) for consumer behavior and
|
||
[the canonical system overview](../../r35157_docs/projects/evelyn/README.md#evelynperpsservice)
|
||
for configuration and deployment status.
|
||
|
||
## REST reads and account model
|
||
|
||
The implementation uses these official endpoints at `https://perp-api.phoenix.trade`:
|
||
|
||
1. [`GET /v1/trader/state/{authority}?traderPdaIndex=0`](https://docs.phoenix.trade/api/trader/get-trader-state)
|
||
returns the wallet authority's version-1 snapshot and subaccounts. The
|
||
[account model](https://docs.phoenix.trade/sdk/accounts) 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 nonzero `basePositionLots`
|
||
as position rows. An omitted `positions` field is documented as optional and
|
||
means empty; null or a wrong type is rejected.
|
||
2. [`GET /v1/view/exchange/markets`](https://docs.phoenix.trade/api/exchange/list-markets)
|
||
supplies each symbol's `baseLotsDecimals`, `tickSize`, leverage tiers and taker fee.
|
||
3. [`GET /v1/market/{symbol}/mark-price`](https://docs.phoenix.trade/api/exchange/get-mark-price)
|
||
supplies `markPrice.price` in USD. The API permits an absent mark price; a held
|
||
position remains visible with unknown price-dependent metrics when the mark is absent.
|
||
A present nonpositive or malformed price still fails the lookup.
|
||
4. [`GET /v1/trader/{authority}/twap?traderPdaIndex=0`](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/api/traders/client.ts)
|
||
supplies active TWAP snapshots when the wallet has held positions. Only filled orders
|
||
with remaining children on matching existing positions are returned; see [TWAP observations](#twap-observations).
|
||
5. [`GET /v1/view/trader/{pubkey}`](https://docs.phoenix.trade/api/trader/get-trader)
|
||
supplies risk for each visible account, including funded empty Cross accounts.
|
||
Its key is the derived trader account, not the wallet or a TWAP key. See
|
||
[venue-reported account risk](#venue-reported-account-risk).
|
||
|
||
An empty trader state with no visible funded account needs only the first request.
|
||
A funded empty Cross account additionally needs its risk read, without market/TWAP reads.
|
||
Otherwise metadata is fetched once
|
||
and each active symbol's price once per wallet lookup, including when that symbol is
|
||
held in several subaccounts, followed by one TWAP read and each visible account's
|
||
risk read. 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](https://docs.phoenix.trade/sdk/ws-api-best-practices);
|
||
it does not guarantee a fixed quota.
|
||
|
||
## Units, precision and identity
|
||
|
||
The official [SDK's `baseLotsToBaseUnits`](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/units.ts)
|
||
uses `lots / 10 ** baseLotsDecimals`; the exponent may be negative. The adapter uses:
|
||
|
||
```text
|
||
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. EMC hides the venue heading
|
||
when it has neither position rows nor Cross accounts; its read status remains in
|
||
the wallet tooltip and the scope still contributes to snapshot totals.
|
||
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 and one visible account need at least about 35 seconds for their reads). Failed
|
||
reads can leave old data visible indefinitely. A visible venue shows `(retained)` after
|
||
a failed refresh; cell tooltips include the last-success time. Childless venues are hidden,
|
||
with failed or pending first-read status available in their wallet's tooltip. Such unknown
|
||
scopes cannot contribute fabricated zero totals.
|
||
Trader PDA indices other than 0, streaming and position management are out of scope.
|
||
|
||
## Venue-reported account risk
|
||
|
||
The [official address derivation](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/pdas.ts)
|
||
uses program `EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih` and seeds
|
||
`trader`, authority bytes, a one-byte PDA index and a one-byte subaccount index.
|
||
The adapter uses the existing local Solana derivation, validates the returned
|
||
authority/trader key/PDA/subaccount, and verifies the nonzero position membership
|
||
and signed quantities against trader state. A fill during the separate reads can
|
||
therefore cause a retained failed observation rather than attach risk to mismatched
|
||
position state. Lookup works without any active TWAP.
|
||
|
||
TraderView returns `riskState`, `riskTier`, `effectiveCollateral`,
|
||
`maintenanceMargin` and position `liquidationPrice`. The actual deployed amount
|
||
objects contain integer `value` and `decimals`; the reader computes exact decimal
|
||
amounts with decimal exponents from -18 through 18 and ignores formatted `ui` strings.
|
||
Positive liquidation prices are
|
||
retained; `-1`, zero, other nonpositive values and absent prices are unavailable.
|
||
They are not zero risk or infinite safety. Optional missing fields and unsupported
|
||
future tiers cannot silently become Safe. Malformed amounts, identity mismatches
|
||
and rejected HTTP reads fail the complete wallet/venue observation.
|
||
|
||
Cross risk belongs to the shared account. The reported effective collateral can
|
||
include funding, discounted positive PnL and collateral haircuts; it is distinct
|
||
from the overview's undiscounted equity and estimated closing Value. Risk therefore
|
||
can be available even where the older capital valuation cannot value spot collateral.
|
||
The reported thresholds and tier are preserved without inventing a liquidation engine.
|
||
See [Phoenix account health](https://docs.phoenix.trade/phoenix/margin-and-risk/account-health)
|
||
and [margin math](https://docs.phoenix.trade/phoenix/margin-and-risk/margin-math).
|
||
|
||
Account risk carries its original response receipt time. Isolated risk combines
|
||
that account's reported liquidation price with its already fetched Phoenix mark;
|
||
its observation time is the older receipt. Prices and account data are separate
|
||
reads and can lag the chain. Existing failures retain original timestamps. The
|
||
[Risk presentation](Evelyn.md#risiko-i-perps) defines five-minute expiration,
|
||
neutral isolated distance, venue tier colors and group summaries.
|
||
|
||
## TWAP observations
|
||
|
||
TWAP data is matched by requested authority, PDA index, subaccount and market `assetId`
|
||
using Phoenix metadata. Only an existing nonzero position can carry a TWAP, and the
|
||
order itself must have `baseLotsFilled > 0` and remaining children. A new order without
|
||
fills stays invisible even if the market already has a position. No TWAP-only row or
|
||
group total is created. A successful snapshot without that active order clears its
|
||
TWAP data. A failed or invalid TWAP read fails the whole wallet/venue observation,
|
||
retaining the previous complete snapshot and its original timestamps as `(retained)`.
|
||
|
||
The [SDK snapshot](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/api/traders/twap.ts)
|
||
reports `nChildOrders` as the total execution count including dust. With explicit
|
||
`nDustOrders > 0`, regular count is total minus dust count. Legacy zero/omitted dust
|
||
count with a positive `dustOrderSize` replaces one final regular child with dust.
|
||
Total quantity is regular count × regular lot size plus dust count × dust lot size;
|
||
the next size switches to dust when the regular children have executed. Convert all
|
||
sizes and cumulative `baseLotsFilled` with `baseLotsDecimals`, using exact arithmetic.
|
||
If size/dust metadata is unavailable, sizes remain unknown while independent counters
|
||
and fills remain available. Execution progress is executed/total children; it does
|
||
not assert that the planned quantity has filled completely.
|
||
|
||
Time estimates use the current nominal **200 ms per Solana slot**, documented by the
|
||
[Solana Foundation in October 2026](https://solana.com/fr/upgrades/reduced-slot-times).
|
||
The older 400 ms assumption no longer applies. At receipt of the TWAP response:
|
||
|
||
```text
|
||
estimated next = observation time + (dueSlot - snapshot.slot) × 200 ms
|
||
final slot = dueSlot + (childOrdersRemaining - 1) × cooldownSlots
|
||
estimated end = observation time + (final slot - snapshot.slot) × 200 ms
|
||
```
|
||
|
||
Receipt time is an approximate anchor; API lag, variable slot times and delayed
|
||
execution can shift the estimates. No separate RPC sampling or live timer is used.
|
||
Already-due schedules yield unavailable estimates, as do overflow or expiry before
|
||
the scheduled child/completion. `lastValidSlot` is only an expiry boundary.
|
||
|
||
EMC appends **TWAP** after uPnL. Its progressbar overlays a snapshot duration with
|
||
unbounded total hours, such as `≈ 28:32:10` or `≈ 128:32:10`. It updates only with new
|
||
snapshots and is explicitly as of the tooltip's `Updated` time. Due/unavailable
|
||
timing shows `Due / unknown`; a failed venue shows `Retained` instead of a fresh
|
||
countdown. Other venues, positions without a relevant order and all groups have
|
||
empty TWAP cells. Tooltips show progress, `Estimated completion`, total/next/fill
|
||
quantities, different dust sizes where relevant, observation time and limitations.
|
||
|
||
Total and next-child USD values reuse that position's already fetched Phoenix mark,
|
||
independently of the Ticker. They are approximate current notionals, excluding fees,
|
||
not historical spend or a promise about future execution cost. An absent mark keeps
|
||
token quantities visible with unavailable USD values; affected exposure, margin,
|
||
PnL/equity/Value and dependent totals are unavailable rather than zero.
|
||
|
||
|
||
## Valuations, funding and margin
|
||
|
||
Monetary arithmetic uses exact `BigDecimal`/`BigInteger` values. Quote lots are
|
||
1e-6 USD. The [official SDK computation](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/margin/compute.ts)
|
||
and [PnL definition](https://docs.phoenix.trade/phoenix/margin-and-risk/entry-price-pnl)
|
||
separate price PnL from funding and fees:
|
||
|
||
```text
|
||
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](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/margin/math.ts)
|
||
and [integer price conversion](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/units.ts):
|
||
|
||
```text
|
||
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. Risk separately displays reported maintenance-margin utilization
|
||
against effective collateral. It is not total initial-margin utilization, free
|
||
buying power or the ratio used for the Leverage column.
|
||
|
||
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 `—`.
|