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
+80 -10
View File
@@ -1,9 +1,63 @@
# Jupiter Perps Price Alarm # Jupiter Perps Price Alarm
A small Java 17 program that listens to Jupiter Perps' on-chain aggregated oracle account through Solana WebSocket `accountSubscribe`. A small Java program that listens to Jupiter Perps' on-chain aggregated oracle accounts through Solana WebSocket `accountSubscribe`.
It does **not** poll once per second. Every account update observed by the connected RPC node is decoded immediately. The program reconnects automatically, performs an initial/reconnect state fetch, and can connect to multiple independent RPC endpoints for redundancy. It does **not** poll once per second. Every account update observed by the connected RPC node is decoded immediately. The program reconnects automatically, performs an initial/reconnect state fetch, and can connect to multiple independent RPC endpoints for redundancy.
The program supports any number of alarms for SOL, ETH, and BTC. Only one oracle stream is created per configured asset and RPC endpoint, regardless of how many alarms use that asset.
## Java version
The project uses the current JDK compiler but generates Java 17-compatible class files:
```gradle
tasks.withType(JavaCompile).configureEach {
options.release = 17
}
```
## Alarm configuration
The default file is `price-alarms.conf`:
```text
# Asset Direction Target TRIGGER SEVERITY NOTE
##################################################################################################
SOL ABOVE 75.7 ONETIME 2 "ALARM: Risiko for Perps Solana short LIKVIDERING!"
SOL BELOW 60.8 ONETIME 2 "ALARM: Risiko for Perps Solana long LIKVIDERING!"
SOL BELOW 71.4 ONETIME 2 "ALARM: Risiko for Solana Raydium LÅN LIKVIDERING!"
ETH ABOVE 1848.41 ONETIME 2 "ALARM: Risiko for Perps Ethereum short LIKVIDERING!"
ETH BELOW 1789 ONETIME 1 "OK: Perps Ethereum short er lukket!"
```
Supported values:
- Asset: `SOL`, `ETH`, or `BTC`
- Direction: `ABOVE` or `BELOW`
- Trigger: `ONETIME` or `PERSISTENT`
- Target: positive decimal USD price
- Severity: zero or positive integer
- Note: quoted text; escaped quotes can be written as `\"`
`SEVERITY` and `NOTE` are parsed and retained in `PriceAlarmDefinition`, but are intentionally not used by the alarm actions yet.
## Trigger behavior
On the first received price after program start:
- An already satisfied alarm triggers immediately.
- An unsatisfied alarm waits for the price to cross into its triggered side.
Alarm state is retained across WebSocket reconnects within the same process. If the price moves from the safe side to the triggered side during a connection outage, the first price received after reconnect will therefore trigger the alarm.
After that:
- `ONETIME` triggers only once during the current program run.
- `PERSISTENT` triggers each time the price crosses from the safe side into the triggered side.
- Remaining on the triggered side does not repeatedly fire the alarm.
`ONETIME` state is currently kept in memory. Restarting the process arms the alarm again.
## Build and test ## Build and test
```bash ```bash
@@ -11,16 +65,32 @@ gradle classes
gradle run --args='--self-test' gradle run --args='--self-test'
``` ```
## Monitor a SOL short liquidation threshold The self-test covers:
- binary oracle decoding
- configuration parsing
- initial satisfied alarm behavior
- `ONETIME` behavior
- `PERSISTENT` crossing behavior
## Run
Using the default `price-alarms.conf` in the working directory:
```bash ```bash
gradle run --args='--asset=SOL --target=175.00 --direction=above' gradle run
``` ```
For a long position, liquidation is normally below the current price: Using another file:
```bash ```bash
gradle run --args='--asset=SOL --target=120.00 --direction=below' gradle run --args='--config=/path/to/price-alarms.conf'
```
The path can also be selected through:
```bash
export PRICE_ALARMS_CONFIG='/path/to/price-alarms.conf'
``` ```
## Use two RPC WebSocket streams ## Use two RPC WebSocket streams
@@ -29,24 +99,24 @@ A single WebSocket/RPC provider is not a durable event log. For better resilienc
```bash ```bash
export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example' export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example'
gradle run --args='--asset=SOL --target=175 --direction=above' gradle run
``` ```
The same URL is converted from `wss://` to `https://` for initial and reconnect state retrieval. This works with the usual Solana RPC endpoint format, including API-key query parameters. With two configured assets and two RPC endpoints, the program opens four WebSocket connections.
## Pushover emergency alarm ## Pushover emergency alarm
```bash ```bash
export PUSHOVER_APP_TOKEN='...' export PUSHOVER_APP_TOKEN='...'
export PUSHOVER_USER_KEY='...' export PUSHOVER_USER_KEY='...'
gradle run --args='--asset=SOL --target=175 --direction=above' gradle run
``` ```
The program sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`. The current implementation sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`.
## Important limitations ## Important limitations
- `processed` is intentionally used for minimum delay, but a processed update may belong to a fork that is later abandoned. - `processed` is intentionally used for minimum delay, but a processed update may belong to a fork that is later abandoned.
- Solana PubSub is not guaranteed delivery. Two independent RPC streams reduce, but do not eliminate, the risk of missing an update. - Solana PubSub is not guaranteed delivery. Two independent RPC streams reduce, but do not eliminate, the risk of missing an update.
- The alarm reports the Jupiter Perps oracle price. It does not prove that your position was liquidated. For that, also subscribe to your Jupiter position account or relevant program transactions. - The alarm reports the Jupiter Perps oracle price. It does not prove that a specific position was liquidated.
- This is an alerting aid, not a substitute for placing an on-platform stop-loss or reducing leverage. - This is an alerting aid, not a substitute for placing an on-platform stop-loss or reducing leverage.
@@ -1,8 +1,6 @@
package com.r35157.jupiterperpsalarm; package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
@FunctionalInterface @FunctionalInterface
public interface AlarmAction { 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; package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
public final class CompositeAlarmAction implements AlarmAction { public final class CompositeAlarmAction implements AlarmAction {
@@ -10,10 +9,10 @@ public final class CompositeAlarmAction implements AlarmAction {
} }
@Override @Override
public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) { public void trigger(OraclePrice price, PriceAlarmDefinition alarm) {
for (AlarmAction action : actions) { for (AlarmAction action : actions) {
try { try {
action.trigger(price, target, direction); action.trigger(price, alarm);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
System.err.println("Alarm action failed: " + exception.getMessage()); System.err.println("Alarm action failed: " + exception.getMessage());
} }
@@ -1,21 +1,20 @@
package com.r35157.jupiterperpsalarm; package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
public final class ConsoleAlarmAction implements AlarmAction { public final class ConsoleAlarmAction implements AlarmAction {
@Override @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.println("============================================================"); System.err.println("============================================================");
System.err.printf( System.err.printf(
"ALARM: %s is %s USD; target %s %s USD%n", "ALARM: %s is %s USD; target %s %s USD%n",
price.asset(), price.asset(),
price.priceUsd().toPlainString(), price.priceUsd().toPlainString(),
direction, alarm.direction(),
target.toPlainString() alarm.target().toPlainString()
); );
System.err.printf( 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.oracleTime(),
price.slot(), price.slot(),
price.source() price.source()
@@ -3,9 +3,7 @@ package com.r35157.jupiterperpsalarm;
public enum JupiterPerpsAsset { public enum JupiterPerpsAsset {
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"), SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"), ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC"), BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC");
USDC("6Jp2xZUTWdDD2ZyUPRzeMdc6AFQ5K3pFgZxk2EijfjnM"),
USDT("Fgc93D641F8N2d1xLjQ4jmShuD3GE3BsCXA56KBQbF5u");
JupiterPerpsAsset(String oracleAccount) { JupiterPerpsAsset(String oracleAccount) {
this.oracleAccount = oracleAccount; this.oracleAccount = oracleAccount;
@@ -1,11 +1,11 @@
package com.r35157.jupiterperpsalarm; package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.net.URI; import java.net.URI;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -23,8 +23,17 @@ public final class Main {
} }
if (config.selfTest()) { if (config.selfTest()) {
DovesAgPriceFeedDecoder.selfTest(); SelfTest.run();
System.out.println("Decoder self-test passed."); 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; return;
} }
@@ -45,60 +54,92 @@ public final class Main {
} }
AlarmAction action = new CompositeAlarmAction(actions); AlarmAction action = new CompositeAlarmAction(actions);
PriceAlarm alarm = new PriceAlarm( Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> definitionsByAsset =
config.asset(), groupByAsset(definitions);
config.target(),
config.direction(),
action
);
List<OracleWebSocketClient> clients = config.webSocketEndpoints().stream() Map<JupiterPerpsAsset, AssetPriceAlarmMonitor> monitors = new EnumMap<>(
.map(endpoint -> new OracleWebSocketClient(endpoint, config.asset(), alarm::accept)) JupiterPerpsAsset.class
.toList(); );
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( Runtime.getRuntime().addShutdownHook(new Thread(
() -> clients.forEach(OracleWebSocketClient::close), () -> clients.forEach(OracleWebSocketClient::close),
"shutdown" "shutdown"
)); ));
System.out.printf( System.out.println("Alarm configuration: " + config.alarmConfiguration().toAbsolutePath());
"Monitoring Jupiter Perps %s oracle. Alarm when price is %s %s USD.%n", System.out.println("Loaded alarms: " + definitions.size());
config.asset(), definitionsByAsset.forEach((asset, assetDefinitions) -> {
config.direction(), System.out.printf(
config.target().toPlainString() " %s: %d alarm(s), oracle account %s%n",
); asset,
System.out.println("Oracle account: " + config.asset().oracleAccount()); assetDefinitions.size(),
System.out.println("RPC streams: " + config.webSocketEndpoints().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); clients.forEach(OracleWebSocketClient::start);
new CountDownLatch(1).await(); 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() { private static void printUsage() {
System.err.println(""" System.err.println("""
Usage: Usage:
gradle run --args='--asset=SOL --target=175.00 --direction=above' gradle run
gradle run --args='--config=/path/to/price-alarms.conf'
Options: Options:
--asset=SOL|ETH|BTC Default: SOL --config=<path> Default: price-alarms.conf
--target=<USD price> Required
--direction=above|below Default: above
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com --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: Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
PUSHOVER_APP_TOKEN Pushover application token PUSHOVER_APP_TOKEN Pushover application token
PUSHOVER_USER_KEY Pushover user/group key PUSHOVER_USER_KEY Pushover user/group key
For a SOL short liquidation warning, use --direction=above.
"""); """);
} }
private record Config( private record Config(
JupiterPerpsAsset asset, Path alarmConfiguration,
BigDecimal target,
PriceDirection direction,
List<URI> webSocketEndpoints, List<URI> webSocketEndpoints,
String pushoverToken, String pushoverToken,
String pushoverUserKey, String pushoverUserKey,
@@ -115,26 +156,14 @@ public final class Main {
(first, second) -> second (first, second) -> second
)); ));
JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf( String configurationText = options.get("config");
options.getOrDefault("asset", "SOL").toUpperCase(Locale.ROOT) if (configurationText == null || configurationText.isBlank()) {
); configurationText = environment.getOrDefault(
"PRICE_ALARMS_CONFIG",
BigDecimal target = null; "price-alarms.conf"
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");
}
} }
PriceDirection direction = PriceDirection.valueOf(
options.getOrDefault("direction", "above").toUpperCase(Locale.ROOT)
);
String endpointText = options.get("ws"); String endpointText = options.get("ws");
if (endpointText == null || endpointText.isBlank()) { if (endpointText == null || endpointText.isBlank()) {
endpointText = environment.getOrDefault( endpointText = environment.getOrDefault(
@@ -154,9 +183,7 @@ public final class Main {
} }
return new Config( return new Config(
asset, Path.of(configurationText),
target,
direction,
endpoints, endpoints,
blankToNull(environment.get("PUSHOVER_APP_TOKEN")), blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
blankToNull(environment.get("PUSHOVER_USER_KEY")), blankToNull(environment.get("PUSHOVER_USER_KEY")),
@@ -1,65 +1,53 @@
package com.r35157.jupiterperpsalarm; 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 final class PriceAlarm {
public PriceAlarm( public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
JupiterPerpsAsset asset, this.definition = definition;
BigDecimal target,
PriceDirection direction,
AlarmAction action
) {
this.asset = asset;
this.target = target;
this.direction = direction;
this.action = action; this.action = action;
} }
public synchronized void accept(OraclePrice price) { public synchronized void accept(OraclePrice price) {
String eventKey = price.rawPrice() + ":" + price.exponent() + ":" + if (price.asset() != definition.asset()) {
price.oracleTime().getEpochSecond(); throw new IllegalArgumentException(
if (!recentEvents.add(eventKey)) { "Received " + price.asset() + " price for " + definition.asset() + " alarm"
return; );
} }
trimRecentEvents();
long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds(); boolean reached = definition.direction().reached(
System.out.printf( price.priceUsd(),
"%s %s=%s USD oracleAge=%ds slot=%d source=%s%n", definition.target()
Instant.now(),
asset,
price.priceUsd().toPlainString(),
ageSeconds,
price.slot(),
price.source()
); );
if (direction.reached(price.priceUsd(), target) && triggered.compareAndSet(false, true)) { boolean enteredTriggeredSide = previousReached == null
action.trigger(price, target, direction); ? reached
: reached && !previousReached;
previousReached = reached;
if (!enteredTriggeredSide) {
return;
} }
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) {
return;
}
triggerCount++;
action.trigger(price, definition);
} }
private void trimRecentEvents() { public PriceAlarmDefinition definition() {
while (recentEvents.size() > MAX_RECENT_EVENTS) { return definition;
Iterator<String> iterator = recentEvents.iterator();
iterator.next();
iterator.remove();
}
} }
private final JupiterPerpsAsset asset; public synchronized long triggerCount() {
private final BigDecimal target; return triggerCount;
private final PriceDirection direction; }
private final PriceAlarmDefinition definition;
private final AlarmAction action; 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; package com.r35157.jupiterperpsalarm;
import java.math.BigDecimal;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -17,18 +16,20 @@ public final class PushoverAlarmAction implements AlarmAction {
} }
@Override @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 title = "Jupiter Perps " + price.asset() + " alarm";
String message = String.format( String message = String.format(
"%s is %s USD. Target: %s %s USD. Oracle time: %s. Slot: %d.", "%s is %s USD. Target: %s %s USD. Oracle time: %s. Slot: %d.",
price.asset(), price.asset(),
price.priceUsd().toPlainString(), price.priceUsd().toPlainString(),
direction, alarm.direction(),
target.toPlainString(), alarm.target().toPlainString(),
price.oracleTime(), price.oracleTime(),
price.slot() 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) + "&" + String body = form("token", applicationToken) + "&" +
form("user", userKey) + "&" + form("user", userKey) + "&" +
form("title", title) + "&" + form("title", title) + "&" +
@@ -38,7 +39,7 @@ public final class PushoverAlarmAction implements AlarmAction {
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI) HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
.timeout(Duration.ofSeconds(15)) .timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/x-www-form-urlencoded") .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)) .POST(HttpRequest.BodyPublishers.ofString(body))
.build(); .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() {
}
}