Jupiter alarm extended with multi alarm and config file

This commit is contained in:
2026-06-16 00:16:52 +02:00
parent d784cd2fd5
commit e1d691f2a7
13 changed files with 600 additions and 127 deletions
@@ -1,8 +1,6 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
@FunctionalInterface
public interface AlarmAction {
void trigger(OraclePrice price, BigDecimal target, PriceDirection direction);
void trigger(OraclePrice price, PriceAlarmDefinition alarm);
}
@@ -0,0 +1,161 @@
package com.r35157.jupiterperpsalarm;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public final class AlarmConfigurationParser {
public static List<PriceAlarmDefinition> parse(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
List<PriceAlarmDefinition> alarms = new ArrayList<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = lines.get(lineNumber - 1);
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
try {
alarms.add(parseLine(line));
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (alarms.isEmpty()) {
throw new IllegalArgumentException("No alarms found in " + path);
}
return List.copyOf(alarms);
}
static PriceAlarmDefinition parseLine(String line) {
Cursor cursor = new Cursor(line);
JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf(
cursor.nextToken("asset").toUpperCase(Locale.ROOT)
);
PriceDirection direction = PriceDirection.valueOf(
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
);
BigDecimal target = new BigDecimal(cursor.nextToken("target"));
AlarmTrigger trigger = AlarmTrigger.valueOf(
cursor.nextToken("trigger").toUpperCase(Locale.ROOT)
);
int severity = Integer.parseInt(cursor.nextToken("severity"));
String note = cursor.nextQuotedString("note");
cursor.skipWhitespace();
if (!cursor.atEnd() && cursor.current() != '#') {
throw new IllegalArgumentException(
"Unexpected text after note: " + cursor.remaining()
);
}
return new PriceAlarmDefinition(
asset,
direction,
target,
trigger,
severity,
note
);
}
private static final class Cursor {
private Cursor(String line) {
this.line = line;
}
private String nextToken(String fieldName) {
skipWhitespace();
if (atEnd()) {
throw new IllegalArgumentException("Missing " + fieldName);
}
int start = position;
while (!atEnd() && !Character.isWhitespace(current())) {
position++;
}
return line.substring(start, position);
}
private String nextQuotedString(String fieldName) {
skipWhitespace();
if (atEnd() || current() != '"') {
throw new IllegalArgumentException(
"Missing quoted " + fieldName + "; expected \"...\""
);
}
position++;
StringBuilder result = new StringBuilder();
boolean escaped = false;
while (!atEnd()) {
char character = current();
position++;
if (escaped) {
result.append(switch (character) {
case 'n' -> '\n';
case 'r' -> '\r';
case 't' -> '\t';
case '"' -> '"';
case '\\' -> '\\';
default -> character;
});
escaped = false;
continue;
}
if (character == '\\') {
escaped = true;
continue;
}
if (character == '"') {
return result.toString();
}
result.append(character);
}
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
}
private void skipWhitespace() {
while (!atEnd() && Character.isWhitespace(current())) {
position++;
}
}
private boolean atEnd() {
return position >= line.length();
}
private char current() {
return line.charAt(position);
}
private String remaining() {
return line.substring(position);
}
private final String line;
private int position;
}
private AlarmConfigurationParser() {
}
}
@@ -0,0 +1,6 @@
package com.r35157.jupiterperpsalarm;
public enum AlarmTrigger {
ONETIME,
PERSISTENT
}
@@ -0,0 +1,79 @@
package com.r35157.jupiterperpsalarm;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public final class AssetPriceAlarmMonitor {
public AssetPriceAlarmMonitor(
JupiterPerpsAsset asset,
List<PriceAlarmDefinition> definitions,
AlarmAction action
) {
this.asset = asset;
this.alarms = definitions.stream()
.map(definition -> {
if (definition.asset() != asset) {
throw new IllegalArgumentException(
"Alarm asset " + definition.asset() + " does not match monitor " + asset
);
}
return new PriceAlarm(definition, action);
})
.toList();
if (alarms.isEmpty()) {
throw new IllegalArgumentException("At least one alarm is required for " + asset);
}
}
public synchronized void accept(OraclePrice price) {
if (price.asset() != asset) {
throw new IllegalArgumentException(
"Received " + price.asset() + " price for " + asset + " monitor"
);
}
String eventKey = price.rawPrice() + ":" + price.exponent() + ":" +
price.oracleTime().getEpochSecond();
if (!recentEvents.add(eventKey)) {
return;
}
trimRecentEvents();
long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds();
System.out.printf(
"%s %s=%s USD oracleAge=%ds slot=%d source=%s%n",
Instant.now(),
asset,
price.priceUsd().toPlainString(),
ageSeconds,
price.slot(),
price.source()
);
alarms.forEach(alarm -> alarm.accept(price));
}
public List<PriceAlarm> alarms() {
return alarms;
}
private void trimRecentEvents() {
while (recentEvents.size() > MAX_RECENT_EVENTS) {
Iterator<String> iterator = recentEvents.iterator();
iterator.next();
iterator.remove();
}
}
private final JupiterPerpsAsset asset;
private final List<PriceAlarm> alarms;
private final Set<String> recentEvents = new LinkedHashSet<>();
private static final int MAX_RECENT_EVENTS = 512;
}
@@ -1,6 +1,5 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.util.List;
public final class CompositeAlarmAction implements AlarmAction {
@@ -10,10 +9,10 @@ public final class CompositeAlarmAction implements AlarmAction {
}
@Override
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
for (AlarmAction action : actions) {
try {
action.trigger(price, target, direction);
action.trigger(price, alarm);
} catch (RuntimeException exception) {
System.err.println("Alarm action failed: " + exception.getMessage());
}
@@ -1,21 +1,20 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
public final class ConsoleAlarmAction implements AlarmAction {
@Override
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
System.err.println();
System.err.println("============================================================");
System.err.printf(
"ALARM: %s is %s USD; target %s %s USD%n",
price.asset(),
price.priceUsd().toPlainString(),
direction,
target.toPlainString()
alarm.direction(),
alarm.target().toPlainString()
);
System.err.printf(
"Oracle time: %s, slot: %d, source: %s%n",
"Trigger: %s, oracle time: %s, slot: %d, source: %s%n",
alarm.trigger(),
price.oracleTime(),
price.slot(),
price.source()
@@ -3,9 +3,7 @@ package com.r35157.jupiterperpsalarm;
public enum JupiterPerpsAsset {
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC"),
USDC("6Jp2xZUTWdDD2ZyUPRzeMdc6AFQ5K3pFgZxk2EijfjnM"),
USDT("Fgc93D641F8N2d1xLjQ4jmShuD3GE3BsCXA56KBQbF5u");
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC");
JupiterPerpsAsset(String oracleAccount) {
this.oracleAccount = oracleAccount;
@@ -1,11 +1,11 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
@@ -23,8 +23,17 @@ public final class Main {
}
if (config.selfTest()) {
DovesAgPriceFeedDecoder.selfTest();
System.out.println("Decoder self-test passed.");
SelfTest.run();
System.out.println("All self-tests passed.");
return;
}
List<PriceAlarmDefinition> definitions;
try {
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
} catch (Exception exception) {
System.err.println("Could not load alarm configuration: " + exception.getMessage());
System.exit(2);
return;
}
@@ -45,60 +54,92 @@ public final class Main {
}
AlarmAction action = new CompositeAlarmAction(actions);
PriceAlarm alarm = new PriceAlarm(
config.asset(),
config.target(),
config.direction(),
action
);
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> definitionsByAsset =
groupByAsset(definitions);
List<OracleWebSocketClient> clients = config.webSocketEndpoints().stream()
.map(endpoint -> new OracleWebSocketClient(endpoint, config.asset(), alarm::accept))
.toList();
Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>(
JupiterPerpsAsset.class
);
definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put(
asset,
new AssetPriceAlarmMonitor(asset, assetDefinitions, action)
));
List<OracleWebSocketClient> clients = new ArrayList<>();
for (Map.Entry<JupiterPerpsAsset, AssetPriceAlarmMonitor> entry : monitors.entrySet()) {
for (URI endpoint : config.webSocketEndpoints()) {
clients.add(new OracleWebSocketClient(
endpoint,
entry.getKey(),
entry.getValue()::accept
));
}
}
Runtime.getRuntime().addShutdownHook(new Thread(
() -> clients.forEach(OracleWebSocketClient::close),
"shutdown"
));
System.out.printf(
"Monitoring Jupiter Perps %s oracle. Alarm when price is %s %s USD.%n",
config.asset(),
config.direction(),
config.target().toPlainString()
);
System.out.println("Oracle account: " + config.asset().oracleAccount());
System.out.println("RPC streams: " + config.webSocketEndpoints().size());
System.out.println("Alarm configuration: " + config.alarmConfiguration().toAbsolutePath());
System.out.println("Loaded alarms: " + definitions.size());
definitionsByAsset.forEach((asset, assetDefinitions) -> {
System.out.printf(
" %s: %d alarm(s), oracle account %s%n",
asset,
assetDefinitions.size(),
asset.oracleAccount()
);
assetDefinitions.forEach(definition -> System.out.printf(
" %s %s USD, %s, severity=%d%n",
definition.direction(),
definition.target().toPlainString(),
definition.trigger(),
definition.severity()
));
});
System.out.println("RPC endpoints per asset: " + config.webSocketEndpoints().size());
System.out.println("Total WebSocket connections: " + clients.size());
clients.forEach(OracleWebSocketClient::start);
new CountDownLatch(1).await();
}
private static Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> groupByAsset(
List<PriceAlarmDefinition> definitions
) {
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> result = new EnumMap<>(
JupiterPerpsAsset.class
);
for (PriceAlarmDefinition definition : definitions) {
result.computeIfAbsent(definition.asset(), ignored -> new ArrayList<>())
.add(definition);
}
result.replaceAll((asset, alarms) -> List.copyOf(alarms));
return result;
}
private static void printUsage() {
System.err.println("""
Usage:
gradle run --args='--asset=SOL --target=175.00 --direction=above'
gradle run
gradle run --args='--config=/path/to/price-alarms.conf'
Options:
--asset=SOL|ETH|BTC Default: SOL
--target=<USD price> Required
--direction=above|below Default: above
--config=<path> Default: price-alarms.conf
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
--self-test Test the binary decoder and exit
--self-test Test parser, alarm semantics and oracle decoder
Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
PUSHOVER_APP_TOKEN Pushover application token
PUSHOVER_USER_KEY Pushover user/group key
For a SOL short liquidation warning, use --direction=above.
""");
}
private record Config(
JupiterPerpsAsset asset,
BigDecimal target,
PriceDirection direction,
Path alarmConfiguration,
List<URI> webSocketEndpoints,
String pushoverToken,
String pushoverUserKey,
@@ -115,26 +156,14 @@ public final class Main {
(first, second) -> second
));
JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf(
options.getOrDefault("asset", "SOL").toUpperCase(Locale.ROOT)
);
BigDecimal target = null;
if (!selfTest) {
String targetText = options.get("target");
if (targetText == null || targetText.isBlank()) {
throw new IllegalArgumentException("Missing required --target=<USD price>");
}
target = new BigDecimal(targetText);
if (target.signum() <= 0) {
throw new IllegalArgumentException("Target price must be positive");
}
String configurationText = options.get("config");
if (configurationText == null || configurationText.isBlank()) {
configurationText = environment.getOrDefault(
"PRICE_ALARMS_CONFIG",
"price-alarms.conf"
);
}
PriceDirection direction = PriceDirection.valueOf(
options.getOrDefault("direction", "above").toUpperCase(Locale.ROOT)
);
String endpointText = options.get("ws");
if (endpointText == null || endpointText.isBlank()) {
endpointText = environment.getOrDefault(
@@ -154,9 +183,7 @@ public final class Main {
}
return new Config(
asset,
target,
direction,
Path.of(configurationText),
endpoints,
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
blankToNull(environment.get("PUSHOVER_USER_KEY")),
@@ -1,65 +1,53 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
public final class PriceAlarm {
public PriceAlarm(
JupiterPerpsAsset asset,
BigDecimal target,
PriceDirection direction,
AlarmAction action
) {
this.asset = asset;
this.target = target;
this.direction = direction;
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
this.definition = definition;
this.action = action;
}
public synchronized void accept(OraclePrice price) {
String eventKey = price.rawPrice() + ":" + price.exponent() + ":" +
price.oracleTime().getEpochSecond();
if (!recentEvents.add(eventKey)) {
return;
if (price.asset() != definition.asset()) {
throw new IllegalArgumentException(
"Received " + price.asset() + " price for " + definition.asset() + " alarm"
);
}
trimRecentEvents();
long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds();
System.out.printf(
"%s %s=%s USD oracleAge=%ds slot=%d source=%s%n",
Instant.now(),
asset,
price.priceUsd().toPlainString(),
ageSeconds,
price.slot(),
price.source()
boolean reached = definition.direction().reached(
price.priceUsd(),
definition.target()
);
if (direction.reached(price.priceUsd(), target) && triggered.compareAndSet(false, true)) {
action.trigger(price, target, direction);
boolean enteredTriggeredSide = previousReached == null
? reached
: reached && !previousReached;
previousReached = reached;
if (!enteredTriggeredSide) {
return;
}
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) {
return;
}
triggerCount++;
action.trigger(price, definition);
}
private void trimRecentEvents() {
while (recentEvents.size() > MAX_RECENT_EVENTS) {
Iterator<String> iterator = recentEvents.iterator();
iterator.next();
iterator.remove();
}
public PriceAlarmDefinition definition() {
return definition;
}
private final JupiterPerpsAsset asset;
private final BigDecimal target;
private final PriceDirection direction;
public synchronized long triggerCount() {
return triggerCount;
}
private final PriceAlarmDefinition definition;
private final AlarmAction action;
private final AtomicBoolean triggered = new AtomicBoolean();
private final Set<String> recentEvents = new LinkedHashSet<>();
private static final int MAX_RECENT_EVENTS = 512;
private Boolean previousReached;
private long triggerCount;
}
@@ -0,0 +1,28 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.util.Objects;
public record PriceAlarmDefinition(
JupiterPerpsAsset asset,
PriceDirection direction,
BigDecimal target,
AlarmTrigger trigger,
int severity,
String note
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(trigger, "trigger");
Objects.requireNonNull(note, "note");
if (target.signum() <= 0) {
throw new IllegalArgumentException("Target price must be positive");
}
if (severity < 0) {
throw new IllegalArgumentException("Severity must be zero or positive");
}
}
}
@@ -1,6 +1,5 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
@@ -17,18 +16,20 @@ public final class PushoverAlarmAction implements AlarmAction {
}
@Override
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) {
public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
String title = "Jupiter Perps " + price.asset() + " alarm";
String message = String.format(
"%s is %s USD. Target: %s %s USD. Oracle time: %s. Slot: %d.",
price.asset(),
price.priceUsd().toPlainString(),
direction,
target.toPlainString(),
alarm.direction(),
alarm.target().toPlainString(),
price.oracleTime(),
price.slot()
);
// Severity and note are intentionally parsed and retained in the model,
// but are not used by Pushover yet.
String body = form("token", applicationToken) + "&" +
form("user", userKey) + "&" +
form("title", title) + "&" +
@@ -38,7 +39,7 @@ public final class PushoverAlarmAction implements AlarmAction {
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("User-Agent", "jupiter-perps-price-alarm/1.0")
.header("User-Agent", "jupiter-perps-price-alarm/1.1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
@@ -0,0 +1,119 @@
package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public final class SelfTest {
public static void run() {
DovesAgPriceFeedDecoder.selfTest();
configurationParserTest();
oneTimeAlarmTest();
persistentAlarmTest();
initialTriggerTest();
}
private static void configurationParserTest() {
PriceAlarmDefinition alarm = AlarmConfigurationParser.parseLine(
"SOL ABOVE 75.7 ONETIME 2 \"ALARM: Risiko for likvidering!\""
);
require(alarm.asset() == JupiterPerpsAsset.SOL, "Asset parsing failed");
require(alarm.direction() == PriceDirection.ABOVE, "Direction parsing failed");
require(alarm.target().compareTo(new BigDecimal("75.7")) == 0, "Target parsing failed");
require(alarm.trigger() == AlarmTrigger.ONETIME, "Trigger parsing failed");
require(alarm.severity() == 2, "Severity parsing failed");
require(alarm.note().equals("ALARM: Risiko for likvidering!"), "Note parsing failed");
}
private static void oneTimeAlarmTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.ABOVE, "100", AlarmTrigger.ONETIME),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "90");
accept(alarm, "100");
accept(alarm, "110");
accept(alarm, "90");
accept(alarm, "101");
require(triggeredPrices.equals(List.of(new BigDecimal("100"))), "ONETIME semantics failed");
}
private static void persistentAlarmTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.BELOW, "100", AlarmTrigger.PERSISTENT),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "110");
accept(alarm, "99");
accept(alarm, "98");
accept(alarm, "101");
accept(alarm, "100");
require(
triggeredPrices.equals(List.of(new BigDecimal("99"), new BigDecimal("100"))),
"PERSISTENT crossing semantics failed"
);
}
private static void initialTriggerTest() {
List<BigDecimal> triggeredPrices = new ArrayList<>();
PriceAlarm alarm = new PriceAlarm(
definition(PriceDirection.ABOVE, "100", AlarmTrigger.PERSISTENT),
(price, definition) -> triggeredPrices.add(price.priceUsd())
);
accept(alarm, "105");
accept(alarm, "106");
require(
triggeredPrices.equals(List.of(new BigDecimal("105"))),
"Initial satisfied alarm did not trigger exactly once"
);
}
private static PriceAlarmDefinition definition(
PriceDirection direction,
String target,
AlarmTrigger trigger
) {
return new PriceAlarmDefinition(
JupiterPerpsAsset.SOL,
direction,
new BigDecimal(target),
trigger,
2,
"ignored for now"
);
}
private static void accept(PriceAlarm alarm, String price) {
BigDecimal decimal = new BigDecimal(price);
alarm.accept(new OraclePrice(
JupiterPerpsAsset.SOL,
decimal.unscaledValue().abs(),
-decimal.scale(),
decimal,
Instant.ofEpochSecond(1_700_000_000L),
1L,
"self-test"
));
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private SelfTest() {
}
}