99: Add read-only Phoenix Perps positions to EvelynPerpsService

This commit is contained in:
2026-10-23 12:11:22 +02:00
parent df5cc0eda7
commit 7e36b7ef95
17 changed files with 734 additions and 40 deletions
+25 -12
View File
@@ -436,24 +436,37 @@ Price Index beregnes og vises, mens de øvrige fem felter fortsat er nul. Legacy
`EvelynPerpsService` udleverer et immutable `PerpsSnapshot` gennem det parameterløse
`getPositionsSnapshot()`. Læsning foretager ingen integrationskald. Hub'en starter separate processer
`evelyn.perps.prod` og `evelyn.perps.test` og injicerer de tilsvarende eksisterende `JupiterPerpsService`-views.
`evelyn.perps.prod` og `evelyn.perps.test`, injicerer de tilsvarende eksisterende `JupiterPerpsService`-views
og en fælles `PhoenixPerpsService` uden positionscache registreret som `phoenix.perps.default`.
`PerpsWalletConfiguration` indeholder en `SolanaWalletId`-adresse, der også bruges som række-ID og label.
Hub'en ejer de hardcodede lister: seks PROD-adresser og én TEST-adresse fra issue #98.
Begge integrationer læser de samme wallets i det pågældende miljø.
Servicen henter åbne Jupiter-positioner asynkront ved start og venter 60 sekunder efter hvert gennemløb,
så runder ikke overlapper. Den eksisterende Solana-cache med 60 sekunders TTL bevares. Data er derfor ikke
øjeblikkelige børsdata. Positionskontoen er stabilt ID; retning og `positionSize()` vises, kendte mints
bliver SOL-PERP/BTC-PERP/ETH-PERP, og ukendte mints vises som adressen. Positioner sorteres efter konto.
Servicen henter åbne Jupiter- og Phoenix-positioner asynkront ved start og venter 60 sekunder efter hvert
fuldført gennemløb, så runder ikke overlapper. Den eksisterende Solana-cache med 60 sekunders TTL bevares
for Jupiter. Phoenix læses gennem REST med 10 sekunders timeout pr. request, mindst ét sekund mellem
request-start på tværs af miljøer og fælles cooldown ved HTTP 429. Data er periodiske snapshots.
Før første vellykkede opslag er der ingen positioner. Hver wallets succes publiceres i et nyt snapshot;
fejl logges og bevarer den wallets sidste data uden at afbryde de øvrige. Et vellykket tomt svar fjerner
positionerne, men bevarer wallet/Jupiter-gruppen. Der vises ingen indikator for forældede data.
Implementationens `close()` afbryder worker'en; consumer-API'et indeholder kun læsning.
Jupiters positionskonto er stabilt ID; retning og `positionSize()` vises, kendte mints bliver
SOL-PERP/BTC-PERP/ETH-PERP, og ukendte mints vises som adressen. Positioner sorteres efter konto.
Phoenix bruger authority-wallet og trader PDA index 0 med alle returnerede cross/isolated-subaccounts.
Kun nonzero-positioner medtages. ID og sortering bygger på PDA-index, subaccount-index og markedssymbol;
sekvensnumre indgår ikke. Retningen kommer fra signed quantity, label er fx SOL-PERP, og USD-størrelsen er
absolut base-mængde gange mark price, beregnet med decimalpræcision. Se [Phoenix-integrationen](PhoenixPerps.md)
for dokumenterede enheder, endpoints og begrænsninger.
Før første vellykkede opslag er der ingen positioner. Succes publiceres i et nyt snapshot pr. wallet/venue;
fejl logges og bevarer netop den kombinations sidste data. De øvrige venues og wallets forsøges stadig.
Et vellykket tomt svar fjerner kun den pågældende venues positioner og bevarer gruppen.
Fejl i HTTP, authentication, svarformat eller konvertering er ikke tomme porteføljer.
Der vises ingen indikator for forældede data. Implementationens `close()` afbryder worker'en;
consumer-API'et indeholder kun læsning.
EMC spørger hver miljøservice straks ved åbning og hvert tredje sekund uden at blokere JavaFX-tråden.
Skjul/luk stopper kun EMC-polling; services fortsætter. Træstruktur, tre kolonner, farver, formatering,
foldestatus og markering bevares for eksisterende rækker. Nye/lukkede positioner afstemmes efter ID.
Der er ingen sample-positioner, Phoenix-eksempler, sample-tekster, Refresh-knap eller handel.
Skjul/luk stopper kun EMC-polling; services fortsætter. Træet er wallet → venue → position med Jupiter før
Phoenix og uden et ekstra subaccount-niveau. Tre kolonner, farver, formatering, foldestatus og markering
bevares for eksisterende rækker. Nye/lukkede positioner afstemmes efter ID.
Der er ingen sample-data, Refresh-knap, handel, signering eller kontoaktivering i denne læsevej.
Den kanoniske systembeskrivelse findes i
[Evelyn: services og status](../../r35157_docs/projects/evelyn/README.md#evelynperpsservice).
+4 -1
View File
@@ -289,12 +289,15 @@ Mission Control kræver både `evelyn.perps.prod` og `evelyn.perps.test` før `e
i den private `conf/NenjimHub.conf`. Erstat den tidligere `evelyn.perps.default`-linje med de to nye ID'er;
det gamle ID accepteres ikke længere. Hub'en konstruerer, registrerer og starter to separate
`EvelynPerpsServiceImpl`-processer med separate hardcodede wallet-lister og tilhørende Jupiter-integrationer.
Begge får desuden den fælles `PhoenixPerpsService` uden positionscache, som Hub'en registrerer passivt som
`phoenix.perps.default`; den skal ikke tilføjes til `[AUTO_START]`.
Hub'en injicerer deres read-only
service-views til de tilsvarende miljøfaner i EMC. Process Manager viser begge ID'er efter vellykket startup.
Den lokale udviklingskonfiguration er opdateret; andre deployments med EMC skal migreres tilsvarende.
Udeladt eller forkert rækkefølge giver en tydelig konstruktionsfejl uden automatisk indsættelse.
Det minimale versionsstyrede bootstrap-eksempel er uændret. Hver service ejer sit eget snapshot og
opdaterer det fra Jupiter i baggrunden med 60 sekunders pause mellem gennemløb. EMC-læsninger bruger kun
opdaterer det fra Jupiter og Phoenix i baggrunden med 60 sekunders pause efter fuldførte gennemløb.
Sidste vellykkede data bevares separat pr. wallet og venue. EMC-læsninger bruger kun
det publicerede snapshot. Wallet-listerne fra issue #98 indeholder seks PROD-adresser og én TEST-adresse.
Hvis `evelyn.iou-burner.prod` vælges, skal både `assetaz.ticker.default` og
+85
View File
@@ -0,0 +1,85 @@
# Phoenix Perps read-only integration
`PhoenixPerpsService` 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](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. Read every returned subaccount and include only nonzero
`basePositionLots`. 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`.
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 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](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 existing EMC display formatter.
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`, with no extra subaccount tree level. 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
clears only that venue's positions, preserving its group. 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; there is no stale/error indicator yet.
Trader PDA indices other than 0, streaming and position management are out of scope.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22
@@ -0,0 +1,31 @@
# Design
## Context
See proposal.md and issue #99. The existing worker handles Jupiter wallets and publishes immutable snapshots; EMC already supports multiple venues and stable row reconciliation. Phoenix trader state contains all subaccounts but represents positions in signed base lots rather than USD.
## Goals / Non-Goals
Add only read-only REST access and venue-level failure isolation. Keep wallet lists, process names, UI, polling and Jupiter behavior. System documentation in r35157_docs is explicitly in scope. No SDK runtime, trading, streaming or generalized integration framework.
## Decisions
- Add PhoenixPerpsService and PhoenixPerpsPosition in com.r35157.libs.phoenix.perps; implementation in impl.ref. Return subaccount identity, symbol, signed base quantity and current USD size as domain data, without referencing Evelyn or UI types.
- GET trader/state/{authority}?traderPdaIndex=0, exchange/markets for baseLotsDecimals, and market/{symbol}/mark-price for active symbols. Empty portfolios need only trader state. Reuse each symbol's mark price within a lookup. Use the existing HTTP/Jackson stack with 10-second request timeout and no redirects/retries. All numeric conversion uses BigInteger/BigDecimal; JSON floating numbers must be parsed directly as decimal.
- Formula verified against official Rise SDK ts/src/units.ts: baseLotsToBaseUnits(lots, decimals) = lots / 10**decimals. USD notional = abs(base quantity) * markPrice.price. This avoids additional Solana PDA derivation needed for trader-view notional. Negative decimal exponents are supported. No use of entryPriceUsd or virtualQuotePositionLots.
- Validate authority, PDA index, required arrays/numeric fields, duplicate subaccount/position identities and matching market symbols. Missing positions arrays are permitted only as the documented optional field; null/wrong types fail. A malformed nonempty portfolio fails atomically, not partially.
- Hub supplies one shared read-only Phoenix adapter with no portfolio cache; each Evelyn instance owns independent last-successful state keyed by wallet and venue. Existing single scheduled worker reads Jupiter and Phoenix separately with independent catches; publish ordered Jupiter/Phoenix venues after each success. Interruption ends the cycle.
- Stable Phoenix IDs are PDA:subaccount:symbol; sequence numbers are not identity. Labels add -PERP only if absent. No new tree level.
## Risks / Trade-offs
- REST prices and position snapshots may come from different slots → periodic current estimates, not an atomic exchange valuation; document this limitation.
- Public reads currently work without credentials although generated OpenAPI security annotations suggest auth → use observed public access; reject 401/403 and retain data, never attempt signing/login automatically.
- Up to one mark request per active symbol per wallet → live verification exposed HTTP 429 during bursts. Space request starts at least one second apart in the shared adapter, and honor Retry-After (seconds/HTTP date, 60 seconds fallback). During cooldown fail fast without external calls, preserving venue failure isolation. No automatic retries or generic cache framework. This increases cycle duration before the existing one-minute delay.
- Previous data may persist on failure → log wallet/venue and document the existing lack of stale indicators.
## Migration Plan
No new configuration entries or dependencies. Build and restart through normal deployment after review. Do not start full Hub/trading during verification. Leave changes uncommitted and the OpenSpec change active.
## Sources
- https://docs.phoenix.trade/api/trader/get-trader-state
- https://docs.phoenix.trade/api/exchange/list-markets
- https://docs.phoenix.trade/api/exchange/get-mark-price
- https://docs.phoenix.trade/sdk/accounts
- https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/units.ts
@@ -0,0 +1,19 @@
# Proposal
## Why
The owner has Phoenix cross-margin positions that are missing from EMC. Issue #99 extends the existing view-only Perps overview to Phoenix using the same wallet configuration and service boundaries.
## What Changes
- Add a read-only Phoenix REST integration covering cross-margin and isolated subaccounts, with exact current USD notional conversion.
- Inject it through Hub and retain successful snapshots independently per wallet and venue.
- Keep the 60-second background refresh delay, 3-second EMC polling, and existing table presentation.
- Update project and canonical r35157_docs documentation, including API limitations and live verification evidence.
## Capabilities
### New Capabilities
- `phoenix-perps-service`: Read-only position retrieval and precise USD mapping from Phoenix REST data.
### Modified Capabilities
- `evelyn-perps-service`: Multi-venue refresh, construction and failure isolation.
## Impact
New Phoenix API/reference implementation, EvelynPerpsService implementation, Hub composition and documentation. No UI redesign, transaction signing, trading, new configuration framework or external library dependencies.
@@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: Wallet configuration belongs to service construction
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98 and the corresponding existing Jupiter integration plus a read-only Phoenix integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless.
#### Scenario: Separate configured instances
- **WHEN** the Hub constructs and starts both instances
- **THEN** each instance queries only its configured wallets through its injected integrations
- **AND** the implementation does not define the wallet list internally
### Requirement: Background Jupiter refresh
The service SHALL refresh immediately after startup and wait 60 seconds between completed cycles, without overlapping cycles or depending on EMC visibility. It SHALL use Jupiter and Phoenix read operations and respect the existing Jupiter cache. Snapshot reads SHALL make no integration calls. No transaction signing or position-management operation SHALL occur.
#### Scenario: Background update while EMC is hidden
- **WHEN** EMC is hidden and the next refresh is due
- **THEN** the service still refreshes configured wallets and publishes immutable snapshots
- **AND** no freshness guarantee bypasses the existing 60-second integration cache
### Requirement: Position mapping is stable and complete
The service SHALL map position account to stable ID, mint to known instrument label or raw mint fallback, direction to Long/Short, and positionSize to USD size. Wallet order SHALL follow configuration, and positions SHALL be ordered deterministically by position account. Phoenix SHALL use PDA index, subaccount index and symbol as stable identity, signed quantity for direction and absolute current USD notional for size. Jupiter and Phoenix SHALL appear as separate venues, with no subaccount tree level.
#### Scenario: Changed and unknown positions
- **WHEN** a successful read returns newly opened positions including an unknown token mint
- **THEN** all returned positions appear with stable IDs and exact USD sizes, and the unknown mint is displayed rather than dropped
### Requirement: Refresh failures are isolated per wallet
A failed read or mapping SHALL retain the last successful data for that wallet and venue, log the failure and allow other wallets and venues to refresh. Successful empty results SHALL remove only that wallet/venue's previous positions. Interruption SHALL stop the current cycle. Failed first reads SHALL not invent positions.
#### Scenario: Failure followed by recovery
- **WHEN** one wallet fails while another succeeds
- **THEN** only the successful wallet/venue's data is replaced
- **AND** a later successful empty read clears the recovered wallet/venue's previous positions
#### Scenario: One venue fails
- **WHEN** Phoenix fails for a wallet while Jupiter succeeds, or vice versa
- **THEN** only the successful venue is updated and the failed venue retains its previous data
- **AND** subsequent wallets are still attempted
@@ -0,0 +1,36 @@
## Purpose
Read actual Phoenix perpetual positions for a Solana authority without signing or modifying accounts, preserving precise current USD notional and subaccount identities.
## ADDED Requirements
### Requirement: Read every active position under the authority
The integration SHALL read official Phoenix REST trader state for the requested wallet authority at PDA index 0. It SHALL include nonzero positions from cross-margin and isolated subaccounts, preserve subaccount/symbol identity independently of sequence numbers, and return an immutable deterministically ordered result. No signing, trading or account activation SHALL occur.
#### Scenario: Cross and isolated positions share a symbol
- **WHEN** subaccounts 0 and 2 both have a nonzero SOL position
- **THEN** both positions are returned with distinct identities
- **AND** zero-sized positions are excluded
### Requirement: Current USD notional uses documented market units
Position direction SHALL follow signed quantity. USD size SHALL be the absolute current notional, calculated using exact decimal arithmetic from base lots divided by 10 to the market's base-lot decimal exponent and multiplied by its current mark price. Collateral, entry value and raw lot counts SHALL NOT be used as USD size. Missing conversion data SHALL fail the read.
#### Scenario: Convert a short position
- **WHEN** a position has -125 base lots, base-lot exponent 2 and mark price 104.45 USD
- **THEN** it represents -1.25 base units, Short direction and 130.5625 USD notional
### Requirement: Read failures cannot masquerade as empty portfolios
HTTP, authentication, malformed schema, identity mismatch and numeric conversion errors SHALL fail the read. Requests SHALL have bounded timeouts. Only a valid response with no nonzero positions SHALL produce an empty result. Required mark prices SHALL be positive and finite.
#### Scenario: Missing mark price or rejected access
- **WHEN** a required price is missing or an HTTP request is rejected
- **THEN** the read fails instead of returning an empty or partial portfolio
#### Scenario: Empty account
- **WHEN** a valid trader snapshot has no subaccounts or no nonzero positions
- **THEN** an empty result is returned without market-price requests
#### Scenario: Public API rate limit
- **WHEN** the shared adapter issues requests for PROD and TEST
- **THEN** request starts SHALL be spaced at least one second apart
- **AND** HTTP 429 SHALL fail the lookup and establish a shared cooldown from Retry-After (seconds or HTTP date, 60 seconds fallback)
- **AND** lookups during cooldown SHALL fail without external calls, with no immediate retry
@@ -0,0 +1,14 @@
# Tasks
## 1. Phoenix integration
- [x] 1.1 Implement read-only REST API/domain types and strict exact-decimal conversion; verify documented units, cross/isolated identities, empty results, malformed data, errors and timeouts in isolated execution and live nonempty responses.
- [x] 1.2 Document REST sources, unit conversion and limitations in project documentation; check statements against official docs and observed responses.
## 2. Evelyn integration and system documentation
- [x] 2.1 Inject Phoenix through Hub and isolate last-successful data per wallet/venue; verify failure combinations, empty clearing, unchanged Jupiter mapping, polling and cached snapshot reads in isolated execution.
- [x] 2.2 Update r35157_docs and related project documentation; verify removal of obsolete Jupiter-only statements and relative links.
## 3. Integration verification
- [x] 3.1 Run build/existing tests, strict OpenSpec validation, live/UI checks and final diff review; record evidence and leave all edits uncommitted.
Verification evidence: [verification.md](verification.md).
@@ -0,0 +1,65 @@
# Verification — 2026-10-22
## Build and specifications
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build`: successful; 42 existing tests, no failures/errors.
- `openspec validate 99-phoenix-perps-positions --strict`: valid.
- Relative file links in the changed project/system documentation resolve; whitespace checks pass.
- No repository test suite was added, in accordance with OpenSpec project context. Focused temporary execution harnesses are under `/tmp/emc99`.
## Phoenix reader
Temporary local HTTP responses exercised exact quantities beyond double's integer precision,
negative lot exponents, precise decimal prices, nonzero filtering, both cross and isolated
subaccounts holding the same symbol, deterministic sorting, immutable results and sequence-independent identity.
Verified valid empty states (including omitted optional positions), rejection of malformed/null
state, metadata and prices, wrong authority/PDA/version, duplicate identities, missing markets,
conversion overflow, HTTP 401/403/404/429/500, header and body timeouts, and interruption.
The shared Jackson mapper is unchanged. The harness accepts GET only. Pacing, fail-fast cooldown
and Retry-After seconds/HTTP-date parsing (including invalid/overflow fallback) were exercised.
## Evelyn and EMC
Temporary readers exercised immediate asynchronous startup, defensive wallet configuration,
1,000 cached snapshot reads during a blocked integration request, immutable snapshots,
unchanged Jupiter mapping, Phoenix subaccount identity/direction and deterministic ordering.
Independent combinations of Jupiter/Phoenix success/failure/empty for two wallets confirmed
retention and clearing per wallet/venue, including continued attempts after errors and cycle
termination on interruption. The single scheduled worker and 60-second fixed delay remain in place.
The existing six PROD/one TEST wallet lists are unchanged and validate.
A standalone JavaFX Perps view exercised same-symbol subaccounts, value updates, added/removed
positions and empty results while retaining row identity, selection and expansion. The existing
three columns and direction colors rendered the captured live data correctly. Screenshot:
`/tmp/emc99/view.png`. EMC code and its three-second snapshot polling are unchanged.
## Live reference
The adapter read all seven configured wallets through a local recording proxy forwarding
GET requests to the official Phoenix API. Captured trader state, metadata and mark-price responses
were independently checked with Python Decimal against every adapter result, including exact
quantity, notional, authority/subaccount/symbol identity and count.
| Environment / wallet prefix | Positions | Long | Short |
| --- | ---: | ---: | ---: |
| PROD H5Yrdm7B… | 32 | 30 | 2 |
| PROD 7PttBqap… | 32 | 29 | 3 |
| PROD vshTPUDa… | 31 | 10 | 21 |
| PROD 8XGrkDQC… | 32 | 3 | 29 |
| PROD 4wzxcYQ8… | 29 | 6 | 23 |
| PROD HM13QYMv… | 14 | 7 | 7 |
| TEST 6NyYEoXm… | 0 | 0 | 0 |
All 170 nonzero positions matched; H5's short positions were FET and TLT.
These are observations, not fixed expectations. Isolated positions were verified with controlled
responses, as the live positions were cross-margin.
An initial burst of price requests hit HTTP 429. The final shared one-second pacing completed
the live check without errors. At this portfolio size the sequential reads take about three
minutes before the existing 60-second pause; this is not a fresh full portfolio every 60 seconds.
Positions and mark prices are not atomic snapshots. These limitations and retained old data
on errors are documented in `docs/PhoenixPerps.md` and the canonical Evelyn system page.
No full Hub, trading/signing flow or deployment was started. Code and documentation are left
uncommitted; this OpenSpec change remains active for the owner's review/archive.
+12 -7
View File
@@ -22,15 +22,15 @@ The service SHALL run as two separate Hub-managed processes registered as `evely
- **AND** no placeholder positions are generated before successful retrieval
### Requirement: Wallet configuration belongs to service construction
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98 and the corresponding existing Jupiter integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless.
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98 and the corresponding existing Jupiter integration plus a read-only Phoenix integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless.
#### Scenario: Separate configured instances
- **WHEN** the Hub constructs and starts both instances
- **THEN** each instance queries only its configured wallets through its corresponding integration
- **THEN** each instance queries only its configured wallets through its injected integrations
- **AND** the implementation does not define the wallet list internally
### Requirement: Background Jupiter refresh
The service SHALL refresh immediately after startup and wait 60 seconds between completed cycles, without overlapping cycles or depending on EMC visibility. It SHALL use only existing Jupiter position reads and respect their cache. Snapshot reads SHALL make no integration calls. No transaction signing or position-management operation SHALL occur.
The service SHALL refresh immediately after startup and wait 60 seconds between completed cycles, without overlapping cycles or depending on EMC visibility. It SHALL use Jupiter and Phoenix read operations and respect the existing Jupiter cache. Snapshot reads SHALL make no integration calls. No transaction signing or position-management operation SHALL occur.
#### Scenario: Background update while EMC is hidden
- **WHEN** EMC is hidden and the next refresh is due
@@ -38,16 +38,21 @@ The service SHALL refresh immediately after startup and wait 60 seconds between
- **AND** no freshness guarantee bypasses the existing 60-second integration cache
### Requirement: Position mapping is stable and complete
The service SHALL map position account to stable ID, mint to known instrument label or raw mint fallback, direction to Long/Short, and positionSize to USD size. Wallet order SHALL follow configuration, and positions SHALL be ordered deterministically by position account. Only Jupiter venues and actual positions SHALL be published.
The service SHALL map position account to stable ID, mint to known instrument label or raw mint fallback, direction to Long/Short, and positionSize to USD size. Wallet order SHALL follow configuration, and positions SHALL be ordered deterministically by position account. Phoenix SHALL use PDA index, subaccount index and symbol as stable identity, signed quantity for direction and absolute current USD notional for size. Jupiter and Phoenix SHALL appear as separate venues, with no subaccount tree level.
#### Scenario: Changed and unknown positions
- **WHEN** a successful read returns newly opened positions including an unknown token mint
- **THEN** all returned positions appear with stable IDs and exact USD sizes, and the unknown mint is displayed rather than dropped
### Requirement: Refresh failures are isolated per wallet
A failed wallet read or mapping SHALL retain its last successful data, log the failure and allow other wallets to refresh. Successful empty results SHALL remove previous positions. Interruption SHALL stop the current cycle. Failed first reads SHALL not invent positions.
A failed read or mapping SHALL retain the last successful data for that wallet and venue, log the failure and allow other wallets and venues to refresh. Successful empty results SHALL remove only that wallet/venue's previous positions. Interruption SHALL stop the current cycle. Failed first reads SHALL not invent positions.
#### Scenario: Failure followed by recovery
- **WHEN** one wallet fails while another succeeds
- **THEN** only the successful wallet's data is replaced
- **AND** a later successful empty read clears the recovered wallet's previous positions
- **THEN** only the successful wallet/venue's data is replaced
- **AND** a later successful empty read clears the recovered wallet/venue's previous positions
#### Scenario: One venue fails
- **WHEN** Phoenix fails for a wallet while Jupiter succeeds, or vice versa
- **THEN** only the successful venue is updated and the failed venue retains its previous data
- **AND** subsequent wallets are still attempted
@@ -0,0 +1,38 @@
# phoenix-perps-service Specification
## Purpose
Read actual Phoenix perpetual positions for a Solana authority without signing or modifying accounts, preserving precise current USD notional and subaccount identities.
## Requirements
### Requirement: Read every active position under the authority
The integration SHALL read official Phoenix REST trader state for the requested wallet authority at PDA index 0. It SHALL include nonzero positions from cross-margin and isolated subaccounts, preserve subaccount/symbol identity independently of sequence numbers, and return an immutable deterministically ordered result. No signing, trading or account activation SHALL occur.
#### Scenario: Cross and isolated positions share a symbol
- **WHEN** subaccounts 0 and 2 both have a nonzero SOL position
- **THEN** both positions are returned with distinct identities
- **AND** zero-sized positions are excluded
### Requirement: Current USD notional uses documented market units
Position direction SHALL follow signed quantity. USD size SHALL be the absolute current notional, calculated using exact decimal arithmetic from base lots divided by 10 to the market's base-lot decimal exponent and multiplied by its current mark price. Collateral, entry value and raw lot counts SHALL NOT be used as USD size. Missing conversion data SHALL fail the read.
#### Scenario: Convert a short position
- **WHEN** a position has -125 base lots, base-lot exponent 2 and mark price 104.45 USD
- **THEN** it represents -1.25 base units, Short direction and 130.5625 USD notional
### Requirement: Read failures cannot masquerade as empty portfolios
HTTP, authentication, malformed schema, identity mismatch and numeric conversion errors SHALL fail the read. Requests SHALL have bounded timeouts. Only a valid response with no nonzero positions SHALL produce an empty result. Required mark prices SHALL be positive and finite.
#### Scenario: Missing mark price or rejected access
- **WHEN** a required price is missing or an HTTP request is rejected
- **THEN** the read fails instead of returning an empty or partial portfolio
#### Scenario: Empty account
- **WHEN** a valid trader snapshot has no subaccounts or no nonzero positions
- **THEN** an empty result is returned without market-price requests
#### Scenario: Public API rate limit
- **WHEN** the shared adapter issues requests for PROD and TEST
- **THEN** request starts SHALL be spaced at least one second apart
- **AND** HTTP 429 SHALL fail the lookup and establish a shared cooldown from Retry-After (seconds or HTTP date, 60 seconds fallback)
- **AND** lookups during cooldown SHALL fail without external calls, with no immediate retry
@@ -10,6 +10,8 @@ import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition;
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
import com.r35157.nenjim.process.NenjimProcess;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
@@ -26,12 +28,14 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Reads Jupiter positions in the background; consumers only read the published snapshot. */
/** Reads Jupiter and Phoenix positions in the background; consumers only read the published snapshot. */
public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimProcess, AutoCloseable {
public EvelynPerpsServiceImpl(@NotNull List<PerpsWalletConfiguration> wallets,
@NotNull JupiterPerpsService jupiter) {
@NotNull JupiterPerpsService jupiter,
@NotNull PhoenixPerpsService phoenix) {
this.wallets = List.copyOf(wallets);
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
this.phoenix = Objects.requireNonNull(phoenix, "phoenix");
var addresses = new HashSet<String>();
var base58 = new Base58CodecImpl();
for (PerpsWalletConfiguration wallet : this.wallets) {
@@ -60,32 +64,60 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
private void refresh() {
for (PerpsWalletConfiguration wallet : wallets) {
if (closed || Thread.currentThread().isInterrupted()) {
return;
}
try {
List<Position> positions = jupiter.getOpenPositions(wallet.address()).stream()
refreshVenue(wallet, "jupiter", "Jupiter", () -> jupiter.getOpenPositions(wallet.address()).stream()
.sorted(Comparator.comparing(JupiterPerpsPosition::positionAccount))
.map(EvelynPerpsServiceImpl::position)
.toList();
if (closed || Thread.currentThread().isInterrupted()) {
return;
}
lastSuccessful.put(wallet.address(), new Wallet(wallet.address(), wallet.address(),
List.of(new Venue("jupiter", "Jupiter", positions))));
snapshot = new PerpsSnapshot(wallets.stream()
.map(configured -> lastSuccessful.get(configured.address()))
.filter(Objects::nonNull)
.toList());
refreshVenue(wallet, "phoenix", "Phoenix", () -> phoenix.getOpenPositions(wallet.address()).stream()
.sorted(Comparator.comparingInt(PhoenixPerpsPosition::traderPdaIndex)
.thenComparingInt(PhoenixPerpsPosition::subaccountIndex)
.thenComparing(PhoenixPerpsPosition::symbol))
.map(EvelynPerpsServiceImpl::position)
.toList());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
}
}
}
private void refreshVenue(PerpsWalletConfiguration wallet, String venueId, String venueName,
PositionReader reader) throws InterruptedException {
if (closed || Thread.currentThread().isInterrupted()) {
throw new InterruptedException("Perps refresh stopped");
}
try {
List<Position> positions = reader.read();
if (closed || Thread.currentThread().isInterrupted()) {
throw new InterruptedException("Perps refresh stopped");
}
lastSuccessful.put(new WalletVenue(wallet.address(), venueId), new Venue(venueId, venueName, positions));
snapshot = new PerpsSnapshot(wallets.stream().map(configured -> {
List<Venue> venues = VENUE_ORDER.stream()
.map(id -> lastSuccessful.get(new WalletVenue(configured.address(), id)))
.filter(Objects::nonNull).toList();
return new Wallet(configured.address(), configured.address(), venues);
}).filter(current -> !current.venues().isEmpty()).toList());
} catch (IOException | RuntimeException exception) {
log.warn("Unable to refresh Jupiter Perps positions for wallet {}", wallet.address(), exception);
log.warn("Unable to refresh {} Perps positions for wallet {}", venueName, wallet.address(), exception);
}
}
private static Position position(PhoenixPerpsPosition position) {
String id = position.traderPdaIndex() + ":" + position.subaccountIndex() + ":" + position.symbol();
String instrument = position.symbol().endsWith("-PERP") ? position.symbol() : position.symbol() + "-PERP";
Direction direction = position.quantity().signum() > 0 ? Direction.LONG : Direction.SHORT;
return new Position(id, instrument, direction, position.sizeUsd());
}
@FunctionalInterface
private interface PositionReader {
List<Position> read() throws IOException, InterruptedException;
}
private record WalletVenue(String wallet, String venue) { }
private static Position position(JupiterPerpsPosition position) {
Direction direction = switch (position.direction()) {
case LONG -> Direction.LONG;
@@ -126,8 +158,10 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
private static final long REFRESH_SECONDS = 60;
private final List<PerpsWalletConfiguration> wallets;
private final JupiterPerpsService jupiter;
private final PhoenixPerpsService phoenix;
private static final List<String> VENUE_ORDER = List.of("jupiter", "phoenix");
// Accessed only by the single refresh worker; consumers see immutable snapshots.
private final Map<String, Wallet> lastSuccessful = new HashMap<>();
private final Map<WalletVenue, Venue> lastSuccessful = new HashMap<>();
private ScheduledExecutorService worker;
private volatile boolean closed;
private volatile PerpsSnapshot snapshot;
@@ -0,0 +1,23 @@
package com.r35157.libs.phoenix.perps;
import java.math.BigDecimal;
import java.util.Objects;
/**
* A nonzero Phoenix position. Quantity is signed base units, not raw lots; sizeUsd is
* absolute current notional (abs(quantity) * mark price), not collateral or entry value.
* Identity within a wallet is traderPdaIndex + subaccountIndex + symbol, independent of
* changing sequence numbers. Prices and trader state are separate REST snapshots.
*/
public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol,
ΩAmountΩ quantity, ΩAmountΩ sizeUsd) {
public PhoenixPerpsPosition {
Objects.requireNonNull(symbol, "symbol");
Objects.requireNonNull(quantity, "quantity");
Objects.requireNonNull(sizeUsd, "sizeUsd");
if (traderPdaIndex < 0 || subaccountIndex < 0 || symbol.isBlank()
|| !symbol.equals(symbol.strip()) || quantity.signum() == 0 || sizeUsd.signum() <= 0) {
throw new IllegalArgumentException("Invalid Phoenix position");
}
}
}
@@ -0,0 +1,19 @@
package com.r35157.libs.phoenix.perps;
import com.r35157.nenjim.component.NenjimComponent;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.List;
/** Read-only Phoenix positions; no signing, activation or trading operations. */
public interface PhoenixPerpsService extends NenjimComponent {
/**
* Reads all nonzero cross-margin and isolated positions for authority's trader PDA index 0.
* A successful empty list means no open positions. Failed reads/conversions throw rather
* than returning an empty or partial portfolio. The returned list is immutable and ordered
* by trader PDA index, subaccount index and market symbol.
*/
@NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
throws IOException, InterruptedException;
}
@@ -0,0 +1,262 @@
package com.r35157.libs.phoenix.perps.impl.ref;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition;
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.DateTimeException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/** Public REST reader with shared request pacing and no portfolio cache. See docs/PhoenixPerps.md for API contracts and units. */
public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper) {
this(httpClient, objectMapper, URI.create("https://perp-api.phoenix.trade/"), Duration.ofSeconds(10));
}
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper,
@NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout) {
this(httpClient, objectMapper, endpoint, timeout, Duration.ofSeconds(1));
}
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper,
@NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout,
@NotNull Duration minimumRequestInterval) {
this.httpClient = Objects.requireNonNull(httpClient, "httpClient");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper").copy()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS,
DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY,
DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
this.endpoint = Objects.requireNonNull(endpoint, "endpoint");
this.timeout = Objects.requireNonNull(timeout, "timeout");
this.minimumRequestNanos = Objects.requireNonNull(minimumRequestInterval, "minimumRequestInterval").toNanos();
if (minimumRequestNanos < 0) {
throw new IllegalArgumentException("Phoenix request interval must not be negative");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("Phoenix request timeout must be positive");
}
}
@Override
public @NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
throws IOException, InterruptedException {
if (new Base58CodecImpl().decode(authority).length != 32) {
throw new IllegalArgumentException("Invalid Solana authority: " + authority);
}
JsonNode state = get("v1/trader/state/" + authority + "?traderPdaIndex=0");
if (!authority.equals(text(state, "authority")) || integer(state, "traderPdaIndex") != 0) {
throw invalid("Trader state does not match requested authority/PDA index");
}
JsonNode snapshot = required(state, "snapshot");
if (integer(snapshot, "version") != 1) {
throw invalid("Unsupported trader snapshot version");
}
var rawPositions = new ArrayList<RawPosition>();
var subaccounts = new HashSet<Integer>();
for (JsonNode subaccount : array(required(snapshot, "subaccounts"), "subaccounts")) {
int index = integer(subaccount, "subaccountIndex");
if (index < 0 || !subaccounts.add(index)) {
throw invalid("Invalid or duplicate subaccount index: " + index);
}
// The API explicitly permits omitted positions for an empty subaccount.
if (!subaccount.has("positions")) {
continue;
}
var symbols = new HashSet<String>();
for (JsonNode position : array(required(subaccount, "positions"), "positions")) {
String symbol = text(position, "symbol");
if (!symbols.add(symbol)) {
throw invalid("Duplicate position in subaccount " + index + ": " + symbol);
}
BigInteger lots = lots(position);
if (lots.signum() != 0) {
rawPositions.add(new RawPosition(index, symbol, lots));
}
}
}
if (rawPositions.isEmpty()) {
return List.of();
}
rawPositions.sort(Comparator.comparingInt(RawPosition::subaccountIndex)
.thenComparing(RawPosition::symbol));
Map<String, Integer> decimals = marketDecimals();
var prices = new HashMap<String, BigDecimal>();
var positions = new ArrayList<PhoenixPerpsPosition>();
for (RawPosition position : rawPositions) {
Integer baseLotsDecimals = decimals.get(position.symbol());
if (baseLotsDecimals == null) {
throw invalid("Missing market metadata for " + position.symbol());
}
BigDecimal price = prices.get(position.symbol());
if (price == null) {
price = markPrice(position.symbol());
prices.put(position.symbol(), price);
}
try {
// Official SDK: baseLotsToBaseUnits(lots, d) = lots / 10**d (d can be negative).
BigDecimal quantity = new BigDecimal(position.lots())
.scaleByPowerOfTen(Math.negateExact(baseLotsDecimals));
positions.add(new PhoenixPerpsPosition(0, position.subaccountIndex(), position.symbol(),
quantity, quantity.abs().multiply(price)));
} catch (ArithmeticException | IllegalArgumentException exception) {
throw new IOException("Cannot convert Phoenix position " + position.symbol(), exception);
}
}
return List.copyOf(positions);
}
private Map<String, Integer> marketDecimals() throws IOException, InterruptedException {
var decimals = new HashMap<String, Integer>();
for (JsonNode market : array(get("v1/view/exchange/markets"), "markets")) {
String symbol = text(market, "symbol");
if (decimals.putIfAbsent(symbol, integer(market, "baseLotsDecimals")) != null) {
throw invalid("Duplicate market metadata for " + symbol);
}
}
return decimals;
}
private ΩPriceΩ markPrice(String symbol) throws IOException, InterruptedException {
String encodedSymbol = URLEncoder.encode(symbol, StandardCharsets.UTF_8).replace("+", "%20");
JsonNode response = get("v1/market/" + encodedSymbol + "/mark-price");
if (!symbol.equals(text(response, "symbol"))) {
throw invalid("Mark price symbol does not match " + symbol);
}
JsonNode price = required(required(response, "markPrice"), "price");
// Jackson parses JSON decimals directly as BigDecimal; never go through double.
if (!price.isNumber() || price.decimalValue().signum() <= 0) {
throw invalid("Missing or invalid mark price for " + symbol);
}
return price.decimalValue();
}
private JsonNode get(String path) throws IOException, InterruptedException {
awaitRequestSlot();
HttpRequest request = HttpRequest.newBuilder(endpoint.resolve(path))
.timeout(timeout).header("Accept", "application/json").GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 429) {
deferRequests(response.headers().firstValue("Retry-After").orElse("60"));
}
if (response.statusCode() != 200) {
throw new IOException("Phoenix GET " + path + " returned HTTP " + response.statusCode());
}
JsonNode result = objectMapper.readTree(response.body());
if (result == null || result.isNull()) {
throw invalid("Empty JSON response for " + path);
}
return result;
}
// Shared by PROD and TEST; wait interruptibly, without holding either service's snapshot lock.
private synchronized void awaitRequestSlot() throws IOException, InterruptedException {
checkCooldown();
long remaining = nextRequestNanos - System.nanoTime();
if (remaining > 0) {
TimeUnit.NANOSECONDS.sleep(remaining);
}
checkCooldown();
nextRequestNanos = System.nanoTime() + minimumRequestNanos;
}
private void checkCooldown() throws IOException {
if (Instant.now().isBefore(retryNotBefore)) {
throw new IOException("Phoenix rate-limit cooldown until " + retryNotBefore);
}
}
private synchronized void deferRequests(String retryAfter) {
Instant now = Instant.now();
Instant retryAt;
try {
retryAt = retryAfter.matches("[0-9]+")
? now.plusSeconds(Long.parseLong(retryAfter))
: ZonedDateTime.parse(retryAfter, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
if (!retryAt.isAfter(now)) {
retryAt = now.plusSeconds(60);
}
} catch (DateTimeException | ArithmeticException | NumberFormatException exception) {
retryAt = now.plusSeconds(60);
}
if (retryAt.isAfter(retryNotBefore)) {
retryNotBefore = retryAt;
}
}
private static JsonNode required(JsonNode object, String field) throws IOException {
if (!object.isObject() || !object.hasNonNull(field)) {
throw invalid("Missing or null field: " + field);
}
return object.get(field);
}
private static JsonNode array(JsonNode value, String field) throws IOException {
if (!value.isArray()) {
throw invalid("Expected array: " + field);
}
return value;
}
private static String text(JsonNode object, String field) throws IOException {
JsonNode value = required(object, field);
if (!value.isTextual() || value.textValue().isBlank()
|| !value.textValue().equals(value.textValue().strip())) {
throw invalid("Expected nonblank text: " + field);
}
return value.textValue();
}
private static int integer(JsonNode object, String field) throws IOException {
JsonNode value = required(object, field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalid("Expected integer: " + field);
}
return value.intValue();
}
private static BigInteger lots(JsonNode position) throws IOException {
String value = text(position, "basePositionLots");
if (!value.matches("-?[0-9]+")) {
throw invalid("Expected signed integer basePositionLots");
}
return new BigInteger(value);
}
private static IOException invalid(String message) {
return new IOException("Invalid Phoenix response: " + message);
}
private record RawPosition(int subaccountIndex, String symbol, BigInteger lots) { }
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final ΩRestEndpointBaseΩ endpoint;
private final Duration timeout;
private final long minimumRequestNanos;
private long nextRequestNanos = System.nanoTime();
private Instant retryNotBefore = Instant.MIN;
}
@@ -28,6 +28,8 @@ import com.r35157.evelyn.emc.impl.ref.EvelynMissionControlImpl;
import com.r35157.jupiterperpsalarm.JupiterPerpsAlarm;
import com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
import com.r35157.libs.phoenix.perps.impl.ref.PhoenixPerpsServiceImpl;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.jupiter.swap.impl.ref.JupiterSwapServiceImpl;
@@ -247,6 +249,9 @@ public final class NenjimHubImpl implements NenjimHub {
JUPITER_PERPS_EVELYN_TEST_ID,
jupiterServicePerpsTest);
phoenixPerpsService = new PhoenixPerpsServiceImpl(httpClient, objectMapper);
registryServiceManager.registerComponent(PHOENIX_PERPS_ID, phoenixPerpsService);
jupiterSwapService = new JupiterSwapServiceImpl(solanaBlockChain, solanaWalletEvelynBurnerProd);
registryServiceManager.registerComponent(
JUPITER_SWAP_EVELYN_BURNER_PROD_ID,
@@ -362,13 +367,13 @@ public final class NenjimHubImpl implements NenjimHub {
}
private NenjimProcess constructEvelynPerpsProdProcess() {
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterServicePerpsProd);
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterServicePerpsProd, phoenixPerpsService);
evelynPerpsServiceProd = process;
return process;
}
private NenjimProcess constructEvelynPerpsTestProcess() {
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterServicePerpsTest);
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterServicePerpsTest, phoenixPerpsService);
evelynPerpsServiceTest = process;
return process;
}
@@ -547,6 +552,8 @@ public final class NenjimHubImpl implements NenjimHub {
new NenjimComponentId("jupiter.perps.evelyn-prod");
private static final NenjimComponentId JUPITER_PERPS_EVELYN_TEST_ID =
new NenjimComponentId("jupiter.perps.evelyn-test");
private static final NenjimComponentId PHOENIX_PERPS_ID =
new NenjimComponentId("phoenix.perps.default");
private static final NenjimComponentId JUPITER_PERPS_ALARM_ID =
new NenjimComponentId("jupiter-perps-alarm.default");
private static final NenjimComponentId EVELYN_STATUS_PROD_ID =
@@ -604,6 +611,7 @@ public final class NenjimHubImpl implements NenjimHub {
private SolanaWallet solanaWalletEvelynBurnerProd;
private JupiterPerpsService jupiterServicePerpsProd;
private JupiterPerpsService jupiterServicePerpsTest;
private PhoenixPerpsService phoenixPerpsService;
// Price-source components
private PriceSource hardcodedPriceSource;