6 Commits

26 changed files with 92 additions and 870 deletions
+8 -56
View File
@@ -1,56 +1,8 @@
# General configurations # Id Asset Direction Target Trigger Severity Note
##################################################### ######################################################################################################################
{{JUPITER_PERPS_WALLET}} 8abcYourWalletAddressHere 1 SOL ABOVE 97.03-1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana short LIKVIDERING!"
2 SOL BELOW 48.72+1.25% ONETIME CRITICAL "CRITICAL: Risiko for Perps Solana long LIKVIDERING!"
3 BTC ABOVE 85032.87-1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Bitcoin short LIKVIDERING!"
# Constant Name Value 4 BTC BELOW 42779.40+1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Bitcoin long LIKVIDERING!"
#################################### 5 ETH ABOVE 2296.13-1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Ethereum short LIKVIDERING!"
6 ETH BELOW 1155.19+1% ONETIME CRITICAL "CRITICAL: Risiko for Perps Ethereum long LIKVIDERING!"
{{SOL_LONG_ENTRY_PRICE}} 73.67
{{SOL_LONG_LIQ_PRICE}} 70.28
{{SOL_SHORT_ENTRY_PRICE}} 70.47
{{SOL_SHORT_LIQ_PRICE}} 75.01
{{ETH_LONG_ENTRY_PRICE}} 1589.63
{{ETH_LONG_LIQ_PRICE}} 1461.11
{{ETH_SHORT_ENTRY_PRICE}} 1545.29
{{ETH_SHORT_LIQ_PRICE}} 1698.42
{{BTC_LONG_ENTRY_PRICE}} 61236.03
{{BTC_LONG_LIQ_PRICE}} 40984.70
{{BTC_SHORT_ENTRY_PRICE}} 59451.94
{{BTC_SHORT_LIQ_PRICE}} 65084.53
# Id Asset Direction Target Trigger Severity Note
###############################################################################################################################
# SOL SHORT
1 SOL ABOVE {{SOL_SHORT_LIQ_PRICE}}-1.25% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko SOL Short!"
2 SOL ABOVE {{SOL_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short SOL now"
3 SOL BELOW {{SOL_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close SOL Short"
# SOL LONG
4 SOL BELOW {{SOL_LONG_LIQ_PRICE}}+1.25% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko SOL Long!"
5 SOL BELOW {{SOL_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long SOL now"
6 SOL ABOVE {{SOL_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close SOL Long"
# ETH SHORT
7 ETH ABOVE {{ETH_SHORT_LIQ_PRICE}}-1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko ETH Short!"
8 ETH ABOVE {{ETH_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short ETH now"
9 ETH BELOW {{ETH_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close ETH Short"
# ETH LONG
10 ETH BELOW {{ETH_LONG_LIQ_PRICE}}+1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko ETH Long!"
11 ETH BELOW {{ETH_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long ETH now"
12 ETH ABOVE {{ETH_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close ETH Long"
# BTC SHORT
13 BTC ABOVE {{BTC_SHORT_LIQ_PRICE}}-1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko BTC Short!"
14 BTC ABOVE {{BTC_SHORT_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "🌱 Short BTC now"
15 BTC BELOW {{BTC_SHORT_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "💵 Close BTC Short"
# BTC LONG
16 BTC BELOW {{BTC_LONG_LIQ_PRICE}}+1% ONETIME CRITICAL "🚨 LIKVIDERINGS-risiko BTC Long!"
17 BTC BELOW {{BTC_LONG_ENTRY_PRICE}}-3% PERSISTENT:900 INFO "🌱 Long BTC now"
18 BTC ABOVE {{BTC_LONG_ENTRY_PRICE}}+3% PERSISTENT:900 INFO "💵 Close BTC Long"
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <github-snapshot-branch>" >&2
exit 1
fi
BRANCH="$1"
SOURCE="$HOME/projects/com_r35157_nenjim-hubd-impl_ref"
TARGET="$HOME/projects/com_r35157_nenjim-hubd-impl_ref_github_snapshot"
cd "$TARGET"
git fetch origin
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git switch "$BRANCH"
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
git switch --track "origin/$BRANCH"
else
git switch --create "$BRANCH"
fi
rsync -a --delete \
--exclude '.git' \
--exclude 'conf/*.conf' \
--exclude 'conf/*.xml' \
"$SOURCE/" \
"$TARGET/"
git add -A
if git diff --cached --quiet; then
echo "No snapshot changes to publish on branch '$BRANCH'."
exit 0
fi
git commit -m "Mirror snapshot"
git push -u origin "$BRANCH"
@@ -2,5 +2,5 @@ package com.r35157.jupiterperpsalarm.impl.ref;
@FunctionalInterface @FunctionalInterface
public interface AlarmAction { public interface AlarmAction {
void trigger(OraclePrice price, ResolvedPriceAlarm alarm); void trigger(OraclePrice price, PriceAlarmDefinition alarm);
} }
@@ -1,19 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public record AlarmConfiguration(
List<PriceAlarmDefinition> definitions,
Map<String, String> variables
) {
public AlarmConfiguration {
Objects.requireNonNull(definitions, "definitions");
Objects.requireNonNull(variables, "variables");
definitions = List.copyOf(definitions);
variables = new ConcurrentHashMap<>(variables);
}
}
@@ -3,13 +3,14 @@ package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity; import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
public final class AlarmConfigurationParser { public final class AlarmConfigurationParser {
public static AlarmConfiguration parse(Path path) throws IOException { public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
List<String> lines = Files.readAllLines(path); List<String> lines = Files.readAllLines(path);
List<PriceAlarmDefinition> alarms = new ArrayList<>(); List<PriceAlarmDefinition> alarms = new ArrayList<>();
Map<String, String> constants = new LinkedHashMap<>(); Map<String, String> constants = new LinkedHashMap<>();
@@ -26,7 +27,8 @@ public final class AlarmConfigurationParser {
if(isConstantDefinition(trimmed)) { if(isConstantDefinition(trimmed)) {
parseConstantDefinition(constants, line); parseConstantDefinition(constants, line);
} else { } else {
alarms.add(parseLine(line)); String resolvedLine = replaceConstants(line, constants);
alarms.add(parseLine(resolvedLine));
} }
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -40,7 +42,7 @@ public final class AlarmConfigurationParser {
throw new IllegalArgumentException("No alarms found in " + path); throw new IllegalArgumentException("No alarms found in " + path);
} }
return new AlarmConfiguration(alarms, constants); return List.copyOf(alarms);
} }
static PriceAlarmDefinition parseLine(String line) { static PriceAlarmDefinition parseLine(String line) {
@@ -56,7 +58,7 @@ public final class AlarmConfigurationParser {
cursor.nextToken("direction").toUpperCase(Locale.ROOT) cursor.nextToken("direction").toUpperCase(Locale.ROOT)
); );
String targetExpression = cursor.nextToken("target"); BigDecimal target = parseTarget(cursor.nextToken("target"));
TriggerConfiguration triggerConfiguration = parseTrigger( TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger") cursor.nextToken("trigger")
@@ -79,7 +81,7 @@ public final class AlarmConfigurationParser {
id, id,
asset, asset,
direction, direction,
targetExpression, target,
triggerConfiguration.trigger(), triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(), triggerConfiguration.gracePeriod(),
severity, severity,
@@ -87,6 +89,28 @@ public final class AlarmConfigurationParser {
); );
} }
private static BigDecimal parseTargetPercentage(
String targetStr,
int operatorIndex,
boolean add
) {
BigDecimal base = new BigDecimal(targetStr.substring(0, operatorIndex));
String percentStr = targetStr.substring(operatorIndex + 1, targetStr.length() - 1);
BigDecimal percent = new BigDecimal(percentStr);
BigDecimal delta = base
.multiply(percent)
.divide(BigDecimal.valueOf(100));
BigDecimal target = add ? base.add(delta) : base.subtract(delta);
validateTarget(base, targetStr);
validateTarget(percent, targetStr);
validateTarget(target, targetStr);
return target;
}
private static TriggerConfiguration parseTrigger(String triggerText) { private static TriggerConfiguration parseTrigger(String triggerText) {
String normalized = triggerText.toUpperCase(Locale.ROOT); String normalized = triggerText.toUpperCase(Locale.ROOT);
@@ -131,6 +155,35 @@ public final class AlarmConfigurationParser {
throw new IllegalArgumentException("Unknown trigger: " + triggerText); throw new IllegalArgumentException("Unknown trigger: " + triggerText);
} }
private static void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Target must be zero or positive: " + originalTargetStr
);
}
}
private static BigDecimal parseTarget(String targetStr) {
String trimmedTargetStr = targetStr.trim();
if (trimmedTargetStr.endsWith("%")) {
int plusIndex = trimmedTargetStr.indexOf('+');
int minusIndex = trimmedTargetStr.indexOf('-', 1);
if (plusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, plusIndex, true);
}
if (minusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, minusIndex, false);
}
}
BigDecimal target = new BigDecimal(trimmedTargetStr);
validateTarget(target, targetStr);
return target;
}
private static final class Cursor { private static final class Cursor {
private Cursor(String line) { private Cursor(String line) {
this.line = line; this.line = line;
@@ -237,21 +290,9 @@ public final class AlarmConfigurationParser {
validateConstantName(name); validateConstantName(name);
constants.put(parseVariableName(name), value); if (constants.putIfAbsent(name, value) != null) {
} throw new IllegalArgumentException("Duplicate constant: " + name);
private static String parseVariableName(String variableName) {
if (!variableName.startsWith("{{") || !variableName.endsWith("}}")) {
throw new IllegalArgumentException("Invalid variable name: " + variableName);
} }
String parsedVariableName = variableName.substring(2, variableName.length() - 2);
if (parsedVariableName.isBlank()) {
throw new IllegalArgumentException("Variable name cannot be blank: " + variableName);
}
return parsedVariableName;
} }
private static String replaceConstants( private static String replaceConstants(
@@ -1,60 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public final class AlarmTargetParser {
private AlarmTargetParser() {
}
public static BigDecimal parse(String targetStr) {
String trimmedTargetStr = targetStr.trim();
if (trimmedTargetStr.endsWith("%")) {
int plusIndex = trimmedTargetStr.indexOf('+');
int minusIndex = trimmedTargetStr.indexOf('-', 1);
if (plusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, plusIndex, true);
}
if (minusIndex >= 0) {
return parseTargetPercentage(trimmedTargetStr, minusIndex, false);
}
}
BigDecimal target = new BigDecimal(trimmedTargetStr);
validateTarget(target, targetStr);
return target;
}
private static BigDecimal parseTargetPercentage(
String targetStr,
int operatorIndex,
boolean add
) {
BigDecimal base = new BigDecimal(targetStr.substring(0, operatorIndex));
String percentStr = targetStr.substring(operatorIndex + 1, targetStr.length() - 1);
BigDecimal percent = new BigDecimal(percentStr);
BigDecimal delta = base
.multiply(percent)
.divide(BigDecimal.valueOf(100));
BigDecimal target = add ? base.add(delta) : base.subtract(delta);
validateTarget(base, targetStr);
validateTarget(percent, targetStr);
validateTarget(target, targetStr);
return target;
}
private static void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Target must be zero or positive: " + originalTargetStr
);
}
}
}
@@ -1,30 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.util.Map;
import java.util.Objects;
public final class AlarmVariableResolver {
public AlarmVariableResolver(Map<String, String> variables) {
this.variables = Objects.requireNonNull(variables, "variables");
}
public String resolve(String text) {
Objects.requireNonNull(text, "text");
String resolvedText = text;
for (Map.Entry<String, String> entry : variables.entrySet()) {
String placeholder = "{{" + entry.getKey() + "}}";
resolvedText = resolvedText.replace(placeholder, entry.getValue());
}
if (resolvedText.contains("{{") || resolvedText.contains("}}")) {
throw new IllegalArgumentException("Unresolved variable in text: " + text);
}
return resolvedText;
}
private final Map<String, String> variables;
}
@@ -12,7 +12,6 @@ public final class AssetPriceAlarmMonitor {
public AssetPriceAlarmMonitor( public AssetPriceAlarmMonitor(
JupiterPerpsAsset asset, JupiterPerpsAsset asset,
List<PriceAlarmDefinition> definitions, List<PriceAlarmDefinition> definitions,
AlarmVariableResolver variableResolver,
AlarmAction action AlarmAction action
) { ) {
this.asset = asset; this.asset = asset;
@@ -23,7 +22,7 @@ public final class AssetPriceAlarmMonitor {
"Alarm asset " + definition.asset() + " does not match monitor " + asset "Alarm asset " + definition.asset() + " does not match monitor " + asset
); );
} }
return new PriceAlarm(definition, variableResolver, action); return new PriceAlarm(definition, action);
}) })
.toList(); .toList();
@@ -9,7 +9,7 @@ public final class CompositeAlarmAction implements AlarmAction {
} }
@Override @Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) { public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
for (AlarmAction action : actions) { for (AlarmAction action : actions) {
try { try {
action.trigger(price, alarm); action.trigger(price, alarm);
@@ -2,7 +2,7 @@ package com.r35157.jupiterperpsalarm.impl.ref;
public final class ConsoleAlarmAction implements AlarmAction { public final class ConsoleAlarmAction implements AlarmAction {
@Override @Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) { public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
System.err.println(); System.err.println();
System.err.println("============================================================"); System.err.println("============================================================");
System.err.printf( System.err.printf(
@@ -1,10 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref; package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
import java.net.URI; import java.net.URI;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
@@ -18,7 +13,6 @@ public final class JupiterPerpsAlarmImpl {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
Config config; Config config;
try { try {
config = Config.parse(args, System.getenv()); config = Config.parse(args, System.getenv());
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
@@ -28,34 +22,13 @@ public final class JupiterPerpsAlarmImpl {
} }
List<PriceAlarmDefinition> definitions; List<PriceAlarmDefinition> definitions;
AlarmConfiguration alarmConfiguration;
try { try {
alarmConfiguration = AlarmConfigurationParser.parse(config.alarmConfiguration()); definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
definitions = alarmConfiguration.definitions();
} catch (Exception exception) { } catch (Exception exception) {
String errMsg = "Could not load alarm configuration: " + exception.getMessage() + "!"; String errMsg = "Could not load alarm configuration: " + exception.getMessage() + "!";
throw new IllegalStateException(errMsg, exception); throw new IllegalStateException(errMsg, exception);
} }
AlarmVariableResolver variableResolver = new AlarmVariableResolver(
alarmConfiguration.variables()
);
SolanaBlockChain solanaBlockChain = new SolanaBlockChainImpl();
JupiterPerpsService jupiterPerpsService = new AnchorIdlJupiterPerpsServiceImpl(solanaBlockChain);
JupiterPerpsEntryPriceVariableRefresher entryPriceVariableRefresher =
new JupiterPerpsEntryPriceVariableRefresher(alarmConfiguration.variables(), jupiterPerpsService);
entryPriceVariableRefresher.refresh();
JupiterPerpsEntryPriceVariableRefreshWatcher entryPriceVariableRefreshWatcher =
new JupiterPerpsEntryPriceVariableRefreshWatcher(
config.alarmConfiguration().getParent(),
entryPriceVariableRefresher
);
entryPriceVariableRefreshWatcher.start();
List<AlarmAction> actions = new ArrayList<>(); List<AlarmAction> actions = new ArrayList<>();
actions.add(new ConsoleAlarmAction()); actions.add(new ConsoleAlarmAction());
@@ -79,10 +52,9 @@ public final class JupiterPerpsAlarmImpl {
Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>( Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>(
JupiterPerpsAsset.class JupiterPerpsAsset.class
); );
definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put( definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put(
asset, asset,
new AssetPriceAlarmMonitor(asset, assetDefinitions, variableResolver, action) new AssetPriceAlarmMonitor(asset, assetDefinitions, action)
)); ));
List<OracleWebSocketClient> clients = new ArrayList<>(); List<OracleWebSocketClient> clients = new ArrayList<>();
@@ -113,7 +85,7 @@ public final class JupiterPerpsAlarmImpl {
assetDefinitions.forEach(definition -> System.out.printf( assetDefinitions.forEach(definition -> System.out.printf(
" %s %s USD, %s, severity=%s%n", " %s %s USD, %s, severity=%s%n",
definition.direction(), definition.direction(),
definition.targetExpression(), definition.target().toPlainString(),
definition.trigger(), definition.trigger(),
definition.severity() definition.severity()
)); ));
@@ -1,92 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.nio.file.*;
import java.util.Objects;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.FileSystems;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
public final class JupiterPerpsEntryPriceVariableRefreshWatcher {
public JupiterPerpsEntryPriceVariableRefreshWatcher(
Path confDirectory,
JupiterPerpsEntryPriceVariableRefresher refresher
) {
this.confDirectory = Objects.requireNonNull(confDirectory, "confDirectory");
this.refresher = Objects.requireNonNull(refresher, "refresher");
}
public void start() {
Thread thread = new Thread(
this::run,
"jupiter-perps-entry-price-variable-refresh-watcher"
);
thread.setDaemon(true);
thread.start();
}
private void run() {
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
confDirectory.register(
watchService,
StandardWatchEventKinds.ENTRY_CREATE
);
System.out.println(
"Jupiter Perps entry price variable refresh watcher started for directory: "
+ confDirectory
);
while (true) {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
Path path = (Path) event.context();
if (!REFRESH_TRIGGER_FILE_NAME.equals(path.getFileName().toString())) {
continue;
}
Path triggerFile = confDirectory.resolve(path);
System.out.println("Refresh trigger file detected: " + triggerFile);
try {
refresher.refresh();
} finally {
Files.deleteIfExists(triggerFile);
}
}
if (!key.reset()) {
System.err.println(
"Jupiter Perps entry price variable refresh watcher stopped: watch key is no longer valid"
);
return;
}
}
} catch (IOException exception) {
System.err.println(
"Could not start Jupiter Perps entry price variable refresh watcher: "
+ exception.getMessage()
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
System.err.println(
"Jupiter Perps entry price variable refresh watcher interrupted"
);
}
}
private static final String REFRESH_TRIGGER_FILE_NAME =
"jupiter-perps-alarm-var.refresh";
private final Path confDirectory;
private final JupiterPerpsEntryPriceVariableRefresher refresher;
}
@@ -1,86 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public final class JupiterPerpsEntryPriceVariableRefresher {
public JupiterPerpsEntryPriceVariableRefresher(
Map<String, String> variables,
JupiterPerpsService jupiterPerpsService
) {
this.variables = Objects.requireNonNull(variables, "variables");
this.jupiterPerpsService = Objects.requireNonNull(jupiterPerpsService, "jupiterPerpsService");
}
public void refresh() {
ΩSolanaWalletIdΩ wallet = variables.get("JUPITER_PERPS_WALLET");
if (wallet == null || wallet.isBlank()) {
System.err.println("Cannot refresh Jupiter Perps entry price variables: JUPITER_PERPS_WALLET is not configured");
return;
}
try {
Set<JupiterPerpsPosition> positions = jupiterPerpsService.getOpenPositions(wallet);
System.out.println(
"Fetched " + positions.size()
+ " open Jupiter Perps positions for wallet: " + wallet
);
removeEntryPriceVariables();
for (JupiterPerpsPosition position : positions) {
String variableName = createEntryPriceVariableName(position);
System.out.println(
"Jupiter Perps position maps to variable "
+ variableName
+ " = "
+ position.entryPrice()
);
variables.put(variableName, position.entryPrice().toPlainString());
}
} catch (IOException | InterruptedException exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
System.err.println(
"Could not refresh Jupiter Perps entry price variables: "
+ exception.getMessage()
);
}
}
private void removeEntryPriceVariables() {
variables.remove("SOL_LONG_ENTRY_PRICE");
variables.remove("SOL_SHORT_ENTRY_PRICE");
variables.remove("BTC_LONG_ENTRY_PRICE");
variables.remove("BTC_SHORT_ENTRY_PRICE");
variables.remove("ETH_LONG_ENTRY_PRICE");
variables.remove("ETH_SHORT_ENTRY_PRICE");
}
private static String createEntryPriceVariableName(JupiterPerpsPosition position) {
String asset = switch (position.tradedTokenMint()) {
case "So11111111111111111111111111111111111111112" -> "SOL";
case "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh" -> "BTC";
case "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs" -> "ETH";
default -> throw new IllegalArgumentException(
"Unsupported Jupiter Perps traded token mint: " + position.tradedTokenMint()
);
};
return asset + "_" + position.direction() + "_ENTRY_PRICE";
}
private final Map<String, String> variables;
private final JupiterPerpsService jupiterPerpsService;
}
@@ -1,17 +1,11 @@
package com.r35157.jupiterperpsalarm.impl.ref; package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
public final class PriceAlarm { public final class PriceAlarm {
public PriceAlarm( public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
PriceAlarmDefinition definition,
AlarmVariableResolver variableResolver,
AlarmAction action
) {
this.definition = definition; this.definition = definition;
this.variableResolver = variableResolver;
this.action = action; this.action = action;
} }
@@ -22,23 +16,9 @@ public final class PriceAlarm {
); );
} }
BigDecimal target;
try {
target = AlarmTargetParser.parse(
variableResolver.resolve(definition.targetExpression())
);
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve target for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
boolean reached = definition.direction().reached( boolean reached = definition.direction().reached(
price.priceUsd(), price.priceUsd(),
target definition.target()
); );
boolean enteredTriggeredSide = previousReached == null boolean enteredTriggeredSide = previousReached == null
@@ -57,13 +37,13 @@ public final class PriceAlarm {
return; return;
} }
trigger(price, target); trigger(price);
return; return;
} }
if (definition.trigger() == AlarmTrigger.PERSISTENT) { if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) { if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price, target); trigger(price);
} }
return; return;
} }
@@ -95,39 +75,13 @@ public final class PriceAlarm {
); );
} }
private void trigger(OraclePrice price, BigDecimal target) { private void trigger(OraclePrice price) {
triggerCount++; triggerCount++;
lastTriggeredAt = Instant.now(); lastTriggeredAt = Instant.now();
action.trigger(price, definition);
String note;
try {
note = variableResolver.resolve(definition.note());
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve note for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
definition.id(),
definition.asset(),
definition.direction(),
target,
definition.trigger(),
definition.triggerGracePeriod(),
definition.severity(),
note
);
action.trigger(price, resolvedAlarm);
} }
private final PriceAlarmDefinition definition; private final PriceAlarmDefinition definition;
private final AlarmVariableResolver variableResolver;
private final AlarmAction action; private final AlarmAction action;
private Instant lastTriggeredAt; private Instant lastTriggeredAt;
@@ -2,13 +2,14 @@ package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity; import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
import java.util.Objects; import java.util.Objects;
public record PriceAlarmDefinition( public record PriceAlarmDefinition(
int id, int id,
JupiterPerpsAsset asset, JupiterPerpsAsset asset,
PriceDirection direction, PriceDirection direction,
String targetExpression, BigDecimal target,
AlarmTrigger trigger, AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod, ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity, AlarmSeverity severity,
@@ -17,13 +18,12 @@ public record PriceAlarmDefinition(
public PriceAlarmDefinition { public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset"); Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction"); Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(targetExpression, "targetExpression"); Objects.requireNonNull(target, "target");
Objects.requireNonNull(trigger, "trigger"); Objects.requireNonNull(trigger, "trigger");
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note"); Objects.requireNonNull(note, "note");
if (targetExpression.isBlank()) { if (target.signum() < 0) {
throw new IllegalArgumentException("Target expression cannot be blank"); throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
} }
if (triggerGracePeriod < 0) { if (triggerGracePeriod < 0) {
@@ -18,7 +18,7 @@ public final class PushoverAlarmAction implements AlarmAction {
} }
@Override @Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) { public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
String title = "Jupiter Perps " + price.asset() + " alarm"; String title = "Jupiter Perps " + price.asset() + " alarm";
String message = createMessage(price, alarm); String message = createMessage(price, alarm);
@@ -26,7 +26,7 @@ public final class PushoverAlarmAction implements AlarmAction {
form("user", userKey) + "&" + form("user", userKey) + "&" +
form("title", title) + "&" + form("title", title) + "&" +
form("message", message) + "&" + form("message", message) + "&" +
createPushoverSeverityParameters(alarm.severity()); createPushoverSeverityParameters(alarm.severity());;
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI) HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
.timeout(Duration.ofSeconds(15)) .timeout(Duration.ofSeconds(15))
@@ -69,7 +69,7 @@ public final class PushoverAlarmAction implements AlarmAction {
}; };
} }
private static String createMessage(OraclePrice price, ResolvedPriceAlarm alarm) { private static String createMessage(OraclePrice price, PriceAlarmDefinition alarm) {
return String.format( return String.format(
"%d - %s: %s%n%n%s is %s USD.%nTarget: %s %s USD.%nOracle time: %s.%nSlot: %d.", "%d - %s: %s%n%n%s is %s USD.%nTarget: %s %s USD.%nOracle time: %s.%nSlot: %d.",
alarm.id(), alarm.id(),
@@ -1,16 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
public record ResolvedPriceAlarm(
int id,
JupiterPerpsAsset asset,
PriceDirection direction,
BigDecimal target,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
) {
}
@@ -1,5 +0,0 @@
package com.r35157.libs.codec;
public interface Base58Codec {
String encode(byte[] input);
}
@@ -1,27 +0,0 @@
package com.r35157.libs.jupiter.perps;
import java.math.BigDecimal;
/**
* Represents a Jupiter Perps position.
*
* <p>A Jupiter Perps position is represented on-chain by a Solana account owned by
* the Jupiter Perps program. This record contains the public API view of such a
* position.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @param entryPrice the entry price of the position, denominated in USDC
* @param direction whether the position is long or short
* @param tradedTokenMint the mint address of the token being traded
* @param sizeUsd the size of this position in USD
* @param collateralUsd the amount of USD representing the collateral for this position
*/
public record JupiterPerpsPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
ΩUSDCPriceΩ entryPrice,
JupiterPerpsPositionDirection direction,
ΩSPLMintAddressΩ tradedTokenMint,
ΩUSDCAmountΩ sizeUsd,
ΩUSDCAmountΩ collateralUsd
) {
}
@@ -1,9 +0,0 @@
package com.r35157.libs.jupiter.perps;
/**
* Direction of a Jupiter Perps position.
*/
public enum JupiterPerpsPositionDirection {
LONG,
SHORT
}
@@ -1,45 +0,0 @@
package com.r35157.libs.jupiter.perps;
import java.io.IOException;
import java.util.Set;
/**
* Service for reading Jupiter Perps data.
*
* <p>This service is read-only. It does not open, close, modify, or sign transactions
* for Jupiter Perpetual Contracts.</p>
*
* <p>NOTICE: The first supported operation is reading a known Jupiter Perps position account
* and returning its decoded position data.</p>
*/
public interface JupiterPerpsService {
/**
* Reads a Jupiter Perps position from a known position account.
*
* <p>The supplied account must be the Solana account that stores the Jupiter Perps
* position state. It is not the wallet address, token account, custody account, pool
* account, or position request account.</p>
*
* @param positionAccount the Solana account address of the Jupiter Perps position
* @return the decoded Jupiter Perps position
* @throws IOException if the position account could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching
* the position account
*/
JupiterPerpsPosition getPosition(ΩJupiterPerpsPositionAccountΩ positionAccount)
throws IOException, InterruptedException;
/**
* Finds open Jupiter Perps positions owned by a wallet.
*
* <p>This method returns decoded Jupiter Perps position objects. It does not return
* raw Solana accounts or account ids.</p>
*
* @param owner the wallet address that owns the Jupiter Perps positions
* @return the open Jupiter Perps positions owned by the wallet
* @throws IOException if the position accounts could not be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted while fetching positions
*/
Set<JupiterPerpsPosition> getOpenPositions(ΩSolanaWalletIdΩ owner)
throws IOException, InterruptedException;
}
@@ -38,31 +38,11 @@ class AnchorIdlJupiterPerpsPositionDecoder {
.valueOf(rawEntryPrice) .valueOf(rawEntryPrice)
.movePointLeft(6); .movePointLeft(6);
long rawCollateralUsd = ByteBuffer
.wrap(data, COLLATERAL_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ collateralUsd = BigDecimal
.valueOf(rawCollateralUsd)
.movePointLeft(6);
long rawSizeUsd = ByteBuffer
.wrap(data, SIZE_USD_OFFSET, U64_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN)
.getLong();
ΩUSDCAmountΩ sizeUsd = BigDecimal
.valueOf(rawSizeUsd)
.movePointLeft(6);
JupiterPerpsPosition pos = new JupiterPerpsPosition( JupiterPerpsPosition pos = new JupiterPerpsPosition(
positionAccount, positionAccount,
entryPrice, entryPrice,
direction, direction,
tradedTokenMint, tradedTokenMint
sizeUsd,
collateralUsd
); );
return pos; return pos;
@@ -124,13 +104,11 @@ class AnchorIdlJupiterPerpsPositionDecoder {
private static final int OWNER_OFFSET = ANCHOR_DISCRIMINATOR_LENGTH; private static final int OWNER_OFFSET = ANCHOR_DISCRIMINATOR_LENGTH;
private static final int POOL_OFFSET = OWNER_OFFSET + PUBLIC_KEY_LENGTH; private static final int POOL_OFFSET = OWNER_OFFSET + PUBLIC_KEY_LENGTH;
private static final int CUSTODY_OFFSET = POOL_OFFSET + PUBLIC_KEY_LENGTH; private static final int CUSTODY_OFFSET = POOL_OFFSET + PUBLIC_KEY_LENGTH;
private static final int COLLATERAL_CUSTODY_OFFSET = CUSTODY_OFFSET + PUBLIC_KEY_LENGTH; private static final int COLLATERAL_CUSTODY_OFFSET = CUSTODY_OFFSET + PUBLIC_KEY_LENGTH; // custody
private static final int OPEN_TIME_OFFSET = COLLATERAL_CUSTODY_OFFSET + PUBLIC_KEY_LENGTH; private static final int OPEN_TIME_OFFSET = COLLATERAL_CUSTODY_OFFSET + PUBLIC_KEY_LENGTH;
private static final int UPDATE_TIME_OFFSET = OPEN_TIME_OFFSET + I64_LENGTH; private static final int UPDATE_TIME_OFFSET = OPEN_TIME_OFFSET + I64_LENGTH; // openTime
private static final int SIDE_OFFSET = UPDATE_TIME_OFFSET + I64_LENGTH; private static final int SIDE_OFFSET = UPDATE_TIME_OFFSET + I64_LENGTH;
private static final int PRICE_OFFSET = SIDE_OFFSET + SIDE_ENUM_LENGTH; private static final int PRICE_OFFSET = SIDE_OFFSET + SIDE_ENUM_LENGTH;
private static final int SIZE_USD_OFFSET = PRICE_OFFSET + U64_LENGTH;
private static final int COLLATERAL_USD_OFFSET = SIZE_USD_OFFSET + U64_LENGTH;
private static final Base58Codec base58 = new Base58CodecImpl(); private static final Base58Codec base58 = new Base58CodecImpl();
} }
@@ -1,167 +0,0 @@
package com.r35157.libs.solana;
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Provides read-oriented access to the Solana blockchain.
*
* <p>This interface exposes the Solana operations needed by higher-level
* integrations. It can fetch native SOL balances, SPL token holdings, NFT-like
* token holding candidates, account information and program derived addresses.</p>
*
* <p>The interface is intentionally generic and does not contain Raydium-specific
* logic. Higher-level integrations are expected to interpret Solana accounts,
* token holdings and derived addresses according to their own domain rules.</p>
*/
public interface SolanaBlockChain {
/**
* Fetches the native SOL balance for a Solana address.
*
* @param address the Solana address to inspect
* @return the native SOL balance for the address
* @throws IOException if the balance could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
*/
ΩSolanaAmountΩ getBalanceInSolana(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
/**
* Fetches the native SOL balance for a Solana address in lamports.
*
* <p>Lamports are the smallest unit of native SOL.</p>
*
* @param address the Solana address to inspect
* @return the native SOL balance for the address in lamports
* @throws IOException if the balance could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching the balance
*/
ΩlamportsΩ getBalanceInLamport(ΩSolanaAddressΩ address) throws IOException, InterruptedException;
/**
* Fetches SPL token holdings owned by a Solana address for a specific token program.
*
* <p>The supplied token program decides which token accounts are inspected. For example,
* callers may query the original SPL Token Program or the Token-2022 Program depending
* on which token accounts they need to discover.</p>
*
* @param ownerAddress the Solana owner address whose token holdings should be inspected
* @param splProgramId the SPL token program to query
* @return a map of SPL mint addresses to token holding information
* @throws IOException if the token holdings could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching token holdings
*/
Map<ΩSPLMintAddressΩ, SPLTokenHolding> getSPLTokenHoldings(
ΩSolanaAddressΩ ownerAddress,
SolanaSPLTokenProgram splProgramId
) throws IOException, InterruptedException;
/**
* Fetches NFT-like token mint address candidates owned by a Solana address for a specific token program.
*
* <p>This method identifies token holdings that look like NFTs within the supplied token
* program. A returned address is only a candidate. Higher-level integrations are responsible
* for deciding whether the returned address has domain-specific meaning.</p>
*
* // TODO This method currently identifies candidates from the owner's token holdings only.
* // A token with zero decimals and an owner balance of one is not guaranteed to be a real NFT,
* // because the mint's total supply may still be greater than one. A future implementation
* // should verify the mint supply, for example by using Solana getTokenSupply, before treating
* // the result as a confirmed NFT.
*
* @param ownerAddress the Solana owner address whose NFT-like holdings should be inspected
* @param splProgram the SPL token program to query
* @return the NFT-like Solana mint address candidates owned by the address
* @throws IOException if the NFT candidate addresses could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching NFT candidate addresses
*/
Set<ΩSolanaNFTAddressΩ> getSolanaNFTCandidateAddresses(
ΩSolanaAddressΩ ownerAddress,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Finds a Solana program derived address for a program id and a set of seeds.
*
* <p>The seeds describe the logical inputs used to derive the address. The implementation
* is responsible for converting each seed into the byte representation required by Solana.</p>
*
* @param programId the Solana program id used to derive the address
* @param seeds the seeds used when deriving the program address
* @return the derived Solana address together with its bump value
*/
SolanaProgramDerivedAddress findProgramAddress(
ΩSolanaProgramIdΩ programId,
List<SolanaProgramAddressSeed> seeds
);
/**
* Fetches account information for a Solana account address.
*
* <p>If the account does not exist, this method returns {@code null}. If the account exists,
* the returned value contains the account address, the owning Solana program id and the
* account data encoded as Base64.</p>
*
* @param accountAddress the Solana account address to inspect
* @return account information, or {@code null} if the account does not exist
* @throws IOException if the account information could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching account information
*/
SolanaAccountInfo getAccountInfo(ΩSolanaAddressΩ accountAddress)
throws IOException, InterruptedException;
/**
* Encodes a raw 32-byte Solana address into its textual Solana address representation.
*
* <p>This method is intended for callers that have obtained raw Solana address bytes from
* account data and need the normal string representation used elsewhere in the API.</p>
*
* @param addressBytes the raw 32-byte Solana address
* @return the encoded Solana address
* @throws IllegalArgumentException if the supplied byte array is not a valid Solana address length
*/
ΩSolanaAddressΩ encodeSolanaAddress(byte[] addressBytes);
/**
* Fetches the total supply of an SPL token mint for a specific token program.
*
* <p>The supplied token program identifies which SPL token program owns the mint,
* for example the original SPL Token Program or the Token-2022 Program.</p>
*
* @param mintAddress the SPL mint address whose supply should be fetched
* @param splProgram the SPL token program to query
* @return the SPL token supply for the mint
* @throws IOException if the token supply could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching token supply
*/
SPLTokenSupply getSPLTokenSupply(
ΩSPLMintAddressΩ mintAddress,
SolanaSPLTokenProgram splProgram
) throws IOException, InterruptedException;
/**
* Fetches accounts owned by a Solana program using server-side account data filters.
*
* <p>This method uses Solana's {@code getProgramAccounts} RPC call. The supplied filters
* are sent to the RPC node, so matching is performed server-side instead of fetching all
* accounts owned by the program and filtering them locally.</p>
*
* <p>The initial supported filter type is {@link SolanaProgramAccountMemcmpFilter}, which
* matches bytes at a specific offset in the account data.</p>
*
* @param programId the Solana program id that owns the accounts to search
* @param filters the memcmp filters to apply when searching program accounts
* @return the matching program accounts
* @throws IOException if the program accounts could not be fetched or parsed
* @throws InterruptedException if the calling thread is interrupted while fetching program accounts
*/
Set<SolanaAccountInfo> getProgramAccounts(
ΩSolanaProgramIdΩ programId,
Set<SolanaProgramAccountMemcmpFilter> filters
) throws IOException, InterruptedException;
}
@@ -1,17 +0,0 @@
package com.r35157.libs.solana;
/**
* Filter used when fetching accounts owned by a Solana program.
*
* <p>The initial supported filter type is {@code memcmp}, which asks the
* Solana RPC node to only return accounts where the account data at a specific
* byte offset matches a base58 encoded value.</p>
*
* @param offset the byte offset in the account data where comparison starts
* @param bytes the base58 encoded bytes to match
*/
public record SolanaProgramAccountMemcmpFilter(
int offset,
String bytes
) {
}
@@ -1,52 +0,0 @@
package com.r35157.libs.solana.valuetypes;
import com.r35157.libs.valuetypes.basic.CurrencyType;
import java.util.UUID;
/**
* Defines well-known currency types used by the Solana integration.
*
* <p>Each enum value wraps a {@link CurrencyType} with a stable identifier and a
* human-readable currency name. These predefined values are intended for common
* currencies that the Solana-related modules need to reference consistently.</p>
*/
public enum WellKnownCurrencyTypes {
/**
* Native Solana currency.
*/
SOLANA(new CurrencyType(
UUID.fromString("019e0116-fce5-792f-a647-fa6da4dffec5"),
"Solana",
"SOL")
),
/**
* Syrup USDC token currency.
*/
SYRUPUSDC(new CurrencyType(
UUID.fromString("019e1d51-0600-7956-8231-f3b7058a91c2"),
"SyrupUSDC",
"SyrupUSDC")
);
/**
* Creates a well-known currency type entry.
*
* @param currencyType the currency type represented by this enum value
*/
WellKnownCurrencyTypes(CurrencyType currencyType) {
this.currencyType = currencyType;
}
/**
* Returns the currency type represented by this enum value.
*
* @return the represented currency type
*/
public CurrencyType getCurrencyType() {
return currencyType;
}
private final CurrencyType currencyType;
}
@@ -1,8 +0,0 @@
package com.r35157.libs.valuetypes.basic;
import java.math.BigDecimal;
public record MoneyPrice(
ΩPriceΩ price,
CurrencyType currencyType
) { }