Files
com_r35157_nenjim-hubd-impl…/docs/PhoenixPerps.md
T

258 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `—`.