101: EMC Perps - show Phoenix TWAP progress and time until next suborder

This commit is contained in:
2026-10-24 19:11:50 +02:00
parent 28640f3203
commit dab55e19ed
21 changed files with 712 additions and 32 deletions
+16 -3
View File
@@ -444,8 +444,8 @@ og alarmer beholder deres afhængigheder og cache.
Servicen læser asynkront ved start og venter 60 sekunder efter hvert fuldført gennemløb. Servicen læser asynkront ved start og venter 60 sekunder efter hvert fuldført gennemløb.
Runder overlapper ikke og afhænger ikke af EMC's synlighed. Jupiter-overblikket bruger Runder overlapper ikke og afhænger ikke af EMC's synlighed. Jupiter-overblikket bruger
ét REST-opslag pr. wallet. Phoenix bruger trader-state, metadata og én mark price pr. ét REST-opslag pr. wallet. Phoenix bruger trader-state, metadata, én mark price pr.
unikt åbent marked. Begge har 10 sekunders request-timeout; Phoenix bevarer fælles unikt åbent marked og ét TWAP-opslag, når der er positioner. Begge har 10 sekunders request-timeout; Phoenix bevarer fælles
pacing og 429-cooldown. En fuld opdatering tager opslagstid plus pausen, ikke nødvendigvis pacing og 429-cooldown. En fuld opdatering tager opslagstid plus pausen, ikke nødvendigvis
ét minut. Implementeringens `close()` afbryder worker'en; consumer-API'et har kun læsning. ét minut. Implementeringens `close()` afbryder worker'en; consumer-API'et har kun læsning.
@@ -457,7 +457,7 @@ oprindelige succes-tidspunkt. Andre wallets/venues forsøges stadig. Et gyldigt
rydder den pågældende scope og kan totalisere nul; HTTP-/formatfejl kan ikke. En gyldig rydder den pågældende scope og kan totalisere nul; HTTP-/formatfejl kan ikke. En gyldig
Cross-konto med kapital bevares, selv om dens sidste position lukkes. Cross-konto med kapital bevares, selv om dens sidste position lukkes.
EMC viser **Position | Direction | Margin | Collateral | Leverage | Exposure | Value | uPnL** EMC viser **Position | Direction | Margin | Collateral | Leverage | Exposure | Value | uPnL | TWAP**
under den synlige rod **Perpetual Contracts**. Isolerede positioner har wallet → venue → under den synlige rod **Perpetual Contracts**. Isolerede positioner har wallet → venue →
position; Phoenix Cross har wallet → venue → Cross → position. Positionens konto-ID position; Phoenix Cross har wallet → venue → Cross → position. Positionens konto-ID
bestemmer identitet og tilhørsforhold, aldrig instrumentteksten. Wallets følger den bestemmer identitet og tilhørsforhold, aldrig instrumentteksten. Wallets følger den
@@ -468,6 +468,19 @@ holder derfor Phoenix synlig, selv uden åbne positioner. Eksisterende foldestat
rækkeobjekter bevares; uændrede snapshots opdaterer ikke træet. Vinduesstart er 1100 × 600, rækkeobjekter bevares; uændrede snapshots opdaterer ikke træet. Vinduesstart er 1100 × 600,
og smallere vinduer kan scrolle vandret, når kolonnernes minimumsbredder kræver det. og smallere vinduer kan scrolle vandret, når kolonnernes minimumsbredder kræver det.
TWAP vises kun på en eksisterende Phoenix-position, når ordren selv har et faktisk
fill og resterende delordrer. Ufyldte ordrer opretter ingen rækker og vises heller
ikke på en position, som allerede fandtes. Progressbaren måler udførte delordrer;
teksten er estimeret resterende tid fra seneste TWAP-observation, fx `≈ 28:32:10`
eller `≈ 128:32:10`, uden timerollover eller live-nedtælling. Tooltip viser
`Estimated completion`, totalstørrelse, næste delordrestørrelse, fyldt mængde og
observationstid. Restordrer kan være mindre. Dollarstørrelser bruger samme Phoenix-
markpris som positionen og kræver ikke Ticker-priser; uden markpris bevares mængderne,
mens berørte dollarværdier og totaler bliver utilgængelige. En TWAP-læsefejl beholder
hele venue-observationen og viser `Retained` i cellen. Se
[TWAP-beregninger og begrænsninger](PhoenixPerps.md#twap-observations) for slots,
forfaldne tidsplaner og prisestimater.
Exposure er brutto: 100 USD Long + 100 USD Short giver 200 USD. Dollar-uPnL summeres Exposure er brutto: 100 USD Long + 100 USD Short giver 200 USD. Dollar-uPnL summeres
med fortegn. Collateral/Value tæller hver isoleret position og hver delt Cross-konto én med fortegn. Collateral/Value tæller hver isoleret position og hver delt Cross-konto én
gang, inklusive ubrugt kapital på de inkluderede Cross-konti. Cross-positionernes gang, inklusive ubrugt kapital på de inkluderede Cross-konti. Cross-positionernes
+58 -4
View File
@@ -1,6 +1,6 @@
# Phoenix Perps read-only integration # Phoenix Perps read-only integration
`PhoenixPerpsService`, `PhoenixPerpsAccount` and `PhoenixPerpsPosition` live in `PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition` and `PhoenixTwapOrder` live in
`com.r35157.libs.phoenix.perps`; the REST implementation is in `impl.ref`. `com.r35157.libs.phoenix.perps`; the REST implementation is in `impl.ref`.
They contain no Evelyn/UI types or signing/trading operations. NenjimHub creates 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 one adapter without a portfolio cache as `phoenix.perps.default` and injects it into both Evelyn
@@ -26,11 +26,15 @@ The implementation uses these official endpoints at `https://perp-api.phoenix.tr
supplies each symbol's `baseLotsDecimals`, `tickSize`, leverage tiers and taker fee. 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) 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 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. 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).
An empty trader state needs only the first request. Otherwise metadata is fetched once 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 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 held in several subaccounts, followed by one TWAP 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 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 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 `Retry-After` (seconds or HTTP date; 60 seconds if missing/invalid). Further calls
@@ -88,13 +92,63 @@ external data; EMC's three-second polling remains unchanged.
Positions and prices are separate REST reads and can belong to different slots. 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. 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 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 60-second pause (32 active markets need at least about 34 seconds for their reads). Failed
reads can leave old data visible indefinitely. A visible venue shows `(retained)` after 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, 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 with failed or pending first-read status available in their wallet's tooltip. Such unknown
scopes cannot contribute fabricated zero totals. scopes cannot contribute fabricated zero totals.
Trader PDA indices other than 0, streaming and position management are out of scope. Trader PDA indices other than 0, streaming and position management are out of scope.
## 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 ## Valuations, funding and margin
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24
@@ -0,0 +1,30 @@
# Design
## Context
See proposal.md for motivation. Phoenix account reads already fetch one mark per held market and are published atomically per wallet/venue. A failed read retains that venue's complete previous snapshot. EMC reconciles immutable snapshots and has no exchange dependencies.
## Goals / Non-Goals
**Goals:** Attach precise, optional TWAP observations to existing positions and preserve their observation time through the service/UI boundary.
**Non-Goals:** Order placement, rows for unfilled orders, group summaries, new polling loops, live countdown timers or Ticker changes.
## Decisions
1. Read one TWAP snapshot per wallet with held positions, through the existing paced HTTP adapter. Validate authority/PDA/subaccount/asset identities against market metadata and trader state. Attach only orders with positive filled base lots and remaining children; never synthesize a position. A TWAP/schema/HTTP failure fails the complete venue read rather than masquerading as order disappearance. Independent partial TWAP refresh/cache would require a larger status model.
2. Keep dedicated immutable Phoenix and Evelyn TWAP domain records, with exact integer counts, decimal quantities, a nullable mark and observation/estimated instants. Existing position constructor overloads remain convenient for consumers without TWAP data. A valid absent mark yields unknown price-dependent metrics and still exposes quantities; invalid prices and HTTP failures remain errors.
3. Anchor `dueSlot - snapshot.slot` at response receipt and use nominal 200 ms per slot. Completion is `dueSlot + (remaining - 1) × cooldownSlots`; expiry is never used as completion. Already-due schedules, expiry before completion and unrepresentable estimates yield unavailable timing. These estimates require no new RPC client, network dependency or sampling cache. The current nominal 200 ms is documented by the [Solana Foundation](https://solana.com/fr/upgrades/reduced-slot-times), updated October 2026 (400 ms is obsolete). Measured slot duration is deferred; nominal estimates and observation age are explicit in tooltips/docs.
4. Decode snapshot counts as total children including dust. Explicit dust subtracts its count from total; legacy positive dust size with zero count replaces one final regular child. Missing dust metadata makes sizes unknown, never silently assumes an equal split. The next child switches to dust once regular children have executed.
5. Append a graphic TWAP column using a ProgressBar and overlaid duration label. Counters measure execution count, with fills shown separately. Duration uses integer total hours and updates only with new snapshots. Failed venues show `Retained` instead of a fresh countdown. Tooltips show approximate USD values from the same mark already used for the position and state snapshot timing limitations.
## Risks / Trade-offs
- Nominal slot speed, API lag and delayed child execution → label estimates, show observation time, do not imply actual execution or advance counts locally.
- More sequential reads → retain shared pacing/cooldown, skip TWAP reads without held positions; preserve 60-second post-cycle pause.
- Nullable marks affect valuations → propagate unknowns into affected totals rather than publish partial totals or hide held positions.
- Completed orders can disappear from the active endpoint → no historical completion claim or synthetic completed-order cache.
## Migration Plan
No persisted data or configuration changes. Build and inspect focused temporary harnesses/JavaFX previews, update existing documentation and sync the three delta specs. Leave the running Hub untouched; rollback is reverting the source change before a normal owner-controlled deployment.
@@ -0,0 +1,28 @@
# Proposal
## Why
The owner needs to distinguish ongoing Phoenix TWAP execution from the current position size in EMC. Issue #101 requests progress and the estimated wait until the next child without changing refresh frequency or exposing unfilled orders.
## What Changes
- Read Phoenix TWAP snapshots alongside existing account observations and attach only filled orders to matching existing positions.
- Add a ninth TWAP column with child-count progress, elapsed-hour duration text and detailed estimated timing/quantity tooltips.
- Reuse Phoenix mark prices, preserving quantities when a valid response has no mark price and propagating unknown valuations.
- Preserve whole-venue observations on TWAP errors; document periodic timing and nominal slot estimates.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `phoenix-perps-service`: precise TWAP quantities, identities, optional mark prices and read failure behavior.
- `evelyn-perps-service`: immutable TWAP observations on existing positions, with retained venue status.
- `evelyn-perps-overview`: TWAP progress, long durations and detailed tooltips.
## Impact
Phoenix DTOs/reader, Evelyn snapshot/mapping, JavaFX Perps cells and relevant existing project/shared documentation. No trading, new dependencies, faster polling, commits or pushes.
@@ -0,0 +1,32 @@
## MODIFIED Requirements
### Requirement: Perps overview displays a collapsible position hierarchy
The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios and TWAP blank, and be expanded by default.
#### Scenario: Open either environment's Perps tab
- **WHEN** the owner opens Perps in Production or Test
- **THEN** all nine columns, root totals and the account-aware hierarchy are available, with horizontal scrolling where required
#### Scenario: Collapse and expand groups
- **WHEN** the owner collapses and expands a wallet, venue or Cross account
- **THEN** descendants hide and return without changing totals
## ADDED Requirements
### Requirement: TWAP cells show execution progress and snapshot duration
Matching filled Phoenix TWAPs SHALL show a progressbar using executed/total child counts and an overlaid estimated duration to the next child in unbounded hours, minutes and seconds. Other cells SHALL be empty. Updates SHALL follow existing snapshots without a live timer. Failed retained observations SHALL be labeled retained; due or unknown schedules SHALL not show negative or wrapped times or imply execution.
#### Scenario: Long interval
- **WHEN** the snapshot's estimated wait is 28 hours 32 minutes 10 seconds, or 128 hours 32 minutes 10 seconds
- **THEN** the text is approximately 28:32:10 or 128:32:10 respectively
#### Scenario: Failed or due observation
- **WHEN** the venue read fails or the latest observed next slot is already due
- **THEN** the cell communicates retained or awaiting-update status without advancing progress
### Requirement: TWAP tooltip distinguishes estimates from fills
TWAP tooltips SHALL show executed/total progress, Estimated completion, total size, next child size, filled quantity and observation time. Sizes SHALL include their symbol and approximate USD values from Phoenix mark prices where available. Distinct regular/dust sizes and periodic nominal-slot timing assumptions SHALL be explained. Unknown values SHALL remain unknown; expiry SHALL never be presented as completion.
#### Scenario: Missing price or smaller next child
- **WHEN** the mark price is unavailable or the next child is a smaller rest order
- **THEN** token amounts remain visible with unknown USD values, or the next size reflects that rest order respectively
@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: TWAP observations belong to existing filled positions
The service SHALL publish immutable TWAP observations only on matching existing Phoenix positions whose order has a positive actual fill and remaining executions. It SHALL preserve counters, quantities, nullable price and observation/estimated times without UI formatting. No new position SHALL be synthesized. TWAP errors SHALL retain the complete prior venue observation with failure status. Polling frequency SHALL remain unchanged.
#### Scenario: Unfilled order or no position
- **WHEN** an active TWAP has no actual fills or has no matching open position
- **THEN** it is absent from every overview row and cannot create a visible venue
#### Scenario: TWAP read fails
- **WHEN** the TWAP read fails after a successful venue observation
- **THEN** prior positions, TWAP observations and original timestamps remain with failed venue status
- **AND** other venues and wallets continue refreshing
@@ -0,0 +1,44 @@
## MODIFIED Requirements
### 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. Present mark prices SHALL be positive and finite; an explicitly absent mark SHALL preserve positions and quantities with unavailable price-dependent valuations.
#### Scenario: Missing mark price
- **WHEN** a valid mark response has no mark price for a held position
- **THEN** the position and its TWAP quantities remain present with unknown USD valuations
- **AND** affected aggregate valuations remain unknown
#### Scenario: Rejected access or invalid price
- **WHEN** an HTTP request is rejected or a present mark price is invalid
- **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 or TWAP 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
## ADDED Requirements
### Requirement: Read precise TWAP snapshots for held markets
The reader SHALL read official TWAP snapshots through its existing paced transport and associate orders by authority, PDA, subaccount and market identity. Only orders with actual fills and remaining children on an existing position SHALL be exposed. Quantities SHALL use exact lot conversion and explicit/legacy dust semantics. Unavailable size inputs SHALL remain unknown. Existing mark prices SHALL be reused without Ticker reads.
#### Scenario: Explicit and legacy dust
- **WHEN** total children are 5, regular size 10 lots and explicit dust count/size are 2/3
- **THEN** total size is 36 lots and the fourth child is 3 lots
- **AND** legacy zero dust count with size 3 instead represents four regular children and a final 3-lot child
#### Scenario: Identity mismatch or unfilled order
- **WHEN** authority/PDA/subaccount/asset identities disagree or an order has no fills
- **THEN** disagreement fails the read, while a valid unfilled order is absent from returned positions' TWAP data
### Requirement: TWAP time estimates disclose their slot assumption
The reader SHALL estimate future execution times from snapshot slot, due slot and cooldown using a documented nominal slot duration and response observation time. Completion SHALL derive from remaining executions, never expiry. Already-due schedules, invalidated completion schedules and unrepresentable estimates SHALL remain unavailable instead of predicting fills.
#### Scenario: Expiry before planned completion
- **WHEN** the scheduled final child lies beyond the order's expiry
- **THEN** the reader does not provide that schedule as an estimated completion time
@@ -0,0 +1,16 @@
# Tasks
## 1. Phoenix observations
- [x] 1.1 Add immutable TWAP data and paced snapshot decoding with precise sizes, identities, nullable marks and estimates; verify with focused temporary fixtures for dust, missing prices, errors and matching.
- [x] 1.2 Update Phoenix integration documentation for the implemented endpoint, units and timing/error behavior; verify against code and source contracts.
## 2. Evelyn and EMC
- [x] 2.1 Map TWAP data into immutable Evelyn position snapshots and render progressbar cells/tooltips; verify long durations, hidden orders, blank groups and retained failures in temporary service/UI checks.
- [x] 2.2 Update existing Evelyn and shared r35157_docs documentation to match the ninth column, visibility and estimate limitations; verify links and remove conflicting claims.
## 3. Integration verification
- [x] 3.1 Run the normal build, focused read-only live/fixture checks and JavaFX visual checks; record results and material limitations.
- [x] 3.2 Sync delta specs, validate the change and affected main specs, and inspect final diffs without committing or pushing.
@@ -0,0 +1,61 @@
# Verification — issue #101
Verified locally on 2026-10-24. No Hub restart, deployment, signing, trading,
commit or push was performed. Existing unrelated staged/deleted OpenSpec files
were preserved.
## Build
`JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build` succeeded.
All 42 existing tests passed, with zero failures/errors. No permanent tests were
added; focused harnesses and screenshots are temporary files under `/tmp`.
## Focused integration checks
`Issue101Check` passed 48 checks covering:
- Explicit, legacy, absent and invalid dust metadata; exact total/next/fill quantities.
- Positive fills required even on existing positions; no order-only synthetic rows.
- Same-symbol Cross/isolated matching, authority/PDA/asset mismatch and duplicate rejection.
- Invalid counters, completed observations, already-due timing, expiry before completion and overflow.
- Missing marks preserving token amounts and propagating unknown price-dependent totals.
- One TWAP and one mark request for two same-market positions; no extra reads for empty wallets.
- HTTP/TWAP failures retaining the complete prior venue observation, original timestamp and independent Jupiter refresh.
- Snapshot reads without I/O, recovery clearing absent/unfilled TWAPs, and shared 429 cooldown without immediate retry.
- Decoding captured real Phoenix data, including the BTC order with 236 smaller children out of 237 total executions.
## JavaFX and visual inspection
`Issue101Preview` passed 33 checks. Inspected the actual rendered table and tooltip:
nine columns; visible progress and overlaid `≈ 28:32:10` / `≈ 128:32:10`; blank
groups/Jupiter/non-TWAP cells; missing USD values; due/retained labels; tooltip fields;
clearing recycled cell graphics/tooltips; preserved selection/expansion; horizontal
scrolling at small widths. Temporary captures are under `/tmp/issue101-check/`.
## Read-only live check
At 2026-10-24 17:07:27–17:08:03 UTC, the real adapter returned 32 positions and 32
active filled TWAPs for the checked PROD wallet and no positions for TEST. Only GET
requests were used, with existing pacing/timeouts. Observed SOL order: 890/1779,
17.79 SOL total and 0.01 SOL next. Observed BTC order: 119/237, 0.0238 BTC total and
0.0001 BTC next. Marks were taken directly from Phoenix, with no Ticker dependency.
## Specifications and documentation
- All three deltas synced into existing main specs.
- Strict validation passed for `101-phoenix-twap-progress`, `phoenix-perps-service`,
`evelyn-perps-service` and `evelyn-perps-overview`.
- Project/shared documentation links and `git diff --check` passed.
- Checking the entire existing index still reports three trailing-blank-line warnings
in the unrelated, pre-existing staged issue #97 specs. The index check passes when
restricted to this change; those older files were not edited.
## Limits
The current nominal slot duration is 200 ms according to the Solana Foundation's
October 2026 update, superseding the old 400 ms assumption. Response receipt is only
an approximate time anchor. API lag, varying slot times and execution delays can
shift estimates, especially long completion estimates. Cell duration is explicitly
as of Updated and does not tick between snapshots. Progress counts child executions,
not guaranteed filled quantity. USD sizes are current approximate notionals before
fees. The existing polling cycle and live Hub were left unchanged.
+20 -2
View File
@@ -7,11 +7,11 @@ Let the owner inspect Evelyn perpetual positions grouped by wallet and venue in
## Requirements ## Requirements
### Requirement: Perps overview displays a collapsible position hierarchy ### Requirement: Perps overview displays a collapsible position hierarchy
The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value and uPnL in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios blank, and be expanded by default. The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios and TWAP blank, and be expanded by default.
#### Scenario: Open either environment's Perps tab #### Scenario: Open either environment's Perps tab
- **WHEN** the owner opens Perps in Production or Test - **WHEN** the owner opens Perps in Production or Test
- **THEN** all eight columns, root totals and the account-aware hierarchy are visible - **THEN** all nine columns, root totals and the account-aware hierarchy are available, with horizontal scrolling where required
#### Scenario: Collapse and expand groups #### Scenario: Collapse and expand groups
- **WHEN** the owner collapses and expands a wallet, venue or Cross account - **WHEN** the owner collapses and expands a wallet, venue or Cross account
@@ -89,3 +89,21 @@ EMC SHALL hide venue rows with neither positions nor Cross accounts and show the
- **WHEN** a childless venue has never been read successfully and its first read is pending or failed - **WHEN** a childless venue has never been read successfully and its first read is pending or failed
- **THEN** the heading is hidden and its read status is available on the wallet tooltip - **THEN** the heading is hidden and its read status is available on the wallet tooltip
- **AND** required totals remain unavailable rather than reporting zero - **AND** required totals remain unavailable rather than reporting zero
### Requirement: TWAP cells show execution progress and snapshot duration
Matching filled Phoenix TWAPs SHALL show a progressbar using executed/total child counts and an overlaid estimated duration to the next child in unbounded hours, minutes and seconds. Other cells SHALL be empty. Updates SHALL follow existing snapshots without a live timer. Failed retained observations SHALL be labeled retained; due or unknown schedules SHALL not show negative or wrapped times or imply execution.
#### Scenario: Long interval
- **WHEN** the snapshot's estimated wait is 28 hours 32 minutes 10 seconds, or 128 hours 32 minutes 10 seconds
- **THEN** the text is approximately 28:32:10 or 128:32:10 respectively
#### Scenario: Failed or due observation
- **WHEN** the venue read fails or the latest observed next slot is already due
- **THEN** the cell communicates retained or awaiting-update status without advancing progress
### Requirement: TWAP tooltip distinguishes estimates from fills
TWAP tooltips SHALL show executed/total progress, Estimated completion, total size, next child size, filled quantity and observation time. Sizes SHALL include their symbol and approximate USD values from Phoenix mark prices where available. Distinct regular/dust sizes and periodic nominal-slot timing assumptions SHALL be explained. Unknown values SHALL remain unknown; expiry SHALL never be presented as completion.
#### Scenario: Missing price or smaller next child
- **WHEN** the mark price is unavailable or the next child is a smaller rest order
- **THEN** token amounts remain visible with unknown USD values, or the next size reflects that rest order respectively
@@ -83,3 +83,15 @@ Each venue SHALL carry its last-success time and whether the latest attempt fail
#### Scenario: Retained last success #### Scenario: Retained last success
- **WHEN** a subsequent read fails - **WHEN** a subsequent read fails
- **THEN** its previous observations remain available with their original success time and failure indication - **THEN** its previous observations remain available with their original success time and failure indication
### Requirement: TWAP observations belong to existing filled positions
The service SHALL publish immutable TWAP observations only on matching existing Phoenix positions whose order has a positive actual fill and remaining executions. It SHALL preserve counters, quantities, nullable price and observation/estimated times without UI formatting. No new position SHALL be synthesized. TWAP errors SHALL retain the complete prior venue observation with failure status. Polling frequency SHALL remain unchanged.
#### Scenario: Unfilled order or no position
- **WHEN** an active TWAP has no actual fills or has no matching open position
- **THEN** it is absent from every overview row and cannot create a visible venue
#### Scenario: TWAP read fails
- **WHEN** the TWAP read fails after a successful venue observation
- **THEN** prior positions, TWAP observations and original timestamps remain with failed venue status
- **AND** other venues and wallets continue refreshing
+28 -4
View File
@@ -21,15 +21,20 @@ Position direction SHALL follow signed quantity. USD size SHALL be the absolute
- **THEN** it represents -1.25 base units, Short direction and 130.5625 USD notional - **THEN** it represents -1.25 base units, Short direction and 130.5625 USD notional
### Requirement: Read failures cannot masquerade as empty portfolios ### 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. 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. Present mark prices SHALL be positive and finite; an explicitly absent mark SHALL preserve positions and quantities with unavailable price-dependent valuations.
#### Scenario: Missing mark price or rejected access #### Scenario: Missing mark price
- **WHEN** a required price is missing or an HTTP request is rejected - **WHEN** a valid mark response has no mark price for a held position
- **THEN** the position and its TWAP quantities remain present with unknown USD valuations
- **AND** affected aggregate valuations remain unknown
#### Scenario: Rejected access or invalid price
- **WHEN** an HTTP request is rejected or a present mark price is invalid
- **THEN** the read fails instead of returning an empty or partial portfolio - **THEN** the read fails instead of returning an empty or partial portfolio
#### Scenario: Empty account #### Scenario: Empty account
- **WHEN** a valid trader snapshot has no subaccounts or no nonzero positions - **WHEN** a valid trader snapshot has no subaccounts or no nonzero positions
- **THEN** an empty result is returned without market-price requests - **THEN** an empty result is returned without market-price or TWAP requests
#### Scenario: Public API rate limit #### Scenario: Public API rate limit
- **WHEN** the shared adapter issues requests for PROD and TEST - **WHEN** the shared adapter issues requests for PROD and TEST
@@ -55,3 +60,22 @@ Equity SHALL include collateral, undiscounted price PnL and signed unsettled fun
- **WHEN** collateral is 100 USD, PnL 5 USD, unsettled funding -2 USD, and estimated exit fee 1 USD - **WHEN** collateral is 100 USD, PnL 5 USD, unsettled funding -2 USD, and estimated exit fee 1 USD
- **THEN** equity is 103 USD and estimated Value 102 USD - **THEN** equity is 103 USD and estimated Value 102 USD
- **AND** effective Cross leverage uses 103 USD as its denominator - **AND** effective Cross leverage uses 103 USD as its denominator
### Requirement: Read precise TWAP snapshots for held markets
The reader SHALL read official TWAP snapshots through its existing paced transport and associate orders by authority, PDA, subaccount and market identity. Only orders with actual fills and remaining children on an existing position SHALL be exposed. Quantities SHALL use exact lot conversion and explicit/legacy dust semantics. Unavailable size inputs SHALL remain unknown. Existing mark prices SHALL be reused without Ticker reads.
#### Scenario: Explicit and legacy dust
- **WHEN** total children are 5, regular size 10 lots and explicit dust count/size are 2/3
- **THEN** total size is 36 lots and the fourth child is 3 lots
- **AND** legacy zero dust count with size 3 instead represents four regular children and a final 3-lot child
#### Scenario: Identity mismatch or unfilled order
- **WHEN** authority/PDA/subaccount/asset identities disagree or an order has no fills
- **THEN** disagreement fails the read, while a valid unfilled order is absent from returned positions' TWAP data
### Requirement: TWAP time estimates disclose their slot assumption
The reader SHALL estimate future execution times from snapshot slot, due slot and cooldown using a documented nominal slot duration and response observation time. Completion SHALL derive from remaining executions, never expiry. Already-due schedules, invalidated completion schedules and unrepresentable estimates SHALL remain unavailable instead of predicting fills.
#### Scenario: Expiry before planned completion
- **WHEN** the scheduled final child lies beyond the order's expiry
- **THEN** the reader does not provide that schedule as an estimated completion time
@@ -4,11 +4,14 @@ import com.r35157.evelyn.service.perps.PerpsSnapshot;
import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction; import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction;
import com.r35157.evelyn.service.perps.PerpsSnapshot.Margin; import com.r35157.evelyn.service.perps.PerpsSnapshot.Margin;
import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics; import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics;
import com.r35157.evelyn.service.perps.PerpsSnapshot.Twap;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringWrapper; import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.control.Tab; import javafx.scene.control.Tab;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Tooltip; import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeItem; import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeTableCell; import javafx.scene.control.TreeTableCell;
@@ -17,12 +20,15 @@ import javafx.scene.control.TreeTableRow;
import javafx.scene.control.TreeTableView; import javafx.scene.control.TreeTableView;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color; import javafx.scene.paint.Color;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -87,6 +93,7 @@ final class PerpsTab {
+ "Isolated percentage = 100 × uPnL / positive Collateral. Cross position ROI convention " + "Isolated percentage = 100 × uPnL / positive Collateral. Cross position ROI convention "
+ "is unverified, so dollar PnL only. Group percentages are never summed or averaged. " + "is unverified, so dollar PnL only. Group percentages are never summed or averaged. "
+ "Rounded zero is neutral; unavailable is not zero.")); + "Rounded zero is neutral; unavailable is not zero."));
table.getColumns().add(twapColumn());
table.setColumnResizePolicy(TreeTableView.UNCONSTRAINED_RESIZE_POLICY); table.setColumnResizePolicy(TreeTableView.UNCONSTRAINED_RESIZE_POLICY);
table.setRowFactory(view -> new TreeTableRow<>() { table.setRowFactory(view -> new TreeTableRow<>() {
@Override @Override
@@ -145,6 +152,93 @@ final class PerpsTab {
return tooltip; return tooltip;
} }
private TreeTableColumn<PositionRow, PositionRow> twapColumn() {
var column = new TreeTableColumn<PositionRow, PositionRow>("TWAP");
column.setPrefWidth(170);
column.setMinWidth(155);
column.setSortable(false);
column.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getValue()));
column.setCellFactory(ignored -> new TreeTableCell<>() {
private final ProgressBar bar = new ProgressBar();
private final Label label = new Label();
private final StackPane graphic = new StackPane(bar, label);
{
bar.setMaxWidth(Double.MAX_VALUE);
bar.setPrefHeight(22);
bar.setStyle("-fx-accent: #75acd2;");
label.setStyle("-fx-text-fill: #20252b; -fx-font-weight: normal;");
label.setMouseTransparent(true);
graphic.prefWidthProperty().bind(widthProperty().subtract(12));
setStyle("-fx-alignment: CENTER;");
}
@Override
protected void updateItem(PositionRow row, boolean empty) {
super.updateItem(row, empty);
setText(null);
setGraphic(null);
setTooltip(null);
if (!empty && row != null && row.twap() != null) {
Twap twap = row.twap();
bar.setProgress(new BigDecimal(twap.executed())
.divide(new BigDecimal(twap.total()), 12, RoundingMode.HALF_EVEN).doubleValue());
label.setText(twapText(twap, row.retained()));
setGraphic(graphic);
setTooltip(tooltip(twapDetails(twap, row.retained()) + "\n\n" + row.notes()));
}
}
});
return column;
}
private static String twapText(Twap twap, boolean retained) {
if (retained) {
return "Retained";
}
if (twap.nextExecution() == null || !twap.nextExecution().isAfter(twap.observedAt())) {
return "Due / unknown";
}
long seconds = Duration.between(twap.observedAt(), twap.nextExecution()).getSeconds();
return String.format(Locale.ROOT, "≈ %d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60);
}
private static String twapDetails(Twap twap, boolean retained) {
BigDecimal percent = new BigDecimal(twap.executed()).multiply(BigDecimal.valueOf(100))
.divide(new BigDecimal(twap.total()), 1, RoundingMode.HALF_UP);
String details = "Progress: " + twap.executed() + "/" + twap.total() + " (" + percent + "%)"
+ "\nEstimated completion: " + timestamp(twap.estimatedCompletion())
+ "\nOrder size: " + twapSize(twap.orderQuantity(), twap)
+ "\nNext suborder size: " + twapSize(twap.nextQuantity(), twap)
+ "\nFilled: " + quantity(twap.filledQuantity()) + " " + twap.symbol()
+ "\nUpdated: " + timestamp(twap.observedAt());
if (twap.dustQuantity() != null) {
details += "\nRegular suborder: " + twapSize(twap.regularQuantity(), twap)
+ "\nSmaller rest suborder (dust): " + twapSize(twap.dustQuantity(), twap);
}
return details + "\n\n" + (retained ? "Latest read failed; retained observation. " : "")
+ "Time remaining is as of Updated; periodic snapshots, no live countdown. "
+ "Times assume nominal 200 ms slots anchored at receipt; API lag and execution delays can shift them. "
+ "Due / unknown means the next schedule is due or cannot be estimated; no fill is inferred. "
+ "An unavailable completion estimate is shown as —; expiry is not completion. "
+ "Progress counts child executions; Filled is the actual traded amount. "
+ "USD sizes use the latest observed Phoenix mark, excluding fees; future execution prices can differ.";
}
private static String twapSize(ΩAmountΩ amount, Twap twap) {
return quantity(amount) + " " + twap.symbol() + " ("
+ (amount == null || twap.markPrice() == null ? "USD unavailable"
: "≈ " + usd(amount.multiply(twap.markPrice()))) + ")";
}
private static String quantity(ΩAmountΩ amount) {
return amount == null ? "—" : amount.stripTrailingZeros().toPlainString();
}
private static String timestamp(Instant time) {
return time == null ? "—" : DateTimeFormatter.ofPattern("EEE uuuu/MM/dd HH:mm:ss z", Locale.ENGLISH)
.withZone(ZoneId.systemDefault()).format(time);
}
Tab tab() { Tab tab() {
return tab; return tab;
} }
@@ -186,17 +280,17 @@ final class PerpsTab {
for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) { for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) {
TreeItem<PositionRow> accountItem = item(existingPositions, new PositionRow( TreeItem<PositionRow> accountItem = item(existingPositions, new PositionRow(
account.id(), account.name(), null, null, true, account.metrics(), account.id(), account.name(), null, null, true, account.metrics(),
status + "\n" + account.notes())); status + "\n" + account.notes(), null, false));
var children = new ArrayList<TreeItem<PositionRow>>(); var children = new ArrayList<TreeItem<PositionRow>>();
var previous = childrenById(accountItem); var previous = childrenById(accountItem);
for (PerpsSnapshot.Position position : account.positions()) { for (PerpsSnapshot.Position position : account.positions()) {
children.add(item(previous, row(position, status))); children.add(item(previous, row(position, status, venue.failed())));
} }
updateChildren(accountItem, children); updateChildren(accountItem, children);
positions.add(accountItem); positions.add(accountItem);
} }
for (PerpsSnapshot.Position position : venue.positions()) { for (PerpsSnapshot.Position position : venue.positions()) {
positions.add(item(existingPositions, row(position, status))); positions.add(item(existingPositions, row(position, status, venue.failed())));
} }
updateChildren(venueItem, positions); updateChildren(venueItem, positions);
venues.add(venueItem); venues.add(venueItem);
@@ -226,13 +320,13 @@ final class PerpsTab {
: ". Periodic observation; state and reference prices can differ in time."); : ". Periodic observation; state and reference prices can differ in time.");
} }
private static PositionRow row(PerpsSnapshot.Position position, String status) { private static PositionRow row(PerpsSnapshot.Position position, String status, boolean retained) {
return new PositionRow(position.id(), position.instrument(), position.direction(), position.margin(), return new PositionRow(position.id(), position.instrument(), position.direction(), position.margin(),
false, position.metrics(), status + "\n" + position.notes()); false, position.metrics(), status + "\n" + position.notes(), position.twap(), retained);
} }
private static PositionRow group(String id, String label, Metrics metrics, String notes) { private static PositionRow group(String id, String label, Metrics metrics, String notes) {
return new PositionRow(id, label, null, null, false, metrics, notes); return new PositionRow(id, label, null, null, false, metrics, notes, null, false);
} }
private static String usd(ΩAmountΩ value) { private static String usd(ΩAmountΩ value) {
@@ -291,7 +385,7 @@ final class PerpsTab {
} }
private record PositionRow(String id, String label, Direction direction, Margin margin, private record PositionRow(String id, String label, Direction direction, Margin margin,
boolean crossAccount, Metrics metrics, String notes) { } boolean crossAccount, Metrics metrics, String notes, Twap twap, boolean retained) { }
private static final String TOTAL_NOTES = "Totals count isolated collateral/Value and each shared Cross " private static final String TOTAL_NOTES = "Totals count isolated collateral/Value and each shared Cross "
+ "account exactly once, including free capital in those accounts. Cross position requirements are " + "account exactly once, including free capital in those accounts. Cross position requirements are "
@@ -1,6 +1,7 @@
package com.r35157.evelyn.service.perps; package com.r35157.evelyn.service.perps;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
@@ -63,7 +64,11 @@ public record PerpsSnapshot(List<Wallet> wallets) {
} }
public record Position(String id, String instrument, Direction direction, Margin margin, public record Position(String id, String instrument, Direction direction, Margin margin,
Metrics metrics, String notes) { Metrics metrics, String notes, Twap twap) {
public Position(String id, String instrument, Direction direction, Margin margin, Metrics metrics, String notes) {
this(id, instrument, direction, margin, metrics, notes, null);
}
public Position { public Position {
Objects.requireNonNull(id, "id"); Objects.requireNonNull(id, "id");
Objects.requireNonNull(instrument, "instrument"); Objects.requireNonNull(instrument, "instrument");
@@ -74,6 +79,24 @@ public record PerpsSnapshot(List<Wallet> wallets) {
} }
} }
/** Active filled order; nullable quantities/prices/times are unavailable. Times are slot estimates. */
public record Twap(String symbol, BigInteger executed, BigInteger total, ΩAmountΩ orderQuantity,
ΩAmountΩ nextQuantity, ΩAmountΩ regularQuantity, ΩAmountΩ dustQuantity,
ΩAmountΩ filledQuantity, ΩPriceΩ markPrice, Instant nextExecution,
Instant estimatedCompletion, Instant observedAt) {
public Twap {
Objects.requireNonNull(symbol, "symbol");
Objects.requireNonNull(executed, "executed");
Objects.requireNonNull(total, "total");
Objects.requireNonNull(filledQuantity, "filledQuantity");
Objects.requireNonNull(observedAt, "observedAt");
if (executed.signum() < 0 || total.signum() <= 0 || executed.compareTo(total) >= 0
|| filledQuantity.signum() <= 0 || markPrice != null && markPrice.signum() <= 0) {
throw new IllegalArgumentException("Invalid active filled TWAP observation");
}
}
}
/** USD monetary values; leverage is a ratio, pnlPercent is in percentage points. */ /** USD monetary values; leverage is a ratio, pnlPercent is in percentage points. */
public record Metrics(ΩAmountΩ collateralUsd, BigDecimal leverage, ΩAmountΩ exposureUsd, public record Metrics(ΩAmountΩ collateralUsd, BigDecimal leverage, ΩAmountΩ exposureUsd,
ΩAmountΩ valueUsd, ΩAmountΩ pnlUsd, BigDecimal pnlPercent) { ΩAmountΩ valueUsd, ΩAmountΩ pnlUsd, BigDecimal pnlPercent) {
@@ -148,11 +148,16 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
Direction direction = position.quantity().signum() > 0 ? Direction.LONG : Direction.SHORT; Direction direction = position.quantity().signum() > 0 ? Direction.LONG : Direction.SHORT;
boolean cross = account.cross(); boolean cross = account.cross();
ΩAmountΩ collateral = cross ? position.initialMarginUsd() : account.collateralUsd(); ΩAmountΩ collateral = cross ? position.initialMarginUsd() : account.collateralUsd();
var order = position.twap();
PerpsSnapshot.Twap twap = order == null ? null : new PerpsSnapshot.Twap(position.symbol(),
order.executed(), order.total(), order.orderQuantity(), order.nextQuantity(),
order.regularQuantity(), order.dustQuantity(), order.filledQuantity(), order.markPrice(),
order.nextExecution(), order.estimatedCompletion(), order.observedAt());
return new Position(id, instrument, direction, cross ? Margin.CROSS : Margin.ISOLATED, return new Position(id, instrument, direction, cross ? Margin.CROSS : Margin.ISOLATED,
new Metrics(collateral, cross ? null : PerpsSnapshot.ratio(position.sizeUsd(), collateral), new Metrics(collateral, cross ? null : PerpsSnapshot.ratio(position.sizeUsd(), collateral),
position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(), position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(),
cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)), cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)),
cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES); cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES, twap);
} }
@FunctionalInterface @FunctionalInterface
@@ -6,18 +6,27 @@ import java.util.Objects;
/** /**
* A nonzero Phoenix position. Quantity is signed base units, not raw lots; sizeUsd is * 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. * absolute current notional (abs(quantity) * mark price), not collateral or entry value.
* Identity within a wallet is traderPdaIndex + subaccountIndex + symbol, independent of * Missing mark price leaves price-dependent metrics null. Identity within a wallet is
* traderPdaIndex + subaccountIndex + symbol, independent of
* changing sequence numbers. Prices and trader state are separate REST snapshots. * changing sequence numbers. Prices and trader state are separate REST snapshots.
*/ */
public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol, public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol,
ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd, ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd,
ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd) { ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd,
PhoenixTwapOrder twap) {
public PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol,
ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd,
ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd) {
this(traderPdaIndex, subaccountIndex, symbol, quantity, sizeUsd, initialMarginUsd,
pnlUsd, fundingUsd, closeFeeUsd, null);
}
public PhoenixPerpsPosition { public PhoenixPerpsPosition {
Objects.requireNonNull(symbol, "symbol"); Objects.requireNonNull(symbol, "symbol");
Objects.requireNonNull(quantity, "quantity"); Objects.requireNonNull(quantity, "quantity");
Objects.requireNonNull(sizeUsd, "sizeUsd");
if (traderPdaIndex < 0 || subaccountIndex < 0 || symbol.isBlank() if (traderPdaIndex < 0 || subaccountIndex < 0 || symbol.isBlank()
|| !symbol.equals(symbol.strip()) || quantity.signum() == 0 || sizeUsd.signum() <= 0) { || !symbol.equals(symbol.strip()) || quantity.signum() == 0
|| sizeUsd != null && sizeUsd.signum() <= 0) {
throw new IllegalArgumentException("Invalid Phoenix position"); throw new IllegalArgumentException("Invalid Phoenix position");
} }
} }
@@ -19,7 +19,9 @@ public interface PhoenixPerpsService extends NenjimComponent {
return getAccounts(authority).stream().flatMap(account -> account.positions().stream()).toList(); return getAccounts(authority).stream().flatMap(account -> account.positions().stream()).toList();
} }
/** Complete account observations, including balances when the last Cross position has closed. */ /** Complete account observations, including balances when the last Cross position has closed.
* Filled active TWAPs are attached only to existing positions. A TWAP read failure fails the
* complete observation; a valid absent mark leaves price-dependent metrics unavailable. */
@NotNull List<PhoenixPerpsAccount> getAccounts(@NotNull ΩSolanaWalletIdΩ authority) @NotNull List<PhoenixPerpsAccount> getAccounts(@NotNull ΩSolanaWalletIdΩ authority)
throws IOException, InterruptedException; throws IOException, InterruptedException;
} }
@@ -0,0 +1,24 @@
package com.r35157.libs.phoenix.perps;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Objects;
/** Filled, active TWAP attached to a held position. Quantities are base units; null means unknown.
* Times use the nominal 200 ms slot estimate anchored at observedAt, not an execution guarantee. */
public record PhoenixTwapOrder(BigInteger executed, BigInteger total, ΩAmountΩ orderQuantity,
ΩAmountΩ nextQuantity, ΩAmountΩ regularQuantity, ΩAmountΩ dustQuantity,
ΩAmountΩ filledQuantity, ΩPriceΩ markPrice, Instant nextExecution,
Instant estimatedCompletion, Instant observedAt) {
public PhoenixTwapOrder {
Objects.requireNonNull(executed, "executed");
Objects.requireNonNull(total, "total");
Objects.requireNonNull(filledQuantity, "filledQuantity");
Objects.requireNonNull(observedAt, "observedAt");
if (executed.signum() < 0 || total.signum() <= 0 || executed.compareTo(total) >= 0
|| filledQuantity.signum() <= 0 || markPrice != null && markPrice.signum() <= 0) {
throw new IllegalArgumentException("Invalid active filled TWAP");
}
}
}
@@ -7,6 +7,7 @@ import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition; import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition;
import com.r35157.libs.phoenix.perps.PhoenixPerpsAccount; import com.r35157.libs.phoenix.perps.PhoenixPerpsAccount;
import com.r35157.libs.phoenix.perps.PhoenixPerpsService; import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
import com.r35157.libs.phoenix.perps.PhoenixTwapOrder;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.IOException; import java.io.IOException;
@@ -108,6 +109,9 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
for (String symbol : symbolsNeeded.stream().sorted().toList()) { for (String symbol : symbolsNeeded.stream().sorted().toList()) {
prices.put(symbol, markPrice(symbol)); prices.put(symbol, markPrice(symbol));
} }
var twaps = symbolsNeeded.isEmpty() ? Map.<String, PhoenixTwapOrder>of()
: PhoenixTwapDecoder.decode(get("v1/trader/" + authority + "/twap?traderPdaIndex=0"),
authority, rawAccounts, markets, prices, Instant.now());
BigDecimal feeMultiplier = optionalDecimal(snapshot, "takerFeeOverrideMultiplier"); BigDecimal feeMultiplier = optionalDecimal(snapshot, "takerFeeOverrideMultiplier");
if (feeMultiplier != null && feeMultiplier.signum() < 0) { if (feeMultiplier != null && feeMultiplier.signum() < 0) {
throw invalid("Negative REST fee override multiplier"); throw invalid("Negative REST fee override multiplier");
@@ -132,18 +136,18 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
} }
ΩAmountΩ quantity = new BigDecimal(lots).scaleByPowerOfTen(-exponent); ΩAmountΩ quantity = new BigDecimal(lots).scaleByPowerOfTen(-exponent);
ΩPriceΩ price = prices.get(symbol); ΩPriceΩ price = prices.get(symbol);
ΩAmountΩ notional = quantity.abs().multiply(price); ΩAmountΩ notional = price == null ? null : quantity.abs().multiply(price);
ΩAmountΩ quote = optionalQuote(raw, "virtualQuotePositionLots"); ΩAmountΩ quote = optionalQuote(raw, "virtualQuotePositionLots");
ΩAmountΩ pnl = quote == null ? null : quantity.multiply(price).add(quote); ΩAmountΩ pnl = quote == null || price == null ? null : quantity.multiply(price).add(quote);
BigDecimal feeRate = optionalDecimal(market, "takerFee"); BigDecimal feeRate = optionalDecimal(market, "takerFee");
if (feeRate != null && feeRate.signum() < 0) { if (feeRate != null && feeRate.signum() < 0) {
throw invalid("Negative taker fee"); throw invalid("Negative taker fee");
} }
ΩAmountΩ closeFee = feeRate == null || feeMultiplier == null ? null ΩAmountΩ closeFee = notional == null || feeRate == null || feeMultiplier == null ? null
: notional.multiply(feeRate).multiply(feeMultiplier).setScale(6, RoundingMode.CEILING); : notional.multiply(feeRate).multiply(feeMultiplier).setScale(6, RoundingMode.CEILING);
positions.add(new PhoenixPerpsPosition(0, index, symbol, quantity, notional, positions.add(new PhoenixPerpsPosition(0, index, symbol, quantity, notional,
initialMargin(market, lots.abs(), price, exponent), pnl, initialMargin(market, lots.abs(), price, exponent), pnl,
optionalQuote(raw, "unsettledFundingQuoteLots"), closeFee)); optionalQuote(raw, "unsettledFundingQuoteLots"), closeFee, twaps.get(index + ":" + symbol)));
} }
positions.sort(Comparator.comparing(PhoenixPerpsPosition::symbol)); positions.sort(Comparator.comparing(PhoenixPerpsPosition::symbol));
ΩAmountΩ collateral = optionalQuote(rawAccount, "collateral"); ΩAmountΩ collateral = optionalQuote(rawAccount, "collateral");
@@ -183,7 +187,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
/** SDK: floor price to ticks, truncate interpolated whole leverage, ceil required quote lots. */ /** SDK: floor price to ticks, truncate interpolated whole leverage, ceil required quote lots. */
private static ΩAmountΩ initialMargin(JsonNode market, BigInteger size, ΩPriceΩ price, int exponent) private static ΩAmountΩ initialMargin(JsonNode market, BigInteger size, ΩPriceΩ price, int exponent)
throws IOException { throws IOException {
if (!market.hasNonNull("tickSize") || !market.hasNonNull("leverageTiers")) { if (price == null || !market.hasNonNull("tickSize") || !market.hasNonNull("leverageTiers")) {
return null; return null;
} }
BigDecimal tickDecimal = decimal(market, "tickSize"); BigDecimal tickDecimal = decimal(market, "tickSize");
@@ -270,6 +274,9 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
if (!symbol.equals(text(response, "symbol"))) { if (!symbol.equals(text(response, "symbol"))) {
throw invalid("Mark price symbol does not match " + symbol); throw invalid("Mark price symbol does not match " + symbol);
} }
if (!response.hasNonNull("markPrice")) {
return null;
}
JsonNode price = required(required(response, "markPrice"), "price"); JsonNode price = required(required(response, "markPrice"), "price");
// Jackson parses JSON decimals directly as BigDecimal; never go through double. // Jackson parses JSON decimals directly as BigDecimal; never go through double.
if (!price.isNumber() || price.decimalValue().signum() <= 0) { if (!price.isNumber() || price.decimalValue().signum() <= 0) {
@@ -0,0 +1,169 @@
package com.r35157.libs.phoenix.perps.impl.ref;
import com.fasterxml.jackson.databind.JsonNode;
import com.r35157.libs.phoenix.perps.PhoenixTwapOrder;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.DateTimeException;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
/** Decodes only existing positions' active, filled orders. SDK snapshot counts include dust. */
final class PhoenixTwapDecoder {
static Map<String, PhoenixTwapOrder> decode(JsonNode snapshot, String authority,
List<JsonNode> accounts, Map<String, JsonNode> markets, Map<String, BigDecimal> prices,
Instant observedAt) throws IOException {
if (!authority.equals(text(snapshot, "authority")) || natural(snapshot, "traderPdaIndex").signum() != 0) {
throw invalid("TWAP authority/PDA mismatch");
}
BigInteger slot = natural(snapshot, "slot");
var held = new HashSet<String>();
var knownSubaccounts = new HashSet<Integer>();
for (JsonNode account : accounts) {
int subaccount = natural(account, "subaccountIndex").intValueExact();
knownSubaccounts.add(subaccount);
for (JsonNode position : account.path("positions")) {
if (new BigInteger(position.path("basePositionLots").textValue()).signum() != 0) {
held.add(subaccount + ":" + text(position, "symbol"));
}
}
}
var byAsset = new HashMap<BigInteger, JsonNode>();
for (JsonNode market : markets.values()) {
if (byAsset.put(natural(market, "assetId"), market) != null) {
throw invalid("Duplicate market assetId");
}
}
JsonNode entries = snapshot.get("accounts");
if (entries == null || !entries.isArray()) {
throw invalid("Missing TWAP accounts array");
}
var seen = new HashSet<String>();
var result = new HashMap<String, PhoenixTwapOrder>();
for (JsonNode entry : entries) {
int subaccount = natural(entry, "traderSubaccountIndex").intValueExact();
BigInteger asset = natural(entry, "assetId");
if (!seen.add(subaccount + ":" + asset)) {
throw invalid("Duplicate TWAP subaccount/asset");
}
// Optional means absent, not null or an invalid object.
if (!entry.has("order")) {
continue;
}
JsonNode order = entry.get("order");
if (!knownSubaccounts.contains(subaccount) || !authority.equals(text(order, "traderAuthority"))
|| natural(order, "traderPdaIndex").signum() != 0
|| !natural(order, "traderSubaccountIndex").equals(BigInteger.valueOf(subaccount))
|| !natural(order, "assetId").equals(asset)) {
throw invalid("TWAP order identity mismatch");
}
JsonNode market = byAsset.get(asset);
String key = market == null ? null : subaccount + ":" + text(market, "symbol");
if (!held.contains(key)) {
continue;
}
BigInteger filled = natural(order, "baseLotsFilled");
BigInteger executed = natural(order, "childOrdersExecuted");
BigInteger total = natural(order, "nChildOrders");
BigInteger remaining = natural(order, "childOrdersRemaining");
if (total.signum() <= 0 || !executed.add(remaining).equals(total)) {
throw invalid("Inconsistent TWAP execution counts");
}
if (filled.signum() == 0 || remaining.signum() == 0) {
continue;
}
int exponent = market.path("baseLotsDecimals").intValue(); // validated for held positions by reader
if (exponent < -18 || exponent > 18 || !market.path("baseLotsDecimals").isIntegralNumber()) {
throw invalid("Invalid TWAP base-lot exponent");
}
BigInteger regular = optionalNatural(order, "numBaseLots");
BigInteger dustSize = optionalNatural(order, "dustOrderSize");
BigInteger dustCount = optionalNatural(order, "nDustOrders");
BigDecimal orderQuantity = null;
BigDecimal nextQuantity = null;
BigDecimal regularQuantity = null;
BigDecimal dustQuantity = null;
// Older servers omit dust metadata: retain independent counters/fills, leave sizes unknown.
if (regular != null && dustSize != null) {
if (dustCount == null) {
dustCount = BigInteger.ZERO; // legacy final-child dust
}
if (dustCount.signum() == 0 && dustSize.signum() > 0) {
dustCount = BigInteger.ONE;
}
if (regular.signum() <= 0 || dustCount.compareTo(total) > 0
|| dustCount.signum() > 0 && (dustSize.signum() <= 0 || dustSize.compareTo(regular) >= 0)) {
throw invalid("Invalid TWAP dust distribution");
}
BigInteger regularCount = total.subtract(dustCount);
orderQuantity = units(regular.multiply(regularCount).add(dustSize.multiply(dustCount)), exponent);
nextQuantity = units(executed.compareTo(regularCount) < 0 ? regular : dustSize, exponent);
regularQuantity = regularCount.signum() > 0 ? units(regular, exponent) : null;
dustQuantity = dustCount.signum() > 0 ? units(dustSize, exponent) : null;
}
BigInteger due = natural(order, "dueSlot");
BigInteger cooldown = natural(order, "cooldownSlots");
BigInteger expiry = natural(order, "lastValidSlot");
BigInteger finalSlot = due.add(remaining.subtract(BigInteger.ONE).multiply(cooldown));
Instant next = due.compareTo(expiry) > 0 ? null : estimate(observedAt, due.subtract(slot));
Instant completion = due.compareTo(slot) <= 0 || finalSlot.compareTo(expiry) > 0
? null : estimate(observedAt, finalSlot.subtract(slot));
result.put(key, new PhoenixTwapOrder(executed, total, orderQuantity, nextQuantity,
regularQuantity, dustQuantity, units(filled, exponent), prices.get(text(market, "symbol")),
next, completion, observedAt));
}
return Map.copyOf(result);
}
private static Instant estimate(Instant observedAt, BigInteger slots) {
if (slots.signum() <= 0) {
return null;
}
try {
// Nominal slot duration; receipt time is only an approximate anchor. See docs/PhoenixPerps.md.
return observedAt.plusMillis(slots.multiply(BigInteger.valueOf(200)).longValueExact());
} catch (ArithmeticException | DateTimeException exception) {
return null;
}
}
private static BigDecimal units(BigInteger lots, int exponent) {
return new BigDecimal(lots).scaleByPowerOfTen(-exponent);
}
private static BigInteger natural(JsonNode object, String field) throws IOException {
BigInteger value = optionalNatural(object, field);
if (value == null) {
throw invalid("Missing TWAP field " + field);
}
return value;
}
private static BigInteger optionalNatural(JsonNode object, String field) throws IOException {
JsonNode value = object.get(field);
if (value == null || value.isNull()) {
return null;
}
if (!value.isIntegralNumber() || value.bigIntegerValue().signum() < 0) {
throw invalid("Invalid TWAP integer " + field);
}
return value.bigIntegerValue();
}
private static String text(JsonNode object, String field) throws IOException {
JsonNode value = object.get(field);
if (value == null || !value.isTextual() || value.textValue().isBlank()) {
throw invalid("Invalid TWAP text " + field);
}
return value.textValue();
}
private static IOException invalid(String message) {
return new IOException("Invalid Phoenix response: " + message);
}
}