diff --git a/docs/Evelyn.md b/docs/Evelyn.md index 2a0bdb1..ffdd44b 100644 --- a/docs/Evelyn.md +++ b/docs/Evelyn.md @@ -445,7 +445,9 @@ 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 og ét TWAP-opslag, når der er positioner. Begge har 10 sekunders request-timeout; Phoenix bevarer fælles +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. +Begge har 10 sekunders request-timeout; Phoenix bevarer fælles pacing og 429-cooldown. En fuld opdatering tager opslagstid plus pausen, ikke nødvendigvis ét minut. Implementeringens `close()` afbryder worker'en; consumer-API'et har kun læsning. @@ -457,7 +459,7 @@ oprindelige succes-tidspunkt. Andre wallets/venues forsøges stadig. Et gyldigt rydder den pågældende scope og kan totalisere nul; HTTP-/formatfejl kan ikke. En gyldig Cross-konto med kapital bevares, selv om dens sidste position lukkes. -EMC viser **Position | Direction | Margin | Collateral | Leverage | Exposure | Value | uPnL | TWAP** +EMC viser **Position | Risk | Direction | Margin | Collateral | Leverage | Exposure | Value | uPnL | TWAP** under den synlige rod **Perpetual Contracts**. Isolerede positioner har wallet → venue → position; Phoenix Cross har wallet → venue → Cross → position. Positionens konto-ID bestemmer identitet og tilhørsforhold, aldrig instrumentteksten. Wallets følger den @@ -465,7 +467,8 @@ hidtidige konfiguration med Jupiter før Phoenix. En venue uden positioner eller 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 -rækkeobjekter bevares; uændrede snapshots opdaterer ikke træet. Vinduesstart er 1100 × 600, +rækkeobjekter bevares; kun ændrede rækkeværdier eller børn opdateres, også når risikodata +æ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 @@ -493,8 +496,8 @@ positiv Collateral. På Cross-positioner er Collateral et initialt marginkrav i ikke særskilt ejet kapital. Cross-gruppens leverage er bruttoeksponering / udiskonteret equity før estimerede exitgebyrer. Cross-positionernes leverage og ROI-procent er utilgængelige, fordi Phoenix-konventionerne ikke er verificeret; dollar-uPnL vises. -Cross-positioners Value er ikke entydigt defineret og vises som —. Kontoudnyttelse er -ikke implementeret. +Cross-positioners Value er ikke entydigt defineret og vises som —. Risk viser særskilt +vedligeholdelsesmarginens udnyttelse; det er ikke samlet initial-marginudnyttelse eller fri købekraft. Value er et estimat ved referencepriser, ikke et eksekverbart tilbud. Phoenix medregner collateral, pris-uPnL og signed uafregnet funding og fratrækker estimerede takergebyrer @@ -515,5 +518,59 @@ på wallet-rækkens tooltip. Gamle tal beskrives ikke som nye. EMC spørger hver miljøservice straks ved åbning og hvert tredje sekund uden at blokere JavaFX-tråden. Skjul/luk stopper kun EMC-polling. Der er ingen sample-data, Refresh-knap, handel, signering, kontoaktivering eller automatisk genstart af Hub'en i denne læsevej. + +### Risiko i Perps + +Risk står ved positionsnavnet og bruger tekst og en statusprik, mens Long/Short- +baggrundene bevares. På isolerede positioner vises rapporteret likvideringsafstand: + +```text +Long: 100 × (venue-referencepris − likvideringspris) / venue-referencepris +Short: 100 × (likvideringspris − venue-referencepris) / venue-referencepris +``` + +Ejeren valgte 24. oktober 2026 **neutral positiv afstand og rød ved nået/overskredet +grænse**. Der er ingen generelle grøn/gul/rød-grænser pr. marked. Positiv uPnL eller +lav leverage tildeler ikke en sikkerhedsstatus. Negativ afstand bevarer fortegnet; +det er en observeret grænseoverskridelse, ikke bevis for gennemført likvidering. +Manglende, nul og negative sentinel-priser er ukendte, ikke uendelig sikkerhed. +Tooltip viser venue-priser, præcis afstand, eventuel rapporteret tier og observationstid. + +Phoenix' egne tiers bruger grøn **OK**, gul **At risk**, orange **Cancellable** og +rød **Liquidatable**, **Backstop** eller **High risk**. En selvstændigt gyldig +kontoklasse kan bruges, selv om en positiv likvideringspris mangler. På Cross-kontoen: + +```text +MM utilization = 100 × maintenance margin / positiv effective collateral +USD headroom = effective collateral − maintenance margin +``` + +MM er marginudnyttelse, ikke likvideringssandsynlighed eller en prisafstand. Phoenix' +præcise tier styrer likvideringsstatus; visningsafrunding bruges ikke til klassifikation. +Ved ikke-positiv effektiv sikkerhed er procenten utilgængelig. En kendt tom konto +uden vedligeholdelseskrav vises som **No positions**; det lover ikke noget om fremtidige +ordrefills. Manglende nødvendige data eller modstridende +Safe-data må ikke vises grønt. Cross-positioner viser **Shared** med konto-ID i tooltip, +og kontoens risiko tælles kun én gang uanset antallet af positioner. + +Grupper viser værste kendte risiko og antal ukendte/forældede scopes uden gennemsnit +af afstande eller MM-procenter. En neutral afstand gør ikke hele gruppen grøn; **Distance +only** angiver, at der ikke er tildelt en sikkerhedsklasse. Før første succes er en +venue ukendt. En tidligere tom venue kan også blive forældet, selv om dens overskrift +er skjult. Kendt kritisk risiko forbliver rød med usikkerhedsmarkering efter fejl/aldring. + +Risikodata bliver **Stale** ved fem minutter fra ældste nødvendige observation, +straks ved venue- eller snapshot-læsefejl eller ved et fremtidigt tidsstempel. Også +et fejlet `getPositionsSnapshot()` genviser den beholdte observation med fejlstatus. +Fem minutter er en grænse +for datakvalitet, ikke en garanti for sikkerhed i perioden. Langsomme normale runder +kan derfor give Stale. Den eksisterende tresekunders UI-polling genvurderer alder, +også når servicen returnerer samme snapshot, uden en ny timer eller flere venue-kald. +Forældet eller ufuldstændig ikke-kritisk risiko får ingen grøn statusprik. +Tidsstempler er modtagelsestider; API-lag og separate pris-/kontolæs begrænser aktualiteten. +Visningen viser senest observeret risiko og er ikke en livealarm. Datakilderne er +beskrevet i [Jupiter-overblikket](JupiterPerpsOverview.md) og +[Phoenix-risikolæsningen](PhoenixPerps.md#venue-reported-account-risk). + Den kanoniske systembeskrivelse findes i [Evelyn: services og status](../../r35157_docs/projects/evelyn/README.md#evelynperpsservice). diff --git a/docs/JupiterPerpsOverview.md b/docs/JupiterPerpsOverview.md index 8051867..4b5a8c9 100644 --- a/docs/JupiterPerpsOverview.md +++ b/docs/JupiterPerpsOverview.md @@ -26,12 +26,22 @@ amounts fail the read. | uPnL | `pnlBeforeFeesUsd / 10^6`, price PnL before separately reported fees | | uPnL percent | 100 × uPnL / positive Collateral | | Value | Unavailable; API net-value semantics have not passed verification | +| Risk reference price | Positive `markPriceUsd / 10^6` from the same response | +| Reported liquidation price | Positive `liquidationPriceUsd / 10^6`; missing/nonpositive values are unavailable | All amounts are parsed as decimal integers and converted exactly. The API's `pnlBeforeFeesPct` is already percentage points (95.48 means 95.48%), but the overview derives the requested percentage against precise collateral itself. The API's rounded `leverage` is likewise not used for the derived ratio. +The overview preserves one original response observation time on all positions. +It does not treat a position's `updatedTime` as proof of current price freshness. +Risk uses the venue's reported prices, with no new liquidation-price formula or +Ticker dependency. Missing/sentinel prices retain the position and its independent +metrics. Present malformed amounts still fail the read. Isolated signed distance, +the owner's neutral-distance policy and expiration are described in +[Perps risk presentation](Evelyn.md#risiko-i-perps). + ## Why API Value is not displayed On 24 October 2026 the funded configured wallet's six API sizes and collateral diff --git a/docs/PhoenixPerps.md b/docs/PhoenixPerps.md index 6c46145..b02b38f 100644 --- a/docs/PhoenixPerps.md +++ b/docs/PhoenixPerps.md @@ -1,10 +1,12 @@ # Phoenix Perps read-only integration -`PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition` and `PhoenixTwapOrder` live in +`PhoenixPerpsService`, `PhoenixPerpsAccount`, `PhoenixPerpsPosition`, `PhoenixAccountRisk` and `PhoenixTwapOrder` live in `com.r35157.libs.phoenix.perps`; the REST implementation is in `impl.ref`. They contain no Evelyn/UI types or signing/trading operations. NenjimHub creates one adapter without a portfolio cache as `phoenix.perps.default` and injects it into both Evelyn Perps processes. Each process owns its own wallet list, refresh worker and snapshot. +The adapter also receives the existing `SolanaBlockChain` for local trader PDA +derivation only; risk lookup does not use its RPC or signing operations. See [Evelyn](Evelyn.md#12-perps-service-og-mission-control) for consumer behavior and [the canonical system overview](../../r35157_docs/projects/evelyn/README.md#evelynperpsservice) for configuration and deployment status. @@ -31,10 +33,17 @@ The implementation uses these official endpoints at `https://perp-api.phoenix.tr 4. [`GET /v1/trader/{authority}/twap?traderPdaIndex=0`](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/api/traders/client.ts) supplies active TWAP snapshots when the wallet has held positions. Only filled orders with remaining children on matching existing positions are returned; see [TWAP observations](#twap-observations). +5. [`GET /v1/view/trader/{pubkey}`](https://docs.phoenix.trade/api/trader/get-trader) + supplies risk for each visible account, including funded empty Cross accounts. + Its key is the derived trader account, not the wallet or a TWAP key. See + [venue-reported account risk](#venue-reported-account-risk). -An empty trader state needs only the first request. Otherwise metadata is fetched once +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. Request starts are spaced at least one second apart across both environments to avoid +held in several subaccounts, followed by one TWAP read and each visible account's +risk read. Request starts are spaced at least one second apart across both environments to avoid bursts of per-market reads. Each HTTP request has a 10-second timeout and uses the Hub's existing HTTP client (no redirects by default). On HTTP 429 the adapter respects `Retry-After` (seconds or HTTP date; 60 seconds if missing/invalid). Further calls @@ -92,13 +101,49 @@ external data; EMC's three-second polling remains unchanged. Positions and prices are separate REST reads and can belong to different slots. This is a periodic current-notional estimate, not an atomic exchange valuation. A cycle's duration includes all sequential reads and request pacing before the -60-second pause (32 active markets need at least about 34 seconds for their reads). Failed +60-second pause (32 active markets and one visible account need at least about 35 seconds for their reads). Failed reads can leave old data visible indefinitely. A visible venue shows `(retained)` after a failed refresh; cell tooltips include the last-success time. Childless venues are hidden, with failed or pending first-read status available in their wallet's tooltip. Such unknown scopes cannot contribute fabricated zero totals. Trader PDA indices other than 0, streaming and position management are out of scope. +## Venue-reported account risk + +The [official address derivation](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/pdas.ts) +uses program `EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih` and seeds +`trader`, authority bytes, a one-byte PDA index and a one-byte subaccount index. +The adapter uses the existing local Solana derivation, validates the returned +authority/trader key/PDA/subaccount, and verifies the nonzero position membership +and signed quantities against trader state. A fill during the separate reads can +therefore cause a retained failed observation rather than attach risk to mismatched +position state. Lookup works without any active TWAP. + +TraderView returns `riskState`, `riskTier`, `effectiveCollateral`, +`maintenanceMargin` and position `liquidationPrice`. The actual deployed amount +objects contain integer `value` and `decimals`; the reader computes exact decimal +amounts with decimal exponents from -18 through 18 and ignores formatted `ui` strings. +Positive liquidation prices are +retained; `-1`, zero, other nonpositive values and absent prices are unavailable. +They are not zero risk or infinite safety. Optional missing fields and unsupported +future tiers cannot silently become Safe. Malformed amounts, identity mismatches +and rejected HTTP reads fail the complete wallet/venue observation. + +Cross risk belongs to the shared account. The reported effective collateral can +include funding, discounted positive PnL and collateral haircuts; it is distinct +from the overview's undiscounted equity and estimated closing Value. Risk therefore +can be available even where the older capital valuation cannot value spot collateral. +The reported thresholds and tier are preserved without inventing a liquidation engine. +See [Phoenix account health](https://docs.phoenix.trade/phoenix/margin-and-risk/account-health) +and [margin math](https://docs.phoenix.trade/phoenix/margin-and-risk/margin-math). + +Account risk carries its original response receipt time. Isolated risk combines +that account's reported liquidation price with its already fetched Phoenix mark; +its observation time is the older receipt. Prices and account data are separate +reads and can lag the chain. Existing failures retain original timestamps. The +[Risk presentation](Evelyn.md#risiko-i-perps) defines five-minute expiration, +neutral isolated distance, venue tier colors and group summaries. + ## TWAP observations TWAP data is matched by requested authority, PDA index, subaccount and market `assetId` @@ -202,8 +247,9 @@ venue's displayed conventions have not been verified; the UI explains this and shows dollar PnL. Isolated leverage/ROI use actual collateral. Cross position Value is inapplicable; Cross account Value counts all shared capital once, including free collateral when its last position closes. Other aggregate ratios -are blank. No account margin-utilization percentage is claimed; orders/risk -adjustments are not included in such a metric. +are blank. Risk separately displays reported maintenance-margin utilization +against effective collateral. It is not total initial-margin utilization, free +buying power or the ratio used for the Leverage column. Value is an estimate at separately read reference prices, not an executable quote. Slippage, price impact, swaps, network fees, account rent, future funding and diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/.openspec.yaml b/openspec/changes/archive/2026-10-26-102-perps-risk-health/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/design.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/design.md new file mode 100644 index 0000000..efdbf59 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/design.md @@ -0,0 +1,33 @@ +# Design + +## Context + +See proposal.md for motivation. Positions/account capital and TWAP already travel through immutable integration DTOs and per-venue retained snapshots. EMC polls these snapshots every three seconds, currently skips equal snapshots and reconciles tree items by identity. Risk cannot use closing Value or undiscounted equity as effective collateral. + +## Goals / Non-Goals + +**Goals:** Venue-reported risk with original timestamps, shared-account scope, exact arithmetic and honest incomplete/expired summaries. + +**Non-Goals:** A liquidation engine, position management, notifications, faster polling, external writes or a general risk-policy platform. + +## Decisions + +1. The owner explicitly selected neutral isolated price distance with red only at a reached/crossed boundary. Phoenix tiers retain their venue-specific colors. Do not introduce speculative per-market safety thresholds. Never use rounded display numbers for classification. +2. Use five minutes from observation receipt as the maximum age; failures immediately mark retained observations stale. Normal sequential reads across six PROD wallets can approach five minutes, so slow cycles can deliberately produce stale indications. This is a data-quality cutoff, not a claim that prices remain safe for five minutes. Future timestamps are uncertain too. Source/API lag remains an explicit limitation. +3. Extend Jupiter's overview-only DTO with micro-USD reference/liquidation prices and the original response observation time. Preserve signing/alarm readers. +4. Inject the existing SolanaBlockChain into the Phoenix adapter for local PDA derivation only. Use official program `EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih`, seeds `trader`, authority bytes, one-byte PDA index and one-byte subaccount. GET TraderView per visible account, validate its authority/account/PDA/subaccount and position identities/quantities. Decode value/decimals exactly, ignore UI strings and normalize nonpositive liquidation prices to unavailable. Reuse already-read Phoenix marks with their receipt times; isolated risk freshness uses the oldest required observation. Account risk uses the self-contained TraderView receipt time. No TWAP account key or signature is needed. +5. Keep distinct Phoenix risk DTOs and Evelyn risk observations. Cross account risk belongs to CrossAccount; Cross positions only refer to that scope in presentation. Exact signed distance, MM utilization and headroom are domain calculations; colors/text belong to EMC. Missing essential fields remain unknown. A valid account tier can exist without an actionable liquidation price. +6. Risk is the second column. A domain helper evaluates each isolated position and each Cross account once, plus unknown/expired venue scopes. Group summaries retain the worst known level, unknown count and stale count, without averaging. Known critical observations remain prominent even if old; incomplete/noncritical summaries are never unconditional green. +7. Re-evaluate risk with the existing UI poll and an injectable Clock, including equal snapshots. On a consumer snapshot-read failure, re-render the retained snapshot with an explicit read-failure flag; do not let a failing getter bypass freshness indefinitely. Reconcile immutable row values only when they differ; do not call table.refresh or replace tree items. This expires unchanged snapshots without a new timer/network loop and preserves selection, expansion and TWAP behavior. +8. A malformed/rejected risk read fails the venue read and preserves its previous complete observation/status. Missing optional metrics or unknown future risk tiers remain explicitly unavailable. Other wallets/venues continue. Existing request pacing, timeout and cooldown apply to the new GETs. + +## Risks / Trade-offs + +- API/mark/state reads are not atomic → validate identity/position size, use oldest required receipt time and explain reference-price lag. +- Extra per-account reads → reuse current pacing; no risk reads for invisible empty isolated accounts; source address derivation is local. +- Nullable/sentinel fields → independent valid metrics survive, missing risk never becomes zero or an all-clear. +- Different venues have different meanings → isolated distance and Cross MM utilization retain distinct labels and definitions. + +## Migration Plan + +No persisted data or owner configuration migration. Wire the existing Solana dependency in NenjimHub, build and verify with temporary fixtures, UI checks and read-only observations. Update existing docs/shared docs and sync affected specs. Leave deployment and the running Hub unchanged. diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/proposal.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/proposal.md new file mode 100644 index 0000000..3c3d449 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/proposal.md @@ -0,0 +1,31 @@ +# Proposal + +## Why + +The owner cannot currently distinguish liquidation risk from profitability in EMC. Issue #102 requires venue-reported risk at isolated-position and shared Cross-account level, including honest handling of old or incomplete observations. + +## What Changes + +- Add venue reference/liquidation prices to Jupiter's overview-only observations. +- Read Phoenix TraderView risk using officially derived trader addresses independently of TWAP, preserving account thresholds and sentinel handling. +- **BREAKING**: Phoenix adapter constructors now require the existing SolanaBlockChain dependency for local address derivation; Hub wiring is updated. +- Add a Risk column, scope-aware summaries and freshness expiration during existing UI polling. +- Use the owner's selected policy: neutral isolated distance, red only for reached/crossed boundary; Phoenix's own tiers retain their colors. +- Expire risk observations at five minutes without implying a five-minute safety guarantee. Preserve prior critical states with age/failure annotations. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `jupiter-perps-overview`: reported risk prices and observation timestamps. +- `phoenix-perps-service`: official address resolution and reported account/position risk. +- `evelyn-perps-service`: immutable risk scopes with exact distance/utilization and retained status. +- `evelyn-perps-overview`: Risk cells, deduplicated group summaries and expiration of unchanged snapshots. + +## Impact + +Integration DTOs/readers, Phoenix's existing Solana dependency wiring, Evelyn snapshots, JavaFX cells and existing project/shared documentation. No new dependencies, trading, live polling, commits or pushes. diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-overview/spec.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-overview/spec.md new file mode 100644 index 0000000..39d847b --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-overview/spec.md @@ -0,0 +1,40 @@ +## 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 and TWAP blank, and be expanded by default. + +#### Scenario: Open either environment's Perps tab +- **WHEN** the owner opens Perps in Production or Test +- **THEN** all 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: 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. + +#### 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 + +## ADDED Requirements + +### Requirement: Risk cells distinguish distance and account health +Risk cells SHALL show neutral isolated distance and red reached/crossed boundaries, while Phoenix tiers use their own severity colors. Cross account cells SHALL label MM utilization; their children SHALL indicate Shared scope. Missing risk SHALL be Unknown. Text, indicators and tooltips SHALL explain units, reference/liquidation prices, account scope, thresholds, original observation time and limitations without changing direction backgrounds. + +#### Scenario: Safe account without liquidation estimate +- **WHEN** Phoenix reports a valid Safe account but a nonpositive liquidation-price sentinel +- **THEN** account health remains available while the position price estimate is unavailable + +### Requirement: Risk freshness expires unchanged snapshots +EMC SHALL mark risk stale after five minutes from its original observation, on read failure or for future timestamps. Existing UI polling SHALL update freshness even when the snapshot is unchanged, without additional venue calls/timers. Old critical states SHALL remain prominent with uncertainty; stale or incomplete noncritical observations SHALL NOT appear fresh green. Reconciliation SHALL preserve row identity, selection, expansion and TWAP state. + +#### Scenario: Unchanged successful snapshot grows old +- **WHEN** time passes the five-minute risk age limit without new data or errors +- **THEN** existing UI polling changes its freshness indication without resetting the tree + +#### 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 diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-service/spec.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-service/spec.md new file mode 100644 index 0000000..c13f71c --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/evelyn-perps-service/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### 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. + +#### Scenario: Shared positions and incomplete data +- **WHEN** a Cross account owns multiple positions and another scope has unknown risk +- **THEN** account risk contributes once and the aggregate exposes the unknown count +- **AND** its closing Value is not substituted for effective collateral + +#### Scenario: Boundary and zero denominator +- **WHEN** isolated distance reaches/crosses zero or effective collateral is nonpositive +- **THEN** the signed boundary is preserved or MM percentage is unavailable respectively +- **AND** display rounding does not determine risk classification diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/jupiter-perps-overview/spec.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/jupiter-perps-overview/spec.md new file mode 100644 index 0000000..e9a80a1 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/jupiter-perps-overview/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: Report venue risk prices with observation time +The overview SHALL expose the venue reference and liquidation prices in exact USD units with the original response observation time. Missing or nonpositive risk prices SHALL be unavailable, never zero safety or infinite distance. Stable position identity and the separate transaction/alarm read path SHALL remain unchanged. + +#### Scenario: Missing or sentinel price +- **WHEN** a valid position has no actionable liquidation price +- **THEN** its other metrics remain present and its liquidation distance is unavailable diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/phoenix-perps-service/spec.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/phoenix-perps-service/spec.md new file mode 100644 index 0000000..1c3710b --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/specs/phoenix-perps-service/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: Read venue account health independently of TWAP +The reader SHALL resolve the official trader account for authority/PDA/subaccount and read its venue risk classification, effective collateral, maintenance margin and position liquidation prices. It SHALL validate identities and amounts, use venue reference prices with original observation times and normalize sentinel liquidation prices to unavailable. Existing pacing, cooldown, timeout and failure retention contracts SHALL apply without signing or TWAP dependence. + +#### Scenario: Same-symbol isolated and Cross accounts +- **WHEN** the wallet holds the same market in multiple accounts without active TWAPs +- **THEN** each visible account receives only its own reported risk and liquidation data +- **AND** a mismatched trader identity or position size fails the observation + +#### Scenario: Missing or unsupported risk field +- **WHEN** a response has missing optional risk values or an unknown tier +- **THEN** those values remain unavailable without assuming a safe state +- **AND** a nonpositive liquidation-price sentinel does not invalidate independent account health diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/tasks.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/tasks.md new file mode 100644 index 0000000..d287cb4 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/tasks.md @@ -0,0 +1,17 @@ +# Tasks + +## 1. Integration risk observations + +- [x] 1.1 Extend Jupiter overview risk prices and Phoenix account/position risk reads with validated local PDA resolution; verify exact units, sentinels, identities and paced failures in temporary fixtures. +- [x] 1.2 Update integration documentation for the actual risk sources, units, dependencies and failure behavior; verify against the code and official contracts. + +## 2. Snapshot and presentation + +- [x] 2.1 Add immutable risk mapping and scope-aware assessments/summaries; verify signed distances, tiers, partial scopes, deduplication and nonpositive collateral with temporary checks. +- [x] 2.2 Render Risk cells/tooltips and age unchanged snapshots on existing UI polling; verify direction/TWAP preservation, shared scope, critical retention and selection/expansion with JavaFX checks. +- [x] 2.3 Update Evelyn and shared r35157_docs documentation with the owner-selected neutral-distance policy and five-minute freshness cutoff; verify links and remove conflicting descriptions. + +## 3. Integration verification + +- [x] 3.1 Run the normal build and focused fixtures/UI/read-only live observations, recording evidence and limitations. +- [x] 3.2 Sync all four delta specs, validate affected specs/change and inspect final diffs without committing or pushing. diff --git a/openspec/changes/archive/2026-10-26-102-perps-risk-health/verification.md b/openspec/changes/archive/2026-10-26-102-perps-risk-health/verification.md new file mode 100644 index 0000000..5527c71 --- /dev/null +++ b/openspec/changes/archive/2026-10-26-102-perps-risk-health/verification.md @@ -0,0 +1,96 @@ +# Verification — issue #102 + +Verified locally on 2026-10-24. The owner explicitly selected neutral isolated +distance, red only at a reached/crossed boundary, and Phoenix's own tier colors. +No commit, push, deployment, Hub restart, trading or signing was performed. +Unrelated pre-existing staged/deleted issue #97/#99 OpenSpec files were preserved. + +## Build and focused fixtures + +`JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build` passed; all 42 existing +tests passed with no failures/errors. Intermittent inability to delete NFS-held +test binary output was resolved by normal build retries; no source/build workaround +was introduced for it. + +Temporary `/tmp/Issue102Check.java` passed 68 focused assertions. Coverage includes: + +- Exact Long/Short distance, sign-preserved boundary crossing and tiny positive distance. +- All supported Phoenix tiers, unknown future tiers and missing/sentinel prices. +- Distinct effective collateral, MM utilization and headroom; negative/zero collateral, + zero MM and accounts without positions; missing/contradictory Safe inputs stay uncertain. +- Five-minute expiration including the exact boundary, future timestamps and failures. +- Cross counted once, neutral scopes never becoming a group safety rating, and retained + critical severity alongside unknown/stale counts, including hidden empty venues. +- Actual local PDA derivation matching the independently observed official Cross address, + distinct isolated addresses and no currency lookup/RPC needed for derivation. +- HTTP fixtures without active TWAPs, with separate same-market Cross/isolated risk. +- Amount objects with different/signed decimal exponents, ignored UI strings and rejected + invalid scales; original/oldest observation timestamps. +- Trader authority/key/PDA/subaccount, position membership/quantity and duplicate validation. +- Missing optional values remain unavailable. HTTP failure retains the prior complete + venue/time; independent venue refresh still succeeds; consumer reads cause no I/O. +- Risk requests obey shared 429 cooldown; empty wallets need no risk calls. +- Captured Jupiter price fields convert from micro-USD and preserve observation time. + +No permanent automated tests were added, consistent with repository conventions. + +## JavaFX and visual checks + +Temporary `Issue102Preview` passed, including repeated assertions that recycled empty +cells clear graphics/tooltips. Verified on the actual JavaFX rendering: + +- Risk is second among ten columns; neutral isolated distances and color/text tier indicators. +- Tiny positive distance is shown as `<0.1%`; unsupported values never imply safety. +- Cross account MM labels, Shared children and owning-account tooltip identification. +- Worst known group severity plus visibly rendered unknown/stale counts on a separate line. +- Old critical observations stay red; unchanged snapshots expire with only the injected + clock advanced; failed snapshot getters suppress fresh green immediately and recovery works. +- Stable row-value objects on unchanged fresh data, selection/expansion retention, + no table.refresh/rebuild and preserved TWAP duration/graphic behavior. +- Horizontal scrolling and clearing removed rows. Actual table/tooltip PNGs inspected + under `/tmp/issue102-check/`. + +The initial extra-line implementation was clipped by JavaFX despite its text being +present. Final cells use actual primary/secondary graphic labels; the final check +also inspects rendered Text nodes and the screenshot for the complete counts. + +## Read-only live observation + +2026-10-24 18:37:35–18:37:49 UTC: + +- Jupiter: six configured PROD positions, all with positive reference/liquidation prices. +- Phoenix: ten positions and one account with independently read/validated account risk + on the checked PROD wallet; tier `safe`, effective collateral 92.502602 USD, + maintenance margin 31.093010 USD, MM utilization 33.613119336902544644%, + headroom 61.409592 USD. +- TEST Phoenix: zero positions. + +Jupiter and Phoenix were checked on different PROD wallets to keep the additional +Phoenix read volume bounded. These are independent observations, not one atomic +portfolio valuation; the normal service still passes each configured wallet to +both venue readers. + +An earlier attempt to read the larger wallet encountered HTTP 429 on a mark-price +request and stopped. No immediate retry occurred; the later smaller-wallet check +followed cooldown and completed successfully. Live reads used existing pacing and +only GET operations. Verification artifacts contain no signing keys. + +## Specs and documentation + +All four deltas were synchronized into existing main specs. Strict validation passed +for the change and `jupiter-perps-overview`, `phoenix-perps-service`, +`evelyn-perps-service`, `evelyn-perps-overview`. Relevant project docs and the existing +shared Evelyn overview were updated; local link targets and change-scoped whitespace +checks passed. Unrelated existing staged issue #97 specs retain their prior trailing- +blank-line warnings and were not changed. + +## Limits + +Risk is the latest observation, not a live alarm or safety guarantee. The five-minute +cutoff ages receipt timestamps; source APIs can lag the chain, and mark/state/view +reads are not atomic. Position changes during Phoenix reads can fail identity/quantity +validation and retain the previous observation until a later successful cycle. Slow +normal cycles can expire observations. No faster polling, notifications, automatic +collateral adjustment or local liquidation engine was introduced. Cross tiers and +MM utilization do not promise future execution outcomes; isolated distance has no +generic green/yellow safety bands. diff --git a/openspec/specs/evelyn-perps-overview/spec.md b/openspec/specs/evelyn-perps-overview/spec.md index 484c78c..1c28be2 100644 --- a/openspec/specs/evelyn-perps-overview/spec.md +++ b/openspec/specs/evelyn-perps-overview/spec.md @@ -7,15 +7,15 @@ Let the owner inspect Evelyn perpetual positions grouped by wallet and venue in ## Requirements ### Requirement: Perps overview displays a collapsible position hierarchy -The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL and TWAP in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios and TWAP blank, and be expanded by default. +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. #### Scenario: Open either environment's Perps tab - **WHEN** the owner opens Perps in Production or Test -- **THEN** all nine columns, root totals and the account-aware hierarchy are available, with horizontal scrolling where required +- **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 and expands a wallet, venue or Cross account -- **THEN** descendants hide and return without changing totals +- **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: Asynchronous polling follows window visibility EMC SHALL request the current Perps snapshot from each environment's own service immediately on opening and every three seconds while visible, without blocking the UI thread. UI updates SHALL occur only on the JavaFX thread. Hiding the window SHALL stop polling; reopening SHALL create only one active polling loop and ignore results belonging to an earlier window session. Poll failures SHALL preserve the current view and allow subsequent attempts. @@ -35,11 +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 existing rows, expansion, selection and formatting untouched without flicker. New groups SHALL be expanded by default, and future 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 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 no rebuild or refresh +- **THEN** the same rows remain with the same expansion and selection +- **AND** only a changed risk 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. @@ -107,3 +108,21 @@ TWAP tooltips SHALL show executed/total progress, Estimated completion, total si #### Scenario: Missing price or smaller next child - **WHEN** the mark price is unavailable or the next child is a smaller rest order - **THEN** token amounts remain visible with unknown USD values, or the next size reflects that rest order respectively + +### Requirement: Risk cells distinguish distance and account health +Risk cells SHALL show neutral isolated distance and red reached/crossed boundaries, while Phoenix tiers use their own severity colors. Cross account cells SHALL label MM utilization; their children SHALL indicate Shared scope. Missing risk SHALL be Unknown. Text, indicators and tooltips SHALL explain units, reference/liquidation prices, account scope, thresholds, original observation time and limitations without changing direction backgrounds. + +#### Scenario: Safe account without liquidation estimate +- **WHEN** Phoenix reports a valid Safe account but a nonpositive liquidation-price sentinel +- **THEN** account health remains available while the position price estimate is unavailable + +### Requirement: Risk freshness expires unchanged snapshots +EMC SHALL mark risk stale after five minutes from its original observation, on read failure or for future timestamps. Existing UI polling SHALL update freshness even when the snapshot is unchanged, without additional venue calls/timers. Old critical states SHALL remain prominent with uncertainty; stale or incomplete noncritical observations SHALL NOT appear fresh green. Reconciliation SHALL preserve row identity, selection, expansion and TWAP state. + +#### Scenario: Unchanged successful snapshot grows old +- **WHEN** time passes the five-minute risk age limit without new data or errors +- **THEN** existing UI polling changes its freshness indication without resetting the tree + +#### 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 diff --git a/openspec/specs/evelyn-perps-service/spec.md b/openspec/specs/evelyn-perps-service/spec.md index a5c196d..dea6eda 100644 --- a/openspec/specs/evelyn-perps-service/spec.md +++ b/openspec/specs/evelyn-perps-service/spec.md @@ -95,3 +95,16 @@ The service SHALL publish immutable TWAP observations only on matching existing - **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 + +### 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. + +#### Scenario: Shared positions and incomplete data +- **WHEN** a Cross account owns multiple positions and another scope has unknown risk +- **THEN** account risk contributes once and the aggregate exposes the unknown count +- **AND** its closing Value is not substituted for effective collateral + +#### Scenario: Boundary and zero denominator +- **WHEN** isolated distance reaches/crosses zero or effective collateral is nonpositive +- **THEN** the signed boundary is preserved or MM percentage is unavailable respectively +- **AND** display rounding does not determine risk classification diff --git a/openspec/specs/jupiter-perps-overview/spec.md b/openspec/specs/jupiter-perps-overview/spec.md index bf3d185..50018f2 100644 --- a/openspec/specs/jupiter-perps-overview/spec.md +++ b/openspec/specs/jupiter-perps-overview/spec.md @@ -27,3 +27,10 @@ The overview SHALL preserve size basis, use post-fee USD collateral and price Pn #### Scenario: Nonpositive collateral - **WHEN** a position's collateral is zero or negative - **THEN** its leverage and PnL percentage are unavailable + +### Requirement: Report venue risk prices with observation time +The overview SHALL expose the venue reference and liquidation prices in exact USD units with the original response observation time. Missing or nonpositive risk prices SHALL be unavailable, never zero safety or infinite distance. Stable position identity and the separate transaction/alarm read path SHALL remain unchanged. + +#### Scenario: Missing or sentinel price +- **WHEN** a valid position has no actionable liquidation price +- **THEN** its other metrics remain present and its liquidation distance is unavailable diff --git a/openspec/specs/phoenix-perps-service/spec.md b/openspec/specs/phoenix-perps-service/spec.md index 89eba23..e8b1e48 100644 --- a/openspec/specs/phoenix-perps-service/spec.md +++ b/openspec/specs/phoenix-perps-service/spec.md @@ -79,3 +79,16 @@ The reader SHALL estimate future execution times from snapshot slot, due slot an #### Scenario: Expiry before planned completion - **WHEN** the scheduled final child lies beyond the order's expiry - **THEN** the reader does not provide that schedule as an estimated completion time + +### Requirement: Read venue account health independently of TWAP +The reader SHALL resolve the official trader account for authority/PDA/subaccount and read its venue risk classification, effective collateral, maintenance margin and position liquidation prices. It SHALL validate identities and amounts, use venue reference prices with original observation times and normalize sentinel liquidation prices to unavailable. Existing pacing, cooldown, timeout and failure retention contracts SHALL apply without signing or TWAP dependence. + +#### Scenario: Same-symbol isolated and Cross accounts +- **WHEN** the wallet holds the same market in multiple accounts without active TWAPs +- **THEN** each visible account receives only its own reported risk and liquidation data +- **AND** a mismatched trader identity or position size fails the observation + +#### Scenario: Missing or unsupported risk field +- **WHEN** a response has missing optional risk values or an unknown tier +- **THEN** those values remain unavailable without assuming a safe state +- **AND** a nonpositive liquidation-price sentinel does not invalidate independent account health diff --git a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/EvelynMissionControlImpl.tjava b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/EvelynMissionControlImpl.tjava index c7d8707..6ce0d65 100644 --- a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/EvelynMissionControlImpl.tjava +++ b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/EvelynMissionControlImpl.tjava @@ -309,6 +309,11 @@ public final class EvelynMissionControlImpl implements EvelynMissionControl, Nen }); } catch (RuntimeException exception) { log.warn("Unable to refresh {} Perps positions", environment, exception); + javaFxRuntimeService.runLater(() -> { + if (perpsPolling == session && window.isShowing()) { + tab.readFailed(); + } + }); } } diff --git a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsRiskPresentation.tjava b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsRiskPresentation.tjava new file mode 100644 index 0000000..006b022 --- /dev/null +++ b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsRiskPresentation.tjava @@ -0,0 +1,152 @@ +package com.r35157.evelyn.emc.impl.ref; + +import com.r35157.evelyn.service.perps.PerpsRisk; +import com.r35157.evelyn.service.perps.PerpsRisk.Assessment; +import com.r35157.evelyn.service.perps.PerpsRisk.Level; +import com.r35157.evelyn.service.perps.PerpsRisk.Summary; +import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction; +import com.r35157.evelyn.service.perps.PerpsSnapshot.Risk; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.NumberFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** Presentation only. No network calls, position management or local liquidation engine. */ +final class PerpsRiskPresentation { + record Cell(String text, String details, String color) { } + + static Cell isolated(Risk risk, Direction direction, boolean failed, Instant now) { + Assessment assessment = PerpsRisk.assess(risk, direction, true, failed, now); + String distance = assessment.distancePercent() == null ? "liq. —" + : distance(assessment.distancePercent()) + " to liq."; + String value = assessment.level() == Level.DISTANCE_ONLY ? distance + : level(assessment.level()) + (assessment.distancePercent() == null ? "" : " · " + distance); + return new Cell(prefix(assessment) + value, details(risk, assessment, false), color(assessment)); + } + + static Cell cross(Risk risk, boolean hasPositions, boolean failed, Instant now) { + Assessment assessment = PerpsRisk.assess(risk, null, hasPositions, failed, now); + String value = level(assessment.level()) + (assessment.stale() || assessment.level().critical() ? "\nMM " : " · MM ") + + utilization(assessment.maintenancePercent()); + return new Cell(prefix(assessment) + value, details(risk, assessment, true), color(assessment)); + } + + static Cell shared(Risk risk, boolean hasPositions, boolean failed, Instant now, String accountId) { + Assessment assessment = PerpsRisk.assess(risk, null, hasPositions, failed, now); + return new Cell(assessment.stale() ? "Shared · Stale" : assessment.unknown() ? "Shared · Unknown" : "Shared", + "Shared Cross risk belongs to account " + accountId + ", not to this position independently.\n" + + details(risk, assessment, true), "#626b75"); + } + + static Cell summary(Summary summary) { + String value = summary.worst() == Level.NONE && summary.unknown() > 0 ? "Unknown" + : summary.worst() == Level.NONE && summary.stale() > 0 ? "Stale" : level(summary.worst()); + String counts = ""; + if (summary.unknown() > 0) counts = summary.unknown() + " unknown"; + if (summary.stale() > 0) counts += (counts.isEmpty() ? "" : " · ") + summary.stale() + " stale"; + if (!counts.isEmpty()) value += "\n" + counts; + String color = summary.worst().critical() ? "#b32424" + : summary.unknown() > 0 || summary.stale() > 0 ? "#626b75" : color(summary.worst()); + return new Cell(value, "Worst known risk: " + level(summary.worst()) + + "\nRisk scopes: " + summary.scopes() + "\nUnknown: " + summary.unknown() + + "\nStale/retained: " + summary.stale() + + "\nEach isolated position and each shared Cross account counts once. " + + "Distances, MM utilization and severity are not averaged. A Distance only scope has no safety rating. " + + "Groups can combine different observation times; inspect individual scopes.\n" + FRESHNESS, color); + } + + private static String prefix(Assessment assessment) { + return assessment.stale() ? "Stale · " : assessment.unknown() && assessment.level() != Level.UNKNOWN + ? "Unknown · " : ""; + } + + private static String details(Risk risk, Assessment assessment, boolean cross) { + String status = (assessment.stale() ? "STALE/RETAINED: " : "") + + (assessment.unknown() ? "Incomplete/unknown risk. " : "") + + "Latest observed status: " + level(assessment.level()); + return status + "\nVenue risk tier: " + (risk == null || risk.venueTier() == null ? "—" : risk.venueTier()) + + "\nVenue risk state: " + (risk == null || risk.venueState() == null ? "—" : risk.venueState()) + + (cross ? "\nEffective collateral: " + money(risk == null ? null : risk.effectiveCollateralUsd()) + + "\nMaintenance margin: " + money(risk == null ? null : risk.maintenanceMarginUsd()) + + "\nUSD headroom: " + money(assessment.headroomUsd()) + + "\nMM utilization: " + utilization(assessment.maintenancePercent()) + + "\nMM = 100 × maintenance margin / positive effective collateral. It is not liquidation " + + "probability or a price-movement distance. Exact venue tier governs liquidation eligibility. " + + "Nonpositive collateral makes the ratio unavailable; closing Value is not effective collateral." + : "\nVenue reference price: " + price(risk == null ? null : risk.referencePriceUsd()) + + "\nReported liquidation price: " + price(risk == null ? null : risk.liquidationPriceUsd()) + + "\nSigned distance: " + (assessment.distancePercent() == null ? "—" + : assessment.distancePercent().stripTrailingZeros().toPlainString() + "%") + + "\nLong: 100 × (reference − liquidation) / reference. Short: 100 × (liquidation − reference) / reference. " + + "Positive distance is neutral; no universal safety bands are assigned. A reached/crossed " + + "boundary is red and does not prove liquidation has occurred. Phoenix's own account tiers " + + "retain their independent meaning. Missing/sentinel prices do not mean infinite safety.") + + "\nObserved: " + (assessment.observedAt() == null ? "—" + : DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault()) + .format(assessment.observedAt())) + + "\n" + (assessment.failed() ? "Latest required read failed; original observation time retained. " : "") + + FRESHNESS; + } + + private static String utilization(BigDecimal value) { + if (value == null) return "—"; + BigDecimal rounded = value.setScale(1, RoundingMode.HALF_UP); + if (value.compareTo(BigDecimal.valueOf(100)) < 0 && rounded.compareTo(BigDecimal.valueOf(100)) >= 0) { + return "<100%"; + } + if (value.compareTo(BigDecimal.valueOf(100)) > 0 && rounded.compareTo(BigDecimal.valueOf(100)) <= 0) { + return ">100%"; + } + return rounded.toPlainString() + "%"; + } + + private static String distance(BigDecimal value) { + return value.signum() > 0 && value.compareTo(new BigDecimal("0.1")) < 0 ? "<0.1%" + : value.setScale(1, RoundingMode.HALF_UP).toPlainString() + "%"; + } + + private static String level(Level level) { + return switch (level) { + case UNKNOWN -> "Unknown"; + case NONE -> "No positions"; + case SAFE -> "OK"; + case DISTANCE_ONLY -> "Distance only"; + case AT_RISK -> "At risk"; + case CANCELLABLE -> "Cancellable"; + case BOUNDARY_REACHED -> "Boundary reached"; + case LIQUIDATABLE -> "Liquidatable"; + case BACKSTOP -> "Backstop"; + case HIGH_RISK -> "High risk"; + }; + } + + private static String color(Assessment assessment) { + return assessment.level().critical() ? "#b32424" : assessment.stale() || assessment.unknown() + ? "#626b75" : color(assessment.level()); + } + + private static String color(Level level) { + return level.critical() ? "#b32424" : switch (level) { + case SAFE -> "#16743c"; + case AT_RISK -> "#987300"; + case CANCELLABLE -> "#c35d13"; + default -> "#626b75"; + }; + } + + private static String money(ΩAmountΩ value) { + return value == null ? "—" : NumberFormat.getCurrencyInstance(Locale.US).format(value); + } + + private static String price(ΩPriceΩ value) { + return value == null || value.signum() <= 0 ? "—" : "$" + value.stripTrailingZeros().toPlainString(); + } + + private static final String FRESHNESS = "Risk expires five minutes after its oldest required observation. " + + "Failures and future timestamps are uncertain immediately. This is a data-quality cutoff, not a " + + "five-minute safety guarantee. API/price reads can lag or differ in time; the view is not a live alarm."; +} diff --git a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava index 0ee1683..d035a32 100644 --- a/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava +++ b/src/main/tjava/com/r35157/evelyn/emc/impl/ref/PerpsTab.tjava @@ -1,6 +1,8 @@ package com.r35157.evelyn.emc.impl.ref; import com.r35157.evelyn.service.perps.PerpsSnapshot; +import com.r35157.evelyn.service.perps.PerpsRisk; +import com.r35157.evelyn.emc.impl.ref.PerpsRiskPresentation.Cell; import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction; import com.r35157.evelyn.service.perps.PerpsSnapshot.Margin; import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics; @@ -9,6 +11,7 @@ import javafx.application.Platform; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.ReadOnlyStringWrapper; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.control.Tab; import javafx.scene.control.Label; import javafx.scene.control.ProgressBar; @@ -20,8 +23,10 @@ import javafx.scene.control.TreeTableRow; import javafx.scene.control.TreeTableView; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; import java.math.BigDecimal; import java.math.RoundingMode; @@ -29,6 +34,7 @@ import java.text.NumberFormat; import java.time.ZoneId; import java.time.Duration; import java.time.Instant; +import java.time.Clock; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; @@ -40,6 +46,11 @@ import java.util.function.Function; final class PerpsTab { PerpsTab(String environmentColor) { + this(environmentColor, Clock.systemUTC()); + } + + PerpsTab(String environmentColor, Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); var position = new TreeTableColumn("Position"); position.setPrefWidth(240); position.setMinWidth(180); @@ -63,6 +74,7 @@ final class PerpsTab { }); table.setTreeColumn(position); table.getColumns().add(position); + table.getColumns().add(riskColumn()); table.getColumns().add(column("Direction", 76, 70, row -> row.direction() == null ? "" : row.direction() == Direction.LONG ? "Long" : "Short", false, false, "Long or Short; group rows have no direction.")); @@ -152,6 +164,54 @@ final class PerpsTab { return tooltip; } + private TreeTableColumn riskColumn() { + var column = new TreeTableColumn("Risk"); + column.setPrefWidth(220); + column.setMinWidth(170); + column.setSortable(false); + column.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getValue())); + column.setCellFactory(ignored -> new TreeTableCell<>() { + private final Circle indicator = new Circle(4); + private final Label primary = new Label(); + private final Label secondary = new Label(); + private final VBox labels = new VBox(primary, secondary); + private final HBox graphic = new HBox(5, indicator, labels); + { + graphic.setAlignment(Pos.CENTER_LEFT); + graphic.prefWidthProperty().bind(widthProperty().subtract(12)); + HBox.setHgrow(labels, Priority.ALWAYS); + labels.setMinWidth(0); + primary.setMaxWidth(Double.MAX_VALUE); + secondary.setMaxWidth(Double.MAX_VALUE); + primary.setTextFill(Color.web("#30343b")); + secondary.setTextFill(Color.web("#59636f")); + secondary.setStyle("-fx-font-size: 11px; -fx-font-weight: normal;"); + } + + @Override + protected void updateItem(PositionRow row, boolean empty) { + super.updateItem(row, empty); + setText(null); + setGraphic(null); + setTooltip(null); + setTextFill(Color.web("#30343b")); + if (!empty && row != null) { + Cell risk = row.risk(); + String[] lines = risk.text().split("\n", 2); + primary.setText(lines[0]); + boolean extra = lines.length > 1; + secondary.setText(extra ? lines[1] : null); + secondary.setManaged(extra); + secondary.setVisible(extra); + indicator.setFill(Color.web(risk.color())); + setGraphic(graphic); + setTooltip(tooltip(row.label() + "\n" + risk.details() + "\n\n" + row.notes())); + } + } + }); + return column; + } + private TreeTableColumn twapColumn() { var column = new TreeTableColumn("TWAP"); column.setPrefWidth(170); @@ -244,15 +304,28 @@ final class PerpsTab { } void update(PerpsSnapshot snapshot) { + render(snapshot, false); + lastSnapshot = snapshot; + } + + void readFailed() { + if (lastSnapshot != null) render(lastSnapshot, true); + } + + private void render(PerpsSnapshot snapshot, boolean snapshotReadFailed) { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("Perps views must be updated on the JavaFX Application Thread"); } Objects.requireNonNull(snapshot, "snapshot"); - if (snapshot.equals(renderedSnapshot)) { - return; - } + // Risk ages during existing polling even if the service snapshot is unchanged. + // Reconciliation below mutates only row values/children that actually differ. + Instant now = clock.instant(); TreeItem selected = table.getSelectionModel().getSelectedItem(); - table.getRoot().setValue(group("root", "Perpetual Contracts", snapshot.totals(), TOTAL_NOTES)); + 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)); + if (!rootRow.equals(table.getRoot().getValue())) table.getRoot().setValue(rootRow); List> wallets = new ArrayList<>(); Map> existingWallets = childrenById(table.getRoot()); for (PerpsSnapshot.Wallet wallet : snapshot.wallets()) { @@ -263,34 +336,43 @@ final class PerpsTab { } } TreeItem walletItem = item(existingWallets, - group(wallet.id(), wallet.name(), wallet.totals(), walletNotes)); + group(wallet.id(), wallet.name(), wallet.totals(), walletNotes, PerpsRiskPresentation.summary( + PerpsRisk.Summary.combine(wallet.venues().stream() + .map(venue -> PerpsRisk.venue(venue, now, snapshotReadFailed)).toList())))); List> venues = new ArrayList<>(); Map> existingVenues = childrenById(walletItem); for (PerpsSnapshot.Venue venue : wallet.venues()) { if (venue.positions().isEmpty() && venue.crossAccounts().isEmpty()) { continue; } - String status = status(venue); + boolean failed = venue.failed() || snapshotReadFailed; + String status = status(venue) + (snapshotReadFailed + ? "\nLatest service snapshot read failed; the displayed observation is retained." : ""); String label = venue.name() + (venue.lastSuccess() == null - ? venue.failed() ? " (unavailable)" : " (loading)" : venue.failed() ? " (retained)" : ""); + ? failed ? " (unavailable)" : " (loading)" : failed ? " (retained)" : ""); TreeItem venueItem = item(existingVenues, - group(venue.id(), label, venue.totals(), status + "\n" + TOTAL_NOTES)); + group(venue.id(), label, venue.totals(), status + "\n" + TOTAL_NOTES, + PerpsRiskPresentation.summary(PerpsRisk.venue(venue, now, snapshotReadFailed)))); List> positions = new ArrayList<>(); Map> existingPositions = childrenById(venueItem); for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) { TreeItem accountItem = item(existingPositions, new PositionRow( account.id(), account.name(), null, null, true, account.metrics(), - status + "\n" + account.notes(), null, false)); + status + "\n" + account.notes(), null, false, + PerpsRiskPresentation.cross(account.risk(), !account.positions().isEmpty(), failed, now))); var children = new ArrayList>(); var previous = childrenById(accountItem); for (PerpsSnapshot.Position position : account.positions()) { - children.add(item(previous, row(position, status, venue.failed()))); + children.add(item(previous, row(position, status, failed, + PerpsRiskPresentation.shared(account.risk(), !account.positions().isEmpty(), + failed, now, account.id())))); } updateChildren(accountItem, children); positions.add(accountItem); } for (PerpsSnapshot.Position position : venue.positions()) { - positions.add(item(existingPositions, row(position, status, venue.failed()))); + positions.add(item(existingPositions, row(position, status, failed, + PerpsRiskPresentation.isolated(position.risk(), position.direction(), failed, now)))); } updateChildren(venueItem, positions); venues.add(venueItem); @@ -305,7 +387,6 @@ final class PerpsTab { table.getSelectionModel().select(selectedRow); } } - renderedSnapshot = snapshot; } private static String status(PerpsSnapshot.Venue venue) { @@ -320,13 +401,17 @@ final class PerpsTab { : ". Periodic observation; state and reference prices can differ in time."); } - private static PositionRow row(PerpsSnapshot.Position position, String status, boolean retained) { + private static PositionRow row(PerpsSnapshot.Position position, String status, boolean retained, Cell risk) { return new PositionRow(position.id(), position.instrument(), position.direction(), position.margin(), - false, position.metrics(), status + "\n" + position.notes(), position.twap(), retained); + false, position.metrics(), status + "\n" + position.notes(), position.twap(), retained, risk); } private static PositionRow group(String id, String label, Metrics metrics, String notes) { - return new PositionRow(id, label, null, null, false, metrics, notes, null, false); + return group(id, label, metrics, notes, PerpsRiskPresentation.summary(new PerpsRisk.Summary(PerpsRisk.Level.NONE, 1, 1, 0))); + } + + 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); } private static String usd(ΩAmountΩ value) { @@ -385,7 +470,7 @@ final class PerpsTab { } private record PositionRow(String id, String label, Direction direction, Margin margin, - boolean crossAccount, Metrics metrics, String notes, Twap twap, boolean retained) { } + boolean crossAccount, Metrics metrics, String notes, Twap twap, boolean retained, Cell risk) { } 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 " @@ -398,5 +483,6 @@ final class PerpsTab { + "-fx-control-inner-background-alt: #fce8e6;"; private final Tab tab; private final TreeTableView table = new TreeTableView<>(); - private PerpsSnapshot renderedSnapshot; + private final Clock clock; + private PerpsSnapshot lastSnapshot; } diff --git a/src/main/tjava/com/r35157/evelyn/service/perps/PerpsRisk.tjava b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsRisk.tjava new file mode 100644 index 0000000..7a1ab7e --- /dev/null +++ b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsRisk.tjava @@ -0,0 +1,150 @@ +package com.r35157.evelyn.service.perps; + +import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction; +import com.r35157.evelyn.service.perps.PerpsSnapshot.Risk; +import com.r35157.evelyn.service.perps.PerpsSnapshot.Venue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** Snapshot risk semantics, independent of UI formatting and colors. No venue calls or timers. */ +public final class PerpsRisk { + private PerpsRisk() { } + + /** A data-quality cutoff, not a forecast of safety over this interval. */ + public static final Duration MAX_AGE = Duration.ofMinutes(5); + + public enum Level { + UNKNOWN(-1), NONE(0), SAFE(1), DISTANCE_ONLY(2), AT_RISK(3), CANCELLABLE(4), + BOUNDARY_REACHED(5), LIQUIDATABLE(5), BACKSTOP(6), HIGH_RISK(7); + + private final int severity; + Level(int severity) { this.severity = severity; } + public boolean critical() { return severity >= 5; } + } + + public record Assessment(Level level, BigDecimal distancePercent, BigDecimal maintenancePercent, + ΩAmountΩ headroomUsd, boolean unknown, boolean stale, boolean failed, + Instant observedAt) { } + + public record Summary(Level worst, int scopes, int unknown, int stale) { + public static Summary combine(List summaries) { + Level worst = Level.NONE; + int scopes = 0, unknown = 0, stale = 0; + for (Summary summary : summaries) { + if (summary.worst.severity > worst.severity) { + worst = summary.worst; + } + scopes += summary.scopes; + unknown += summary.unknown; + stale += summary.stale; + } + return new Summary(worst, scopes, unknown, stale); + } + } + + public static Assessment assess(Risk risk, Direction direction, boolean hasPositions, + boolean failed, Instant now) { + if (risk == null) { + return new Assessment(Level.UNKNOWN, null, null, null, true, false, failed, null); + } + BigDecimal reference = positive(risk.referencePriceUsd()); + BigDecimal liquidation = positive(risk.liquidationPriceUsd()); + BigDecimal distance = null; + boolean crossed = false; + if (direction != null && reference != null && liquidation != null) { + BigDecimal difference = direction == Direction.LONG + ? reference.subtract(liquidation) : liquidation.subtract(reference); + crossed = difference.signum() <= 0; // Never classify using a rounded percentage. + distance = percent(difference, reference); + } + BigDecimal effective = risk.effectiveCollateralUsd(); + BigDecimal maintenance = risk.maintenanceMarginUsd(); + boolean validMargin = effective != null && maintenance != null && maintenance.signum() >= 0; + BigDecimal utilization = validMargin && effective.signum() > 0 ? percent(maintenance, effective) : null; + BigDecimal headroom = validMargin ? effective.subtract(maintenance) : null; + Level tier = tier(risk.venueTier()); + Level level = crossed && tier.severity < Level.BOUNDARY_REACHED.severity ? Level.BOUNDARY_REACHED : tier; + if (level == Level.UNKNOWN && distance != null) { + level = crossed ? Level.BOUNDARY_REACHED : Level.DISTANCE_ONLY; + } + // An unsupported future tier remains uncertainty even when distance is independently known. + boolean unknown = level == Level.UNKNOWN || risk.venueTier() != null && tier == Level.UNKNOWN; + if (risk.venueTier() != null) { + unknown |= !validMargin; + // Safe with contradictory account collateral is uncertain for isolated accounts too. + unknown |= tier == Level.SAFE && validMargin + && (effective.compareTo(maintenance) < 0 || hasPositions && effective.signum() <= 0); + } + if (direction == null) { + unknown |= !validMargin || tier == Level.UNKNOWN; + if (!unknown && !hasPositions && tier == Level.SAFE && maintenance.signum() == 0 + && effective.signum() >= 0) { + level = Level.NONE; + } + } + Instant observed = risk.observedAt(); + unknown |= observed == null; + boolean stale = observed != null && (failed || observed.isAfter(now) + || Duration.between(observed, now).compareTo(MAX_AGE) >= 0); + return new Assessment(level, distance, utilization, headroom, unknown, stale, failed, observed); + } + + public static Summary venue(Venue venue, Instant now) { + return venue(venue, now, false); + } + + public static Summary venue(Venue venue, Instant now, boolean snapshotReadFailed) { + boolean failed = venue.failed() || snapshotReadFailed; + var assessments = new ArrayList(); + for (var position : venue.positions()) { + assessments.add(assess(position.risk(), position.direction(), true, failed, now)); + } + for (var account : venue.crossAccounts()) { + assessments.add(assess(account.risk(), null, !account.positions().isEmpty(), failed, now)); + } + if (assessments.isEmpty()) { + if (venue.lastSuccess() == null) { + return new Summary(Level.NONE, 1, 1, 0); + } + boolean stale = failed || venue.lastSuccess().isAfter(now) + || Duration.between(venue.lastSuccess(), now).compareTo(MAX_AGE) >= 0; + return new Summary(Level.NONE, stale ? 1 : 0, 0, stale ? 1 : 0); + } + Level worst = Level.NONE; + int unknown = 0, stale = 0; + for (Assessment assessment : assessments) { + if (assessment.level.severity > worst.severity) { + worst = assessment.level; + } + if (assessment.unknown) unknown++; + if (assessment.stale) stale++; + } + return new Summary(worst, assessments.size(), unknown, stale); + } + + private static Level tier(String value) { + if (value == null) return Level.UNKNOWN; + return switch (value) { + case "safe" -> Level.SAFE; + case "atRisk" -> Level.AT_RISK; + case "cancellable" -> Level.CANCELLABLE; + case "liquidatable" -> Level.LIQUIDATABLE; + case "backstopLiquidatable" -> Level.BACKSTOP; + case "highRisk" -> Level.HIGH_RISK; + default -> Level.UNKNOWN; + }; + } + + private static BigDecimal positive(BigDecimal value) { + return value != null && value.signum() > 0 ? value : null; + } + + private static BigDecimal percent(BigDecimal numerator, BigDecimal denominator) { + return numerator.multiply(BigDecimal.valueOf(100)).divide(denominator, 18, RoundingMode.HALF_EVEN); + } +} diff --git a/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava index 7342456..a1630c3 100644 --- a/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava +++ b/src/main/tjava/com/r35157/evelyn/service/perps/PerpsSnapshot.tjava @@ -53,7 +53,11 @@ public record PerpsSnapshot(List wallets) { } } - public record CrossAccount(String id, String name, Metrics metrics, List positions, String notes) { + public record CrossAccount(String id, String name, Metrics metrics, List positions, String notes, Risk risk) { + public CrossAccount(String id, String name, Metrics metrics, List positions, String notes) { + this(id, name, metrics, positions, notes, null); + } + public CrossAccount { Objects.requireNonNull(id, "id"); Objects.requireNonNull(name, "name"); @@ -64,7 +68,12 @@ public record PerpsSnapshot(List wallets) { } public record Position(String id, String instrument, Direction direction, Margin margin, - Metrics metrics, String notes, Twap twap) { + Metrics metrics, String notes, Twap twap, Risk risk) { + 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); + } + public Position(String id, String instrument, Direction direction, Margin margin, Metrics metrics, String notes) { this(id, instrument, direction, margin, metrics, notes, null); } @@ -79,6 +88,10 @@ public record PerpsSnapshot(List wallets) { } } + /** 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) { } + /** Active filled order; nullable quantities/prices/times are unavailable. Times are slot estimates. */ public record Twap(String symbol, BigInteger executed, BigInteger total, ΩAmountΩ orderQuantity, ΩAmountΩ nextQuantity, ΩAmountΩ regularQuantity, ΩAmountΩ dustQuantity, diff --git a/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava b/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava index bbc48e7..e3feea4 100644 --- a/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava +++ b/src/main/tjava/com/r35157/evelyn/service/perps/impl/ref/EvelynPerpsServiceImpl.tjava @@ -133,7 +133,8 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP 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)); + account.valueUsd(), sum.pnlUsd(), null), positions, PHOENIX_ACCOUNT_NOTES, + risk(account, null))); } } else { isolated.addAll(positions); @@ -157,7 +158,17 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP new Metrics(collateral, cross ? null : PerpsSnapshot.ratio(position.sizeUsd(), collateral), position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(), cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)), - cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES, twap); + cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES, twap, + cross ? null : risk(account, position)); + } + + private static PerpsSnapshot.Risk risk(PhoenixPerpsAccount account, PhoenixPerpsPosition position) { + var risk = account.risk(); + return risk == null ? null : new PerpsSnapshot.Risk(risk.state(), risk.tier(), + position == null ? null : position.riskMarkPriceUsd(), + position == null ? null : position.liquidationPriceUsd(), + risk.effectiveCollateralUsd(), risk.maintenanceMarginUsd(), + position == null ? risk.observedAt() : position.riskObservedAt()); } @FunctionalInterface @@ -177,7 +188,8 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP new Metrics(position.collateralUsd(), PerpsSnapshot.ratio(position.sizeUsd(), position.collateralUsd()), position.sizeUsd(), null, position.pnlUsd(), PerpsSnapshot.percentage(position.pnlUsd(), position.collateralUsd())), - JUPITER_NOTES); + JUPITER_NOTES, null, new PerpsSnapshot.Risk(null, null, position.markPriceUsd(), + position.liquidationPriceUsd(), null, null, position.observedAt())); } private static String instrument(ΩSPLMintAddressΩ mint) { diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsOverviewPosition.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsOverviewPosition.tjava index bb7feec..4bbc8b1 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsOverviewPosition.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsOverviewPosition.tjava @@ -1,12 +1,20 @@ package com.r35157.libs.jupiter.perps; import java.math.BigDecimal; +import java.time.Instant; import java.util.Objects; /** Monetary amounts are USD, not raw micro-USD; missing metrics are null. No unverified API net Value. */ public record JupiterPerpsOverviewPosition(ΩJupiterPerpsPositionAccountΩ positionAccount, ΩSPLMintAddressΩ tradedTokenMint, JupiterPerpsPositionDirection direction, - ΩAmountΩ sizeUsd, ΩAmountΩ collateralUsd, ΩAmountΩ pnlUsd) { + ΩAmountΩ sizeUsd, ΩAmountΩ collateralUsd, ΩAmountΩ pnlUsd, + ΩPriceΩ markPriceUsd, ΩPriceΩ liquidationPriceUsd, Instant observedAt) { + public JupiterPerpsOverviewPosition(ΩJupiterPerpsPositionAccountΩ positionAccount, + ΩSPLMintAddressΩ tradedTokenMint, JupiterPerpsPositionDirection direction, + ΩAmountΩ sizeUsd, ΩAmountΩ collateralUsd, ΩAmountΩ pnlUsd) { + this(positionAccount, tradedTokenMint, direction, sizeUsd, collateralUsd, pnlUsd, null, null, null); + } + public JupiterPerpsOverviewPosition { Objects.requireNonNull(positionAccount, "positionAccount"); Objects.requireNonNull(tradedTokenMint, "tradedTokenMint"); diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/impl/ref/RestJupiterPerpsOverview.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/impl/ref/RestJupiterPerpsOverview.tjava index 0dce29f..e1012ca 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/perps/impl/ref/RestJupiterPerpsOverview.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/impl/ref/RestJupiterPerpsOverview.tjava @@ -16,6 +16,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -65,6 +66,7 @@ public final class RestJupiterPerpsOverview implements JupiterPerpsOverview { throw invalid("Incomplete position list/count"); } var positions = new ArrayList(); + Instant observedAt = Instant.now(); var identities = new HashSet(); for (JsonNode row : response.get("dataList")) { String id = text(row, "positionPubkey"); @@ -84,12 +86,17 @@ public final class RestJupiterPerpsOverview implements JupiterPerpsOverview { throw invalid("Missing or nonpositive open position size"); } positions.add(new JupiterPerpsOverviewPosition(id, mint, direction, size, - micros(row, "collateralUsd"), micros(row, "pnlBeforeFeesUsd"))); + micros(row, "collateralUsd"), micros(row, "pnlBeforeFeesUsd"), + positive(micros(row, "markPriceUsd")), positive(micros(row, "liquidationPriceUsd")), observedAt)); } positions.sort(Comparator.comparing(JupiterPerpsOverviewPosition::positionAccount)); return List.copyOf(positions); } + private static ΩPriceΩ positive(ΩPriceΩ value) { + return value != null && value.signum() > 0 ? value : null; + } + private static ΩAmountΩ micros(JsonNode row, String field) throws IOException { if (!row.hasNonNull(field)) { return null; diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixAccountRisk.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixAccountRisk.tjava new file mode 100644 index 0000000..d03fbf3 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixAccountRisk.tjava @@ -0,0 +1,16 @@ +package com.r35157.libs.phoenix.perps; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Objects; + +/** Venue-reported risk, separate from closing Value. Nullable fields are unavailable. */ +public record PhoenixAccountRisk(String state, String tier, ΩAmountΩ effectiveCollateralUsd, + ΩAmountΩ maintenanceMarginUsd, Instant observedAt) { + public PhoenixAccountRisk { + Objects.requireNonNull(observedAt, "observedAt"); + if (maintenanceMarginUsd != null && maintenanceMarginUsd.signum() < 0) { + throw new IllegalArgumentException("Negative maintenance margin"); + } + } +} diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsAccount.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsAccount.tjava index ecad1cd..1af103f 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsAccount.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsAccount.tjava @@ -6,7 +6,12 @@ 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 positions) { + List positions, PhoenixAccountRisk risk) { + public PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boolean hasCollateral, + ΩAmountΩ collateralUsd, ΩAmountΩ equityUsd, ΩAmountΩ valueUsd, List positions) { + this(traderPdaIndex, subaccountIndex, hasCollateral, collateralUsd, equityUsd, valueUsd, positions, null); + } + public PhoenixPerpsAccount { if (traderPdaIndex < 0 || subaccountIndex < 0) { throw new IllegalArgumentException("Invalid Phoenix account identity"); diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava index d36673e..1b1244c 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/PhoenixPerpsPosition.tjava @@ -1,6 +1,7 @@ package com.r35157.libs.phoenix.perps; import java.math.BigDecimal; +import java.time.Instant; import java.util.Objects; /** @@ -13,7 +14,15 @@ import java.util.Objects; public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol, ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd, ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd, - PhoenixTwapOrder twap) { + PhoenixTwapOrder twap, ΩPriceΩ riskMarkPriceUsd, + ΩPriceΩ liquidationPriceUsd, Instant riskObservedAt) { + public PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol, + ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd, ΩAmountΩ pnlUsd, + ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd, PhoenixTwapOrder twap) { + this(traderPdaIndex, subaccountIndex, symbol, quantity, sizeUsd, initialMarginUsd, pnlUsd, + fundingUsd, closeFeeUsd, twap, null, null, null); + } + public PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol, ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd, ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd) { diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava index b7e7603..44f969f 100644 --- a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixPerpsServiceImpl.tjava @@ -8,6 +8,9 @@ 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; import org.jetbrains.annotations.NotNull; import java.io.IOException; @@ -36,19 +39,23 @@ import java.util.concurrent.TimeUnit; /** Public REST reader with shared request pacing and no portfolio cache. See docs/PhoenixPerps.md for API contracts and units. */ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { - public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper) { - this(httpClient, objectMapper, URI.create("https://perp-api.phoenix.trade/"), Duration.ofSeconds(10)); + public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper, + @NotNull SolanaBlockChain solana) { + this(httpClient, objectMapper, solana, URI.create("https://perp-api.phoenix.trade/"), Duration.ofSeconds(10)); } public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper, + @NotNull SolanaBlockChain solana, @NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout) { - this(httpClient, objectMapper, endpoint, timeout, Duration.ofSeconds(1)); + this(httpClient, objectMapper, solana, endpoint, timeout, Duration.ofSeconds(1)); } public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper, + @NotNull SolanaBlockChain solana, @NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout, @NotNull Duration minimumRequestInterval) { this.httpClient = Objects.requireNonNull(httpClient, "httpClient"); + this.solana = Objects.requireNonNull(solana, "solana"); this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper").copy() .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY, @@ -105,9 +112,11 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { rawAccounts.sort(Comparator.comparingInt(account -> account.get("subaccountIndex").intValue())); Map markets = symbolsNeeded.isEmpty() ? Map.of() : markets(); var prices = new HashMap(); + var priceTimes = new HashMap(); // Deterministic price order; one price read per distinct held market, not per position. for (String symbol : symbolsNeeded.stream().sorted().toList()) { prices.put(symbol, markPrice(symbol)); + priceTimes.put(symbol, Instant.now()); } var twaps = symbolsNeeded.isEmpty() ? Map.of() : PhoenixTwapDecoder.decode(get("v1/trader/" + authority + "/twap?traderPdaIndex=0"), @@ -168,7 +177,31 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { fees = add(fees, position.closeFeeUsd()); } ΩAmountΩ value = equity == null || fees == null ? null : equity.subtract(fees); - accounts.add(new PhoenixPerpsAccount(0, index, hasCollateral, collateral, equity, value, positions)); + PhoenixAccountRisk risk = null; + if (!positions.isEmpty() || index == 0 && hasCollateral) { + if (index > 255) { + throw invalid("Phoenix subaccount index exceeds one-byte PDA seed"); + } + ΩSolanaAddressΩ trader = solana.findProgramAddress(PHOENIX_PROGRAM, List.of( + SolanaProgramAddressSeed.utf8("trader"), SolanaProgramAddressSeed.solanaAddress(authority), + SolanaProgramAddressSeed.hex("00"), + SolanaProgramAddressSeed.hex(String.format(java.util.Locale.ROOT, "%02x", index)))).address(); + var view = PhoenixRiskDecoder.decode(get("v1/view/trader/" + trader), authority, trader, + index, positions, Instant.now()); + risk = view.account(); + var withRisk = new ArrayList(); + for (PhoenixPerpsPosition position : positions) { + Instant priceTime = priceTimes.get(position.symbol()); + Instant observedAt = priceTime != null && priceTime.isBefore(risk.observedAt()) + ? priceTime : risk.observedAt(); + withRisk.add(new PhoenixPerpsPosition(0, index, position.symbol(), position.quantity(), + position.sizeUsd(), position.initialMarginUsd(), position.pnlUsd(), position.fundingUsd(), + position.closeFeeUsd(), position.twap(), prices.get(position.symbol()), + view.liquidationPrices().get(position.symbol()), observedAt)); + } + positions = withRisk; + } + accounts.add(new PhoenixPerpsAccount(0, index, hasCollateral, collateral, equity, value, positions, risk)); } return List.copyOf(accounts); } @@ -382,6 +415,8 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService { } private final HttpClient httpClient; + private static final ΩSolanaProgramIdΩ PHOENIX_PROGRAM = "EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih"; + private final SolanaBlockChain solana; private final ObjectMapper objectMapper; private final ΩRestEndpointBaseΩ endpoint; private final Duration timeout; diff --git a/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixRiskDecoder.tjava b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixRiskDecoder.tjava new file mode 100644 index 0000000..0b0f57a --- /dev/null +++ b/src/main/tjava/com/r35157/libs/phoenix/perps/impl/ref/PhoenixRiskDecoder.tjava @@ -0,0 +1,104 @@ +package com.r35157.libs.phoenix.perps.impl.ref; + +import com.fasterxml.jackson.databind.JsonNode; +import com.r35157.libs.phoenix.perps.PhoenixAccountRisk; +import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** TraderView is keyed by the locally derived trader account, not the wallet or TWAP account. */ +final class PhoenixRiskDecoder { + record View(PhoenixAccountRisk account, Map liquidationPrices) { } + + static View decode(JsonNode view, ΩSolanaWalletIdΩ authority, ΩSolanaAddressΩ trader, int subaccount, + List positions, Instant observedAt) throws IOException { + if (!authority.equals(text(view, "authority")) || !trader.equals(text(view, "traderKey")) + || !view.path("traderPdaIndex").isIntegralNumber() || !view.path("traderPdaIndex").canConvertToInt() + || view.path("traderPdaIndex").intValue() != 0 + || !view.path("traderSubaccountIndex").isIntegralNumber() + || !view.path("traderSubaccountIndex").canConvertToInt() + || view.path("traderSubaccountIndex").intValue() != subaccount) { + throw invalid("TraderView identity mismatch"); + } + var account = new PhoenixAccountRisk(optionalText(view, "riskState"), optionalText(view, "riskTier"), + amount(view, "effectiveCollateral"), amount(view, "maintenanceMargin"), observedAt); + JsonNode rows = view.get("positions"); + if (rows == null || !rows.isArray()) { + throw invalid("Missing TraderView positions array"); + } + var reported = new HashMap(); + var prices = new HashMap(); + for (JsonNode row : rows) { + String symbol = text(row, "symbol"); + BigDecimal quantity = amount(row, "positionSize"); + if (quantity == null) { + throw invalid("Missing TraderView position size"); + } + if (reported.putIfAbsent(symbol, quantity) != null) { + throw invalid("Duplicate TraderView position"); + } + BigDecimal price = amount(row, "liquidationPrice"); + if (price != null && price.signum() > 0) { + prices.put(symbol, price); + } + } + var held = new HashMap(); + for (PhoenixPerpsPosition position : positions) { + held.put(position.symbol(), position.quantity()); + } + reported.entrySet().removeIf(entry -> entry.getValue().signum() == 0); + if (!reported.keySet().equals(held.keySet())) { + throw invalid("TraderView position membership changed during read"); + } + for (var entry : held.entrySet()) { + if (entry.getValue().compareTo(reported.get(entry.getKey())) != 0) { + throw invalid("TraderView position size changed during read"); + } + } + return new View(account, Map.copyOf(prices)); + } + + private static BigDecimal amount(JsonNode object, String field) throws IOException { + JsonNode value = object.get(field); + if (value == null || value.isNull()) { + return null; + } + JsonNode raw = value.get("value"); + JsonNode decimals = value.get("decimals"); + if (!value.isObject() || raw == null || !raw.isIntegralNumber() || decimals == null + || !decimals.isIntegralNumber() || !decimals.canConvertToInt() + || decimals.intValue() < -18 || decimals.intValue() > 18) { + throw invalid("Invalid TraderView amount " + field); + } + return new BigDecimal(raw.bigIntegerValue(), decimals.intValue()); + } + + private static String text(JsonNode object, String field) throws IOException { + String value = optionalText(object, field); + if (value == null) { + throw invalid("Missing TraderView text " + field); + } + return value; + } + + private static String optionalText(JsonNode object, String field) throws IOException { + JsonNode value = object.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isTextual() || value.textValue().isBlank() + || !value.textValue().equals(value.textValue().strip())) { + throw invalid("Invalid TraderView text " + field); + } + return value.textValue(); + } + + private static IOException invalid(String message) { + return new IOException("Invalid Phoenix response: " + message); + } +} diff --git a/src/main/tjava/com/r35157/nenjim/hubd/impl/ref/NenjimHubImpl.tjava b/src/main/tjava/com/r35157/nenjim/hubd/impl/ref/NenjimHubImpl.tjava index 77d1868..69327fa 100644 --- a/src/main/tjava/com/r35157/nenjim/hubd/impl/ref/NenjimHubImpl.tjava +++ b/src/main/tjava/com/r35157/nenjim/hubd/impl/ref/NenjimHubImpl.tjava @@ -251,7 +251,7 @@ public final class NenjimHubImpl implements NenjimHub { JUPITER_PERPS_EVELYN_TEST_ID, jupiterServicePerpsTest); - phoenixPerpsService = new PhoenixPerpsServiceImpl(httpClient, objectMapper); + phoenixPerpsService = new PhoenixPerpsServiceImpl(httpClient, objectMapper, solanaBlockChain); jupiterPerpsOverview = new RestJupiterPerpsOverview(httpClient, objectMapper); registryServiceManager.registerComponent(PHOENIX_PERPS_ID, phoenixPerpsService);