100: Add Perps collateral, leverage, value, uPnL and gross-exposure totals in EMC
This commit is contained in:
+58
-28
@@ -435,38 +435,68 @@ Price Index beregnes og vises, mens de øvrige fem felter fortsat er nul. Legacy
|
||||
## 12. Perps-service og Mission Control
|
||||
|
||||
`EvelynPerpsService` udleverer et immutable `PerpsSnapshot` gennem det parameterløse
|
||||
`getPositionsSnapshot()`. Læsning foretager ingen integrationskald. Hub'en starter separate processer
|
||||
`evelyn.perps.prod` og `evelyn.perps.test`, injicerer de tilsvarende eksisterende `JupiterPerpsService`-views
|
||||
og en fælles `PhoenixPerpsService` uden positionscache registreret som `phoenix.perps.default`.
|
||||
`PerpsWalletConfiguration` indeholder en `SolanaWalletId`-adresse, der også bruges som række-ID og label.
|
||||
Hub'en ejer de hardcodede lister: seks PROD-adresser og én TEST-adresse fra issue #98.
|
||||
Begge integrationer læser de samme wallets i det pågældende miljø.
|
||||
`getPositionsSnapshot()`. Læsning foretager ingen integrationskald. Hub'en starter
|
||||
separate processer `evelyn.perps.prod` og `evelyn.perps.test`, hver med sin eksisterende
|
||||
wallet-liste: seks PROD-adresser og én TEST-adresse fra issue #98. Begge får en fælles
|
||||
read-only `JupiterPerpsOverview` og en fælles `PhoenixPerpsService`; sidstnævnte er
|
||||
registreret som `phoenix.perps.default`. De eksisterende Jupiter-services til handel
|
||||
og alarmer beholder deres afhængigheder og cache.
|
||||
|
||||
Servicen henter åbne Jupiter- og Phoenix-positioner asynkront ved start og venter 60 sekunder efter hvert
|
||||
fuldført gennemløb, så runder ikke overlapper. Den eksisterende Solana-cache med 60 sekunders TTL bevares
|
||||
for Jupiter. Phoenix læses gennem REST med 10 sekunders timeout pr. request, mindst ét sekund mellem
|
||||
request-start på tværs af miljøer og fælles cooldown ved HTTP 429. Data er periodiske snapshots.
|
||||
Servicen læser asynkront ved start og venter 60 sekunder efter hvert fuldført gennemløb.
|
||||
Runder overlapper ikke og afhænger ikke af EMC's synlighed. Jupiter-overblikket bruger
|
||||
ét REST-opslag pr. wallet. Phoenix bruger trader-state, metadata og én mark price pr.
|
||||
unikt åbent marked. Begge har 10 sekunders request-timeout; Phoenix bevarer fælles
|
||||
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.
|
||||
|
||||
Jupiters positionskonto er stabilt ID; retning og `positionSize()` vises, kendte mints bliver
|
||||
SOL-PERP/BTC-PERP/ETH-PERP, og ukendte mints vises som adressen. Positioner sorteres efter konto.
|
||||
Phoenix bruger authority-wallet og trader PDA index 0 med alle returnerede cross/isolated-subaccounts.
|
||||
Kun nonzero-positioner medtages. ID og sortering bygger på PDA-index, subaccount-index og markedssymbol;
|
||||
sekvensnumre indgår ikke. Retningen kommer fra signed quantity, label er fx SOL-PERP, og USD-størrelsen er
|
||||
absolut base-mængde gange mark price, beregnet med decimalpræcision. Se [Phoenix-integrationen](PhoenixPerps.md)
|
||||
for dokumenterede enheder, endpoints og begrænsninger.
|
||||
Snapshot'et adskiller isolerede positioner fra Cross-konti med egne balancer. Pengebeløb
|
||||
er præcise USD-decimaler, og null betyder utilgængelig. Før første succes indeholder
|
||||
hver konfigureret wallet/venue en ukendt scope, ingen syntetiske positioner. Succes
|
||||
publiceres pr. wallet/venue; en fejl beholder den kombinations sidste observation og
|
||||
oprindelige succes-tidspunkt. Andre wallets/venues forsøges stadig. Et gyldigt tomt svar
|
||||
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.
|
||||
|
||||
Før første vellykkede opslag er der ingen positioner. Succes publiceres i et nyt snapshot pr. wallet/venue;
|
||||
fejl logges og bevarer netop den kombinations sidste data. De øvrige venues og wallets forsøges stadig.
|
||||
Et vellykket tomt svar fjerner kun den pågældende venues positioner og bevarer gruppen.
|
||||
Fejl i HTTP, authentication, svarformat eller konvertering er ikke tomme porteføljer.
|
||||
Der vises ingen indikator for forældede data. Implementationens `close()` afbryder worker'en;
|
||||
consumer-API'et indeholder kun læsning.
|
||||
EMC viser **Position | Direction | Margin | Collateral | Leverage | Exposure | Value | uPnL**
|
||||
under den synlige rod **Perpetual Contracts**. Isolerede positioner har wallet → venue →
|
||||
position; Phoenix Cross har wallet → venue → Cross → position. Positionens konto-ID
|
||||
bestemmer identitet og tilhørsforhold, aldrig instrumentteksten. Wallets følger den
|
||||
hidtidige konfiguration med Jupiter før Phoenix. Eksisterende foldestatus, markering og
|
||||
rækkeobjekter bevares; uændrede snapshots opdaterer ikke træet. Vinduesstart er 1100 × 600,
|
||||
og smallere vinduer kan scrolle vandret, når kolonnernes minimumsbredder kræver det.
|
||||
|
||||
EMC spørger hver miljøservice straks ved åbning og hvert tredje sekund uden at blokere JavaFX-tråden.
|
||||
Skjul/luk stopper kun EMC-polling; services fortsætter. Træet er wallet → venue → position med Jupiter før
|
||||
Phoenix og uden et ekstra subaccount-niveau. Tre kolonner, farver, formatering, foldestatus og markering
|
||||
bevares for eksisterende rækker. Nye/lukkede positioner afstemmes efter ID.
|
||||
Der er ingen sample-data, Refresh-knap, handel, signering eller kontoaktivering i denne læsevej.
|
||||
Exposure er brutto: 100 USD Long + 100 USD Short giver 200 USD. Dollar-uPnL summeres
|
||||
med fortegn. Collateral/Value tæller hver isoleret position og hver delt Cross-konto én
|
||||
gang, inklusive ubrugt kapital på de inkluderede Cross-konti. Cross-positionernes
|
||||
marginkrav lægges ikke til faktisk kontokapital. Mangler en nødvendig del, er netop
|
||||
feltets total utilgængelig; en delsum præsenteres aldrig som total. Ingen summering eller
|
||||
gennemsnitsberegning af leverage eller PnL-procenter. Totalerne afhænger ikke af foldning.
|
||||
|
||||
Isoleret leverage er Exposure / positiv Collateral; uPnL-procent er 100 × pris-uPnL /
|
||||
positiv Collateral. På Cross-positioner er Collateral et initialt marginkrav i USD,
|
||||
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.
|
||||
|
||||
Value er et estimat ved referencepriser, ikke et eksekverbart tilbud. Phoenix medregner
|
||||
collateral, pris-uPnL og signed uafregnet funding og fratrækker estimerede takergebyrer
|
||||
med kontoens fee override. Slippage, price impact, swaps, netværksgebyrer og kontorent
|
||||
indgår ikke. Ukendt spot-collateral gør de berørte værdiansættelser utilgængelige.
|
||||
**Jupiter Value er foreløbig utilgængelig**, fordi API'ets netværdi trækker åbningsgebyrer
|
||||
fra igen, og collateral-/profitloft-semantikken ikke er fuldt verificeret. Se
|
||||
[Jupiter-overblikket](JupiterPerpsOverview.md) og [Phoenix-integrationen](PhoenixPerps.md)
|
||||
for datakilder, enheder og beregninger.
|
||||
|
||||
Beløb har tusindtalsseparator og to decimaler, leverage × og pris-uPnL et synligt fortegn.
|
||||
Positiv uPnL er grøn, negativ rød, afrundet nul neutral uden negativt nul. De svage
|
||||
Long/Short-baggrunde bevares; grupper har tydeligere skrift og ingen retningsfarve.
|
||||
Tooltips forklarer definitioner, manglende værdier og sidste succesfulde læsetidspunkt.
|
||||
Ved fejl markeres venue som retained eller unavailable; 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.
|
||||
Den kanoniske systembeskrivelse findes i
|
||||
[Evelyn: services og status](../../r35157_docs/projects/evelyn/README.md#evelynperpsservice).
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Jupiter read-only Perps overview
|
||||
|
||||
`JupiterPerpsOverview` is an overview-only interface under
|
||||
`com.r35157.libs.jupiter.perps`; `impl.ref.RestJupiterPerpsOverview` reads
|
||||
`GET https://perps-api.jup.ag/v2/positions?walletAddress=<address>` once per wallet.
|
||||
The Hub injects this reader into both Evelyn Perps processes with their existing
|
||||
separate wallet lists. It has a 10-second request timeout, no immediate retries
|
||||
and no portfolio cache. Signing, transactions, alarms, their existing Solana cache
|
||||
and entry-price behavior remain in the existing `JupiterPerpsService`.
|
||||
|
||||
## Response contract and definitions
|
||||
|
||||
The official [CLI response type](https://github.com/jup-ag/cli/blob/main/src/clients/PerpsClient.ts)
|
||||
and [API reference](https://perps-api.jup.ag/v1/docs) describe the position list.
|
||||
The implementation requires an integer `count` matching the complete `dataList`,
|
||||
unique valid position accounts, valid asset mints, Long/Short side and positive
|
||||
size. HTTP/schema failures throw; only an explicit count-zero empty list clears
|
||||
the scope. Missing optional valuation fields become unavailable; malformed
|
||||
amounts fail the read.
|
||||
|
||||
| Display | Source / definition |
|
||||
| --- | --- |
|
||||
| Exposure | `sizeUsd / 10^6`; preserves the existing recorded USD-size basis |
|
||||
| Collateral | `collateralUsd / 10^6`, the venue's recorded post-fee USD collateral |
|
||||
| Leverage | Exposure / positive Collateral; not equity-based leverage |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## Why API Value is not displayed
|
||||
|
||||
On 24 October 2026 the funded configured wallet's six API sizes and collateral
|
||||
amounts matched read-only on-chain position data. For its SOL Short, the response
|
||||
contained these integer micro-USD values:
|
||||
|
||||
```text
|
||||
collateralUsd = 18559 pnlBeforeFeesUsd = 17721
|
||||
openFeesUsd = 361 closeFeesUsd = 361
|
||||
borrowFeesUsd = 7 valueUsd = 35551
|
||||
```
|
||||
|
||||
`35551 = 18559 + 17721 - 361 - 361 - 7`. The on-chain collateral was also
|
||||
18559; the official [position account documentation](https://developers.jup.ag/docs/perps/position-account)
|
||||
defines collateral as after fees. Blindly displaying this API Value would
|
||||
subtract the settled opening fee again. Adding it back alone does not establish
|
||||
the full contract: collateral-currency valuation and the `lockedAmount` profit
|
||||
cap must also be verified. The response does not expose that cap. The overview
|
||||
therefore shows an em dash with an explanation, and higher Value totals containing
|
||||
such a position also remain unavailable. It does not merge unmatched on-chain
|
||||
observations into the API snapshot or build unsigned closing transactions.
|
||||
|
||||
This is a known first-version limitation, not verified closing-value parity with
|
||||
Jupiter. Counts, amounts and prices above are observations, not fixed expectations.
|
||||
See the change's `verification.md` for observation times and current checks.
|
||||
+78
-8
@@ -1,6 +1,6 @@
|
||||
# Phoenix Perps read-only integration
|
||||
|
||||
`PhoenixPerpsService` and `PhoenixPerpsPosition` live in
|
||||
`PhoenixPerpsService`, `PhoenixPerpsAccount` and `PhoenixPerpsPosition` live in
|
||||
`com.r35157.libs.phoenix.perps`; the REST implementation is in `impl.ref`.
|
||||
They contain no Evelyn/UI types or signing/trading operations. NenjimHub creates
|
||||
one adapter without a portfolio cache as `phoenix.perps.default` and injects it into both Evelyn
|
||||
@@ -17,11 +17,13 @@ The implementation uses these official endpoints at `https://perp-api.phoenix.tr
|
||||
returns the wallet authority's version-1 snapshot and subaccounts. The
|
||||
[account model](https://docs.phoenix.trade/sdk/accounts) currently uses trader PDA
|
||||
index 0 for user accounts. Subaccount 0 is cross-margin; other returned subaccounts
|
||||
are isolated. Read every returned subaccount and include only nonzero
|
||||
`basePositionLots`. An omitted `positions` field is documented as optional and
|
||||
are isolated. `getAccounts()` returns actual balances separately from positions;
|
||||
`getOpenPositions()` remains a convenience flattening of those accounts. Keep funded
|
||||
empty Cross accounts in the overview and include only nonzero `basePositionLots`
|
||||
as position rows. An omitted `positions` field is documented as optional and
|
||||
means empty; null or a wrong type is rejected.
|
||||
2. [`GET /v1/view/exchange/markets`](https://docs.phoenix.trade/api/exchange/list-markets)
|
||||
supplies each symbol's `baseLotsDecimals`.
|
||||
supplies each symbol's `baseLotsDecimals`, `tickSize`, leverage tiers and taker fee.
|
||||
3. [`GET /v1/market/{symbol}/mark-price`](https://docs.phoenix.trade/api/exchange/get-mark-price)
|
||||
supplies `markPrice.price` in USD. The API permits an absent mark price; a held
|
||||
position without a valid positive price fails the lookup rather than appearing as zero.
|
||||
@@ -55,11 +57,14 @@ without a rounding context. Jackson parses JSON price numbers directly as decima
|
||||
without a `double` intermediate. For example, 2,144 SOL lots at `baseLotsDecimals=2`
|
||||
are 21.44 SOL; at a mark price of 104.45 USD, notional is 2,239.408 USD. Entry price,
|
||||
collateral, virtual quote lots and margin are not substitutes for current notional.
|
||||
Rounding to cents occurs only in the existing EMC display formatter.
|
||||
Rounding to cents occurs only in the EMC display formatter. Margin and estimated
|
||||
exit fees additionally follow the venue rounding described below.
|
||||
|
||||
Within each wallet's Phoenix venue, identity is `traderPdaIndex:subaccountIndex:symbol`.
|
||||
Sequence numbers do not affect identity. Rows sort by PDA index, subaccount index
|
||||
and symbol; labels use `SYMBOL-PERP`, with no extra subaccount tree level. Positive
|
||||
and symbol; labels use `SYMBOL-PERP`. Isolated rows remain directly beneath the venue.
|
||||
Cross positions belong beneath their explicit `Cross` account group, which supersedes
|
||||
the former no-subaccount-level restriction from issue #99. Positive
|
||||
quantity means Long and negative means Short. Thus two SOL subaccounts remain two
|
||||
rows even though their labels match.
|
||||
|
||||
@@ -74,12 +79,77 @@ Each Evelyn process performs an initial background refresh, then waits 60 second
|
||||
following each completed cycle. Jupiter and Phoenix are attempted independently for
|
||||
each configured wallet. Successful results replace only that wallet/venue; failures
|
||||
log wallet and venue and retain its last successful result. A valid empty result
|
||||
clears only that venue's positions, preserving its group. Snapshot reads never fetch
|
||||
replaces that venue's positions and account balances, preserving its group.
|
||||
Funded Cross accounts remain visible when they no longer hold positions. Snapshot reads never fetch
|
||||
external data; EMC's three-second polling remains unchanged.
|
||||
|
||||
Positions and prices are separate REST reads and can belong to different slots.
|
||||
This is a periodic current-notional estimate, not an atomic exchange valuation.
|
||||
A cycle's duration includes all sequential reads and request pacing before the
|
||||
60-second pause (32 active markets need at least about 33 seconds for their reads). Failed
|
||||
reads can leave old data visible indefinitely; there is no stale/error indicator yet.
|
||||
reads can leave old data visible indefinitely. The venue now shows `(retained)` after
|
||||
a failed refresh, or `(unavailable)` before any successful read; cell tooltips include
|
||||
the last-success time. Initial not-yet-read scopes show `(loading)` and cannot contribute
|
||||
fabricated zero totals.
|
||||
Trader PDA indices other than 0, streaming and position management are out of scope.
|
||||
|
||||
|
||||
## Valuations, funding and margin
|
||||
|
||||
Monetary arithmetic uses exact `BigDecimal`/`BigInteger` values. Quote lots are
|
||||
1e-6 USD. The [official SDK computation](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/margin/compute.ts)
|
||||
and [PnL definition](https://docs.phoenix.trade/phoenix/margin-and-risk/entry-price-pnl)
|
||||
separate price PnL from funding and fees:
|
||||
|
||||
```text
|
||||
price uPnL = quantity × markPrice + virtualQuotePositionLots / 1e6
|
||||
funding = unsettledFundingQuoteLots / 1e6 (signed; positive adds equity)
|
||||
equity = collateral + sum(price uPnL + funding)
|
||||
exit fee = ceil_to_micro_USD(abs(quantity) × markPrice × takerFee × REST takerFeeOverrideMultiplier)
|
||||
Value = equity - sum(estimated exit fees)
|
||||
```
|
||||
|
||||
The REST override is already a decimal multiplier (observed 0.9), not the raw
|
||||
on-chain i8 representation. Missing override, fee, funding or virtual quote data
|
||||
makes affected valuations unavailable, rather than defaulting to zero. Settled
|
||||
trading fees and `accumulatedFundingQuoteLots` are not subtracted again; collateral
|
||||
already reflects settlement. Equity is undiscounted: no positive-uPnL risk discount
|
||||
or spot-collateral haircut is substituted for owned capital.
|
||||
|
||||
`collateral` is quote collateral in micro-USD. Each nonzero `spotCollaterals.balance`
|
||||
currently makes collateral/equity/Value unavailable: this version does not have a
|
||||
verified spot index-price valuation. It never quietly ignores SOL or another
|
||||
collateral asset. Zero spot balances do not change quote-only valuations. Missing
|
||||
collateral is also unavailable. Independent price PnL/exposure remain computable.
|
||||
|
||||
For a Cross position the Collateral column is its **initial margin requirement**,
|
||||
not a share of the account balance. The implementation follows the
|
||||
[SDK margin math](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/margin/math.ts)
|
||||
and [integer price conversion](https://github.com/Ellipsis-Labs/rise-public/blob/master/ts/src/units.ts):
|
||||
|
||||
```text
|
||||
markTicks = floor(markPrice × 10^(6-baseLotsDecimals) / tickSize)
|
||||
grossQuoteLots = abs(basePositionLots) × markTicks × tickSize
|
||||
L = first-tier leverage, or truncated linear interpolation of leverage between
|
||||
the surrounding maxSizeBaseLots bounds; beyond the final bound use 1
|
||||
initialMarginUsd = ceil(grossQuoteLots / L) / 1e6
|
||||
```
|
||||
|
||||
Interpolation truncates the complete positive leverage, including when leverage
|
||||
is decreasing. For 150 lots between (100 lots, 10×) and (200 lots, 5×), L is 7,
|
||||
not 7.5 or 8. At 100 USD and exponent 2, margin is 21.428572 USD. Exposure retains
|
||||
the exact original REST mark, preserving issue #99's current-notional meaning.
|
||||
|
||||
Cross account leverage is gross exposure / positive undiscounted equity before
|
||||
estimated exit fees. Cross position leverage and ROI are unavailable because the
|
||||
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.
|
||||
|
||||
Value is an estimate at separately read reference prices, not an executable quote.
|
||||
Slippage, price impact, swaps, network fees, account rent, future funding and
|
||||
unrelated wallet balances are excluded. No profit cap is applied to Phoenix.
|
||||
Unsupported/missing values and any higher totals requiring them show `—`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
@@ -0,0 +1,31 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md. The existing service holds only positions and drops first-read failures from the hierarchy. Jupiter's transaction adapter exposes dummy valuation zeros; Phoenix already validates REST trader state and paces requests. Existing local staged/deleted OpenSpec changes are outside this change.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Exact decimal monetary calculations, explicit incomplete/stale scopes, account-owned Cross capital, and a readable first version using existing configured environments.
|
||||
|
||||
**Non-Goals:** Account utilization, inferred Phoenix position ROI/leverage, wallet configuration changes, transaction/quote construction, live deployment, or a permanent test suite.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Extend immutable service snapshots with nullable metrics, isolated positions, explicit Cross accounts and last-success/read-failure metadata. Aggregate in the service domain, independently of tree expansion. Sum actual capital once per isolated position/Cross account; sum gross exposure and dollar PnL; propagate unavailable components separately per field. Never aggregate ratios. Preserve a never-read venue as unknown instead of silently excluding it.
|
||||
2. Add a Jupiter overview-only REST interface and implementation, injected independently of the existing transaction services. Validate count, identities, directions, integer micro-USD fields and complete responses. Use one coherent GET per wallet. Size remains the venue's existing USD size, collateral is its recorded post-fee collateral, price PnL uses pnlBeforeFeesUsd, and leverage/percentage are derived from that collateral. Current live reads match on-chain size/collateral; API percentages are percentage points. Do not use API valueUsd until its valuation is trustworthy: observed valueUsd subtracts openFeesUsd from collateral that already matches post-fee on-chain collateral; profit-cap/collateral valuation semantics are also undocumented. Display unavailable Value with this reason rather than correct one fee while guessing the rest.
|
||||
3. Extend Phoenix's own account/position result, keeping it independent of Evelyn/UI. Subaccount 0 is Cross; other indices are isolated. Read one trader state and one metadata response, then one price per unique held symbol. Convert quote lots at 1e-6 USD. Price PnL is signed quantity times mark plus virtual quote. Funding is the signed unsettled funding amount returned in trader state, added to equity; accumulated/settled funding is not added again. Closing fee estimate uses current notional times market taker fee times the account's REST fee override. Do not subtract settled trading fees again. Nonzero unsupported spot collateral makes collateral/equity/Value unavailable, while independent notional/PnL remain available.
|
||||
4. Phoenix position initial margin follows SDK integer tick conversion (floor), integer tier interpolation (truncate the complete interpolated leverage), and ceil division to quote lots. Exposure/PnL keep the original precise REST mark, preserving existing notional meaning. Isolated leverage and ROI use collateral; Cross position leverage/ROI remain unavailable, Cross position Value is inapplicable. Cross group leverage is gross exposure divided by undiscounted equity before estimated exit fees.
|
||||
5. JavaFX cells only format published domain metrics and notes. Reconcile stable IDs at all levels, including Cross accounts; preserve root and row instances, selection and expansion. Give numbers aligned cells and headers/rows explanatory tooltips, neutral rounded zero, and visually distinct group totals.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Separate Phoenix state/metadata/price observations → describe reference-price estimates and lack of atomic/executable quotes; exclude slippage, swaps, rent and future funding.
|
||||
- Unverified Jupiter net proceeds → explicit unavailable Value propagates to higher Value totals; do not publish a partial total.
|
||||
- Unknown Phoenix spot collateral or missing valuation inputs → only affected fields unavailable; malformed position/identity/required price data still fails the whole scope.
|
||||
- REST failure or cooldown → retain last success with timestamp and failure notice; no immediate retries or overlapping cycles.
|
||||
- Additional metrics exceed former three-column layout → preview in the existing EMC dimensions and allow horizontal scrolling at useful minimum widths.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No runtime configuration or storage migration. Build/verify locally and leave changes uncommitted for review. A later owner-controlled deployment replaces the binary; rollback restores the previous build. Do not restart the live Hub.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
The owner cannot assess Evelyn's committed capital and risk from direction and size alone. Issue #100 adds a first inspectable, read-only valuation overview for existing wallets, directly supporting safe Evelyn operation.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Show Position, Direction, Margin, Collateral, Leverage, Exposure, Value and uPnL beneath a visible Perpetual Contracts root.
|
||||
- **BREAKING**: Extend snapshot and Phoenix result record contracts to represent shared Cross accounts separately from isolated positions, retaining funded empty Cross accounts.
|
||||
- Publish precise venue valuations and complete gross exposure, dollar uPnL and closing-value totals without counting shared capital twice. Unsupported metrics remain explicitly unavailable.
|
||||
- Add an overview-only Jupiter REST reader; preserve the existing trading, signing, alarm and entry-price integration.
|
||||
- Preserve background refresh timing, independent last-success retention, immutable snapshots and stable UI reconciliation; explain retained or incomplete data.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `jupiter-perps-overview`: Validated read-only Jupiter position metrics, independent of transaction operations.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `evelyn-perps-service`: Account-aware immutable metrics and complete, field-specific aggregation with independent refresh status.
|
||||
- `evelyn-perps-overview`: Eight columns, Cross hierarchy, totals, formatting and definitions.
|
||||
- `phoenix-perps-service`: Shared balances, exact price PnL, initial margin and account closing-value estimates.
|
||||
|
||||
## Impact
|
||||
|
||||
Changes are limited to `.tjava` integration/service/presentation code, passive Hub wiring, OpenSpec and applicable project/shared documentation. No new dependencies, configuration system, trading operations, live Hub restart, commits or pushes. Verification uses the normal build and temporary focused/read-only JavaFX harnesses.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Perps overview displays a collapsible position hierarchy
|
||||
The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value and uPnL in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios blank, and be expanded by default.
|
||||
|
||||
#### Scenario: Open either environment's Perps tab
|
||||
- **WHEN** the owner opens Perps in Production or Test
|
||||
- **THEN** all eight columns, root totals and the account-aware hierarchy are visible
|
||||
|
||||
#### 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
|
||||
|
||||
### 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.
|
||||
|
||||
#### Scenario: Small or unavailable PnL
|
||||
- **WHEN** PnL rounds to zero or is unavailable
|
||||
- **THEN** zero has no negative sign/color and unavailable values show an em dash with an explanation
|
||||
|
||||
#### Scenario: Inspect real positions
|
||||
- **WHEN** real Jupiter positions are displayed
|
||||
- **THEN** direction appears as readable text and background color, with formatted gross USD exposure and applicable valuation metrics
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Definitions distinguish owned collateral and margin requirements
|
||||
Tooltips SHALL explain isolated collateral, exposure-based leverage, collateral-based ROI, estimated net Value and excluded slippage/swaps/rent. Cross position collateral SHALL be identified as initial margin required against shared collateral. Cross position Value SHALL be unavailable; unsupported Cross ROI/leverage SHALL be explained. Cross account leverage SHALL identify undiscounted equity before exit fees as its denominator.
|
||||
|
||||
#### Scenario: Inspect a Cross position
|
||||
- **WHEN** the owner inspects a Cross row and its account group
|
||||
- **THEN** the position shows a USD margin requirement and the account shows shared capital once
|
||||
- **AND** no position-only metric is labeled account utilization
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Wallet configuration belongs to service construction
|
||||
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98, a read-only Jupiter overview reader and a read-only Phoenix integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless. Existing Jupiter trading/alarm services SHALL retain their behavior.
|
||||
|
||||
#### Scenario: Separate configured instances
|
||||
- **WHEN** the Hub constructs and starts both instances
|
||||
- **THEN** each instance queries only its configured wallets through its injected integrations
|
||||
- **AND** the implementation does not define the wallet list internally
|
||||
|
||||
### Requirement: Position mapping is stable and complete
|
||||
The service SHALL preserve Jupiter position-account identities and its existing USD position-size basis. Phoenix SHALL preserve PDA/subaccount/symbol identities and absolute current notional. Isolated positions SHALL remain directly under venues; Cross accounts SHALL own their positions and shared balances separately. Wallet and position ordering SHALL be deterministic. No account membership SHALL be inferred from instrument labels.
|
||||
|
||||
#### Scenario: Same-market cross and isolated positions
|
||||
- **WHEN** a Phoenix wallet holds SOL in Cross and an isolated subaccount
|
||||
- **THEN** both positions retain distinct IDs and their actual account membership
|
||||
|
||||
#### Scenario: Changed and unknown positions
|
||||
- **WHEN** a successful read returns newly opened positions including an unknown token mint
|
||||
- **THEN** all returned positions appear with stable IDs and exact USD sizes, and the unknown mint is displayed rather than dropped
|
||||
|
||||
### Requirement: Background Jupiter refresh
|
||||
The service SHALL refresh immediately after startup and wait 60 seconds after completed cycles without overlap or dependence on EMC visibility. It SHALL use a dedicated read-only Jupiter overview and the Phoenix account reader. Snapshot reads SHALL make no integration calls. No signing or position-management operation SHALL occur; existing transaction/alarm reads SHALL retain their behavior.
|
||||
|
||||
#### Scenario: Background update while EMC is hidden
|
||||
- **WHEN** EMC is hidden and the next refresh is due
|
||||
- **THEN** the service refreshes configured wallets and publishes immutable snapshots
|
||||
- **AND** all UI snapshot reads remain in-memory
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Complete gross and account-aware totals
|
||||
Exposure and dollar price PnL SHALL sum through all levels. Exposure SHALL be gross, with positive absolute notionals. Capital and closing Value SHALL count isolated positions and shared Cross accounts once. Missing required components SHALL make only the affected total unavailable. Successfully read empty scopes SHALL total zero. Leverage and PnL percentages SHALL NOT be aggregated.
|
||||
|
||||
#### Scenario: Opposite directions and shared collateral
|
||||
- **WHEN** two Cross positions have notionals 100 USD Long and 100 USD Short, shared collateral 80 USD, combined PnL 5 USD and closing fees 1 USD
|
||||
- **THEN** exposure totals 200 USD, collateral 80 USD and Value 84 USD at account, venue, wallet and root levels
|
||||
- **AND** collapsing any UI group does not change totals
|
||||
|
||||
#### Scenario: First read fails
|
||||
- **WHEN** one configured venue has never been read successfully
|
||||
- **THEN** it remains unknown and cannot silently produce complete portfolio totals
|
||||
|
||||
### Requirement: Retained observations communicate status
|
||||
Each venue SHALL carry its last-success time and whether the latest attempt failed. Failure SHALL preserve positions and account balances independently per wallet/venue. A valid funded empty Cross account SHALL remain visible. Retained data SHALL NOT be described as fresh.
|
||||
|
||||
#### Scenario: Last Cross position closes
|
||||
- **WHEN** a successful refresh returns a Cross account with nonzero collateral and no positions
|
||||
- **THEN** its capital and Value remain included without synthetic positions
|
||||
|
||||
#### Scenario: Retained last success
|
||||
- **WHEN** a subsequent read fails
|
||||
- **THEN** its previous observations remain available with their original success time and failure indication
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## Purpose
|
||||
|
||||
Provide precise read-only Jupiter position metrics for capital and risk inspection without altering transaction or alarm behavior.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Coherent read-only position overview
|
||||
The overview SHALL read the official positions response for one wallet, validate completeness and unique stable identities, and return immutable results. USD integer amounts SHALL be converted from micro-USD without binary floating point. Requests SHALL be interruptible and have bounded timeouts; failed or malformed reads SHALL NOT become empty portfolios.
|
||||
|
||||
#### Scenario: Incomplete response
|
||||
- **WHEN** count differs from the returned position count, or identities are duplicated
|
||||
- **THEN** the read fails and no partial portfolio is published
|
||||
|
||||
#### Scenario: Valid empty response
|
||||
- **WHEN** the response contains count zero and an empty position list
|
||||
- **THEN** the successful result clears that wallet's Jupiter scope
|
||||
|
||||
### Requirement: Verified metrics and unavailable valuations
|
||||
The overview SHALL preserve size basis, use post-fee USD collateral and price PnL before separate fees, and calculate leverage and ROI against positive collateral. Unverified closing valuations SHALL be unavailable with an explanation; placeholder zeros and double subtraction of settled fees SHALL NOT be used.
|
||||
|
||||
#### Scenario: Unverified API closing value
|
||||
- **WHEN** API value subtracts opening fees already settled in collateral or does not establish profit-cap valuation
|
||||
- **THEN** closing Value is unavailable, while independently verified metrics remain available
|
||||
|
||||
#### Scenario: Nonpositive collateral
|
||||
- **WHEN** a position's collateral is zero or negative
|
||||
- **THEN** its leverage and PnL percentage are unavailable
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Separate account capital and precise position metrics
|
||||
The reader SHALL return immutable account balances separately from positions, including funded empty Cross accounts. Quote lots SHALL convert at 1e-6 USD. Price PnL SHALL use precise virtual quote and signed base amounts at mark. Initial position margin SHALL follow size-dependent tiers including venue interpolation and rounding. Unsupported non-USDC collateral SHALL make affected valuations unavailable instead of being ignored.
|
||||
|
||||
#### Scenario: Size between margin tiers
|
||||
- **WHEN** a position lies between two leverage tiers
|
||||
- **THEN** its initial margin follows interpolated integer leverage and upward quote-lot rounding, rather than the market's maximum leverage
|
||||
|
||||
#### Scenario: Unsupported spot collateral
|
||||
- **WHEN** an account has nonzero spot collateral that cannot be valued reliably
|
||||
- **THEN** collateral, equity and closing Value are unavailable while independent exposure and price PnL remain available
|
||||
|
||||
### Requirement: Estimated account and isolated closing proceeds
|
||||
Equity SHALL include collateral, undiscounted price PnL and signed unsettled funding once. Estimated Value SHALL subtract estimated closing taker fees with account fee overrides. Settled fees/funding SHALL NOT be subtracted again. Isolated position leverage/ROI SHALL use actual collateral; Cross position leverage/ROI SHALL remain unavailable unless their venue convention is verified.
|
||||
|
||||
#### Scenario: Funding signs and fee override
|
||||
- **WHEN** collateral is 100 USD, PnL 5 USD, unsettled funding -2 USD, and estimated exit fee 1 USD
|
||||
- **THEN** equity is 103 USD and estimated Value 102 USD
|
||||
- **AND** effective Cross leverage uses 103 USD as its denominator
|
||||
@@ -0,0 +1,22 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Snapshot and totals
|
||||
|
||||
- [x] 1.1 Add immutable metrics, Cross accounts and read status; verify gross totals, missing components and no shared-capital double counting with a temporary focused harness.
|
||||
- [x] 1.2 Update the service contract documentation and verify it distinguishes unknown, empty and retained observations.
|
||||
|
||||
## 2. Venue valuations and refresh
|
||||
|
||||
- [x] 2.1 Add and wire an overview-only Jupiter REST reader; verify live units, completeness, unsupported Value, positive/negative PnL and zero denominators with read-only data and temporary fixtures; document valuation definitions.
|
||||
- [x] 2.2 Extend Phoenix account reads and exact valuation/margin rules; verify tiers, funding signs, fee overrides, unknown collateral and funded empty accounts with temporary fixtures and live data; update Phoenix documentation.
|
||||
- [x] 2.3 Publish per-wallet/venue immutable refresh results and status; verify failure retention, recovery to empty, independent attempts and parameterless no-I/O reads with a temporary harness; update shared Evelyn documentation.
|
||||
|
||||
## 3. Presentation
|
||||
|
||||
- [x] 3.1 Implement eight columns, root/Cross hierarchy, signed formatting and tooltips; verify a standalone JavaFX preview with real read-only data plus controlled edge cases, both environments and preserved tree state.
|
||||
- [x] 3.2 Document UI definitions and unavailable metrics; verify documentation agrees with rendered behavior.
|
||||
|
||||
## 4. Integrated verification
|
||||
|
||||
- [x] 4.1 Run the normal Gradle build and applicable existing checks, validate OpenSpec strictly, and record observation times, preview evidence and remaining valuation limitations in verification.md.
|
||||
- [x] 4.2 Inspect the final diffs for unintended changes and confirm no commits, pushes, shared Detag edits or live Hub restart occurred.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Verification — issue #100
|
||||
|
||||
Verified locally on 24 October 2026; no live Hub was started or restarted, and no
|
||||
signing, trading, activation, deposits or withdrawals were performed. Changes are
|
||||
uncommitted and unpushed. Existing staged/deleted change-97/change-99 artifacts
|
||||
and the pre-existing untracked NFS log file were left untouched.
|
||||
|
||||
## Build and focused checks
|
||||
|
||||
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build` passed against the
|
||||
final sources: 42 existing tests, zero failures/errors. Some intermediate runs
|
||||
encountered transient NFS locks while deleting binary test results; normal
|
||||
reruns succeeded without changing build configuration.
|
||||
- Temporary `/tmp/issue100-check/Issue100Check.java` passed 66 focused checks.
|
||||
No permanent test suite was added. Fixtures exercised gross Long/Short totals,
|
||||
once-only Cross capital/Value, missing components, valid empty scopes, immutable
|
||||
collections, positive/negative PnL, zero/negative denominators, Jupiter response
|
||||
completeness/duplicates/units, Phoenix funding signs and fee overrides,
|
||||
size-dependent tiers and quote-lot rounding, negative base-lot exponents,
|
||||
unsupported spot collateral, funded empty Cross accounts, failed refresh,
|
||||
retained timestamps, recovery to empty, independent wallet/venue attempts,
|
||||
no-I/O snapshot access and Phoenix 429 cooldown.
|
||||
- Hand-checkable Cross fixture: 100 USD Long + 100 USD Short = 200 USD gross
|
||||
exposure; shared collateral 80, price PnL +5, net unsettled funding 0 and
|
||||
exit fees 1 give equity 85 and Value 84. Cross leverage is 200/85. Initial
|
||||
margin requirements of 10 per child are not added to the 80 owned capital.
|
||||
- Tier fixture: 150 base lots at exponent 2 and price 100.009, tick size 100,
|
||||
between tiers (100 lots, 10×) and (200 lots, 5×), uses integer leverage 7
|
||||
and initial margin 21.428572 USD. Exposure remains 150.0135 USD.
|
||||
|
||||
## Read-only live comparison
|
||||
|
||||
The new adapters were read directly, outside the Hub, from
|
||||
**2026-10-24 10:18:38 UTC to 10:19:13 UTC** (12:18–12:19 CEST).
|
||||
The controlled PROD wallet was `H5Yrdm7B4FHhTxhPw9SVwFzQyTEL6bAPptZuTyRxcepq`;
|
||||
the existing TEST wallet was `6NyYEoXmJyBXvU4pvv9aoxZNdyqqtkmPBZFEDkRDHcqp`.
|
||||
The other five PROD wallets remain configured, but were not all read for this
|
||||
preview. Counts and prices are observations, not fixed expectations.
|
||||
|
||||
| Observed scope | Positions | Collateral USD | Gross Exposure USD | Price uPnL USD | Estimated Value USD |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Controlled PROD wallet, Phoenix | 32 | 938.131075 | 4527.060948 | -328.677770 | 608.027266 |
|
||||
| Controlled PROD wallet, Jupiter | 6 | 54.993558 | 704.568047 | -15.752428 | unavailable |
|
||||
| Controlled PROD wallet, combined | 38 | 993.124633 | 5231.628995 | -344.430198 | unavailable |
|
||||
| TEST wallet, both venues | 0 | 0 | 0 | 0 | 0 |
|
||||
|
||||
Jupiter API units and collateral were independently checked against a read-only
|
||||
Solana `getMultipleAccounts` response for all six returned position identities.
|
||||
API `sizeUsd` and `collateralUsd` matched the on-chain values exactly. USD and
|
||||
price fields use six decimal places; percentage fields are already percentage
|
||||
points. The UI's leverage and isolated PnL percentage are derived independently
|
||||
from precise collateral, rather than using rounded API ratios.
|
||||
|
||||
Phoenix uses exact signed quantity × REST mark plus virtual quote for price
|
||||
PnL; quote lots are 1e-6 USD. Estimated equity adds signed unsettled funding,
|
||||
and Value subtracts estimated closing taker fees with the REST account override.
|
||||
Settled funding/fees are not charged again. Tier interpolation and integer price
|
||||
rounding were cross-checked against the official SDK `margin/math.ts`,
|
||||
`margin/compute.ts`, `units.ts` and Rust leverage-tier implementation.
|
||||
|
||||
## JavaFX verification
|
||||
|
||||
A standalone `/tmp/issue100-check/PerpsPreview.java` used the actual immutable
|
||||
read-only PROD/TEST observations above. Controlled edge cases were a separate
|
||||
preview tab and are absent from application sources. It checked eight columns,
|
||||
the visible root, Cross grouping, preserved selection/expansion/row identity on
|
||||
unchanged and changed snapshots, positive/negative/rounded-zero PnL and tooltip
|
||||
definitions. Both 1100-pixel and 600-pixel layouts were inspected; the latter
|
||||
has horizontal scrolling at useful column widths. Root labels use explicit readable text color, including after selection and tab
|
||||
changes; column factories are installed before the initial root.
|
||||
|
||||
Screenshots produced and visually inspected:
|
||||
|
||||
- `/tmp/issue100-check/preview-prod.png`
|
||||
- `/tmp/issue100-check/preview-test.png`
|
||||
- `/tmp/issue100-check/preview-edges.png`
|
||||
- `/tmp/issue100-check/preview-tooltip.png`
|
||||
- `/tmp/issue100-check/preview-small-window.png`
|
||||
|
||||
## OpenSpec and documentation
|
||||
|
||||
The change and all four affected main specifications pass strict validation:
|
||||
|
||||
```text
|
||||
openspec validate 100-perps-capital-and-risk --strict
|
||||
openspec validate evelyn-perps-service --type spec --strict
|
||||
openspec validate evelyn-perps-overview --type spec --strict
|
||||
openspec validate phoenix-perps-service --type spec --strict
|
||||
openspec validate jupiter-perps-overview --type spec --strict
|
||||
```
|
||||
|
||||
Main specs were synchronized; the change remains active for review. Project
|
||||
Evelyn/Phoenix documentation and the shared `r35157_docs/projects/evelyn/README.md`
|
||||
were updated. `git diff --check` passed. Whole-project `openspec validate --all
|
||||
--strict` reports 24 passed and two pre-existing failures: placeholder Purpose
|
||||
sections in `javafx-runtime-service` and `nenjim-process-manager`. Those unrelated
|
||||
specifications were not changed.
|
||||
|
||||
## Known unavailable metrics and valuation limits
|
||||
|
||||
- **Jupiter Value is unavailable**, including higher totals requiring it. The
|
||||
observed API `valueUsd` subtracts opening fees from collateral that already
|
||||
matches post-fee on-chain collateral. Its collateral-valuation/profit-cap
|
||||
treatment is not verified. No fabricated zero, partial total or speculative
|
||||
fee-only correction is displayed; see `docs/JupiterPerpsOverview.md`.
|
||||
- Phoenix Cross position leverage/ROI conventions remain unverified. Those
|
||||
ratios are unavailable; dollar PnL is shown and account effective leverage
|
||||
is provided. Individual Cross position Value is inapplicable.
|
||||
- Nonzero unsupported spot collateral or missing funding/fees/PnL makes the
|
||||
affected collateral/equity/Value unavailable. This version does not claim full
|
||||
account utilization or include open-order risk adjustments in such a metric.
|
||||
- Valuations use periodic reference observations, not executable prices.
|
||||
Slippage, price impact, swaps, network fees, account rent and future funding
|
||||
are excluded. Phoenix state and prices are separate REST observations.
|
||||
Failed refreshes can retain old data indefinitely with explicit status/time.
|
||||
- No changes were made to shared `detag.conf`, wallet lists, trading/alarm
|
||||
implementations, EMC polling cadence or the 60-second post-cycle delay.
|
||||
@@ -7,16 +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 a tree table with columns `Position`, `Direction`, and `Position size, USD`. The hierarchy SHALL be wallet → venue → position, expanded by default, with collapsible wallet and venue groups. Wallet and venue rows SHALL leave direction and size empty.
|
||||
The Perps tab SHALL display Position, Direction, Margin, Collateral, Leverage, Exposure, Value and uPnL in that order beneath the visible Perpetual Contracts root. Isolated hierarchy SHALL be wallet → venue → position; each Phoenix Cross account SHALL add a Cross group above its positions. Groups SHALL show applicable totals, leave direction/aggregate ratios blank, and be expanded by default.
|
||||
|
||||
#### Scenario: Open either environment's Perps tab
|
||||
- **WHEN** the owner opens Perps in Production or Test
|
||||
- **THEN** the three named columns and the fully expanded hierarchy are visible
|
||||
- **AND** direction and size appear only on position rows
|
||||
- **THEN** all eight columns, root totals and the account-aware hierarchy are visible
|
||||
|
||||
#### Scenario: Collapse and expand groups
|
||||
- **WHEN** the owner collapses and then expands a wallet or venue
|
||||
- **THEN** its descendants are hidden and then shown again
|
||||
- **WHEN** the owner collapses and expands a wallet, venue or Cross account
|
||||
- **THEN** descendants hide and return 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.
|
||||
@@ -43,11 +42,15 @@ The Perps table SHALL retain matching wallet, venue and position identities acro
|
||||
- **THEN** the same rows remain, with the same expansion and selection and no rebuild or refresh
|
||||
|
||||
### Requirement: Jupiter positions communicate direction and USD size
|
||||
Position rows SHALL show Long or Short and USD sizes with grouping separators and two decimal places. Long rows SHALL have faint green backgrounds and Short rows faint red backgrounds; wallet and venue rows SHALL have no direction-based color. Existing columns and hierarchy SHALL be preserved.
|
||||
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.
|
||||
|
||||
#### Scenario: Small or unavailable PnL
|
||||
- **WHEN** PnL rounds to zero or is unavailable
|
||||
- **THEN** zero has no negative sign/color and unavailable values show an em dash with an explanation
|
||||
|
||||
#### Scenario: Inspect real positions
|
||||
- **WHEN** real Jupiter positions are displayed
|
||||
- **THEN** direction appears as readable text and background color, with formatted USD size
|
||||
- **THEN** direction appears as readable text and background color, with formatted gross USD exposure and applicable valuation metrics
|
||||
|
||||
### Requirement: EMC presents service data without sample notices
|
||||
EMC SHALL obtain positions only from its environment's in-memory service snapshot and SHALL contain no sample positions, sample notices, illustrative-Phoenix notices, or Refresh button. EMC SHALL own UI components, colors and formatting; integration reads SHALL belong to the service.
|
||||
@@ -56,3 +59,11 @@ EMC SHALL obtain positions only from its environment's in-memory service snapsho
|
||||
- **WHEN** positions are added or removed after refresh
|
||||
- **THEN** EMC reconciles rows by identity while preserving retained selections and expansion state
|
||||
- **AND** no sample labels or Phoenix examples appear
|
||||
|
||||
### Requirement: Definitions distinguish owned collateral and margin requirements
|
||||
Tooltips SHALL explain isolated collateral, exposure-based leverage, collateral-based ROI, estimated net Value and excluded slippage/swaps/rent. Cross position collateral SHALL be identified as initial margin required against shared collateral. Cross position Value SHALL be unavailable; unsupported Cross ROI/leverage SHALL be explained. Cross account leverage SHALL identify undiscounted equity before exit fees as its denominator.
|
||||
|
||||
#### Scenario: Inspect a Cross position
|
||||
- **WHEN** the owner inspects a Cross row and its account group
|
||||
- **THEN** the position shows a USD margin requirement and the account shows shared capital once
|
||||
- **AND** no position-only metric is labeled account utilization
|
||||
|
||||
@@ -22,7 +22,7 @@ The service SHALL run as two separate Hub-managed processes registered as `evely
|
||||
- **AND** no placeholder positions are generated before successful retrieval
|
||||
|
||||
### Requirement: Wallet configuration belongs to service construction
|
||||
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98 and the corresponding existing Jupiter integration plus a read-only Phoenix integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless.
|
||||
The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address specified in issue #98, a read-only Jupiter overview reader and a read-only Phoenix integration at construction. Each service SHALL retain immutable configuration and own its snapshot. Wallet addresses SHALL be used as stable identities and display labels. EMC SHALL neither own nor pass wallet configuration; reads SHALL remain parameterless. Existing Jupiter trading/alarm services SHALL retain their behavior.
|
||||
|
||||
#### Scenario: Separate configured instances
|
||||
- **WHEN** the Hub constructs and starts both instances
|
||||
@@ -30,15 +30,19 @@ The Hub SHALL supply the six PROD wallet addresses and one TEST wallet address s
|
||||
- **AND** the implementation does not define the wallet list internally
|
||||
|
||||
### Requirement: Background Jupiter refresh
|
||||
The service SHALL refresh immediately after startup and wait 60 seconds between completed cycles, without overlapping cycles or depending on EMC visibility. It SHALL use Jupiter and Phoenix read operations and respect the existing Jupiter cache. Snapshot reads SHALL make no integration calls. No transaction signing or position-management operation SHALL occur.
|
||||
The service SHALL refresh immediately after startup and wait 60 seconds after completed cycles without overlap or dependence on EMC visibility. It SHALL use a dedicated read-only Jupiter overview and the Phoenix account reader. Snapshot reads SHALL make no integration calls. No signing or position-management operation SHALL occur; existing transaction/alarm reads SHALL retain their behavior.
|
||||
|
||||
#### Scenario: Background update while EMC is hidden
|
||||
- **WHEN** EMC is hidden and the next refresh is due
|
||||
- **THEN** the service still refreshes configured wallets and publishes immutable snapshots
|
||||
- **AND** no freshness guarantee bypasses the existing 60-second integration cache
|
||||
- **THEN** the service refreshes configured wallets and publishes immutable snapshots
|
||||
- **AND** all UI snapshot reads remain in-memory
|
||||
|
||||
### Requirement: Position mapping is stable and complete
|
||||
The service SHALL map position account to stable ID, mint to known instrument label or raw mint fallback, direction to Long/Short, and positionSize to USD size. Wallet order SHALL follow configuration, and positions SHALL be ordered deterministically by position account. Phoenix SHALL use PDA index, subaccount index and symbol as stable identity, signed quantity for direction and absolute current USD notional for size. Jupiter and Phoenix SHALL appear as separate venues, with no subaccount tree level.
|
||||
The service SHALL preserve Jupiter position-account identities and its existing USD position-size basis. Phoenix SHALL preserve PDA/subaccount/symbol identities and absolute current notional. Isolated positions SHALL remain directly under venues; Cross accounts SHALL own their positions and shared balances separately. Wallet and position ordering SHALL be deterministic. No account membership SHALL be inferred from instrument labels.
|
||||
|
||||
#### Scenario: Same-market cross and isolated positions
|
||||
- **WHEN** a Phoenix wallet holds SOL in Cross and an isolated subaccount
|
||||
- **THEN** both positions retain distinct IDs and their actual account membership
|
||||
|
||||
#### Scenario: Changed and unknown positions
|
||||
- **WHEN** a successful read returns newly opened positions including an unknown token mint
|
||||
@@ -56,3 +60,26 @@ A failed read or mapping SHALL retain the last successful data for that wallet a
|
||||
- **WHEN** Phoenix fails for a wallet while Jupiter succeeds, or vice versa
|
||||
- **THEN** only the successful venue is updated and the failed venue retains its previous data
|
||||
- **AND** subsequent wallets are still attempted
|
||||
|
||||
### Requirement: Complete gross and account-aware totals
|
||||
Exposure and dollar price PnL SHALL sum through all levels. Exposure SHALL be gross, with positive absolute notionals. Capital and closing Value SHALL count isolated positions and shared Cross accounts once. Missing required components SHALL make only the affected total unavailable. Successfully read empty scopes SHALL total zero. Leverage and PnL percentages SHALL NOT be aggregated.
|
||||
|
||||
#### Scenario: Opposite directions and shared collateral
|
||||
- **WHEN** two Cross positions have notionals 100 USD Long and 100 USD Short, shared collateral 80 USD, combined PnL 5 USD and closing fees 1 USD
|
||||
- **THEN** exposure totals 200 USD, collateral 80 USD and Value 84 USD at account, venue, wallet and root levels
|
||||
- **AND** collapsing any UI group does not change totals
|
||||
|
||||
#### Scenario: First read fails
|
||||
- **WHEN** one configured venue has never been read successfully
|
||||
- **THEN** it remains unknown and cannot silently produce complete portfolio totals
|
||||
|
||||
### Requirement: Retained observations communicate status
|
||||
Each venue SHALL carry its last-success time and whether the latest attempt failed. Failure SHALL preserve positions and account balances independently per wallet/venue. A valid funded empty Cross account SHALL remain visible. Retained data SHALL NOT be described as fresh.
|
||||
|
||||
#### Scenario: Last Cross position closes
|
||||
- **WHEN** a successful refresh returns a Cross account with nonzero collateral and no positions
|
||||
- **THEN** its capital and Value remain included without synthetic positions
|
||||
|
||||
#### Scenario: Retained last success
|
||||
- **WHEN** a subsequent read fails
|
||||
- **THEN** its previous observations remain available with their original success time and failure indication
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# jupiter-perps-overview Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide precise read-only Jupiter position metrics for capital and risk inspection without altering transaction or alarm behavior.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Coherent read-only position overview
|
||||
The overview SHALL read the official positions response for one wallet, validate completeness and unique stable identities, and return immutable results. USD integer amounts SHALL be converted from micro-USD without binary floating point. Requests SHALL be interruptible and have bounded timeouts; failed or malformed reads SHALL NOT become empty portfolios.
|
||||
|
||||
#### Scenario: Incomplete response
|
||||
- **WHEN** count differs from the returned position count, or identities are duplicated
|
||||
- **THEN** the read fails and no partial portfolio is published
|
||||
|
||||
#### Scenario: Valid empty response
|
||||
- **WHEN** the response contains count zero and an empty position list
|
||||
- **THEN** the successful result clears that wallet's Jupiter scope
|
||||
|
||||
### Requirement: Verified metrics and unavailable valuations
|
||||
The overview SHALL preserve size basis, use post-fee USD collateral and price PnL before separate fees, and calculate leverage and ROI against positive collateral. Unverified closing valuations SHALL be unavailable with an explanation; placeholder zeros and double subtraction of settled fees SHALL NOT be used.
|
||||
|
||||
#### Scenario: Unverified API closing value
|
||||
- **WHEN** API value subtracts opening fees already settled in collateral or does not establish profit-cap valuation
|
||||
- **THEN** closing Value is unavailable, while independently verified metrics remain available
|
||||
|
||||
#### Scenario: Nonpositive collateral
|
||||
- **WHEN** a position's collateral is zero or negative
|
||||
- **THEN** its leverage and PnL percentage are unavailable
|
||||
@@ -36,3 +36,22 @@ HTTP, authentication, malformed schema, identity mismatch and numeric conversion
|
||||
- **THEN** request starts SHALL be spaced at least one second apart
|
||||
- **AND** HTTP 429 SHALL fail the lookup and establish a shared cooldown from Retry-After (seconds or HTTP date, 60 seconds fallback)
|
||||
- **AND** lookups during cooldown SHALL fail without external calls, with no immediate retry
|
||||
|
||||
### Requirement: Separate account capital and precise position metrics
|
||||
The reader SHALL return immutable account balances separately from positions, including funded empty Cross accounts. Quote lots SHALL convert at 1e-6 USD. Price PnL SHALL use precise virtual quote and signed base amounts at mark. Initial position margin SHALL follow size-dependent tiers including venue interpolation and rounding. Unsupported non-USDC collateral SHALL make affected valuations unavailable instead of being ignored.
|
||||
|
||||
#### Scenario: Size between margin tiers
|
||||
- **WHEN** a position lies between two leverage tiers
|
||||
- **THEN** its initial margin follows interpolated integer leverage and upward quote-lot rounding, rather than the market's maximum leverage
|
||||
|
||||
#### Scenario: Unsupported spot collateral
|
||||
- **WHEN** an account has nonzero spot collateral that cannot be valued reliably
|
||||
- **THEN** collateral, equity and closing Value are unavailable while independent exposure and price PnL remain available
|
||||
|
||||
### Requirement: Estimated account and isolated closing proceeds
|
||||
Equity SHALL include collateral, undiscounted price PnL and signed unsettled funding once. Estimated Value SHALL subtract estimated closing taker fees with account fee overrides. Settled fees/funding SHALL NOT be subtracted again. Isolated position leverage/ROI SHALL use actual collateral; Cross position leverage/ROI SHALL remain unavailable unless their venue convention is verified.
|
||||
|
||||
#### Scenario: Funding signs and fee override
|
||||
- **WHEN** collateral is 100 USD, PnL 5 USD, unsettled funding -2 USD, and estimated exit fee 1 USD
|
||||
- **THEN** equity is 103 USD and estimated Value 102 USD
|
||||
- **AND** effective Cross leverage uses 103 USD as its denominator
|
||||
|
||||
@@ -160,7 +160,7 @@ public final class EvelynMissionControlImpl implements EvelynMissionControl, Nen
|
||||
refreshTimeline.stop();
|
||||
stopPerpsPolling();
|
||||
});
|
||||
window.setScene(new Scene(createRootTabs(), 600, 400));
|
||||
window.setScene(new Scene(createRootTabs(), 1100, 600));
|
||||
}
|
||||
|
||||
refreshCharts();
|
||||
|
||||
@@ -2,81 +2,149 @@ package com.r35157.evelyn.emc.impl.ref;
|
||||
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Margin;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.ReadOnlyStringWrapper;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
import javafx.scene.control.TreeTableRow;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
final class PerpsTab {
|
||||
PerpsTab(String environmentColor) {
|
||||
NumberFormat usdFormat = NumberFormat.getCurrencyInstance(Locale.US);
|
||||
|
||||
TreeTableColumn<PositionRow, String> position = new TreeTableColumn<>("Position");
|
||||
position.setCellValueFactory(cell -> new ReadOnlyStringWrapper(cell.getValue().getValue().label()));
|
||||
position.setPrefWidth(260);
|
||||
var position = new TreeTableColumn<PositionRow, String>("Position");
|
||||
position.setPrefWidth(240);
|
||||
position.setMinWidth(180);
|
||||
position.setSortable(false);
|
||||
|
||||
TreeTableColumn<PositionRow, String> direction = new TreeTableColumn<>("Direction");
|
||||
direction.setCellValueFactory(cell -> {
|
||||
Direction value = cell.getValue().getValue().direction();
|
||||
return new ReadOnlyStringWrapper(value == null ? "" : value == Direction.LONG ? "Long" : "Short");
|
||||
position.setCellValueFactory(cell -> new ReadOnlyStringWrapper(cell.getValue().getValue().label()));
|
||||
position.setCellFactory(ignored -> new TreeTableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(String label, boolean empty) {
|
||||
super.updateItem(label, empty);
|
||||
setText(empty ? null : label);
|
||||
setTextFill(Color.web("#30343b"));
|
||||
Tooltip details = empty ? null : tooltip(label);
|
||||
if (details != null) {
|
||||
details.setOnShowing(event -> {
|
||||
PositionRow row = getTableRow() == null ? null : getTableRow().getItem();
|
||||
details.setText(row == null ? label : row.label() + "\n" + row.notes());
|
||||
});
|
||||
}
|
||||
setTooltip(details);
|
||||
}
|
||||
});
|
||||
direction.setPrefWidth(90);
|
||||
direction.setMinWidth(80);
|
||||
direction.setSortable(false);
|
||||
|
||||
TreeTableColumn<PositionRow, String> size = new TreeTableColumn<>("Position size, USD");
|
||||
size.setCellValueFactory(cell -> {
|
||||
ΩAmountΩ value = cell.getValue().getValue().sizeUsd();
|
||||
return new ReadOnlyStringWrapper(value == null ? "" : usdFormat.format(value));
|
||||
});
|
||||
size.setPrefWidth(170);
|
||||
size.setMinWidth(155);
|
||||
size.setStyle("-fx-alignment: CENTER-RIGHT;");
|
||||
size.setSortable(false);
|
||||
|
||||
table.getRoot().setExpanded(true);
|
||||
table.setShowRoot(false);
|
||||
table.setTreeColumn(position);
|
||||
table.getColumns().add(position);
|
||||
table.getColumns().add(direction);
|
||||
table.getColumns().add(size);
|
||||
table.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN);
|
||||
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."));
|
||||
table.getColumns().add(column("Margin", 74, 66,
|
||||
row -> row.margin() == null ? "" : row.margin() == Margin.ISOLATED ? "Isolated" : "Cross",
|
||||
false, false, "Isolated collateral belongs to one position. Cross positions share account collateral."));
|
||||
table.getColumns().add(column("Collateral", 100, 88, row -> usd(row.metrics().collateralUsd()),
|
||||
true, false, "USD collateral after settled fees. On a Cross position this is its size-dependent initial "
|
||||
+ "margin requirement against shared collateral, not a separate deposit. "
|
||||
+ "Cross group and higher totals count the actual account balance once."));
|
||||
table.getColumns().add(column("Leverage", 84, 74, PerpsTab::leverage,
|
||||
true, false, "Isolated: Exposure / Collateral, excluding uPnL from the denominator. "
|
||||
+ "Cross group: gross position notional / undiscounted account equity including unsettled "
|
||||
+ "funding, before estimated exit fees. Nonpositive or missing denominator: unavailable. "
|
||||
+ "Cross position convention unverified: inspect the Cross group. Other groups have no ratio."));
|
||||
table.getColumns().add(column("Exposure", 102, 90, row -> usd(row.metrics().exposureUsd()),
|
||||
true, false, "Gross USD exposure: add absolute Long and Short notionals. Jupiter retains its "
|
||||
+ "position-size basis; Phoenix uses absolute current quantity × mark price."));
|
||||
table.getColumns().add(column("Value", 102, 90, row -> usd(row.metrics().valueUsd()),
|
||||
true, false, "Estimated net proceeds from closing at reference prices, including outstanding "
|
||||
+ "funding/borrowing and estimated exit fees where verified. Cross account Value includes "
|
||||
+ "unused shared collateral, counted once. It excludes unrelated wallet balances. "
|
||||
+ "Not an executable quote: slippage, price impact, swaps, network fees and account rent "
|
||||
+ "are not included. Cross position Value is inapplicable. "
|
||||
+ "A missing required component makes the entire affected total unavailable."));
|
||||
table.getColumns().add(column("uPnL", 184, 155, PerpsTab::pnl,
|
||||
true, true, "Signed unrealized price PnL before separately accounted funding/fees. "
|
||||
+ "Isolated percentage = 100 × uPnL / positive Collateral. Cross position ROI convention "
|
||||
+ "is unverified, so dollar PnL only. Group percentages are never summed or averaged. "
|
||||
+ "Rounded zero is neutral; unavailable is not zero."));
|
||||
table.setColumnResizePolicy(TreeTableView.UNCONSTRAINED_RESIZE_POLICY);
|
||||
table.setRowFactory(view -> new TreeTableRow<>() {
|
||||
@Override
|
||||
protected void updateItem(PositionRow item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setStyle(empty || item == null || item.direction() == null
|
||||
? ""
|
||||
setStyle(empty || item == null ? "" : item.direction() == null
|
||||
? "-fx-font-weight: bold;"
|
||||
: item.direction() == Direction.LONG ? LONG_ROW_STYLE : SHORT_ROW_STYLE);
|
||||
}
|
||||
});
|
||||
|
||||
var root = new TreeItem<>(group("root", "Perpetual Contracts", Metrics.unknown(), TOTAL_NOTES));
|
||||
root.setExpanded(true);
|
||||
table.setRoot(root);
|
||||
table.setShowRoot(true);
|
||||
|
||||
VBox content = new VBox(table);
|
||||
content.setPadding(new Insets(10));
|
||||
content.setStyle("-fx-background-color: " + environmentColor + ";");
|
||||
VBox.setVgrow(table, Priority.ALWAYS);
|
||||
|
||||
tab = new Tab("Perps", content);
|
||||
tab.setStyle("-fx-background-color: " + environmentColor + ";");
|
||||
}
|
||||
|
||||
private TreeTableColumn<PositionRow, PositionRow> column(String title, double width, double minimum,
|
||||
Function<PositionRow, String> format, boolean numeric, boolean pnl, String definition) {
|
||||
var column = new TreeTableColumn<PositionRow, PositionRow>(title);
|
||||
column.setPrefWidth(width);
|
||||
column.setMinWidth(minimum);
|
||||
column.setSortable(false);
|
||||
column.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getValue()));
|
||||
column.setCellFactory(ignored -> new TreeTableCell<>() {
|
||||
@Override
|
||||
protected void updateItem(PositionRow row, boolean empty) {
|
||||
super.updateItem(row, empty);
|
||||
setText(empty || row == null ? null : format.apply(row));
|
||||
setStyle(numeric ? "-fx-alignment: CENTER-RIGHT;" : "");
|
||||
setTextFill(Color.web("#30343b"));
|
||||
setTooltip(null);
|
||||
if (!empty && row != null) {
|
||||
setTooltip(tooltip(definition + "\n\n" + row.label() + "\n" + row.notes()));
|
||||
if (pnl && row.metrics().pnlUsd() != null) {
|
||||
int sign = rounded(row.metrics().pnlUsd(), 2).signum();
|
||||
setTextFill(Color.web(sign > 0 ? "#16743c" : sign < 0 ? "#b32424" : "#30343b"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return column;
|
||||
}
|
||||
|
||||
private static Tooltip tooltip(String text) {
|
||||
Tooltip tooltip = new Tooltip(text);
|
||||
tooltip.setWrapText(true);
|
||||
tooltip.setMaxWidth(590);
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
Tab tab() {
|
||||
return tab;
|
||||
}
|
||||
@@ -89,21 +157,37 @@ final class PerpsTab {
|
||||
if (snapshot.equals(renderedSnapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
TreeItem<PositionRow> selected = table.getSelectionModel().getSelectedItem();
|
||||
table.getRoot().setValue(group("root", "Perpetual Contracts", snapshot.totals(), TOTAL_NOTES));
|
||||
List<TreeItem<PositionRow>> wallets = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingWallets = childrenById(table.getRoot());
|
||||
for (PerpsSnapshot.Wallet wallet : snapshot.wallets()) {
|
||||
TreeItem<PositionRow> walletItem = item(existingWallets, group(wallet.id(), wallet.name()));
|
||||
TreeItem<PositionRow> walletItem = item(existingWallets,
|
||||
group(wallet.id(), wallet.name(), wallet.totals(), TOTAL_NOTES));
|
||||
List<TreeItem<PositionRow>> venues = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingVenues = childrenById(walletItem);
|
||||
for (PerpsSnapshot.Venue venue : wallet.venues()) {
|
||||
TreeItem<PositionRow> venueItem = item(existingVenues, group(venue.id(), venue.name()));
|
||||
String status = status(venue);
|
||||
String label = venue.name() + (venue.lastSuccess() == null
|
||||
? venue.failed() ? " (unavailable)" : " (loading)" : venue.failed() ? " (retained)" : "");
|
||||
TreeItem<PositionRow> venueItem = item(existingVenues,
|
||||
group(venue.id(), label, venue.totals(), status + "\n" + TOTAL_NOTES));
|
||||
List<TreeItem<PositionRow>> positions = new ArrayList<>();
|
||||
Map<String, TreeItem<PositionRow>> existingPositions = childrenById(venueItem);
|
||||
for (PerpsSnapshot.CrossAccount account : venue.crossAccounts()) {
|
||||
TreeItem<PositionRow> accountItem = item(existingPositions, new PositionRow(
|
||||
account.id(), account.name(), null, null, true, account.metrics(),
|
||||
status + "\n" + account.notes()));
|
||||
var children = new ArrayList<TreeItem<PositionRow>>();
|
||||
var previous = childrenById(accountItem);
|
||||
for (PerpsSnapshot.Position position : account.positions()) {
|
||||
children.add(item(previous, row(position, status)));
|
||||
}
|
||||
updateChildren(accountItem, children);
|
||||
positions.add(accountItem);
|
||||
}
|
||||
for (PerpsSnapshot.Position position : venue.positions()) {
|
||||
positions.add(item(existingPositions, new PositionRow(
|
||||
position.id(), position.instrument(), position.direction(), position.sizeUsd())));
|
||||
positions.add(item(existingPositions, row(position, status)));
|
||||
}
|
||||
updateChildren(venueItem, positions);
|
||||
venues.add(venueItem);
|
||||
@@ -121,8 +205,55 @@ final class PerpsTab {
|
||||
renderedSnapshot = snapshot;
|
||||
}
|
||||
|
||||
private static PositionRow group(String id, String label) {
|
||||
return new PositionRow(id, label, null, null);
|
||||
private static String status(PerpsSnapshot.Venue venue) {
|
||||
if (venue.lastSuccess() == null) {
|
||||
return venue.failed() ? "Latest read failed; no successful observation. Totals unavailable."
|
||||
: "Awaiting first successful read. Totals unavailable.";
|
||||
}
|
||||
String observed = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss z")
|
||||
.withZone(ZoneId.systemDefault()).format(venue.lastSuccess());
|
||||
return "Last successful read: " + observed + (venue.failed()
|
||||
? ". Latest refresh failed; these are retained observations, not fresh data."
|
||||
: ". Periodic observation; state and reference prices can differ in time.");
|
||||
}
|
||||
|
||||
private static PositionRow row(PerpsSnapshot.Position position, String status) {
|
||||
return new PositionRow(position.id(), position.instrument(), position.direction(), position.margin(),
|
||||
false, position.metrics(), status + "\n" + position.notes());
|
||||
}
|
||||
|
||||
private static PositionRow group(String id, String label, Metrics metrics, String notes) {
|
||||
return new PositionRow(id, label, null, null, false, metrics, notes);
|
||||
}
|
||||
|
||||
private static String usd(ΩAmountΩ value) {
|
||||
return value == null ? "—" : NumberFormat.getCurrencyInstance(Locale.US).format(rounded(value, 2));
|
||||
}
|
||||
|
||||
private static BigDecimal rounded(BigDecimal value, int scale) {
|
||||
BigDecimal result = value.setScale(scale, RoundingMode.HALF_UP);
|
||||
return result.signum() == 0 ? BigDecimal.ZERO.setScale(scale) : result;
|
||||
}
|
||||
|
||||
private static String leverage(PositionRow row) {
|
||||
if (row.direction() == null && !row.crossAccount()) {
|
||||
return "";
|
||||
}
|
||||
return row.metrics().leverage() == null ? "—" : rounded(row.metrics().leverage(), 2).toPlainString() + "×";
|
||||
}
|
||||
|
||||
private static String pnl(PositionRow row) {
|
||||
ΩAmountΩ amount = row.metrics().pnlUsd();
|
||||
if (amount == null) {
|
||||
return "—";
|
||||
}
|
||||
ΩAmountΩ display = rounded(amount, 2);
|
||||
String text = (display.signum() > 0 ? "+" : display.signum() < 0 ? "-" : "") + usd(display.abs());
|
||||
if (row.direction() != null && row.metrics().pnlPercent() != null) {
|
||||
BigDecimal percent = rounded(row.metrics().pnlPercent(), 1);
|
||||
text += " (" + (percent.signum() > 0 ? "+" : "") + percent.toPlainString() + "%)";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static Map<String, TreeItem<PositionRow>> childrenById(TreeItem<PositionRow> parent) {
|
||||
@@ -133,8 +264,7 @@ final class PerpsTab {
|
||||
return children;
|
||||
}
|
||||
|
||||
private static TreeItem<PositionRow> item(
|
||||
Map<String, TreeItem<PositionRow>> existing, PositionRow row) {
|
||||
private static TreeItem<PositionRow> item(Map<String, TreeItem<PositionRow>> existing, PositionRow row) {
|
||||
TreeItem<PositionRow> item = existing.get(row.id());
|
||||
if (item == null) {
|
||||
item = new TreeItem<>(row);
|
||||
@@ -151,15 +281,19 @@ final class PerpsTab {
|
||||
}
|
||||
}
|
||||
|
||||
private record PositionRow(String id, String label, Direction direction, ΩAmountΩ sizeUsd) {
|
||||
}
|
||||
private record PositionRow(String id, String label, Direction direction, Margin margin,
|
||||
boolean crossAccount, Metrics metrics, String notes) { }
|
||||
|
||||
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 "
|
||||
+ "not added to owned collateral. Missing inputs make the affected total unavailable. "
|
||||
+ "Successfully read empty scopes total zero. Totals can include last-success observations retained "
|
||||
+ "after failures; inspect venue read status. Ratios are not aggregated.";
|
||||
private static final String LONG_ROW_STYLE = "-fx-control-inner-background: #e6f4ea;"
|
||||
+ "-fx-control-inner-background-alt: #e6f4ea;";
|
||||
private static final String SHORT_ROW_STYLE = "-fx-control-inner-background: #fce8e6;"
|
||||
+ "-fx-control-inner-background-alt: #fce8e6;";
|
||||
private final Tab tab;
|
||||
private final TreeTableView<PositionRow> table = new TreeTableView<>(
|
||||
new TreeItem<>(group("root", "Positions")));
|
||||
private final TreeTableView<PositionRow> table = new TreeTableView<>();
|
||||
private PerpsSnapshot renderedSnapshot;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,120 @@
|
||||
package com.r35157.evelyn.service.perps;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** IDs are stable across reads and unique among siblings, independently of display names. */
|
||||
/** Immutable observations. IDs are stable among siblings; null metrics mean unavailable, never zero. */
|
||||
public record PerpsSnapshot(List<Wallet> wallets) {
|
||||
public PerpsSnapshot {
|
||||
wallets = List.copyOf(wallets);
|
||||
}
|
||||
|
||||
public Metrics totals() {
|
||||
return Metrics.total(wallets.stream().map(Wallet::totals).toList());
|
||||
}
|
||||
|
||||
public record Wallet(String id, String name, List<Venue> venues) {
|
||||
public Wallet {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(name, "name");
|
||||
venues = List.copyOf(venues);
|
||||
}
|
||||
|
||||
public Metrics totals() {
|
||||
return Metrics.total(venues.stream().map(Venue::totals).toList());
|
||||
}
|
||||
}
|
||||
|
||||
public record Venue(String id, String name, List<Position> positions) {
|
||||
/** lastSuccess is null until a complete read; failed identifies a failed latest attempt. */
|
||||
public record Venue(String id, String name, List<Position> positions, List<CrossAccount> crossAccounts,
|
||||
Instant lastSuccess, boolean failed) {
|
||||
public Venue {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(name, "name");
|
||||
positions = List.copyOf(positions);
|
||||
crossAccounts = List.copyOf(crossAccounts);
|
||||
}
|
||||
|
||||
public Metrics totals() {
|
||||
if (lastSuccess == null) {
|
||||
return Metrics.unknown();
|
||||
}
|
||||
var components = new ArrayList<Metrics>();
|
||||
positions.forEach(position -> components.add(position.metrics()));
|
||||
// Account Value/collateral already include descendants and unused shared capital.
|
||||
crossAccounts.forEach(account -> components.add(account.metrics()));
|
||||
return Metrics.total(components);
|
||||
}
|
||||
}
|
||||
|
||||
public record Position(String id, String instrument, Direction direction, ΩAmountΩ sizeUsd) {
|
||||
public record CrossAccount(String id, String name, Metrics metrics, List<Position> positions, String notes) {
|
||||
public CrossAccount {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(metrics, "metrics");
|
||||
Objects.requireNonNull(notes, "notes");
|
||||
positions = List.copyOf(positions);
|
||||
}
|
||||
}
|
||||
|
||||
public record Position(String id, String instrument, Direction direction, Margin margin,
|
||||
Metrics metrics, String notes) {
|
||||
public Position {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(instrument, "instrument");
|
||||
Objects.requireNonNull(direction, "direction");
|
||||
Objects.requireNonNull(sizeUsd, "sizeUsd");
|
||||
Objects.requireNonNull(margin, "margin");
|
||||
Objects.requireNonNull(metrics, "metrics");
|
||||
Objects.requireNonNull(notes, "notes");
|
||||
}
|
||||
}
|
||||
|
||||
public enum Direction {
|
||||
LONG, SHORT
|
||||
/** USD monetary values; leverage is a ratio, pnlPercent is in percentage points. */
|
||||
public record Metrics(ΩAmountΩ collateralUsd, BigDecimal leverage, ΩAmountΩ exposureUsd,
|
||||
ΩAmountΩ valueUsd, ΩAmountΩ pnlUsd, BigDecimal pnlPercent) {
|
||||
public Metrics {
|
||||
if (exposureUsd != null && exposureUsd.signum() < 0) {
|
||||
throw new IllegalArgumentException("Gross exposure must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
public static Metrics unknown() {
|
||||
return new Metrics(null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public static Metrics total(List<Metrics> components) {
|
||||
return new Metrics(sum(components, Metrics::collateralUsd), null,
|
||||
sum(components, Metrics::exposureUsd), sum(components, Metrics::valueUsd),
|
||||
sum(components, Metrics::pnlUsd), null);
|
||||
}
|
||||
|
||||
private static ΩAmountΩ sum(List<Metrics> components, Function<Metrics, BigDecimal> field) {
|
||||
ΩAmountΩ total = BigDecimal.ZERO;
|
||||
for (Metrics component : components) {
|
||||
ΩAmountΩ value = field.apply(component);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
total = total.add(value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public static BigDecimal ratio(ΩAmountΩ numerator, ΩAmountΩ denominator) {
|
||||
return numerator == null || denominator == null || denominator.signum() <= 0
|
||||
? null : numerator.divide(denominator, 18, RoundingMode.HALF_EVEN);
|
||||
}
|
||||
|
||||
public static BigDecimal percentage(ΩAmountΩ pnl, ΩAmountΩ collateral) {
|
||||
return pnl == null ? null : ratio(pnl.multiply(BigDecimal.valueOf(100)), collateral);
|
||||
}
|
||||
|
||||
public enum Direction { LONG, SHORT }
|
||||
public enum Margin { ISOLATED, CROSS }
|
||||
}
|
||||
|
||||
+94
-31
@@ -7,9 +7,13 @@ import com.r35157.evelyn.service.perps.PerpsSnapshot.Position;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Venue;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Wallet;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Margin;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.Metrics;
|
||||
import com.r35157.evelyn.service.perps.PerpsSnapshot.CrossAccount;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsOverviewPosition;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsOverview;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsAccount;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
|
||||
import com.r35157.nenjim.process.NenjimProcess;
|
||||
@@ -18,6 +22,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -31,7 +38,7 @@ import java.util.concurrent.TimeUnit;
|
||||
/** Reads Jupiter and Phoenix positions in the background; consumers only read the published snapshot. */
|
||||
public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimProcess, AutoCloseable {
|
||||
public EvelynPerpsServiceImpl(@NotNull List<PerpsWalletConfiguration> wallets,
|
||||
@NotNull JupiterPerpsService jupiter,
|
||||
@NotNull JupiterPerpsOverview jupiter,
|
||||
@NotNull PhoenixPerpsService phoenix) {
|
||||
this.wallets = List.copyOf(wallets);
|
||||
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
|
||||
@@ -53,7 +60,7 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
if (worker != null || closed) {
|
||||
throw new IllegalStateException("Perps service has already started or closed");
|
||||
}
|
||||
snapshot = new PerpsSnapshot(List.of());
|
||||
publish();
|
||||
worker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "evelyn-perps-refresh");
|
||||
thread.setDaemon(true);
|
||||
@@ -65,16 +72,12 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
private void refresh() {
|
||||
for (PerpsWalletConfiguration wallet : wallets) {
|
||||
try {
|
||||
refreshVenue(wallet, "jupiter", "Jupiter", () -> jupiter.getOpenPositions(wallet.address()).stream()
|
||||
.sorted(Comparator.comparing(JupiterPerpsPosition::positionAccount))
|
||||
.map(EvelynPerpsServiceImpl::position)
|
||||
.toList());
|
||||
refreshVenue(wallet, "phoenix", "Phoenix", () -> phoenix.getOpenPositions(wallet.address()).stream()
|
||||
.sorted(Comparator.comparingInt(PhoenixPerpsPosition::traderPdaIndex)
|
||||
.thenComparingInt(PhoenixPerpsPosition::subaccountIndex)
|
||||
.thenComparing(PhoenixPerpsPosition::symbol))
|
||||
.map(EvelynPerpsServiceImpl::position)
|
||||
.toList());
|
||||
refreshVenue(wallet, "jupiter", "Jupiter", () -> new Venue("jupiter", "Jupiter",
|
||||
jupiter.getPositions(wallet.address()).stream()
|
||||
.sorted(Comparator.comparing(JupiterPerpsOverviewPosition::positionAccount))
|
||||
.map(EvelynPerpsServiceImpl::position).toList(),
|
||||
List.of(), Instant.now(), false));
|
||||
refreshVenue(wallet, "phoenix", "Phoenix", () -> phoenixVenue(phoenix.getAccounts(wallet.address())));
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
@@ -83,48 +86,93 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
}
|
||||
|
||||
private void refreshVenue(PerpsWalletConfiguration wallet, String venueId, String venueName,
|
||||
PositionReader reader) throws InterruptedException {
|
||||
VenueReader reader) throws InterruptedException {
|
||||
if (closed || Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("Perps refresh stopped");
|
||||
}
|
||||
try {
|
||||
List<Position> positions = reader.read();
|
||||
Venue venue = reader.read();
|
||||
if (closed || Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("Perps refresh stopped");
|
||||
}
|
||||
lastSuccessful.put(new WalletVenue(wallet.address(), venueId), new Venue(venueId, venueName, positions));
|
||||
snapshot = new PerpsSnapshot(wallets.stream().map(configured -> {
|
||||
List<Venue> venues = VENUE_ORDER.stream()
|
||||
.map(id -> lastSuccessful.get(new WalletVenue(configured.address(), id)))
|
||||
.filter(Objects::nonNull).toList();
|
||||
return new Wallet(configured.address(), configured.address(), venues);
|
||||
}).filter(current -> !current.venues().isEmpty()).toList());
|
||||
observations.put(new WalletVenue(wallet.address(), venueId), venue);
|
||||
publish();
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
WalletVenue key = new WalletVenue(wallet.address(), venueId);
|
||||
Venue previous = observations.getOrDefault(key, unknownVenue(venueId));
|
||||
observations.put(key, new Venue(venueId, venueName, previous.positions(), previous.crossAccounts(),
|
||||
previous.lastSuccess(), true));
|
||||
publish();
|
||||
log.warn("Unable to refresh {} Perps positions for wallet {}", venueName, wallet.address(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Position position(PhoenixPerpsPosition position) {
|
||||
private void publish() {
|
||||
snapshot = new PerpsSnapshot(wallets.stream().map(configured -> new Wallet(
|
||||
configured.address(), configured.address(), VENUE_ORDER.stream()
|
||||
.map(id -> observations.getOrDefault(new WalletVenue(configured.address(), id), unknownVenue(id)))
|
||||
.toList())).toList());
|
||||
}
|
||||
|
||||
private static Venue unknownVenue(String id) {
|
||||
return new Venue(id, id.equals("jupiter") ? "Jupiter" : "Phoenix", List.of(), List.of(), null, false);
|
||||
}
|
||||
|
||||
private static Venue phoenixVenue(List<PhoenixPerpsAccount> accounts) {
|
||||
var isolated = new ArrayList<Position>();
|
||||
var cross = new ArrayList<CrossAccount>();
|
||||
for (PhoenixPerpsAccount account : accounts.stream()
|
||||
.sorted(Comparator.comparingInt(PhoenixPerpsAccount::traderPdaIndex)
|
||||
.thenComparingInt(PhoenixPerpsAccount::subaccountIndex)).toList()) {
|
||||
List<Position> positions = account.positions().stream()
|
||||
.sorted(Comparator.comparing(PhoenixPerpsPosition::symbol))
|
||||
.map(position -> position(position, account)).toList();
|
||||
if (account.cross()) {
|
||||
if (!positions.isEmpty() || account.hasCollateral()) {
|
||||
Metrics sum = Metrics.total(positions.stream().map(Position::metrics).toList());
|
||||
cross.add(new CrossAccount("cross:" + account.traderPdaIndex() + ":" + account.subaccountIndex(),
|
||||
"Cross", new Metrics(account.collateralUsd(),
|
||||
PerpsSnapshot.ratio(sum.exposureUsd(), account.equityUsd()), sum.exposureUsd(),
|
||||
account.valueUsd(), sum.pnlUsd(), null), positions, PHOENIX_ACCOUNT_NOTES));
|
||||
}
|
||||
} else {
|
||||
isolated.addAll(positions);
|
||||
}
|
||||
}
|
||||
return new Venue("phoenix", "Phoenix", isolated, cross, Instant.now(), false);
|
||||
}
|
||||
|
||||
private static Position position(PhoenixPerpsPosition position, PhoenixPerpsAccount account) {
|
||||
String id = position.traderPdaIndex() + ":" + position.subaccountIndex() + ":" + position.symbol();
|
||||
String instrument = position.symbol().endsWith("-PERP") ? position.symbol() : position.symbol() + "-PERP";
|
||||
Direction direction = position.quantity().signum() > 0 ? Direction.LONG : Direction.SHORT;
|
||||
return new Position(id, instrument, direction, position.sizeUsd());
|
||||
boolean cross = account.cross();
|
||||
ΩAmountΩ collateral = cross ? position.initialMarginUsd() : account.collateralUsd();
|
||||
return new Position(id, instrument, direction, cross ? Margin.CROSS : Margin.ISOLATED,
|
||||
new Metrics(collateral, cross ? null : PerpsSnapshot.ratio(position.sizeUsd(), collateral),
|
||||
position.sizeUsd(), cross ? null : account.valueUsd(), position.pnlUsd(),
|
||||
cross ? null : PerpsSnapshot.percentage(position.pnlUsd(), collateral)),
|
||||
cross ? PHOENIX_CROSS_NOTES : PHOENIX_ACCOUNT_NOTES);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface PositionReader {
|
||||
List<Position> read() throws IOException, InterruptedException;
|
||||
private interface VenueReader {
|
||||
Venue read() throws IOException, InterruptedException;
|
||||
}
|
||||
|
||||
private record WalletVenue(String wallet, String venue) { }
|
||||
|
||||
private static Position position(JupiterPerpsPosition position) {
|
||||
private static Position position(JupiterPerpsOverviewPosition position) {
|
||||
Direction direction = switch (position.direction()) {
|
||||
case LONG -> Direction.LONG;
|
||||
case SHORT -> Direction.SHORT;
|
||||
};
|
||||
return new Position(position.positionAccount(), instrument(position.tradedTokenMint()),
|
||||
direction, position.positionSize());
|
||||
direction, Margin.ISOLATED,
|
||||
new Metrics(position.collateralUsd(), PerpsSnapshot.ratio(position.sizeUsd(), position.collateralUsd()),
|
||||
position.sizeUsd(), null, position.pnlUsd(),
|
||||
PerpsSnapshot.percentage(position.pnlUsd(), position.collateralUsd())),
|
||||
JUPITER_NOTES);
|
||||
}
|
||||
|
||||
private static String instrument(ΩSPLMintAddressΩ mint) {
|
||||
@@ -157,11 +205,26 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
|
||||
private static final Logger log = LoggerFactory.getLogger(EvelynPerpsServiceImpl.class);
|
||||
private static final long REFRESH_SECONDS = 60;
|
||||
private final List<PerpsWalletConfiguration> wallets;
|
||||
private final JupiterPerpsService jupiter;
|
||||
private static final String JUPITER_NOTES = "Jupiter: collateral is recorded post-fee USD collateral; "
|
||||
+ "uPnL is price PnL before separate fees. Value unavailable: the API's valueUsd also subtracts "
|
||||
+ "opening fees already reflected in collateral; collateral valuation and profit-cap treatment "
|
||||
+ "are not verified. A missing metric is not zero.";
|
||||
private static final String PHOENIX_ACCOUNT_NOTES = "Phoenix: account equity = actual collateral + "
|
||||
+ "undiscounted price uPnL + signed unsettled funding. Value estimates equity less closing "
|
||||
+ "taker fees with the account fee override; settled fees/funding are not deducted again. "
|
||||
+ "Nonzero unsupported spot collateral makes collateral/equity/Value unavailable. "
|
||||
+ "Missing funding, fee or PnL inputs make Value unavailable. Prices and state are separate observations.";
|
||||
private static final String PHOENIX_CROSS_NOTES = "Phoenix Cross: Collateral is this position's initial "
|
||||
+ "margin requirement against shared collateral, not separately deposited or owned capital. "
|
||||
+ "It uses size-dependent tiers, integer leverage interpolation and venue quote-lot rounding. "
|
||||
+ "Position Value has no uniquely owned collateral portion. Position leverage and ROI percentage "
|
||||
+ "are unavailable because Phoenix's displayed conventions are not verified; inspect the Cross group. "
|
||||
+ "Dollar uPnL excludes separately accounted funding/fees.";
|
||||
private final JupiterPerpsOverview jupiter;
|
||||
private final PhoenixPerpsService phoenix;
|
||||
private static final List<String> VENUE_ORDER = List.of("jupiter", "phoenix");
|
||||
// Accessed only by the single refresh worker; consumers see immutable snapshots.
|
||||
private final Map<WalletVenue, Venue> lastSuccessful = new HashMap<>();
|
||||
private final Map<WalletVenue, Venue> observations = new HashMap<>();
|
||||
private ScheduledExecutorService worker;
|
||||
private volatile boolean closed;
|
||||
private volatile PerpsSnapshot snapshot;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.r35157.libs.jupiter.perps;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** Overview-only reads; independent of the signing/trading and alarm integration. */
|
||||
@FunctionalInterface
|
||||
public interface JupiterPerpsOverview {
|
||||
List<JupiterPerpsOverviewPosition> getPositions(ΩSolanaWalletIdΩ wallet) throws IOException, InterruptedException;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.r35157.libs.jupiter.perps;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
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) {
|
||||
public JupiterPerpsOverviewPosition {
|
||||
Objects.requireNonNull(positionAccount, "positionAccount");
|
||||
Objects.requireNonNull(tradedTokenMint, "tradedTokenMint");
|
||||
Objects.requireNonNull(direction, "direction");
|
||||
Objects.requireNonNull(sizeUsd, "sizeUsd");
|
||||
if (sizeUsd.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Open position must have positive size");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.r35157.libs.jupiter.perps.impl.ref;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsOverview;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsOverviewPosition;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** One complete official API snapshot per wallet. See docs/JupiterPerpsOverview.md for valuation limits. */
|
||||
public final class RestJupiterPerpsOverview implements JupiterPerpsOverview {
|
||||
public RestJupiterPerpsOverview(HttpClient client, ObjectMapper mapper) {
|
||||
this(client, mapper, URI.create("https://perps-api.jup.ag/"), Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
public RestJupiterPerpsOverview(HttpClient client, ObjectMapper mapper, ΩRestEndpointBaseΩ endpoint,
|
||||
Duration timeout) {
|
||||
this.client = Objects.requireNonNull(client, "client");
|
||||
this.mapper = Objects.requireNonNull(mapper, "mapper").copy()
|
||||
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS,
|
||||
DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY,
|
||||
DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||
this.endpoint = Objects.requireNonNull(endpoint, "endpoint");
|
||||
this.timeout = Objects.requireNonNull(timeout, "timeout");
|
||||
if (timeout.isZero() || timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("Jupiter overview timeout must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<JupiterPerpsOverviewPosition> getPositions(ΩSolanaWalletIdΩ wallet)
|
||||
throws IOException, InterruptedException {
|
||||
address(wallet);
|
||||
HttpRequest request = HttpRequest.newBuilder(endpoint.resolve("v2/positions?walletAddress=" + wallet))
|
||||
.timeout(timeout).header("Accept", "application/json")
|
||||
.header("User-Agent", "nenjim-hub").header("x-client-platform", "nenjim-hub")
|
||||
.GET().build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Jupiter overview returned HTTP " + response.statusCode());
|
||||
}
|
||||
return decode(mapper.readTree(response.body()));
|
||||
}
|
||||
|
||||
private static List<JupiterPerpsOverviewPosition> decode(JsonNode response) throws IOException {
|
||||
if (response == null || !response.isObject() || !response.hasNonNull("count")
|
||||
|| !response.get("count").isIntegralNumber() || !response.get("count").canConvertToInt()
|
||||
|| !response.hasNonNull("dataList") || !response.get("dataList").isArray()
|
||||
|| response.get("count").intValue() != response.get("dataList").size()) {
|
||||
throw invalid("Incomplete position list/count");
|
||||
}
|
||||
var positions = new ArrayList<JupiterPerpsOverviewPosition>();
|
||||
var identities = new HashSet<String>();
|
||||
for (JsonNode row : response.get("dataList")) {
|
||||
String id = text(row, "positionPubkey");
|
||||
String mint = text(row, "assetMint");
|
||||
address(id);
|
||||
address(mint);
|
||||
if (!identities.add(id)) {
|
||||
throw invalid("Duplicate position identity");
|
||||
}
|
||||
JupiterPerpsPositionDirection direction = switch (text(row, "side")) {
|
||||
case "long" -> JupiterPerpsPositionDirection.LONG;
|
||||
case "short" -> JupiterPerpsPositionDirection.SHORT;
|
||||
default -> throw invalid("Unsupported position direction");
|
||||
};
|
||||
ΩAmountΩ size = micros(row, "sizeUsd");
|
||||
if (size == null || size.signum() <= 0) {
|
||||
throw invalid("Missing or nonpositive open position size");
|
||||
}
|
||||
positions.add(new JupiterPerpsOverviewPosition(id, mint, direction, size,
|
||||
micros(row, "collateralUsd"), micros(row, "pnlBeforeFeesUsd")));
|
||||
}
|
||||
positions.sort(Comparator.comparing(JupiterPerpsOverviewPosition::positionAccount));
|
||||
return List.copyOf(positions);
|
||||
}
|
||||
|
||||
private static ΩAmountΩ micros(JsonNode row, String field) throws IOException {
|
||||
if (!row.hasNonNull(field)) {
|
||||
return null;
|
||||
}
|
||||
String raw = text(row, field);
|
||||
if (!raw.matches("-?[0-9]+")) {
|
||||
throw invalid("Expected integer micro-USD: " + field);
|
||||
}
|
||||
return new BigDecimal(new BigInteger(raw), 6);
|
||||
}
|
||||
|
||||
private static String text(JsonNode row, String field) throws IOException {
|
||||
if (!row.isObject() || !row.hasNonNull(field) || !row.get(field).isTextual()
|
||||
|| row.get(field).textValue().isBlank()
|
||||
|| !row.get(field).textValue().equals(row.get(field).textValue().strip())) {
|
||||
throw invalid("Invalid text field: " + field);
|
||||
}
|
||||
return row.get(field).textValue();
|
||||
}
|
||||
|
||||
private static void address(String address) throws IOException {
|
||||
try {
|
||||
if (new Base58CodecImpl().decode(address).length != 32) {
|
||||
throw invalid("Invalid Solana address");
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid Solana address", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException invalid(String message) {
|
||||
return new IOException("Invalid Jupiter overview response: " + message);
|
||||
}
|
||||
|
||||
private final HttpClient client;
|
||||
private final ObjectMapper mapper;
|
||||
private final ΩRestEndpointBaseΩ endpoint;
|
||||
private final Duration timeout;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.r35157.libs.phoenix.perps;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** Actual account-owned capital; null valuation fields mean unsupported/missing inputs. */
|
||||
public record PhoenixPerpsAccount(int traderPdaIndex, int subaccountIndex, boolean hasCollateral,
|
||||
ΩAmountΩ collateralUsd, ΩAmountΩ equityUsd, ΩAmountΩ valueUsd,
|
||||
List<PhoenixPerpsPosition> positions) {
|
||||
public PhoenixPerpsAccount {
|
||||
if (traderPdaIndex < 0 || subaccountIndex < 0) {
|
||||
throw new IllegalArgumentException("Invalid Phoenix account identity");
|
||||
}
|
||||
positions = List.copyOf(positions);
|
||||
if (subaccountIndex != 0 && positions.size() > 1) {
|
||||
throw new IllegalArgumentException("Isolated account has more than one open position");
|
||||
}
|
||||
for (PhoenixPerpsPosition position : positions) {
|
||||
if (position.traderPdaIndex() != traderPdaIndex || position.subaccountIndex() != subaccountIndex) {
|
||||
throw new IllegalArgumentException("Position belongs to a different Phoenix account");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean cross() {
|
||||
return subaccountIndex == 0;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import java.util.Objects;
|
||||
* changing sequence numbers. Prices and trader state are separate REST snapshots.
|
||||
*/
|
||||
public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol,
|
||||
ΩAmountΩ quantity, ΩAmountΩ sizeUsd) {
|
||||
ΩAmountΩ quantity, ΩAmountΩ sizeUsd, ΩAmountΩ initialMarginUsd,
|
||||
ΩAmountΩ pnlUsd, ΩAmountΩ fundingUsd, ΩAmountΩ closeFeeUsd) {
|
||||
public PhoenixPerpsPosition {
|
||||
Objects.requireNonNull(symbol, "symbol");
|
||||
Objects.requireNonNull(quantity, "quantity");
|
||||
|
||||
@@ -14,6 +14,12 @@ public interface PhoenixPerpsService extends NenjimComponent {
|
||||
* than returning an empty or partial portfolio. The returned list is immutable and ordered
|
||||
* by trader PDA index, subaccount index and market symbol.
|
||||
*/
|
||||
@NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
|
||||
default @NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
|
||||
throws IOException, InterruptedException {
|
||||
return getAccounts(authority).stream().flatMap(account -> account.positions().stream()).toList();
|
||||
}
|
||||
|
||||
/** Complete account observations, including balances when the last Cross position has closed. */
|
||||
@NotNull List<PhoenixPerpsAccount> getAccounts(@NotNull ΩSolanaWalletIdΩ authority)
|
||||
throws IOException, InterruptedException;
|
||||
}
|
||||
|
||||
+167
-45
@@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsPosition;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsAccount;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -62,7 +64,7 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
|
||||
public @NotNull List<PhoenixPerpsAccount> getAccounts(@NotNull ΩSolanaWalletIdΩ authority)
|
||||
throws IOException, InterruptedException {
|
||||
if (new Base58CodecImpl().decode(authority).length != 32) {
|
||||
throw new IllegalArgumentException("Invalid Solana authority: " + authority);
|
||||
@@ -75,69 +77,191 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
if (integer(snapshot, "version") != 1) {
|
||||
throw invalid("Unsupported trader snapshot version");
|
||||
}
|
||||
var rawPositions = new ArrayList<RawPosition>();
|
||||
var rawAccounts = new ArrayList<JsonNode>();
|
||||
var symbolsNeeded = new HashSet<String>();
|
||||
var subaccounts = new HashSet<Integer>();
|
||||
for (JsonNode subaccount : array(required(snapshot, "subaccounts"), "subaccounts")) {
|
||||
int index = integer(subaccount, "subaccountIndex");
|
||||
if (index < 0 || !subaccounts.add(index)) {
|
||||
throw invalid("Invalid or duplicate subaccount index: " + index);
|
||||
}
|
||||
// The API explicitly permits omitted positions for an empty subaccount.
|
||||
if (!subaccount.has("positions")) {
|
||||
continue;
|
||||
}
|
||||
rawAccounts.add(subaccount);
|
||||
var symbols = new HashSet<String>();
|
||||
for (JsonNode position : array(required(subaccount, "positions"), "positions")) {
|
||||
for (JsonNode position : positions(subaccount)) {
|
||||
String symbol = text(position, "symbol");
|
||||
if (!symbols.add(symbol)) {
|
||||
throw invalid("Duplicate position in subaccount " + index + ": " + symbol);
|
||||
}
|
||||
BigInteger lots = lots(position);
|
||||
BigInteger lots = lots(position, "basePositionLots");
|
||||
if (lots.signum() != 0) {
|
||||
rawPositions.add(new RawPosition(index, symbol, lots));
|
||||
symbolsNeeded.add(symbol);
|
||||
} else if (nonzero(position, "virtualQuotePositionLots")
|
||||
|| nonzero(position, "unsettledFundingQuoteLots")) {
|
||||
throw invalid("Unvalued residual in a zero-sized position");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rawPositions.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
rawPositions.sort(Comparator.comparingInt(RawPosition::subaccountIndex)
|
||||
.thenComparing(RawPosition::symbol));
|
||||
Map<String, Integer> decimals = marketDecimals();
|
||||
rawAccounts.sort(Comparator.comparingInt(account -> account.get("subaccountIndex").intValue()));
|
||||
Map<String, JsonNode> markets = symbolsNeeded.isEmpty() ? Map.of() : markets();
|
||||
var prices = new HashMap<String, BigDecimal>();
|
||||
var positions = new ArrayList<PhoenixPerpsPosition>();
|
||||
for (RawPosition position : rawPositions) {
|
||||
Integer baseLotsDecimals = decimals.get(position.symbol());
|
||||
if (baseLotsDecimals == null) {
|
||||
throw invalid("Missing market metadata for " + position.symbol());
|
||||
}
|
||||
BigDecimal price = prices.get(position.symbol());
|
||||
if (price == null) {
|
||||
price = markPrice(position.symbol());
|
||||
prices.put(position.symbol(), price);
|
||||
}
|
||||
try {
|
||||
// Official SDK: baseLotsToBaseUnits(lots, d) = lots / 10**d (d can be negative).
|
||||
BigDecimal quantity = new BigDecimal(position.lots())
|
||||
.scaleByPowerOfTen(Math.negateExact(baseLotsDecimals));
|
||||
positions.add(new PhoenixPerpsPosition(0, position.subaccountIndex(), position.symbol(),
|
||||
quantity, quantity.abs().multiply(price)));
|
||||
} catch (ArithmeticException | IllegalArgumentException exception) {
|
||||
throw new IOException("Cannot convert Phoenix position " + position.symbol(), exception);
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
return List.copyOf(positions);
|
||||
BigDecimal feeMultiplier = optionalDecimal(snapshot, "takerFeeOverrideMultiplier");
|
||||
if (feeMultiplier != null && feeMultiplier.signum() < 0) {
|
||||
throw invalid("Negative REST fee override multiplier");
|
||||
}
|
||||
var accounts = new ArrayList<PhoenixPerpsAccount>();
|
||||
for (JsonNode rawAccount : rawAccounts) {
|
||||
int index = integer(rawAccount, "subaccountIndex");
|
||||
var positions = new ArrayList<PhoenixPerpsPosition>();
|
||||
for (JsonNode raw : positions(rawAccount)) {
|
||||
BigInteger lots = lots(raw, "basePositionLots");
|
||||
if (lots.signum() == 0) {
|
||||
continue;
|
||||
}
|
||||
String symbol = text(raw, "symbol");
|
||||
JsonNode market = markets.get(symbol);
|
||||
if (market == null) {
|
||||
throw invalid("Missing market metadata for " + symbol);
|
||||
}
|
||||
int exponent = integer(market, "baseLotsDecimals");
|
||||
if (exponent < -18 || exponent > 18) {
|
||||
throw invalid("Unsupported base-lot exponent for " + symbol);
|
||||
}
|
||||
ΩAmountΩ quantity = new BigDecimal(lots).scaleByPowerOfTen(-exponent);
|
||||
ΩPriceΩ price = prices.get(symbol);
|
||||
ΩAmountΩ notional = quantity.abs().multiply(price);
|
||||
ΩAmountΩ quote = optionalQuote(raw, "virtualQuotePositionLots");
|
||||
ΩAmountΩ pnl = quote == null ? null : quantity.multiply(price).add(quote);
|
||||
BigDecimal feeRate = optionalDecimal(market, "takerFee");
|
||||
if (feeRate != null && feeRate.signum() < 0) {
|
||||
throw invalid("Negative taker fee");
|
||||
}
|
||||
ΩAmountΩ closeFee = feeRate == null || feeMultiplier == null ? null
|
||||
: notional.multiply(feeRate).multiply(feeMultiplier).setScale(6, RoundingMode.CEILING);
|
||||
positions.add(new PhoenixPerpsPosition(0, index, symbol, quantity, notional,
|
||||
initialMargin(market, lots.abs(), price, exponent), pnl,
|
||||
optionalQuote(raw, "unsettledFundingQuoteLots"), closeFee));
|
||||
}
|
||||
positions.sort(Comparator.comparing(PhoenixPerpsPosition::symbol));
|
||||
ΩAmountΩ collateral = optionalQuote(rawAccount, "collateral");
|
||||
boolean hasCollateral = collateral == null || collateral.signum() != 0;
|
||||
if (rawAccount.has("spotCollaterals")) {
|
||||
for (JsonNode spot : array(required(rawAccount, "spotCollaterals"), "spotCollaterals")) {
|
||||
if (lots(spot, "balance").signum() != 0) {
|
||||
hasCollateral = true;
|
||||
// No unverified spot conversion or risk haircut masquerading as account equity.
|
||||
collateral = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
ΩAmountΩ equity = collateral;
|
||||
ΩAmountΩ fees = BigDecimal.ZERO;
|
||||
for (PhoenixPerpsPosition position : positions) {
|
||||
equity = add(equity, add(position.pnlUsd(), position.fundingUsd()));
|
||||
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));
|
||||
}
|
||||
return List.copyOf(accounts);
|
||||
}
|
||||
|
||||
private Map<String, Integer> marketDecimals() throws IOException, InterruptedException {
|
||||
var decimals = new HashMap<String, Integer>();
|
||||
private Map<String, JsonNode> markets() throws IOException, InterruptedException {
|
||||
var markets = new HashMap<String, JsonNode>();
|
||||
for (JsonNode market : array(get("v1/view/exchange/markets"), "markets")) {
|
||||
String symbol = text(market, "symbol");
|
||||
if (decimals.putIfAbsent(symbol, integer(market, "baseLotsDecimals")) != null) {
|
||||
if (markets.putIfAbsent(symbol, market) != null) {
|
||||
throw invalid("Duplicate market metadata for " + symbol);
|
||||
}
|
||||
}
|
||||
return decimals;
|
||||
return markets;
|
||||
}
|
||||
|
||||
/** SDK: floor price to ticks, truncate interpolated whole leverage, ceil required quote lots. */
|
||||
private static ΩAmountΩ initialMargin(JsonNode market, BigInteger size, ΩPriceΩ price, int exponent)
|
||||
throws IOException {
|
||||
if (!market.hasNonNull("tickSize") || !market.hasNonNull("leverageTiers")) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal tickDecimal = decimal(market, "tickSize");
|
||||
BigInteger tick;
|
||||
try {
|
||||
tick = tickDecimal.toBigIntegerExact();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw invalid("Non-integer tick size");
|
||||
}
|
||||
if (tick.signum() <= 0) {
|
||||
throw invalid("Nonpositive tick size");
|
||||
}
|
||||
BigInteger priceTicks = price.scaleByPowerOfTen(6 - exponent)
|
||||
.divide(new BigDecimal(tick), 0, RoundingMode.FLOOR).toBigIntegerExact();
|
||||
BigInteger previousSize = BigInteger.ZERO;
|
||||
BigInteger previousLeverage = null;
|
||||
BigInteger leverage = BigInteger.ONE;
|
||||
boolean found = false;
|
||||
JsonNode tiers = array(required(market, "leverageTiers"), "leverageTiers");
|
||||
if (tiers.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode tier : tiers) {
|
||||
BigInteger upper;
|
||||
BigInteger maximum;
|
||||
try {
|
||||
upper = decimal(tier, "maxSizeBaseLots").toBigIntegerExact();
|
||||
maximum = decimal(tier, "maxLeverage").toBigIntegerExact();
|
||||
} catch (ArithmeticException exception) {
|
||||
throw invalid("Non-integer leverage tier");
|
||||
}
|
||||
if (upper.compareTo(previousSize) <= 0 || maximum.signum() <= 0
|
||||
|| (previousLeverage != null && maximum.compareTo(previousLeverage) > 0)) {
|
||||
throw invalid("Invalid leverage tier bounds");
|
||||
}
|
||||
if (!found && size.compareTo(upper) <= 0) {
|
||||
BigInteger range = upper.subtract(previousSize);
|
||||
leverage = previousLeverage == null ? maximum : previousLeverage.multiply(range)
|
||||
.add(maximum.subtract(previousLeverage).multiply(size.subtract(previousSize)))
|
||||
.divide(range);
|
||||
found = true;
|
||||
}
|
||||
previousSize = upper;
|
||||
previousLeverage = maximum;
|
||||
}
|
||||
BigInteger grossQuoteLots = priceTicks.multiply(tick).multiply(size);
|
||||
BigInteger margin = grossQuoteLots.add(leverage).subtract(BigInteger.ONE).divide(leverage);
|
||||
return new BigDecimal(margin, 6);
|
||||
}
|
||||
|
||||
private static JsonNode positions(JsonNode subaccount) throws IOException {
|
||||
// Official API permits an omitted positions field for empty subaccounts.
|
||||
return subaccount.has("positions") ? array(required(subaccount, "positions"), "positions")
|
||||
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.arrayNode();
|
||||
}
|
||||
|
||||
private static ΩAmountΩ add(ΩAmountΩ left, ΩAmountΩ right) {
|
||||
return left == null || right == null ? null : left.add(right);
|
||||
}
|
||||
|
||||
private static boolean nonzero(JsonNode row, String field) throws IOException {
|
||||
return row.hasNonNull(field) && lots(row, field).signum() != 0;
|
||||
}
|
||||
|
||||
private static ΩAmountΩ optionalQuote(JsonNode row, String field) throws IOException {
|
||||
return row.hasNonNull(field) ? new BigDecimal(lots(row, field), 6) : null;
|
||||
}
|
||||
|
||||
private static BigDecimal optionalDecimal(JsonNode row, String field) throws IOException {
|
||||
return row.hasNonNull(field) ? decimal(row, field) : null;
|
||||
}
|
||||
|
||||
private static BigDecimal decimal(JsonNode row, String field) throws IOException {
|
||||
JsonNode value = required(row, field);
|
||||
if (!value.isNumber()) {
|
||||
throw invalid("Expected decimal number: " + field);
|
||||
}
|
||||
return value.decimalValue();
|
||||
}
|
||||
|
||||
private ΩPriceΩ markPrice(String symbol) throws IOException, InterruptedException {
|
||||
@@ -238,10 +362,10 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
private static BigInteger lots(JsonNode position) throws IOException {
|
||||
String value = text(position, "basePositionLots");
|
||||
private static BigInteger lots(JsonNode position, String field) throws IOException {
|
||||
String value = text(position, field);
|
||||
if (!value.matches("-?[0-9]+")) {
|
||||
throw invalid("Expected signed integer basePositionLots");
|
||||
throw invalid("Expected signed integer " + field);
|
||||
}
|
||||
return new BigInteger(value);
|
||||
}
|
||||
@@ -250,8 +374,6 @@ public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
|
||||
return new IOException("Invalid Phoenix response: " + message);
|
||||
}
|
||||
|
||||
private record RawPosition(int subaccountIndex, String symbol, BigInteger lots) { }
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ΩRestEndpointBaseΩ endpoint;
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.r35157.evelyn.emc.impl.ref.EvelynMissionControlImpl;
|
||||
import com.r35157.jupiterperpsalarm.JupiterPerpsAlarm;
|
||||
import com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsOverview;
|
||||
import com.r35157.libs.jupiter.perps.impl.ref.RestJupiterPerpsOverview;
|
||||
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
|
||||
import com.r35157.libs.phoenix.perps.impl.ref.PhoenixPerpsServiceImpl;
|
||||
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
|
||||
@@ -250,6 +252,7 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
jupiterServicePerpsTest);
|
||||
|
||||
phoenixPerpsService = new PhoenixPerpsServiceImpl(httpClient, objectMapper);
|
||||
jupiterPerpsOverview = new RestJupiterPerpsOverview(httpClient, objectMapper);
|
||||
registryServiceManager.registerComponent(PHOENIX_PERPS_ID, phoenixPerpsService);
|
||||
|
||||
jupiterSwapService = new JupiterSwapServiceImpl(solanaBlockChain, solanaWalletEvelynBurnerProd);
|
||||
@@ -367,13 +370,13 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
}
|
||||
|
||||
private NenjimProcess constructEvelynPerpsProdProcess() {
|
||||
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterServicePerpsProd, phoenixPerpsService);
|
||||
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterPerpsOverview, phoenixPerpsService);
|
||||
evelynPerpsServiceProd = process;
|
||||
return process;
|
||||
}
|
||||
|
||||
private NenjimProcess constructEvelynPerpsTestProcess() {
|
||||
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterServicePerpsTest, phoenixPerpsService);
|
||||
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterPerpsOverview, phoenixPerpsService);
|
||||
evelynPerpsServiceTest = process;
|
||||
return process;
|
||||
}
|
||||
@@ -612,6 +615,7 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
private JupiterPerpsService jupiterServicePerpsProd;
|
||||
private JupiterPerpsService jupiterServicePerpsTest;
|
||||
private PhoenixPerpsService phoenixPerpsService;
|
||||
private JupiterPerpsOverview jupiterPerpsOverview;
|
||||
|
||||
// Price-source components
|
||||
private PriceSource hardcodedPriceSource;
|
||||
|
||||
Reference in New Issue
Block a user