100: Add Perps collateral, leverage, value, uPnL and gross-exposure totals in EMC

This commit is contained in:
2026-10-24 14:52:54 +02:00
parent 7e36b7ef95
commit e0b69f6430
29 changed files with 8965 additions and 180 deletions
@@ -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 }
}
@@ -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;
}
@@ -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;