99: Add read-only Phoenix Perps positions to EvelynPerpsService

This commit is contained in:
2026-10-23 12:11:22 +02:00
parent df5cc0eda7
commit 7e36b7ef95
17 changed files with 734 additions and 40 deletions
@@ -10,6 +10,8 @@ import com.r35157.evelyn.service.perps.PerpsSnapshot.Direction;
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.phoenix.perps.PhoenixPerpsPosition;
import com.r35157.libs.phoenix.perps.PhoenixPerpsService;
import com.r35157.nenjim.process.NenjimProcess;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
@@ -26,12 +28,14 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Reads Jupiter positions in the background; consumers only read the published snapshot. */
/** 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 JupiterPerpsService jupiter,
@NotNull PhoenixPerpsService phoenix) {
this.wallets = List.copyOf(wallets);
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
this.phoenix = Objects.requireNonNull(phoenix, "phoenix");
var addresses = new HashSet<String>();
var base58 = new Base58CodecImpl();
for (PerpsWalletConfiguration wallet : this.wallets) {
@@ -60,32 +64,60 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
private void refresh() {
for (PerpsWalletConfiguration wallet : wallets) {
if (closed || Thread.currentThread().isInterrupted()) {
return;
}
try {
List<Position> positions = jupiter.getOpenPositions(wallet.address()).stream()
refreshVenue(wallet, "jupiter", "Jupiter", () -> jupiter.getOpenPositions(wallet.address()).stream()
.sorted(Comparator.comparing(JupiterPerpsPosition::positionAccount))
.map(EvelynPerpsServiceImpl::position)
.toList();
if (closed || Thread.currentThread().isInterrupted()) {
return;
}
lastSuccessful.put(wallet.address(), new Wallet(wallet.address(), wallet.address(),
List.of(new Venue("jupiter", "Jupiter", positions))));
snapshot = new PerpsSnapshot(wallets.stream()
.map(configured -> lastSuccessful.get(configured.address()))
.filter(Objects::nonNull)
.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());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
} catch (IOException | RuntimeException exception) {
log.warn("Unable to refresh Jupiter Perps positions for wallet {}", wallet.address(), exception);
}
}
}
private void refreshVenue(PerpsWalletConfiguration wallet, String venueId, String venueName,
PositionReader reader) throws InterruptedException {
if (closed || Thread.currentThread().isInterrupted()) {
throw new InterruptedException("Perps refresh stopped");
}
try {
List<Position> positions = 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());
} catch (IOException | RuntimeException exception) {
log.warn("Unable to refresh {} Perps positions for wallet {}", venueName, wallet.address(), exception);
}
}
private static Position position(PhoenixPerpsPosition position) {
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());
}
@FunctionalInterface
private interface PositionReader {
List<Position> read() throws IOException, InterruptedException;
}
private record WalletVenue(String wallet, String venue) { }
private static Position position(JupiterPerpsPosition position) {
Direction direction = switch (position.direction()) {
case LONG -> Direction.LONG;
@@ -126,8 +158,10 @@ public final class EvelynPerpsServiceImpl implements EvelynPerpsService, NenjimP
private static final long REFRESH_SECONDS = 60;
private final List<PerpsWalletConfiguration> wallets;
private final JupiterPerpsService 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<String, Wallet> lastSuccessful = new HashMap<>();
private final Map<WalletVenue, Venue> lastSuccessful = new HashMap<>();
private ScheduledExecutorService worker;
private volatile boolean closed;
private volatile PerpsSnapshot snapshot;
@@ -0,0 +1,23 @@
package com.r35157.libs.phoenix.perps;
import java.math.BigDecimal;
import java.util.Objects;
/**
* A nonzero Phoenix position. Quantity is signed base units, not raw lots; sizeUsd is
* absolute current notional (abs(quantity) * mark price), not collateral or entry value.
* Identity within a wallet is traderPdaIndex + subaccountIndex + symbol, independent of
* changing sequence numbers. Prices and trader state are separate REST snapshots.
*/
public record PhoenixPerpsPosition(int traderPdaIndex, int subaccountIndex, String symbol,
ΩAmountΩ quantity, ΩAmountΩ sizeUsd) {
public PhoenixPerpsPosition {
Objects.requireNonNull(symbol, "symbol");
Objects.requireNonNull(quantity, "quantity");
Objects.requireNonNull(sizeUsd, "sizeUsd");
if (traderPdaIndex < 0 || subaccountIndex < 0 || symbol.isBlank()
|| !symbol.equals(symbol.strip()) || quantity.signum() == 0 || sizeUsd.signum() <= 0) {
throw new IllegalArgumentException("Invalid Phoenix position");
}
}
}
@@ -0,0 +1,19 @@
package com.r35157.libs.phoenix.perps;
import com.r35157.nenjim.component.NenjimComponent;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.List;
/** Read-only Phoenix positions; no signing, activation or trading operations. */
public interface PhoenixPerpsService extends NenjimComponent {
/**
* Reads all nonzero cross-margin and isolated positions for authority's trader PDA index 0.
* A successful empty list means no open positions. Failed reads/conversions throw rather
* 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)
throws IOException, InterruptedException;
}
@@ -0,0 +1,262 @@
package com.r35157.libs.phoenix.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.phoenix.perps.PhoenixPerpsPosition;
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.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.DateTimeException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/** Public REST reader with shared request pacing and no portfolio cache. See docs/PhoenixPerps.md for API contracts and units. */
public final class PhoenixPerpsServiceImpl implements PhoenixPerpsService {
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper) {
this(httpClient, objectMapper, URI.create("https://perp-api.phoenix.trade/"), Duration.ofSeconds(10));
}
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper,
@NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout) {
this(httpClient, objectMapper, endpoint, timeout, Duration.ofSeconds(1));
}
public PhoenixPerpsServiceImpl(@NotNull HttpClient httpClient, @NotNull ObjectMapper objectMapper,
@NotNull ΩRestEndpointBaseΩ endpoint, @NotNull Duration timeout,
@NotNull Duration minimumRequestInterval) {
this.httpClient = Objects.requireNonNull(httpClient, "httpClient");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper").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");
this.minimumRequestNanos = Objects.requireNonNull(minimumRequestInterval, "minimumRequestInterval").toNanos();
if (minimumRequestNanos < 0) {
throw new IllegalArgumentException("Phoenix request interval must not be negative");
}
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("Phoenix request timeout must be positive");
}
}
@Override
public @NotNull List<PhoenixPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ authority)
throws IOException, InterruptedException {
if (new Base58CodecImpl().decode(authority).length != 32) {
throw new IllegalArgumentException("Invalid Solana authority: " + authority);
}
JsonNode state = get("v1/trader/state/" + authority + "?traderPdaIndex=0");
if (!authority.equals(text(state, "authority")) || integer(state, "traderPdaIndex") != 0) {
throw invalid("Trader state does not match requested authority/PDA index");
}
JsonNode snapshot = required(state, "snapshot");
if (integer(snapshot, "version") != 1) {
throw invalid("Unsupported trader snapshot version");
}
var rawPositions = new ArrayList<RawPosition>();
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;
}
var symbols = new HashSet<String>();
for (JsonNode position : array(required(subaccount, "positions"), "positions")) {
String symbol = text(position, "symbol");
if (!symbols.add(symbol)) {
throw invalid("Duplicate position in subaccount " + index + ": " + symbol);
}
BigInteger lots = lots(position);
if (lots.signum() != 0) {
rawPositions.add(new RawPosition(index, symbol, lots));
}
}
}
if (rawPositions.isEmpty()) {
return List.of();
}
rawPositions.sort(Comparator.comparingInt(RawPosition::subaccountIndex)
.thenComparing(RawPosition::symbol));
Map<String, Integer> decimals = marketDecimals();
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);
}
}
return List.copyOf(positions);
}
private Map<String, Integer> marketDecimals() throws IOException, InterruptedException {
var decimals = new HashMap<String, Integer>();
for (JsonNode market : array(get("v1/view/exchange/markets"), "markets")) {
String symbol = text(market, "symbol");
if (decimals.putIfAbsent(symbol, integer(market, "baseLotsDecimals")) != null) {
throw invalid("Duplicate market metadata for " + symbol);
}
}
return decimals;
}
private ΩPriceΩ markPrice(String symbol) throws IOException, InterruptedException {
String encodedSymbol = URLEncoder.encode(symbol, StandardCharsets.UTF_8).replace("+", "%20");
JsonNode response = get("v1/market/" + encodedSymbol + "/mark-price");
if (!symbol.equals(text(response, "symbol"))) {
throw invalid("Mark price symbol does not match " + symbol);
}
JsonNode price = required(required(response, "markPrice"), "price");
// Jackson parses JSON decimals directly as BigDecimal; never go through double.
if (!price.isNumber() || price.decimalValue().signum() <= 0) {
throw invalid("Missing or invalid mark price for " + symbol);
}
return price.decimalValue();
}
private JsonNode get(String path) throws IOException, InterruptedException {
awaitRequestSlot();
HttpRequest request = HttpRequest.newBuilder(endpoint.resolve(path))
.timeout(timeout).header("Accept", "application/json").GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 429) {
deferRequests(response.headers().firstValue("Retry-After").orElse("60"));
}
if (response.statusCode() != 200) {
throw new IOException("Phoenix GET " + path + " returned HTTP " + response.statusCode());
}
JsonNode result = objectMapper.readTree(response.body());
if (result == null || result.isNull()) {
throw invalid("Empty JSON response for " + path);
}
return result;
}
// Shared by PROD and TEST; wait interruptibly, without holding either service's snapshot lock.
private synchronized void awaitRequestSlot() throws IOException, InterruptedException {
checkCooldown();
long remaining = nextRequestNanos - System.nanoTime();
if (remaining > 0) {
TimeUnit.NANOSECONDS.sleep(remaining);
}
checkCooldown();
nextRequestNanos = System.nanoTime() + minimumRequestNanos;
}
private void checkCooldown() throws IOException {
if (Instant.now().isBefore(retryNotBefore)) {
throw new IOException("Phoenix rate-limit cooldown until " + retryNotBefore);
}
}
private synchronized void deferRequests(String retryAfter) {
Instant now = Instant.now();
Instant retryAt;
try {
retryAt = retryAfter.matches("[0-9]+")
? now.plusSeconds(Long.parseLong(retryAfter))
: ZonedDateTime.parse(retryAfter, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
if (!retryAt.isAfter(now)) {
retryAt = now.plusSeconds(60);
}
} catch (DateTimeException | ArithmeticException | NumberFormatException exception) {
retryAt = now.plusSeconds(60);
}
if (retryAt.isAfter(retryNotBefore)) {
retryNotBefore = retryAt;
}
}
private static JsonNode required(JsonNode object, String field) throws IOException {
if (!object.isObject() || !object.hasNonNull(field)) {
throw invalid("Missing or null field: " + field);
}
return object.get(field);
}
private static JsonNode array(JsonNode value, String field) throws IOException {
if (!value.isArray()) {
throw invalid("Expected array: " + field);
}
return value;
}
private static String text(JsonNode object, String field) throws IOException {
JsonNode value = required(object, field);
if (!value.isTextual() || value.textValue().isBlank()
|| !value.textValue().equals(value.textValue().strip())) {
throw invalid("Expected nonblank text: " + field);
}
return value.textValue();
}
private static int integer(JsonNode object, String field) throws IOException {
JsonNode value = required(object, field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalid("Expected integer: " + field);
}
return value.intValue();
}
private static BigInteger lots(JsonNode position) throws IOException {
String value = text(position, "basePositionLots");
if (!value.matches("-?[0-9]+")) {
throw invalid("Expected signed integer basePositionLots");
}
return new BigInteger(value);
}
private static IOException invalid(String message) {
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;
private final Duration timeout;
private final long minimumRequestNanos;
private long nextRequestNanos = System.nanoTime();
private Instant retryNotBefore = Instant.MIN;
}
@@ -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.phoenix.perps.PhoenixPerpsService;
import com.r35157.libs.phoenix.perps.impl.ref.PhoenixPerpsServiceImpl;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.jupiter.swap.impl.ref.JupiterSwapServiceImpl;
@@ -247,6 +249,9 @@ public final class NenjimHubImpl implements NenjimHub {
JUPITER_PERPS_EVELYN_TEST_ID,
jupiterServicePerpsTest);
phoenixPerpsService = new PhoenixPerpsServiceImpl(httpClient, objectMapper);
registryServiceManager.registerComponent(PHOENIX_PERPS_ID, phoenixPerpsService);
jupiterSwapService = new JupiterSwapServiceImpl(solanaBlockChain, solanaWalletEvelynBurnerProd);
registryServiceManager.registerComponent(
JUPITER_SWAP_EVELYN_BURNER_PROD_ID,
@@ -362,13 +367,13 @@ public final class NenjimHubImpl implements NenjimHub {
}
private NenjimProcess constructEvelynPerpsProdProcess() {
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterServicePerpsProd);
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_PROD_WALLETS, jupiterServicePerpsProd, phoenixPerpsService);
evelynPerpsServiceProd = process;
return process;
}
private NenjimProcess constructEvelynPerpsTestProcess() {
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterServicePerpsTest);
EvelynPerpsServiceImpl process = new EvelynPerpsServiceImpl(EVELYN_PERPS_TEST_WALLETS, jupiterServicePerpsTest, phoenixPerpsService);
evelynPerpsServiceTest = process;
return process;
}
@@ -547,6 +552,8 @@ public final class NenjimHubImpl implements NenjimHub {
new NenjimComponentId("jupiter.perps.evelyn-prod");
private static final NenjimComponentId JUPITER_PERPS_EVELYN_TEST_ID =
new NenjimComponentId("jupiter.perps.evelyn-test");
private static final NenjimComponentId PHOENIX_PERPS_ID =
new NenjimComponentId("phoenix.perps.default");
private static final NenjimComponentId JUPITER_PERPS_ALARM_ID =
new NenjimComponentId("jupiter-perps-alarm.default");
private static final NenjimComponentId EVELYN_STATUS_PROD_ID =
@@ -604,6 +611,7 @@ public final class NenjimHubImpl implements NenjimHub {
private SolanaWallet solanaWalletEvelynBurnerProd;
private JupiterPerpsService jupiterServicePerpsProd;
private JupiterPerpsService jupiterServicePerpsTest;
private PhoenixPerpsService phoenixPerpsService;
// Price-source components
private PriceSource hardcodedPriceSource;