diff --git a/docs/Evelyn.md b/docs/Evelyn.md index 09fa7e7..2a0bdb1 100644 --- a/docs/Evelyn.md +++ b/docs/Evelyn.md @@ -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. 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. -unikt åbent marked. Begge har 10 sekunders request-timeout; Phoenix bevarer fælles +ét REST-opslag pr. wallet. Phoenix bruger trader-state, metadata, én mark price pr. +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 é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 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 → position; Phoenix Cross har wallet → venue → Cross → position. Positionens konto-ID 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, 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 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 diff --git a/docs/PhoenixPerps.md b/docs/PhoenixPerps.md index e3702ef..6c46145 100644 --- a/docs/PhoenixPerps.md +++ b/docs/PhoenixPerps.md @@ -1,6 +1,6 @@ # 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`. 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 @@ -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. 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. + 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 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 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 @@ -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. 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 +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 a failed refresh; cell tooltips include the last-success time. Childless venues are hidden, with failed or pending first-read status available in their wallet's tooltip. Such unknown scopes cannot contribute fabricated zero totals. Trader PDA indices other than 0, streaming and position management are out of scope. +## 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 diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/.openspec.yaml b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/design.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/design.md new file mode 100644 index 0000000..90bce15 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/design.md @@ -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. diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/proposal.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/proposal.md new file mode 100644 index 0000000..d584a62 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-overview/spec.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-overview/spec.md new file mode 100644 index 0000000..edabb57 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-overview/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-service/spec.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-service/spec.md new file mode 100644 index 0000000..ea1bca1 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/evelyn-perps-service/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/phoenix-perps-service/spec.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/phoenix-perps-service/spec.md new file mode 100644 index 0000000..176f8d2 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/specs/phoenix-perps-service/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/tasks.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/tasks.md new file mode 100644 index 0000000..cef9486 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/tasks.md @@ -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. diff --git a/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/verification.md b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/verification.md new file mode 100644 index 0000000..0604278 --- /dev/null +++ b/openspec/changes/archive/2026-10-24-101-phoenix-twap-progress/verification.md @@ -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. diff --git a/openspec/specs/evelyn-perps-overview/spec.md b/openspec/specs/evelyn-perps-overview/spec.md index 78a4b3a..484c78c 100644 --- a/openspec/specs/evelyn-perps-overview/spec.md +++ b/openspec/specs/evelyn-perps-overview/spec.md @@ -7,11 +7,11 @@ Let the owner inspect Evelyn perpetual positions grouped by wallet and venue in ## Requirements ### 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 - **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 - **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 - **THEN** the heading is hidden and its read status is available on the wallet tooltip - **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 diff --git a/openspec/specs/evelyn-perps-service/spec.md b/openspec/specs/evelyn-perps-service/spec.md index 48a46ec..a5c196d 100644 --- a/openspec/specs/evelyn-perps-service/spec.md +++ b/openspec/specs/evelyn-perps-service/spec.md @@ -83,3 +83,15 @@ Each venue SHALL carry its last-success time and whether the latest attempt fail #### Scenario: Retained last success - **WHEN** a subsequent read fails - **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 diff --git a/openspec/specs/phoenix-perps-service/spec.md b/openspec/specs/phoenix-perps-service/spec.md index 15ff801..89eba23 100644 --- a/openspec/specs/phoenix-perps-service/spec.md +++ b/openspec/specs/phoenix-perps-service/spec.md @@ -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 ### 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 -- **WHEN** a required price is missing or an HTTP request is rejected +#### 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 requests +- **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 @@ -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 - **THEN** equity is 103 USD and estimated Value 102 USD - **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 diff --git a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava index e6d703b..0ee1683 100644 --- a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava +++ b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava @@ -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.Margin; import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics; +import com.r35157.evelyn.service.perps.PerpsSnapshot.Twap; import javafx.application.Platform; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.ReadOnlyStringWrapper; import javafx.geometry.Insets; import javafx.scene.control.Tab; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; import javafx.scene.control.Tooltip; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeTableCell; @@ -17,12 +20,15 @@ import javafx.scene.control.TreeTableRow; import javafx.scene.control.TreeTableView; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.NumberFormat; import java.time.ZoneId; +import java.time.Duration; +import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; @@ -87,6 +93,7 @@ final class PerpsTab { + "Isolated percentage = 100 × uPnL / positive Collateral. Cross position ROI convention " + "is unverified, so dollar PnL only. Group percentages are never summed or averaged. " + "Rounded zero is neutral; unavailable is not zero.")); + table.getColumns().add(twapColumn()); table.setColumnResizePolicy(TreeTableView.UNCONSTRAINED_RESIZE_POLICY); table.setRowFactory(view -> new TreeTableRow<>() { @Override @@ -145,6 +152,93 @@ final class PerpsTab { return tooltip; } + private TreeTableColumn twapColumn() { + var column = new TreeTableColumn("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() { return tab; } @@ -186,17 +280,17 @@ final class PerpsTab { for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) { TreeItem accountItem = item(existingPositions, new PositionRow( account.id(), account.name(), null, null, true, account.metrics(), - status + "\n" + account.notes())); + status + "\n" + account.notes(), null, false)); var children = new ArrayList>(); var previous = childrenById(accountItem); 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); positions.add(accountItem); } 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); venues.add(venueItem); @@ -226,13 +320,13 @@ final class PerpsTab { : ". 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(), - 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) { - 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) { @@ -291,7 +385,7 @@ final class PerpsTab { } 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 " + "account exactly once, including free capital in those accounts. Cross position requirements are " diff --git a/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava index 6a13e8f..7342456 100644 --- a/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava +++ b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava @@ -1,6 +1,7 @@ package com.r35157.evelyn.service.perps; import java.math.BigDecimal; +import java.math.BigInteger; import java.math.RoundingMode; import java.time.Instant; import java.util.ArrayList; @@ -63,7 +64,11 @@ public record PerpsSnapshot(List wallets) { } 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 { Objects.requireNonNull(id, "id"); Objects.requireNonNull(instrument, "instrument"); @@ -74,6 +79,24 @@ public record PerpsSnapshot(List 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. */ public record Metrics(ΩAmountΩ collateralUsd, BigDecimal leverage, ΩAmountΩ exposureUsd, ΩAmountΩ valueUsd, ΩAmountΩ pnlUsd, BigDecimal pnlPercent) { diff --git a/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava b/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava index aa8a7d4..bbc48e7 100644 --- a/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava +++ b/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava @@ -148,11 +148,16 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP Direction direction = position.quantity().signum() > 0 ? Direction.LONG : Direction.SHORT; boolean cross = account.cross(); Ω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, new Metrics(collateral, cross ? null : PerpsSnapshot.ratio(position.sizeUsd(), collateral), position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(), cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)), - cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES); + cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES, twap); } @FunctionalInterface diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava index ee8a048..d36673e 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava @@ -6,18 +6,27 @@ 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 + * 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. */ public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol, Ω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 { 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) { + || !symbol.equals(symbol.strip()) || quantity.signum() == 0 + || sizeUsd != null && sizeUsd.signum() <= 0) { throw new IllegalArgumentException("Invalid Phoenix position"); } } diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsService.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsService.tjava index 2cf18de..f9bd97d 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsService.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsService.tjava @@ -19,7 +19,9 @@ public interface PhoenixPerpsService extends NenjimComponent { 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 getAccounts(@NotNull ΩSolanaWalletIdΩ authority) throws IOException, InterruptedException; } diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixTwapOrder.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixTwapOrder.tjava new file mode 100644 index 0000000..41686c3 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixTwapOrder.tjava @@ -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"); + } + } +} diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava index 15ffc07..b7e7603 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava @@ -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.PhoenixPerpsAccount; import com.r35157.libs.phoenix.perps.PhoenixPerpsService; +import com.r35157.libs.phoenix.perps.PhoenixTwapOrder; import org.jetbrains.annotations.NotNull; import java.io.IOException; @@ -108,6 +109,9 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { for (String symbol : symbolsNeeded.stream().sorted().toList()) { prices.put(symbol, markPrice(symbol)); } + var twaps = symbolsNeeded.isEmpty() ? Map.of() + : PhoenixTwapDecoder.decode(get("v1/trader/" + authority + "/twap?traderPdaIndex=0"), + authority, rawAccounts, markets, prices, Instant.now()); BigDecimal feeMultiplier = optionalDecimal(snapshot, "takerFeeOverrideMultiplier"); if (feeMultiplier != null && feeMultiplier.signum() < 0) { throw invalid("Negative REST fee override multiplier"); @@ -132,18 +136,18 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { } ΩAmountΩ quantity = new BigDecimal(lots).scaleByPowerOfTen(-exponent); Ω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Ω 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"); if (feeRate != null && feeRate.signum() < 0) { 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); positions.add(new PhoenixPerpsPosition(0, index, symbol, quantity, notional, 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)); Ω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. */ private static ΩAmountΩ initialMargin(JsonNode market, BigInteger size, ΩPriceΩ price, int exponent) throws IOException { - if (!market.hasNonNull("tickSize") || !market.hasNonNull("leverageTiers")) { + if (price == null || !market.hasNonNull("tickSize") || !market.hasNonNull("leverageTiers")) { return null; } BigDecimal tickDecimal = decimal(market, "tickSize"); @@ -270,6 +274,9 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { if (!symbol.equals(text(response, "symbol"))) { throw invalid("Mark price symbol does not match " + symbol); } + if (!response.hasNonNull("markPrice")) { + return null; + } 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) { diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixTwapDecoder.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixTwapDecoder.tjava new file mode 100644 index 0000000..b81f898 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixTwapDecoder.tjava @@ -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 decode(JsonNode snapshot, String authority, + List accounts, Map markets, Map 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(); + var knownSubaccounts = new HashSet(); + 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(); + 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(); + var result = new HashMap(); + 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); + } +}