Jupiter alarm extended with multi alarm and config file
This commit is contained in:
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user