103: EMC Perps: propagate Phoenix TWAP warnings through the tree to Perpetual Contracts
This commit is contained in:
+35
-5
@@ -444,9 +444,11 @@ 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, én mark price pr.
|
||||
unikt åbent marked, ét TWAP-opslag, når der er positioner, og et risikolæs pr. synlig
|
||||
konto. Lokal trader-adresseafledning bruger den eksisterende Solana-afhængighed.
|
||||
ét REST-opslag pr. wallet. Phoenix bruger trader-state og ét TWAP-opslag for wallets
|
||||
med eksisterende subaccounts, også uden åbne positioner. Metadata hentes ved positioner eller fyldte
|
||||
TWAP-ordrer med resterende delordrer; én mark price hentes pr. unikt åbent marked,
|
||||
og der foretages et risikolæs pr. synlig konto. Lokal trader-adresseafledning bruger
|
||||
den eksisterende Solana-afhængighed.
|
||||
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.
|
||||
@@ -463,7 +465,7 @@ EMC viser **Position | Risk | Direction | Margin | Collateral | Leverage | Expos
|
||||
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
|
||||
hidtidige konfiguration med Jupiter før Phoenix. En venue uden positioner eller Cross-konti
|
||||
hidtidige konfiguration med Jupiter før Phoenix. En venue uden positioner, Cross-konti eller TWAP-advarsler
|
||||
skjules og vises igen, når der kommer indhold. Skjulte venues indgår fortsat i snapshot
|
||||
og totaler; deres læsestatus findes på wallet-rækkens tooltip. En Cross-konto med kapital
|
||||
holder derfor Phoenix synlig, selv uden åbne positioner. Eksisterende foldestatus, markering og
|
||||
@@ -471,7 +473,7 @@ rækkeobjekter bevares; kun ændrede rækkeværdier eller børn opdateres, også
|
||||
ældes i et ellers uændret snapshot. 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
|
||||
TWAP-progress vises 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`
|
||||
@@ -519,6 +521,34 @@ EMC spørger hver miljøservice straks ved åbning og hvert tredje sekund uden a
|
||||
JavaFX-tråden. Skjul/luk stopper kun EMC-polling. Der er ingen sample-data, Refresh-knap,
|
||||
handel, signering, kontoaktivering eller automatisk genstart af Hub'en i denne læsevej.
|
||||
|
||||
### TWAP-advarsler
|
||||
|
||||
En fyldt Phoenix-TWAP med resterende delordrer, som mangler markedsmetadata eller
|
||||
ikke matcher en åben position, bevares som en advarsel. TWAP-cellen viser en lille
|
||||
orange trekant med udråbstegn og kort tekst. Advarslen står på positionen, når
|
||||
ordren sikkert kan tilknyttes den; ellers på den ejende Cross-gruppe. Gruppen
|
||||
bevares også uden kapital eller positioner. En umatchet isoleret ordre står på
|
||||
Phoenix med sit korrekte subaccount i tooltip, aldrig på en opdigtet Cross-konto.
|
||||
En matchet ordre uden brugbart estimat for næste udførelse viser en timing-advarsel
|
||||
sammen med sin progressbar; det er ikke bevis på, at udførelsen er stoppet.
|
||||
|
||||
Advarsler samles op gennem **position → Cross → Phoenix → wallet → Perpetual
|
||||
Contracts**, eller den faktiske isolerede gren uden Cross. Hvert niveau tæller
|
||||
hver underliggende ordre én gang, også når undergrene er foldet sammen. Flere
|
||||
ordrer vises med antal. Tooltip kan rulles og bliver stående, mens markøren er over
|
||||
cellen eller detaljerne. Det identificerer wallet, konto/position, årsag,
|
||||
TWAP-adresse, asset/subaccount, rå tællere/fills og oprindeligt observationstidspunkt.
|
||||
Ukendte symboler, enheder, dollarværdier og tider opfindes ikke. Marginrisiko og
|
||||
kapitaltotaler ændres ikke af advarslerne.
|
||||
|
||||
Ordren skal stadig selv have faktiske fills og resterende delordrer. Ufyldte og
|
||||
færdige ordrer er skjult, også i gruppeoptællinger. Succesfulde opslag rydder løste
|
||||
advarsler helt op gennem træet og bevarer de øvrige. Fejl bevarer tidligere data og
|
||||
markerer berørte advarsler og opsummeringer `retained`; efter fem minutter eller ved
|
||||
fremtidigt tidsstempel markeres de `stale`. Originale tider bevares, også ved fejl i
|
||||
EMC's snapshot-læsning. Alderskontrol bruger eksisterende UI-polling; fem minutter
|
||||
er en datakvalitetsgrænse, ikke en udførelsesfrist. Se [Phoenix-læsningen](PhoenixPerps.md#twap-observations).
|
||||
|
||||
### Risiko i Perps
|
||||
|
||||
Risk står ved positionsnavnet og bruger tekst og en statusprik, mens Long/Short-
|
||||
|
||||
+32
-18
@@ -1,6 +1,6 @@
|
||||
# Phoenix Perps read-only integration
|
||||
|
||||
`PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition`, `PhoenixAccountRisk` and `PhoenixTwapOrder` live in
|
||||
`PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition`, `PhoenixAccountRisk`, `PhoenixTwapOrder` and `PhoenixTwapWarning` 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
|
||||
@@ -31,19 +31,21 @@ The implementation uses these official endpoints at `https://perp-api.phoenix.tr
|
||||
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).
|
||||
supplies TWAP snapshots for every wallet with existing subaccounts, including accounts
|
||||
without positions. Filled outstanding orders become matched progress or warning observations;
|
||||
zero-fill and completed orders remain invisible. See [TWAP observations](#twap-observations).
|
||||
5. [`GET /v1/view/trader/{pubkey}`](https://docs.phoenix.trade/api/trader/get-trader)
|
||||
supplies risk for each visible account, including funded empty Cross accounts.
|
||||
Its key is the derived trader account, not the wallet or a TWAP key. See
|
||||
[venue-reported account risk](#venue-reported-account-risk).
|
||||
|
||||
An empty trader state with no visible funded account needs only the first request.
|
||||
A funded empty Cross account additionally needs its risk read, without market/TWAP reads.
|
||||
Otherwise metadata is fetched once
|
||||
and each active symbol's price once per wallet lookup, including when that symbol is
|
||||
held in several subaccounts, followed by one TWAP read and each visible account's
|
||||
risk read. Request starts are spaced at least one second apart across both environments to avoid
|
||||
A trader state with no subaccounts needs only the first request. Otherwise TWAP is
|
||||
read immediately after state, with its original response receipt time preserved.
|
||||
Metadata is fetched once if there are held positions or filled outstanding TWAPs;
|
||||
marks are fetched only for distinct held symbols, followed by each visible account's
|
||||
risk read. Registered empty accounts without relevant TWAPs need no metadata or
|
||||
marks; funded or warning-only empty Cross accounts also receive their risk read.
|
||||
Request starts are spaced at least one second apart across both environments to avoid
|
||||
bursts of per-market reads. Each HTTP request has a 10-second timeout and uses the
|
||||
Hub's existing HTTP client (no redirects by default). On HTTP 429 the adapter respects
|
||||
`Retry-After` (seconds or HTTP date; 60 seconds if missing/invalid). Further calls
|
||||
@@ -93,9 +95,9 @@ following each completed cycle. Jupiter and Phoenix are attempted independently
|
||||
each configured wallet. Successful results replace only that wallet/venue; failures
|
||||
log wallet and venue and retain its last successful result. A valid empty result
|
||||
replaces that venue's positions and account balances. EMC hides the venue heading
|
||||
when it has neither position rows nor Cross accounts; its read status remains in
|
||||
when it has neither position rows, Cross accounts nor TWAP warnings; its read status remains in
|
||||
the wallet tooltip and the scope still contributes to snapshot totals.
|
||||
Funded Cross accounts remain visible when they no longer hold positions. Snapshot reads never fetch
|
||||
Funded Cross accounts and Cross accounts with eligible warnings remain visible when they no longer hold positions. Snapshot reads never fetch
|
||||
external data; EMC's three-second polling remains unchanged.
|
||||
|
||||
Positions and prices are separate REST reads and can belong to different slots.
|
||||
@@ -147,13 +149,25 @@ neutral isolated distance, venue tier colors and group summaries.
|
||||
## TWAP observations
|
||||
|
||||
TWAP data is matched by requested authority, PDA index, subaccount and market `assetId`
|
||||
using Phoenix metadata. Only an existing nonzero position can carry a TWAP, and the
|
||||
order itself must have `baseLotsFilled > 0` and remaining children. A new order without
|
||||
fills stays invisible even if the market already has a position. No TWAP-only row or
|
||||
group total is created. A successful snapshot without that active order clears its
|
||||
TWAP data. A failed or invalid TWAP read fails the whole wallet/venue observation,
|
||||
using Phoenix metadata. Only an existing nonzero position carries a progressbar, but
|
||||
an eligible order that cannot be matched is preserved in its account's immutable
|
||||
warning list. The order itself must have `baseLotsFilled > 0` and remaining children.
|
||||
A new order without fills stays invisible everywhere, even if the market already
|
||||
has a position; completed orders also create no warning. No synthetic position or
|
||||
monetary group total is created. A successful snapshot without that active order
|
||||
clears its progress and warning data. A failed or invalid TWAP read fails the whole wallet/venue observation,
|
||||
retaining the previous complete snapshot and its original timestamps as `(retained)`.
|
||||
|
||||
Warnings retain the TWAP address/order sequence, asset and subaccount IDs, raw
|
||||
execution/fill counters and original receipt time. Missing market metadata or no
|
||||
matching open position is an observed mismatch, not proof of delisting, cancellation
|
||||
or stopped execution. Missing conversion data is shown as raw lots/unknown values;
|
||||
warning-only markets do not trigger mark-price requests. A matched order with no
|
||||
usable future next-execution estimate keeps its progress plus a timing warning.
|
||||
Account/schema/counter mismatches still fail the read; valid missing-market warnings
|
||||
do not discard independent positions or account risk. See the [EMC warning presentation](Evelyn.md#twap-advarsler)
|
||||
for exact placement, ancestor propagation and retained/stale qualifications.
|
||||
|
||||
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
|
||||
@@ -184,8 +198,8 @@ EMC appends **TWAP** after uPnL. Its progressbar overlays a snapshot duration wi
|
||||
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
|
||||
countdown. Cells without relevant progress or warnings stay empty. Groups show warning
|
||||
summaries when applicable, never aggregated progressbars. 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-26
|
||||
@@ -0,0 +1,31 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md for motivation. The adapter currently reads TWAP only when positions exist, and its decoder returns only a position-keyed map. Immutable account/snapshot records and identity-based JavaFX reconciliation already support retained observations.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Preserve eligible orders independently of matching, render actionable reasons with exact ownership, and summarize distinct orders through collapsed trees.
|
||||
|
||||
**Non-Goals:** Order cancellation, predicting execution failure, new polling or notifications, changes to capital or liquidation calculations.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Return matched TWAPs and account-owned warning observations from decoding. Warning data holds TWAP address/order sequence, asset/subaccount identity, raw counters/fills, typed reason, optional matched position symbol and original receipt time. Keep compatible constructors for public records. A generic untyped UI message or synthetic position would lose ownership and distort totals.
|
||||
- Read TWAP after trader state for every existing account. Fetch metadata for held positions or eligible filled outstanding orders; never request marks for warning-only markets. Empty unregistered authorities need no TWAP/market reads. Continue strict schema, counters, duplicate and identity validation, including orders that are ultimately hidden.
|
||||
- Missing market and missing open position produce account-owned warnings. A matched order whose next execution estimate is unavailable keeps its progressbar plus a timing warning: this states unavailable timing, not a stopped order. Zero fills and zero remaining children stay invisible. Do not treat absence of optional USD valuation alone as a warning.
|
||||
- Map matched warnings to positions, unmatched Cross warnings to their real Cross group (including zero-capital accounts), and propagate all through venue/wallet/root. An unmatched isolated order with no reliable position remains on Phoenix with its true isolated subaccount in the tooltip; inventing a Cross owner would violate identity. Existing risk reads also cover newly visible warning-only Cross accounts.
|
||||
- Format warnings only in EMC. Build summaries from underlying observations, keyed by wallet plus account/order identity, rather than counting child indicators. Render an amber vector triangle with a readable label, retained/stale qualifier and detailed source tooltip. Five minutes is the same observation-age cutoff as existing risk; it is not an execution deadline. Existing three-second polling reevaluates age without moving source timestamps or rebuilding retained rows.
|
||||
- A successful observation replaces warning lists; failures retain the complete prior venue. Consumer read failures qualify all visible summaries. Normal TWAP bars and Risk remain separate.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Extra reads for registered empty accounts → reuse shared pacing/cooldown; fetch metadata only if needed and no extra marks.
|
||||
- Missing metadata prevents quantities and symbols → show raw asset ID/lots and explicitly unknown values.
|
||||
- Nonatomic state/TWAP reads can temporarily report no matching position → describe only the observed mismatch and let the next successful read reconcile it.
|
||||
- Aggregation could duplicate orders or hide stale sources → distinct identity keys, per-source times/status, multi-wallet and collapsed-tree checks.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Build and verify locally with temporary harnesses and JavaFX images. Update the three main specs and meaningful docs after implementation. Deployment remains a separate owner action; no commits, pushes or Hub restart. Reverting these source changes restores prior presentation without changing orders.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Issue #103 addresses a real Phoenix order with fills and remaining children that disappears from EMC because its market metadata is missing. Retaining and propagating a concise warning protects Evelyn's account oversight without introducing trading actions.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Preserve eligible unresolved TWAPs as immutable account-owned warning observations, including orders without open positions.
|
||||
- Show a small amber triangle and short description on the owning position or Cross account, with distinct-order summaries on every ancestor through Perpetual Contracts regardless of expansion state.
|
||||
- Preserve original times and failure/freshness qualifications; clear resolved warnings on successful reads. Continue hiding zero-fill and completed orders.
|
||||
- Keep normal TWAP progress, valuations and margin risk independent. Update meaningful documentation and verify with temporary fixtures and JavaFX previews.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `phoenix-perps-service`: discover TWAPs for empty existing accounts and retain unresolved filled orders without weakening identity validation.
|
||||
- `evelyn-perps-service`: preserve warning ownership, including warning-only Cross accounts, through immutable snapshots and retained failures.
|
||||
- `evelyn-perps-overview`: display and aggregate accessible TWAP warnings through the complete tree, including collapsed branches.
|
||||
|
||||
## Impact
|
||||
|
||||
Phoenix account and Evelyn snapshot records gain warning lists with compatible constructors. The decoder, reader, mapping and Perps table change; no new dependencies or transaction operations. Repository docs and the shared Evelyn overview change under the owner's explicit documentation instruction. No commits, pushes, Hub restart or deployment.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Perps overview displays a collapsible position hierarchy
|
||||
The Perps tab SHALL display Position, Risk, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP 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 and risk summaries, leave direction/aggregate ratios blank, show TWAP warnings when applicable, and be expanded by default.
|
||||
|
||||
#### Scenario: Open either environment's Perps tab
|
||||
- **WHEN** the owner opens Perps in Production or Test
|
||||
- **THEN** all ten columns and the account-aware hierarchy are available with Risk near the position name
|
||||
|
||||
#### Scenario: Collapse and expand groups
|
||||
- **WHEN** the owner collapses a wallet, venue or Cross account
|
||||
- **THEN** the group still shows its worst known risk and incomplete-observation counts without changing totals
|
||||
|
||||
### 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. Cells with no eligible progress or warning SHALL be empty. Matched timing warnings SHALL coexist with their progressbar. 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: Venue visibility follows available child rows
|
||||
EMC SHALL hide venue rows with neither positions, Cross accounts nor TWAP warnings and show them when children reappear. Funded empty Cross accounts and accounts with eligible TWAP warnings SHALL keep their venue visible. Unmatched isolated warnings SHALL remain on the venue with true subaccount identity, never on an invented Cross parent. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Wallet rows and the root SHALL remain visible.
|
||||
|
||||
#### Scenario: Empty venue
|
||||
- **WHEN** Jupiter or Phoenix has no positions, Cross accounts or TWAP warnings
|
||||
- **THEN** its heading is absent from that wallet's displayed children
|
||||
- **AND** a successful empty scope still contributes zero to totals
|
||||
|
||||
#### Scenario: Funded Cross account without positions
|
||||
- **WHEN** Phoenix returns a Cross account with capital but no open positions
|
||||
- **THEN** Phoenix and its Cross account remain visible with that capital counted once
|
||||
|
||||
#### Scenario: Positions disappear and return
|
||||
- **WHEN** a venue loses its last child and later returns a position
|
||||
- **THEN** its heading disappears and reappears with the correct child
|
||||
- **AND** unrelated retained rows keep their selection and expansion state
|
||||
|
||||
#### Scenario: Hidden scope has not been read successfully
|
||||
- **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: Polling preserves Perps view state
|
||||
The Perps table SHALL retain matching wallet, venue and position identities across snapshots. Repeated unchanged snapshots SHALL leave retained rows, expansion, selection and formatting untouched except when risk or TWAP-warning freshness changes, without flicker or table rebuild. New groups SHALL be expanded by default, and added or removed positions SHALL be reconciled by identity without resetting retained groups.
|
||||
|
||||
#### Scenario: Poll unchanged positions
|
||||
- **WHEN** a user collapses a group or selects a position and repeated snapshots are unchanged
|
||||
- **THEN** the same rows remain with the same expansion and selection
|
||||
- **AND** only a changed risk or TWAP-warning freshness indication requires a row-value update
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: TWAP warnings propagate through every ancestor
|
||||
EMC SHALL show an amber warning triangle and concise text at the owning position or Cross group and on every actual ancestor through Perpetual Contracts. Summaries SHALL count each distinct order once per scope independently of expansion state. Unfilled and completed orders SHALL remain invisible. Risk and capital totals SHALL remain independent.
|
||||
|
||||
#### Scenario: Collapsed Cross branch
|
||||
- **WHEN** a filled outstanding Cross order has missing market metadata and Cross or Phoenix is collapsed
|
||||
- **THEN** Cross, Phoenix, wallet and Perpetual Contracts still carry the warning
|
||||
- **AND** each summary counts that order once
|
||||
|
||||
#### Scenario: Position and multiple wallets
|
||||
- **WHEN** a position warning and an unmatched Cross warning affect different wallets
|
||||
- **THEN** each follows only its actual ancestors and the root counts two orders
|
||||
- **AND** clearing one warning leaves the other and its ancestor summaries intact
|
||||
|
||||
### Requirement: Warning details preserve reason and freshness
|
||||
Warning tooltips SHALL identify wallet/account/position, reason, TWAP address, asset/subaccount IDs, raw progress/fills and original observation time. Unknown metadata SHALL remain unknown without unsupported cancellation/delisting/stoppage claims. Failure retention, future timestamps and the existing five-minute observation-age cutoff SHALL qualify all affected summaries.
|
||||
|
||||
#### Scenario: Retained or aged warning
|
||||
- **WHEN** a warned venue or consumer read fails, or its original observation becomes five minutes old
|
||||
- **THEN** source and inherited warnings remain visible with retained/stale qualification and original times
|
||||
- **AND** existing UI polling updates age without additional venue reads or resetting selection and expansion
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: TWAP observations belong to existing filled positions
|
||||
The service SHALL publish immutable TWAP observations on matching existing Phoenix positions whose order has a positive actual fill and remaining executions, and account-owned warnings for eligible unresolved orders. 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 no matching open position
|
||||
- **THEN** a zero-fill order is absent from every overview row and cannot create a visible venue
|
||||
- **AND** an eligible filled outstanding order without a position is preserved as a warning on its actual account scope
|
||||
|
||||
#### 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
|
||||
|
||||
#### Scenario: Filled order without position
|
||||
- **WHEN** a filled outstanding order cannot match an open position
|
||||
- **THEN** its warning retains the real account identity and Cross accounts remain present even without capital or positions
|
||||
- **AND** warning-only data does not synthesize positions or valuations
|
||||
|
||||
#### Scenario: Warning recovery and retention
|
||||
- **WHEN** a successful observation resolves or removes a warned order
|
||||
- **THEN** its warning is cleared while unrelated warnings remain
|
||||
- **AND** failed reads instead retain warnings and their original times
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
## 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
|
||||
- **THEN** an empty result is returned without market-price or TWAP requests; existing empty subaccounts still receive TWAP discovery
|
||||
|
||||
#### 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
|
||||
|
||||
### 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 SHALL be exposed. Matched orders SHALL retain position progress; valid unresolved orders SHALL retain account-owned warnings with identity, raw counters/fills and original observation time. Existing accounts without positions SHALL receive TWAP discovery. 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 all TWAP data and warnings
|
||||
|
||||
#### Scenario: Missing market or missing position
|
||||
- **WHEN** a filled outstanding order has missing market metadata or no matching open position
|
||||
- **THEN** a warning preserves its identity and raw progress without inventing a symbol, conversion, price or execution status
|
||||
- **AND** healthy positions and account risk remain available
|
||||
|
||||
#### Scenario: Existing empty account
|
||||
- **WHEN** an account has no held positions but has a filled outstanding TWAP
|
||||
- **THEN** TWAP discovery returns its warning without fetching market prices or creating positions
|
||||
|
||||
#### Scenario: Completed order
|
||||
- **WHEN** an order has no remaining children
|
||||
- **THEN** it is absent from active TWAP data and warnings
|
||||
|
||||
#### Scenario: Matched order with unavailable timing
|
||||
- **WHEN** an otherwise valid matched filled order has no usable future next-execution estimate
|
||||
- **THEN** its progress remains available with a timing warning that does not assert execution has stopped
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Phoenix warning observations
|
||||
|
||||
- [x] 1.1 Preserve eligible unmatched orders and matched timing warnings with strict identities/counters and original times; verify missing metadata, zero-fill/completed suppression and known-market matching with temporary fixtures.
|
||||
- [x] 1.2 Discover TWAPs for existing empty accounts with existing transport safeguards and document changed reads in docs/PhoenixPerps.md; verify empty-account request paths and HTTP failure behavior.
|
||||
|
||||
## 2. Snapshot ownership
|
||||
|
||||
- [x] 2.1 Map warnings onto positions, real Cross accounts and isolated venue fallbacks without valuation changes, preserving them on failures; verify immutable lists, warning-only accounts, identity and recovery with temporary fixtures and document the snapshot behavior.
|
||||
|
||||
## 3. Tree presentation
|
||||
|
||||
- [x] 3.1 Render the amber warning icon, concise descriptions and detailed tooltips with retained/stale source information; verify actual JavaFX cells, normal bars, Risk and row backgrounds in a preview.
|
||||
- [x] 3.2 Propagate distinct warnings through every actual ancestor to Perpetual Contracts independently of collapse state; verify multiple wallets, clearing and stable selection/expansion, and update docs/Evelyn.md plus the shared Evelyn overview.
|
||||
|
||||
## 4. Integration verification
|
||||
|
||||
- [x] 4.1 Run the normal build and integrated temporary fixture/JavaFX checks, inspect a paced read-only live observation if available, validate/sync affected OpenSpec specs and record evidence in verification.md; confirm no commits, pushes or runtime changes.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Verification — issue #103
|
||||
|
||||
Verified locally on 2026-10-26. No commits, pushes, Hub restart, deployment, signing or order changes.
|
||||
|
||||
## Delivered behavior
|
||||
|
||||
- The Phoenix decoder preserves filled outstanding orders with missing market metadata or no matching open position as typed immutable warnings. It also preserves matched progress with a timing warning when the next execution cannot be estimated.
|
||||
- TWAP discovery includes existing empty accounts. Metadata is conditional and warning-only markets cause no mark-price calls. Existing request pacing, timeout and shared 429 cooldown remain in use.
|
||||
- Warnings map to their real position or Cross account. Empty warning-only Cross accounts remain visible without invented positions or capital. Unmatched isolated orders remain on Phoenix with their real subaccount identity.
|
||||
- Amber vector triangles and concise text appear in TWAP cells. Distinct source orders propagate through every actual ancestor to Perpetual Contracts, independently of expansion. Successful recovery clears only resolved orders; failures and five-minute/future-date staleness remain qualified at every level.
|
||||
- Detail tooltips preserve raw counters, IDs and original receipt times. They have bounded scrollable content and remain available while moving between their owner cell and details. Risk and existing normal TWAP bars remain independent.
|
||||
- Updated docs/Evelyn.md, docs/PhoenixPerps.md and the shared r35157_docs/projects/evelyn/README.md; removed obsolete matching/discovery/group-cell claims. The three affected main OpenSpec specifications are synced. No permanent tests or dependencies were added.
|
||||
|
||||
## Checks
|
||||
|
||||
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build`: successful; 42 existing tests, no failures/errors/skips.
|
||||
- `/tmp/Issue103Check.java`: 32 focused assertions passed. Covers the captured real asset-97 response; missing markets; matching and unmatched positions; hidden zero-fill/completed orders; identity/duplicate/counter rejection; immutable lists; correct Cross/isolated mapping; warning-only empty accounts without mark requests; unchanged monetary totals; failure retention/recovery; and 429 cooldown. Uses local HTTP fixtures and the actual decoder/adapter/service.
|
||||
- `/tmp/Issue102Check.java`: all 68 existing temporary risk checks still pass, including exact boundaries, account scope and retained/future observations.
|
||||
- `/tmp/Issue103Preview.java`: 82 JavaFX assertions passed. This is an assertion count, including repeated checks of recycled empty cells, not 82 distinct user scenarios. Checks actual warning graphics, source and ancestor counts over two wallets, collapsed Cross/Phoenix/wallet/root nodes, normal 28-hour progress, timing warning plus bar, margin-risk independence, direction backgrounds, accessible text, tooltip content/scrolling/leave behavior, snapshot failures, five-minute aging, recovery, row identity/selection, warning-only Cross, empty venue hiding and horizontal scrolling.
|
||||
- Rendered JavaFX images inspected in `/tmp/issue103-check/`: `twap-warnings-expanded.png`, `twap-warnings-collapsed.png`, `twap-warning-tooltip.png`, `twap-warning-tooltip-bottom.png`, `twap-warnings-stale.png`, `twap-warning-empty-cross.png`, `twap-warnings-small-window.png`. Long tooltip height and contrast were corrected during inspection. TWAP was scrolled into the viewport before final visual checks.
|
||||
- Strict validation passes for change `103-perps-twap-warnings` and specifications `phoenix-perps-service`, `evelyn-perps-service` and `evelyn-perps-overview`. Existing long modified requirement paragraphs produce informational length hints only.
|
||||
- Changed working-tree and change-specific staged whitespace checks pass; 24 local documentation file links resolve. Pre-existing staged/deleted OpenSpec artifacts for #97 and #99 were left untouched.
|
||||
|
||||
## Read-only live observation
|
||||
|
||||
`/tmp/Issue103Live.java` used the actual default paced Phoenix adapter and local PDA derivation against the owner's reported wallet. One lookup ran from **2026-10-26 05:25:26 to 05:25:38 UTC**. It returned one account with eight held positions and one warning:
|
||||
|
||||
```text
|
||||
PDA/subaccount: 0/0
|
||||
Asset ID: 97
|
||||
Reason: MARKET_UNAVAILABLE
|
||||
Progress: 34/346
|
||||
Remaining: 312
|
||||
Original TWAP receipt: 2026-10-26T05:25:28.553550180Z
|
||||
Mapped Cross warnings: 1
|
||||
Venue fallback warnings: 0
|
||||
```
|
||||
|
||||
The order's market identity and execution status remain unresolved; this verification does not claim cancellation, delisting or stopped execution. It confirms local reader/mapping behavior, not deployment to the running Hub. Live reads are separate observations, not an atomic portfolio snapshot.
|
||||
@@ -7,7 +7,7 @@ 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, Risk, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP 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 and risk summaries, leave direction/aggregate ratios and TWAP blank, and be expanded by default.
|
||||
The Perps tab SHALL display Position, Risk, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP 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 and risk summaries, leave direction/aggregate ratios blank, show TWAP warnings when applicable, and be expanded by default.
|
||||
|
||||
#### Scenario: Open either environment's Perps tab
|
||||
- **WHEN** the owner opens Perps in Production or Test
|
||||
@@ -35,12 +35,12 @@ EMC SHALL request the current Perps snapshot from each environment's own service
|
||||
- **AND** a failure reading one service does not prevent attempting the other service
|
||||
|
||||
### Requirement: Polling preserves Perps view state
|
||||
The Perps table SHALL retain matching wallet, venue and position identities across snapshots. Repeated unchanged snapshots SHALL leave retained rows, expansion, selection and formatting untouched except when risk freshness changes, without flicker or table rebuild. New groups SHALL be expanded by default, and added or removed positions SHALL be reconciled by identity without resetting retained groups.
|
||||
The Perps table SHALL retain matching wallet, venue and position identities across snapshots. Repeated unchanged snapshots SHALL leave retained rows, expansion, selection and formatting untouched except when risk or TWAP-warning freshness changes, without flicker or table rebuild. New groups SHALL be expanded by default, and added or removed positions SHALL be reconciled by identity without resetting retained groups.
|
||||
|
||||
#### Scenario: Poll unchanged positions
|
||||
- **WHEN** a user collapses a group or selects a position and repeated snapshots are unchanged
|
||||
- **THEN** the same rows remain with the same expansion and selection
|
||||
- **AND** only a changed risk freshness indication requires a row-value update
|
||||
- **AND** only a changed risk or TWAP-warning freshness indication requires a row-value update
|
||||
|
||||
### Requirement: Jupiter positions communicate direction and USD size
|
||||
Positions SHALL show readable Long/Short direction and Isolated/Cross margin text. USD amounts SHALL use grouping and two decimals; leverage SHALL use a multiplication sign. Signed uPnL SHALL be green for positive, red for negative and neutral for rounded zero without negative zero. Existing faint direction backgrounds SHALL remain; groups SHALL have distinct weight and no direction color. Numeric columns SHALL be right-aligned with useful widths and tooltips.
|
||||
@@ -70,10 +70,10 @@ Tooltips SHALL explain isolated collateral, exposure-based leverage, collateral-
|
||||
- **AND** no position-only metric is labeled account utilization
|
||||
|
||||
### Requirement: Venue visibility follows available child rows
|
||||
EMC SHALL hide venue rows with neither positions nor Cross accounts and show them when children reappear. Funded empty Cross accounts SHALL keep their venue visible. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Wallet rows and the root SHALL remain visible.
|
||||
EMC SHALL hide venue rows with neither positions, Cross accounts nor TWAP warnings and show them when children reappear. Funded empty Cross accounts and accounts with eligible TWAP warnings SHALL keep their venue visible. Unmatched isolated warnings SHALL remain on the venue with true subaccount identity, never on an invented Cross parent. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Wallet rows and the root SHALL remain visible.
|
||||
|
||||
#### Scenario: Empty venue
|
||||
- **WHEN** Jupiter or Phoenix has no positions and no Cross accounts
|
||||
- **WHEN** Jupiter or Phoenix has no positions, Cross accounts or TWAP warnings
|
||||
- **THEN** its heading is absent from that wallet's displayed children
|
||||
- **AND** a successful empty scope still contributes zero to totals
|
||||
|
||||
@@ -92,7 +92,7 @@ EMC SHALL hide venue rows with neither positions nor Cross accounts and show the
|
||||
- **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.
|
||||
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. Cells with no eligible progress or warning SHALL be empty. Matched timing warnings SHALL coexist with their progressbar. 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
|
||||
@@ -126,3 +126,24 @@ EMC SHALL mark risk stale after five minutes from its original observation, on r
|
||||
#### Scenario: Retained critical risk
|
||||
- **WHEN** a previously critical observation fails to refresh
|
||||
- **THEN** its critical state and original time remain visible with stale/retained indication
|
||||
|
||||
### Requirement: TWAP warnings propagate through every ancestor
|
||||
EMC SHALL show an amber warning triangle and concise text at the owning position or Cross group and on every actual ancestor through Perpetual Contracts. Summaries SHALL count each distinct order once per scope independently of expansion state. Unfilled and completed orders SHALL remain invisible. Risk and capital totals SHALL remain independent.
|
||||
|
||||
#### Scenario: Collapsed Cross branch
|
||||
- **WHEN** a filled outstanding Cross order has missing market metadata and Cross or Phoenix is collapsed
|
||||
- **THEN** Cross, Phoenix, wallet and Perpetual Contracts still carry the warning
|
||||
- **AND** each summary counts that order once
|
||||
|
||||
#### Scenario: Position and multiple wallets
|
||||
- **WHEN** a position warning and an unmatched Cross warning affect different wallets
|
||||
- **THEN** each follows only its actual ancestors and the root counts two orders
|
||||
- **AND** clearing one warning leaves the other and its ancestor summaries intact
|
||||
|
||||
### Requirement: Warning details preserve reason and freshness
|
||||
Warning tooltips SHALL identify wallet/account/position, reason, TWAP address, asset/subaccount IDs, raw progress/fills and original observation time. Unknown metadata SHALL remain unknown without unsupported cancellation/delisting/stoppage claims. Failure retention, future timestamps and the existing five-minute observation-age cutoff SHALL qualify all affected summaries.
|
||||
|
||||
#### Scenario: Retained or aged warning
|
||||
- **WHEN** a warned venue or consumer read fails, or its original observation becomes five minutes old
|
||||
- **THEN** source and inherited warnings remain visible with retained/stale qualification and original times
|
||||
- **AND** existing UI polling updates age without additional venue reads or resetting selection and expansion
|
||||
|
||||
@@ -85,17 +85,28 @@ Each venue SHALL carry its last-success time and whether the latest attempt fail
|
||||
- **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.
|
||||
The service SHALL publish immutable TWAP observations on matching existing Phoenix positions whose order has a positive actual fill and remaining executions, and account-owned warnings for eligible unresolved orders. 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
|
||||
- **WHEN** an active TWAP has no actual fills or no matching open position
|
||||
- **THEN** a zero-fill order is absent from every overview row and cannot create a visible venue
|
||||
- **AND** an eligible filled outstanding order without a position is preserved as a warning on its actual account scope
|
||||
|
||||
#### 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
|
||||
|
||||
#### Scenario: Filled order without position
|
||||
- **WHEN** a filled outstanding order cannot match an open position
|
||||
- **THEN** its warning retains the real account identity and Cross accounts remain present even without capital or positions
|
||||
- **AND** warning-only data does not synthesize positions or valuations
|
||||
|
||||
#### Scenario: Warning recovery and retention
|
||||
- **WHEN** a successful observation resolves or removes a warned order
|
||||
- **THEN** its warning is cleared while unrelated warnings remain
|
||||
- **AND** failed reads instead retain warnings and their original times
|
||||
|
||||
### Requirement: Risk observations preserve scope and exact meaning
|
||||
Snapshots SHALL preserve isolated risk prices and Cross account tiers/thresholds with original observation times. Signed price distance SHALL use the current venue reference as denominator and preserve boundary crossing. Cross MM utilization SHALL use effective collateral, with nonpositive denominators unavailable. Group risk SHALL count each isolated position and shared account once and retain worst known severity plus unknown/stale counts without averaging.
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ HTTP, authentication, malformed schema, identity mismatch and numeric conversion
|
||||
- **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
|
||||
- **WHEN** a valid trader snapshot has no subaccounts
|
||||
- **THEN** an empty result is returned without market-price or TWAP requests; existing empty subaccounts still receive TWAP discovery
|
||||
|
||||
#### Scenario: Public API rate limit
|
||||
- **WHEN** the shared adapter issues requests for PROD and TEST
|
||||
@@ -62,7 +62,7 @@ Equity SHALL include collateral, undiscounted price PnL and signed unsettled fun
|
||||
- **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.
|
||||
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 SHALL be exposed. Matched orders SHALL retain position progress; valid unresolved orders SHALL retain account-owned warnings with identity, raw counters/fills and original observation time. Existing accounts without positions SHALL receive TWAP discovery. 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
|
||||
@@ -71,7 +71,24 @@ The reader SHALL read official TWAP snapshots through its existing paced transpo
|
||||
|
||||
#### 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
|
||||
- **THEN** disagreement fails the read, while a valid unfilled order is absent from all TWAP data and warnings
|
||||
|
||||
#### Scenario: Missing market or missing position
|
||||
- **WHEN** a filled outstanding order has missing market metadata or no matching open position
|
||||
- **THEN** a warning preserves its identity and raw progress without inventing a symbol, conversion, price or execution status
|
||||
- **AND** healthy positions and account risk remain available
|
||||
|
||||
#### Scenario: Existing empty account
|
||||
- **WHEN** an account has no held positions but has a filled outstanding TWAP
|
||||
- **THEN** TWAP discovery returns its warning without fetching market prices or creating positions
|
||||
|
||||
#### Scenario: Completed order
|
||||
- **WHEN** an order has no remaining children
|
||||
- **THEN** it is absent from active TWAP data and warnings
|
||||
|
||||
#### Scenario: Matched order with unavailable timing
|
||||
- **WHEN** an otherwise valid matched filled order has no usable future next-execution estimate
|
||||
- **THEN** its progress remains available with a timing warning that does not assert execution has stopped
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.animation.PauseTransition;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.ReadOnlyStringWrapper;
|
||||
import javafx.geometry.Insets;
|
||||
@@ -16,6 +17,8 @@ import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressBar;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
@@ -27,6 +30,7 @@ import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.shape.Circle;
|
||||
import javafx.scene.shape.Polygon;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -164,6 +168,34 @@ final class PerpsTab {
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private static Tooltip warningTooltip(String text, TreeTableCell<?, ?> owner) {
|
||||
Tooltip tooltip = tooltip(text);
|
||||
var details = new Label(text);
|
||||
details.setWrapText(true);
|
||||
details.setStyle("-fx-text-fill: white; -fx-font-weight: normal; -fx-padding: 6;");
|
||||
var scroll = new ScrollPane(details);
|
||||
scroll.setFitToWidth(true);
|
||||
scroll.setStyle("-fx-background: #333333; -fx-background-color: #333333;");
|
||||
scroll.setPrefViewportWidth(540);
|
||||
scroll.setPrefViewportHeight(420);
|
||||
tooltip.setGraphic(scroll);
|
||||
tooltip.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
|
||||
// The standard tooltip disappears on leaving its cell, preventing scrolling its details.
|
||||
tooltip.setShowDuration(javafx.util.Duration.INDEFINITE);
|
||||
tooltip.setHideDelay(javafx.util.Duration.INDEFINITE);
|
||||
tooltip.setAutoHide(true);
|
||||
var leave = new PauseTransition(javafx.util.Duration.millis(350));
|
||||
leave.setOnFinished(event -> {
|
||||
if (!owner.isHover() && !scroll.isHover()) tooltip.hide();
|
||||
});
|
||||
owner.setOnMouseEntered(event -> leave.stop());
|
||||
owner.setOnMouseExited(event -> leave.playFromStart());
|
||||
scroll.setOnMouseEntered(event -> leave.stop());
|
||||
scroll.setOnMouseExited(event -> leave.playFromStart());
|
||||
tooltip.setOnHidden(event -> leave.stop());
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private TreeTableColumn<PositionRow, PositionRow> riskColumn() {
|
||||
var column = new TreeTableColumn<PositionRow, PositionRow>("Risk");
|
||||
column.setPrefWidth(220);
|
||||
@@ -214,20 +246,33 @@ final class PerpsTab {
|
||||
|
||||
private TreeTableColumn<PositionRow, PositionRow> twapColumn() {
|
||||
var column = new TreeTableColumn<PositionRow, PositionRow>("TWAP");
|
||||
column.setPrefWidth(170);
|
||||
column.setMinWidth(155);
|
||||
column.setPrefWidth(220);
|
||||
column.setMinWidth(180);
|
||||
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);
|
||||
private final StackPane progress = new StackPane(bar, label);
|
||||
private final Label warningText = new Label();
|
||||
private final Label warningStatus = new Label();
|
||||
private final VBox warningLabels = new VBox(warningText, warningStatus);
|
||||
private final HBox warning = new HBox(5, warningIcon(), warningLabels);
|
||||
private final VBox graphic = new VBox(2, warning, progress);
|
||||
{
|
||||
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);
|
||||
warning.setAlignment(Pos.CENTER_LEFT);
|
||||
warningLabels.setMinWidth(0);
|
||||
HBox.setHgrow(warningLabels, Priority.ALWAYS);
|
||||
warningText.setMaxWidth(Double.MAX_VALUE);
|
||||
warningStatus.setMaxWidth(Double.MAX_VALUE);
|
||||
warningText.setStyle("-fx-text-fill: #7c4b00; -fx-font-weight: normal;");
|
||||
warningStatus.setStyle("-fx-text-fill: #626b75; -fx-font-size: 11px; -fx-font-weight: normal;");
|
||||
graphic.setAlignment(Pos.CENTER_LEFT);
|
||||
graphic.prefWidthProperty().bind(widthProperty().subtract(12));
|
||||
setStyle("-fx-alignment: CENTER;");
|
||||
}
|
||||
@@ -238,19 +283,50 @@ final class PerpsTab {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
if (!empty && row != null && row.twap() != null) {
|
||||
setAccessibleText(null);
|
||||
setOnMouseEntered(null);
|
||||
setOnMouseExited(null);
|
||||
if (!empty && row != null && (row.twap() != null || !row.warnings().isEmpty())) {
|
||||
boolean hasWarning = !row.warnings().isEmpty();
|
||||
warning.setVisible(hasWarning);
|
||||
warning.setManaged(hasWarning);
|
||||
warningText.setText(hasWarning ? PerpsTwapWarnings.text(row.warnings()) : null);
|
||||
String qualification = PerpsTwapWarnings.qualification(row.warnings());
|
||||
warningStatus.setText(qualification);
|
||||
warningStatus.setManaged(!qualification.isEmpty());
|
||||
warningStatus.setVisible(!qualification.isEmpty());
|
||||
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()));
|
||||
progress.setVisible(twap != null);
|
||||
progress.setManaged(twap != null);
|
||||
if (twap != null) {
|
||||
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()));
|
||||
setAccessibleText(hasWarning ? "TWAP warning: " + warningText.getText() + " " + qualification : label.getText());
|
||||
String details = (hasWarning ? PerpsTwapWarnings.details(row.warnings()) + "\n\n" : "")
|
||||
+ (twap == null ? "" : twapDetails(twap, row.retained()) + "\n\n") + row.notes();
|
||||
setTooltip(hasWarning ? warningTooltip(details, this) : tooltip(details));
|
||||
}
|
||||
}
|
||||
});
|
||||
return column;
|
||||
}
|
||||
|
||||
private static StackPane warningIcon() {
|
||||
var triangle = new Polygon(8, 0, 16, 15, 0, 15);
|
||||
triangle.setFill(Color.web("#ffa500"));
|
||||
var mark = new Label("!");
|
||||
mark.setStyle("-fx-text-fill: white; -fx-font-size: 12px; -fx-font-weight: bold;");
|
||||
mark.setTranslateY(1);
|
||||
var icon = new StackPane(triangle, mark);
|
||||
icon.setMinSize(16, 16);
|
||||
icon.setMaxSize(16, 16);
|
||||
icon.setAccessibleText("TWAP warning");
|
||||
return icon;
|
||||
}
|
||||
|
||||
private static String twapText(Twap twap, boolean retained) {
|
||||
if (retained) {
|
||||
return "Retained";
|
||||
@@ -324,25 +400,27 @@ final class PerpsTab {
|
||||
var rootRisk = PerpsRisk.Summary.combine(snapshot.wallets().stream().flatMap(wallet -> wallet.venues().stream())
|
||||
.map(venue -> PerpsRisk.venue(venue, now, snapshotReadFailed)).toList());
|
||||
PositionRow rootRow = group("root", "Perpetual Contracts", snapshot.totals(), TOTAL_NOTES,
|
||||
PerpsRiskPresentation.summary(rootRisk));
|
||||
PerpsRiskPresentation.summary(rootRisk), PerpsTwapWarnings.distinct(snapshot.wallets().stream()
|
||||
.flatMap(wallet -> PerpsTwapWarnings.wallet(wallet, now, snapshotReadFailed).stream()).toList()));
|
||||
if (!rootRow.equals(table.getRoot().getValue())) table.getRoot().setValue(rootRow);
|
||||
List<TreeItem<PositionRow>> wallets = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingWallets = childrenById(table.getRoot());
|
||||
for (PerpsSnapshot.Wallet wallet : snapshot.wallets()) {
|
||||
String walletNotes = TOTAL_NOTES;
|
||||
for (PerpsSnapshot.Venue venue : wallet.venues()) {
|
||||
if (venue.positions().isEmpty() && venue.crossAccounts().isEmpty()) {
|
||||
if (venue.positions().isEmpty() && venue.crossAccounts().isEmpty() && venue.twapWarnings().isEmpty()) {
|
||||
walletNotes += "\n" + venue.name() + " (no rows): " + status(venue);
|
||||
}
|
||||
}
|
||||
TreeItem<PositionRow> walletItem = item(existingWallets,
|
||||
group(wallet.id(), wallet.name(), wallet.totals(), walletNotes, PerpsRiskPresentation.summary(
|
||||
PerpsRisk.Summary.combine(wallet.venues().stream()
|
||||
.map(venue -> PerpsRisk.venue(venue, now, snapshotReadFailed)).toList()))));
|
||||
.map(venue -> PerpsRisk.venue(venue, now, snapshotReadFailed)).toList())),
|
||||
PerpsTwapWarnings.wallet(wallet, now, snapshotReadFailed)));
|
||||
List<TreeItem<PositionRow>> venues = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingVenues = childrenById(walletItem);
|
||||
for (PerpsSnapshot.Venue venue : wallet.venues()) {
|
||||
if (venue.positions().isEmpty() && venue.crossAccounts().isEmpty()) {
|
||||
if (venue.positions().isEmpty() && venue.crossAccounts().isEmpty() && venue.twapWarnings().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
boolean failed = venue.failed() || snapshotReadFailed;
|
||||
@@ -352,18 +430,20 @@ final class PerpsTab {
|
||||
? failed ? " (unavailable)" : " (loading)" : failed ? " (retained)" : "");
|
||||
TreeItem<PositionRow> venueItem = item(existingVenues,
|
||||
group(venue.id(), label, venue.totals(), status + "\n" + TOTAL_NOTES,
|
||||
PerpsRiskPresentation.summary(PerpsRisk.venue(venue, now, snapshotReadFailed))));
|
||||
PerpsRiskPresentation.summary(PerpsRisk.venue(venue, now, snapshotReadFailed)),
|
||||
PerpsTwapWarnings.venue(wallet, venue, now, snapshotReadFailed)));
|
||||
List<TreeItem<PositionRow>> positions = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingPositions = childrenById(venueItem);
|
||||
for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) {
|
||||
TreeItem<PositionRow> accountItem = item(existingPositions, new PositionRow(
|
||||
account.id(), account.name(), null, null, true, account.metrics(),
|
||||
status + "\n" + account.notes(), null, false,
|
||||
PerpsRiskPresentation.cross(account.risk(), !account.positions().isEmpty(), failed, now)));
|
||||
PerpsRiskPresentation.cross(account.risk(), !account.positions().isEmpty(), failed, now),
|
||||
PerpsTwapWarnings.cross(wallet, account, venue.name(), failed, now)));
|
||||
var children = new ArrayList<TreeItem<PositionRow>>();
|
||||
var previous = childrenById(accountItem);
|
||||
for (PerpsSnapshot.Position position : account.positions()) {
|
||||
children.add(item(previous, row(position, status, failed,
|
||||
children.add(item(previous, row(wallet, position, venue.name() + " / Cross [" + account.id() + "]", status, failed, now,
|
||||
PerpsRiskPresentation.shared(account.risk(), !account.positions().isEmpty(),
|
||||
failed, now, account.id()))));
|
||||
}
|
||||
@@ -371,7 +451,7 @@ final class PerpsTab {
|
||||
positions.add(accountItem);
|
||||
}
|
||||
for (PerpsSnapshot.Position position : venue.positions()) {
|
||||
positions.add(item(existingPositions, row(position, status, failed,
|
||||
positions.add(item(existingPositions, row(wallet, position, venue.name(), status, failed, now,
|
||||
PerpsRiskPresentation.isolated(position.risk(), position.direction(), failed, now))));
|
||||
}
|
||||
updateChildren(venueItem, positions);
|
||||
@@ -401,9 +481,11 @@ final class PerpsTab {
|
||||
: ". Periodic observation; state and reference prices can differ in time.");
|
||||
}
|
||||
|
||||
private static PositionRow row(PerpsSnapshot.Position position, String status, boolean retained, Cell risk) {
|
||||
private static PositionRow row(PerpsSnapshot.Wallet wallet, PerpsSnapshot.Position position,
|
||||
String parent, String status, boolean retained, Instant now, Cell risk) {
|
||||
return new PositionRow(position.id(), position.instrument(), position.direction(), position.margin(),
|
||||
false, position.metrics(), status + "\n" + position.notes(), position.twap(), retained, risk);
|
||||
false, position.metrics(), status + "\n" + position.notes(), position.twap(), retained, risk,
|
||||
PerpsTwapWarnings.position(wallet, position, parent, retained, now));
|
||||
}
|
||||
|
||||
private static PositionRow group(String id, String label, Metrics metrics, String notes) {
|
||||
@@ -411,7 +493,12 @@ final class PerpsTab {
|
||||
}
|
||||
|
||||
private static PositionRow group(String id, String label, Metrics metrics, String notes, Cell risk) {
|
||||
return new PositionRow(id, label, null, null, false, metrics, notes, null, false, risk);
|
||||
return group(id, label, metrics, notes, risk, List.of());
|
||||
}
|
||||
|
||||
private static PositionRow group(String id, String label, Metrics metrics, String notes, Cell risk,
|
||||
List<PerpsTwapWarnings.Notice> warnings) {
|
||||
return new PositionRow(id, label, null, null, false, metrics, notes, null, false, risk, warnings);
|
||||
}
|
||||
|
||||
private static String usd(ΩAmountΩ value) {
|
||||
@@ -470,7 +557,8 @@ final class PerpsTab {
|
||||
}
|
||||
|
||||
private record PositionRow(String id, String label, Direction direction, Margin margin,
|
||||
boolean crossAccount, Metrics metrics, String notes, Twap twap, boolean retained, Cell risk) { }
|
||||
boolean crossAccount, Metrics metrics, String notes, Twap twap, boolean retained, Cell risk,
|
||||
List<PerpsTwapWarnings.Notice> warnings) { }
|
||||
|
||||
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 "
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.r35157.evelyn.emc.impl.ref;
|
||||
|
||||
import com.r35157.evelyn.service.perps.PerpsRisk;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.TwapWarning;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** UI-owned warning scope, text and freshness; summaries never depend on visible tree children. */
|
||||
final class PerpsTwapWarnings {
|
||||
record Notice(TwapWarning warning, String wallet, String location, boolean retained, boolean stale) { }
|
||||
|
||||
static List<Notice> wallet(PerpsSnapshot.Wallet wallet, Instant now, boolean failed) {
|
||||
return distinct(wallet.venues().stream().flatMap(venue -> venue(wallet, venue, now, failed).stream()).toList());
|
||||
}
|
||||
|
||||
static List<Notice> venue(PerpsSnapshot.Wallet wallet, PerpsSnapshot.Venue venue, Instant now, boolean failed) {
|
||||
boolean retained = failed || venue.failed();
|
||||
var notices = new ArrayList<>(own(venue.twapWarnings(), wallet, venue.name(), retained, now));
|
||||
for (var position : venue.positions()) {
|
||||
notices.addAll(position(wallet, position, venue.name(), retained, now));
|
||||
}
|
||||
for (var account : venue.crossAccounts()) {
|
||||
notices.addAll(cross(wallet, account, venue.name(), retained, now));
|
||||
}
|
||||
return distinct(notices);
|
||||
}
|
||||
|
||||
static List<Notice> cross(PerpsSnapshot.Wallet wallet, PerpsSnapshot.CrossAccount account,
|
||||
String venue, boolean retained, Instant now) {
|
||||
String scope = venue + " / " + account.name() + " [" + account.id() + "]";
|
||||
var notices = new ArrayList<>(own(account.twapWarnings(), wallet, scope, retained, now));
|
||||
for (var position : account.positions()) notices.addAll(position(wallet, position, scope, retained, now));
|
||||
return distinct(notices);
|
||||
}
|
||||
|
||||
static List<Notice> position(PerpsSnapshot.Wallet wallet, PerpsSnapshot.Position position,
|
||||
String parent, boolean retained, Instant now) {
|
||||
return own(position.twapWarnings(), wallet, parent + " / " + position.instrument()
|
||||
+ " [" + position.id() + "]", retained, now);
|
||||
}
|
||||
|
||||
private static List<Notice> own(List<TwapWarning> warnings, PerpsSnapshot.Wallet wallet,
|
||||
String location, boolean retained, Instant now) {
|
||||
return warnings.stream().map(warning -> new Notice(warning, wallet.id(), location, retained,
|
||||
warning.observedAt().isAfter(now)
|
||||
|| !warning.observedAt().plus(PerpsRisk.MAX_AGE).isAfter(now))).toList();
|
||||
}
|
||||
|
||||
static List<Notice> distinct(List<Notice> notices) {
|
||||
var distinct = new TreeMap<String, Notice>();
|
||||
for (Notice notice : notices) {
|
||||
String key = notice.wallet() + ":" + notice.warning().id();
|
||||
distinct.merge(key, notice, (first, other) -> new Notice(first.warning(), first.wallet(),
|
||||
first.location(), first.retained() || other.retained(), first.stale() || other.stale()));
|
||||
}
|
||||
return List.copyOf(distinct.values());
|
||||
}
|
||||
|
||||
static String text(List<Notice> notices) {
|
||||
if (notices.size() != 1) return notices.size() + " TWAP warnings";
|
||||
return switch (notices.getFirst().warning().reason()) {
|
||||
case MARKET_UNAVAILABLE -> "Market unavailable";
|
||||
case POSITION_UNAVAILABLE -> "No matching position";
|
||||
case TIMING_UNAVAILABLE -> "Timing unavailable";
|
||||
};
|
||||
}
|
||||
|
||||
static String qualification(List<Notice> notices) {
|
||||
long retained = notices.stream().filter(Notice::retained).count();
|
||||
long stale = notices.stream().filter(Notice::stale).count();
|
||||
return (retained == 0 ? "" : retained + " retained")
|
||||
+ (retained > 0 && stale > 0 ? " · " : "") + (stale == 0 ? "" : stale + " stale");
|
||||
}
|
||||
|
||||
static String details(List<Notice> notices) {
|
||||
var text = new StringBuilder(notices.size() + (notices.size() == 1 ? " TWAP warning" : " TWAP warnings"));
|
||||
for (Notice notice : notices) {
|
||||
TwapWarning warning = notice.warning();
|
||||
text.append("\n\nWallet: ").append(notice.wallet()).append("\nScope: ").append(notice.location())
|
||||
.append("\nReason: ").append(switch (warning.reason()) {
|
||||
case MARKET_UNAVAILABLE -> "Market metadata is unavailable; the order cannot be matched to a current market.";
|
||||
case POSITION_UNAVAILABLE -> "No matching open position was present in this account's observation.";
|
||||
case TIMING_UNAVAILABLE -> "The next schedule is already due or no usable future time can be estimated; this does not prove execution stopped.";
|
||||
})
|
||||
.append("\nTWAP account: ").append(warning.twapAccount())
|
||||
.append("\nOrder sequence: ").append(warning.orderSequenceNumber())
|
||||
.append("\nAsset ID: ").append(warning.assetId())
|
||||
.append("\nTrader PDA / subaccount: ").append(warning.traderPdaIndex()).append(" / ").append(warning.subaccountIndex())
|
||||
.append("\nPosition: ").append(warning.positionSymbol() == null ? "unmatched" : warning.positionSymbol())
|
||||
.append("\nProgress: ").append(warning.executed()).append('/').append(warning.total())
|
||||
.append("\nRemaining children: ").append(warning.remaining())
|
||||
.append("\nFilled (raw base lots): ").append(warning.filledBaseLots())
|
||||
.append("\nUpdated: ").append(TIMESTAMP.format(warning.observedAt()))
|
||||
.append("\nObservation: ").append(notice.retained() ? "retained; latest read failed" : "periodic snapshot")
|
||||
.append(notice.stale() ? "; stale or future timestamp" : "");
|
||||
}
|
||||
return text.append("\n\nGroup counts refer to distinct orders, not inherited indicators. "
|
||||
+ "Observations expire after five minutes; this is not an execution deadline. "
|
||||
+ "No cancellation, delisting or execution failure is inferred. "
|
||||
+ "Raw lots are not token units or USD values; unresolved conversion and timing remain unknown.").toString();
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter
|
||||
.ofPattern("EEE uuuu/MM/dd HH:mm:ss z", Locale.ENGLISH).withZone(ZoneId.systemDefault());
|
||||
}
|
||||
@@ -33,12 +33,18 @@ public record PerpsSnapshot(List<Wallet> wallets) {
|
||||
|
||||
/** lastSuccess is null until a complete read; failed identifies a failed latest attempt. */
|
||||
public record Venue(String id, String name, List<Position> positions, List<CrossAccount> crossAccounts,
|
||||
Instant lastSuccess, boolean failed) {
|
||||
Instant lastSuccess, boolean failed, List<TwapWarning> twapWarnings) {
|
||||
public Venue(String id, String name, List<Position> positions, List<CrossAccount> crossAccounts,
|
||||
Instant lastSuccess, boolean failed) {
|
||||
this(id, name, positions, crossAccounts, lastSuccess, failed, List.of());
|
||||
}
|
||||
|
||||
public Venue {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(name, "name");
|
||||
positions = List.copyOf(positions);
|
||||
crossAccounts = List.copyOf(crossAccounts);
|
||||
twapWarnings = List.copyOf(twapWarnings);
|
||||
}
|
||||
|
||||
public Metrics totals() {
|
||||
@@ -53,7 +59,12 @@ public record PerpsSnapshot(List<Wallet> wallets) {
|
||||
}
|
||||
}
|
||||
|
||||
public record CrossAccount(String id, String name, Metrics metrics, List<Position> positions, String notes, Risk risk) {
|
||||
public record CrossAccount(String id, String name, Metrics metrics, List<Position> positions, String notes,
|
||||
Risk risk, List<TwapWarning> twapWarnings) {
|
||||
public CrossAccount(String id, String name, Metrics metrics, List<Position> positions, String notes, Risk risk) {
|
||||
this(id, name, metrics, positions, notes, risk, List.of());
|
||||
}
|
||||
|
||||
public CrossAccount(String id, String name, Metrics metrics, List<Position> positions, String notes) {
|
||||
this(id, name, metrics, positions, notes, null);
|
||||
}
|
||||
@@ -64,11 +75,17 @@ public record PerpsSnapshot(List<Wallet> wallets) {
|
||||
Objects.requireNonNull(metrics, "metrics");
|
||||
Objects.requireNonNull(notes, "notes");
|
||||
positions = List.copyOf(positions);
|
||||
twapWarnings = List.copyOf(twapWarnings);
|
||||
}
|
||||
}
|
||||
|
||||
public record Position(String id, String instrument, Direction direction, Margin margin,
|
||||
Metrics metrics, String notes, Twap twap, Risk risk) {
|
||||
Metrics metrics, String notes, Twap twap, Risk risk, List<TwapWarning> twapWarnings) {
|
||||
public Position(String id, String instrument, Direction direction, Margin margin,
|
||||
Metrics metrics, String notes, Twap twap, Risk risk) {
|
||||
this(id, instrument, direction, margin, metrics, notes, twap, risk, List.of());
|
||||
}
|
||||
|
||||
public Position(String id, String instrument, Direction direction, Margin margin,
|
||||
Metrics metrics, String notes, Twap twap) {
|
||||
this(id, instrument, direction, margin, metrics, notes, twap, null);
|
||||
@@ -85,9 +102,39 @@ public record PerpsSnapshot(List<Wallet> wallets) {
|
||||
Objects.requireNonNull(margin, "margin");
|
||||
Objects.requireNonNull(metrics, "metrics");
|
||||
Objects.requireNonNull(notes, "notes");
|
||||
twapWarnings = List.copyOf(twapWarnings);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filled outstanding order whose presentation needs attention; symbol is nullable, fills are raw lots. */
|
||||
public record TwapWarning(ΩSolanaAddressΩ twapAccount, BigInteger orderSequenceNumber,
|
||||
BigInteger assetId, int traderPdaIndex, int subaccountIndex, String positionSymbol,
|
||||
TwapWarningReason reason, BigInteger executed, BigInteger total,
|
||||
BigInteger remaining, BigInteger filledBaseLots, Instant observedAt) {
|
||||
public TwapWarning {
|
||||
Objects.requireNonNull(twapAccount, "twapAccount");
|
||||
Objects.requireNonNull(orderSequenceNumber, "orderSequenceNumber");
|
||||
Objects.requireNonNull(assetId, "assetId");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
Objects.requireNonNull(executed, "executed");
|
||||
Objects.requireNonNull(total, "total");
|
||||
Objects.requireNonNull(remaining, "remaining");
|
||||
Objects.requireNonNull(filledBaseLots, "filledBaseLots");
|
||||
Objects.requireNonNull(observedAt, "observedAt");
|
||||
if (twapAccount.isBlank() || traderPdaIndex < 0 || subaccountIndex < 0
|
||||
|| orderSequenceNumber.signum() < 0 || assetId.signum() < 0 || executed.signum() < 0
|
||||
|| remaining.signum() <= 0 || !executed.add(remaining).equals(total) || filledBaseLots.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid filled outstanding TWAP warning");
|
||||
}
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return traderPdaIndex + ":" + subaccountIndex + ":" + twapAccount + ":" + orderSequenceNumber;
|
||||
}
|
||||
}
|
||||
|
||||
public enum TwapWarningReason { MARKET_UNAVAILABLE, POSITION_UNAVAILABLE, TIMING_UNAVAILABLE }
|
||||
|
||||
/** Venue risk observation. Prices, effective collateral and maintenance have distinct meanings. */
|
||||
public record Risk(String venueState, String venueTier, ΩPriceΩ referencePriceUsd, ΩPriceΩ liquidationPriceUsd,
|
||||
ΩAmountΩ effectiveCollateralUsd, ΩAmountΩ maintenanceMarginUsd, Instant observedAt) { }
|
||||
|
||||
+21
-5
@@ -101,7 +101,7 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
WalletVenue key = new WalletVenue(wallet.address(), venueId);
|
||||
Venue previous = observations.getOrDefault(key, unknownVenue(venueId));
|
||||
observations.put(key, new Venue(venueId, venueName, previous.positions(), previous.crossAccounts(),
|
||||
previous.lastSuccess(), true));
|
||||
previous.lastSuccess(), true, previous.twapWarnings()));
|
||||
publish();
|
||||
log.warn("Unable to refresh {} Perps positions for wallet {}", venueName, wallet.address(), exception);
|
||||
}
|
||||
@@ -121,6 +121,7 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
private static Venue phoenixVenue(List<PhoenixPerpsAccount> accounts) {
|
||||
var isolated = new ArrayList<Position>();
|
||||
var cross = new ArrayList<CrossAccount>();
|
||||
var warnings = new ArrayList<PerpsSnapshot.TwapWarning>();
|
||||
for (PhoenixPerpsAccount account : accounts.stream()
|
||||
.sorted(Comparator.comparingInt(PhoenixPerpsAccount::traderPdaIndex)
|
||||
.thenComparingInt(PhoenixPerpsAccount::subaccountIndex)).toList()) {
|
||||
@@ -128,19 +129,21 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
.sorted(Comparator.comparing(PhoenixPerpsPosition::symbol))
|
||||
.map(position -> position(position, account)).toList();
|
||||
if (account.cross()) {
|
||||
if (!positions.isEmpty() || account.hasCollateral()) {
|
||||
if (!positions.isEmpty() || account.hasCollateral() || !account.twapWarnings().isEmpty()) {
|
||||
Metrics sum = Metrics.total(positions.stream().map(Position::metrics).toList());
|
||||
cross.add(new CrossAccount("cross:" + account.traderPdaIndex() + ":" + account.subaccountIndex(),
|
||||
"Cross", new Metrics(account.collateralUsd(),
|
||||
PerpsSnapshot.ratio(sum.exposureUsd(), account.equityUsd()), sum.exposureUsd(),
|
||||
account.valueUsd(), sum.pnlUsd(), null), positions, PHOENIX_ACCOUNT_NOTES,
|
||||
risk(account, null)));
|
||||
risk(account, null), warnings(account, null)));
|
||||
}
|
||||
} else {
|
||||
isolated.addAll(positions);
|
||||
// No synthetic Cross account for an isolated order without a matching position.
|
||||
warnings.addAll(warnings(account, null));
|
||||
}
|
||||
}
|
||||
return new Venue("phoenix", "Phoenix", isolated, cross, Instant.now(), false);
|
||||
return new Venue("phoenix", "Phoenix", isolated, cross, Instant.now(), false, warnings);
|
||||
}
|
||||
|
||||
private static Position position(PhoenixPerpsPosition position, PhoenixPerpsAccount account) {
|
||||
@@ -159,7 +162,20 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(),
|
||||
cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)),
|
||||
cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES, twap,
|
||||
cross ? null : risk(account, position));
|
||||
cross ? null : risk(account, position), warnings(account, position.symbol()));
|
||||
}
|
||||
|
||||
private static List<PerpsSnapshot.TwapWarning> warnings(PhoenixPerpsAccount account, String positionSymbol) {
|
||||
return account.twapWarnings().stream()
|
||||
.filter(warning -> Objects.equals(warning.positionSymbol(), positionSymbol))
|
||||
.map(warning -> new PerpsSnapshot.TwapWarning(warning.twapAccount(), warning.orderSequenceNumber(),
|
||||
warning.assetId(), account.traderPdaIndex(), account.subaccountIndex(), positionSymbol,
|
||||
switch (warning.reason()) {
|
||||
case MARKET_UNAVAILABLE -> PerpsSnapshot.TwapWarningReason.MARKET_UNAVAILABLE;
|
||||
case POSITION_UNAVAILABLE -> PerpsSnapshot.TwapWarningReason.POSITION_UNAVAILABLE;
|
||||
case TIMING_UNAVAILABLE -> PerpsSnapshot.TwapWarningReason.TIMING_UNAVAILABLE;
|
||||
}, warning.executed(), warning.total(), warning.remaining(), warning.filledBaseLots(),
|
||||
warning.observedAt())).toList();
|
||||
}
|
||||
|
||||
private static PerpsSnapshot.Risk risk(PhoenixPerpsAccount account, PhoenixPerpsPosition position) {
|
||||
|
||||
@@ -6,7 +6,14 @@ import java.util.List;
|
||||
/** Actual account-owned capital; null valuation fields mean unsupported/missing inputs. */
|
||||
public record PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boolean hasCollateral,
|
||||
ΩAmountΩ collateralUsd, ΩAmountΩ equityUsd, ΩAmountΩ valueUsd,
|
||||
List<PhoenixPerpsPosition> positions, PhoenixAccountRisk risk) {
|
||||
List<PhoenixPerpsPosition> positions, PhoenixAccountRisk risk,
|
||||
List<PhoenixTwapWarning> twapWarnings) {
|
||||
public PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boolean hasCollateral,
|
||||
ΩAmountΩ collateralUsd, ΩAmountΩ equityUsd, ΩAmountΩ valueUsd,
|
||||
List<PhoenixPerpsPosition> positions, PhoenixAccountRisk risk) {
|
||||
this(traderPdaIndex, subaccountIndex, hasCollateral, collateralUsd, equityUsd, valueUsd, positions, risk, List.of());
|
||||
}
|
||||
|
||||
public PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boolean hasCollateral,
|
||||
ΩAmountΩ collateralUsd, ΩAmountΩ equityUsd, ΩAmountΩ valueUsd, List<PhoenixPerpsPosition> positions) {
|
||||
this(traderPdaIndex, subaccountIndex, hasCollateral, collateralUsd, equityUsd, valueUsd, positions, null);
|
||||
@@ -17,6 +24,12 @@ public record PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boole
|
||||
throw new IllegalArgumentException("Invalid Phoenix account identity");
|
||||
}
|
||||
positions = List.copyOf(positions);
|
||||
twapWarnings = List.copyOf(twapWarnings);
|
||||
for (PhoenixTwapWarning warning : twapWarnings) {
|
||||
if (warning.subaccountIndex() != subaccountIndex) {
|
||||
throw new IllegalArgumentException("TWAP warning belongs to a different Phoenix account");
|
||||
}
|
||||
}
|
||||
if (subaccountIndex != 0 && positions.size() > 1) {
|
||||
throw new IllegalArgumentException("Isolated account has more than one open position");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.r35157.libs.phoenix.perps;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/** An observed problem presenting a filled, outstanding order; no execution failure is inferred. */
|
||||
public record PhoenixTwapWarning(ΩSolanaAddressΩ twapAccount, BigInteger orderSequenceNumber,
|
||||
BigInteger assetId, int subaccountIndex, String positionSymbol, Reason reason,
|
||||
BigInteger executed, BigInteger total, BigInteger remaining,
|
||||
BigInteger filledBaseLots, Instant observedAt) {
|
||||
public PhoenixTwapWarning {
|
||||
Objects.requireNonNull(twapAccount, "twapAccount");
|
||||
Objects.requireNonNull(orderSequenceNumber, "orderSequenceNumber");
|
||||
Objects.requireNonNull(assetId, "assetId");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
Objects.requireNonNull(executed, "executed");
|
||||
Objects.requireNonNull(total, "total");
|
||||
Objects.requireNonNull(remaining, "remaining");
|
||||
Objects.requireNonNull(filledBaseLots, "filledBaseLots");
|
||||
Objects.requireNonNull(observedAt, "observedAt");
|
||||
if (twapAccount.isBlank() || orderSequenceNumber.signum() < 0 || assetId.signum() < 0
|
||||
|| subaccountIndex < 0 || subaccountIndex > 255 || executed.signum() < 0
|
||||
|| remaining.signum() <= 0 || !executed.add(remaining).equals(total) || filledBaseLots.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid filled outstanding TWAP warning");
|
||||
}
|
||||
}
|
||||
|
||||
public enum Reason { MARKET_UNAVAILABLE, POSITION_UNAVAILABLE, TIMING_UNAVAILABLE }
|
||||
}
|
||||
+12
-8
@@ -7,7 +7,6 @@ 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 com.r35157.libs.phoenix.perps.PhoenixAccountRisk;
|
||||
import com.r35157.libs.solana.SolanaBlockChain;
|
||||
import com.r35157.libs.solana.SolanaProgramAddressSeed;
|
||||
@@ -110,7 +109,12 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
}
|
||||
}
|
||||
rawAccounts.sort(Comparator.comparingInt(account -> account.get("subaccountIndex").intValue()));
|
||||
Map<String, JsonNode> markets = symbolsNeeded.isEmpty() ? Map.of() : markets();
|
||||
JsonNode twapSnapshot = rawAccounts.isEmpty() ? null
|
||||
: get("v1/trader/" + authority + "/twap?traderPdaIndex=0");
|
||||
Instant twapObservedAt = Instant.now();
|
||||
boolean needsMarkets = !symbolsNeeded.isEmpty()
|
||||
|| twapSnapshot != null && PhoenixTwapDecoder.hasFilledOrders(twapSnapshot);
|
||||
Map<String, JsonNode> markets = needsMarkets ? markets() : Map.of();
|
||||
var prices = new HashMap<String, BigDecimal>();
|
||||
var priceTimes = new HashMap<String, Instant>();
|
||||
// Deterministic price order; one price read per distinct held market, not per position.
|
||||
@@ -118,9 +122,8 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
prices.put(symbol, markPrice(symbol));
|
||||
priceTimes.put(symbol, Instant.now());
|
||||
}
|
||||
var twaps = symbolsNeeded.isEmpty() ? Map.<String, PhoenixTwapOrder>of()
|
||||
: PhoenixTwapDecoder.decode(get("v1/trader/" + authority + "/twap?traderPdaIndex=0"),
|
||||
authority, rawAccounts, markets, prices, Instant.now());
|
||||
var twaps = twapSnapshot == null ? new PhoenixTwapDecoder.Decoded(Map.of(), List.of())
|
||||
: PhoenixTwapDecoder.decode(twapSnapshot, authority, rawAccounts, markets, prices, twapObservedAt);
|
||||
BigDecimal feeMultiplier = optionalDecimal(snapshot, "takerFeeOverrideMultiplier");
|
||||
if (feeMultiplier != null && feeMultiplier.signum() < 0) {
|
||||
throw invalid("Negative REST fee override multiplier");
|
||||
@@ -128,6 +131,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
var accounts = new ArrayList<PhoenixPerpsAccount>();
|
||||
for (JsonNode rawAccount : rawAccounts) {
|
||||
int index = integer(rawAccount, "subaccountIndex");
|
||||
var warnings = twaps.warnings().stream().filter(warning -> warning.subaccountIndex() == index).toList();
|
||||
var positions = new ArrayList<PhoenixPerpsPosition>();
|
||||
for (JsonNode raw : positions(rawAccount)) {
|
||||
BigInteger lots = lots(raw, "basePositionLots");
|
||||
@@ -156,7 +160,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
: 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, twaps.get(index + ":" + symbol)));
|
||||
optionalQuote(raw, "unsettledFundingQuoteLots"), closeFee, twaps.positions().get(index + ":" + symbol)));
|
||||
}
|
||||
positions.sort(Comparator.comparing(PhoenixPerpsPosition::symbol));
|
||||
ΩAmountΩ collateral = optionalQuote(rawAccount, "collateral");
|
||||
@@ -178,7 +182,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
}
|
||||
ΩAmountΩ value = equity == null || fees == null ? null : equity.subtract(fees);
|
||||
PhoenixAccountRisk risk = null;
|
||||
if (!positions.isEmpty() || index == 0 && hasCollateral) {
|
||||
if (!positions.isEmpty() || index == 0 && (hasCollateral || !warnings.isEmpty())) {
|
||||
if (index > 255) {
|
||||
throw invalid("Phoenix subaccount index exceeds one-byte PDA seed");
|
||||
}
|
||||
@@ -201,7 +205,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
}
|
||||
positions = withRisk;
|
||||
}
|
||||
accounts.add(new PhoenixPerpsAccount(0, index, hasCollateral, collateral, equity, value, positions, risk));
|
||||
accounts.add(new PhoenixPerpsAccount(0, index, hasCollateral, collateral, equity, value, positions, risk, warnings));
|
||||
}
|
||||
return List.copyOf(accounts);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.r35157.libs.phoenix.perps.impl.ref;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixTwapOrder;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixTwapWarning;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixTwapWarning.Reason;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
@@ -9,13 +12,29 @@ import java.math.BigInteger;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Decodes only existing positions' active, filled orders. SDK snapshot counts include dust. */
|
||||
/** Preserves filled outstanding orders independently of position matching. Counts include dust. */
|
||||
final class PhoenixTwapDecoder {
|
||||
static Map<String, PhoenixTwapOrder> decode(JsonNode snapshot, String authority,
|
||||
record Decoded(Map<String, PhoenixTwapOrder> positions, List<PhoenixTwapWarning> warnings) {
|
||||
Decoded {
|
||||
positions = Map.copyOf(positions);
|
||||
warnings = List.copyOf(warnings);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean hasFilledOrders(JsonNode snapshot) throws IOException {
|
||||
for (JsonNode entry : entries(snapshot)) {
|
||||
if (entry.has("order") && natural(entry.get("order"), "baseLotsFilled").signum() > 0
|
||||
&& natural(entry.get("order"), "childOrdersRemaining").signum() > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Decoded 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) {
|
||||
@@ -39,16 +58,18 @@ final class PhoenixTwapDecoder {
|
||||
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 addresses = new HashSet<String>();
|
||||
var result = new HashMap<String, PhoenixTwapOrder>();
|
||||
for (JsonNode entry : entries) {
|
||||
var warnings = new ArrayList<PhoenixTwapWarning>();
|
||||
for (JsonNode entry : entries(snapshot)) {
|
||||
int subaccount = natural(entry, "traderSubaccountIndex").intValueExact();
|
||||
BigInteger asset = natural(entry, "assetId");
|
||||
if (!seen.add(subaccount + ":" + asset)) {
|
||||
String address = text(entry, "twapAccount");
|
||||
if (new Base58CodecImpl().decode(address).length != 32 || subaccount > 255) {
|
||||
throw invalid("Invalid TWAP account identity");
|
||||
}
|
||||
if (!seen.add(subaccount + ":" + asset) || !addresses.add(address)) {
|
||||
throw invalid("Duplicate TWAP subaccount/asset");
|
||||
}
|
||||
// Optional means absent, not null or an invalid object.
|
||||
@@ -62,11 +83,7 @@ final class PhoenixTwapDecoder {
|
||||
|| !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 sequence = natural(order, "orderSequenceNumber");
|
||||
BigInteger filled = natural(order, "baseLotsFilled");
|
||||
BigInteger executed = natural(order, "childOrdersExecuted");
|
||||
BigInteger total = natural(order, "nChildOrders");
|
||||
@@ -77,6 +94,15 @@ final class PhoenixTwapDecoder {
|
||||
if (filled.signum() == 0 || remaining.signum() == 0) {
|
||||
continue;
|
||||
}
|
||||
JsonNode market = byAsset.get(asset);
|
||||
String symbol = market == null ? null : text(market, "symbol");
|
||||
String key = symbol == null ? null : subaccount + ":" + symbol;
|
||||
if (market == null || !held.contains(key)) {
|
||||
warnings.add(new PhoenixTwapWarning(address, sequence, asset, subaccount, null,
|
||||
market == null ? Reason.MARKET_UNAVAILABLE : Reason.POSITION_UNAVAILABLE,
|
||||
executed, total, remaining, filled, observedAt));
|
||||
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");
|
||||
@@ -116,8 +142,18 @@ final class PhoenixTwapDecoder {
|
||||
result.put(key, new PhoenixTwapOrder(executed, total, orderQuantity, nextQuantity,
|
||||
regularQuantity, dustQuantity, units(filled, exponent), prices.get(text(market, "symbol")),
|
||||
next, completion, observedAt));
|
||||
if (next == null) {
|
||||
warnings.add(new PhoenixTwapWarning(address, sequence, asset, subaccount, symbol,
|
||||
Reason.TIMING_UNAVAILABLE, executed, total, remaining, filled, observedAt));
|
||||
}
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
return new Decoded(result, warnings);
|
||||
}
|
||||
|
||||
private static JsonNode entries(JsonNode snapshot) throws IOException {
|
||||
JsonNode entries = snapshot.get("accounts");
|
||||
if (entries == null || !entries.isArray()) throw invalid("Missing TWAP accounts array");
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static Instant estimate(Instant observedAt, BigInteger slots) {
|
||||
|
||||
Reference in New Issue
Block a user