2 Commits

Author SHA256 Message Date
minimons 050969ed16 12: Add grace period support for persistent alarms 2026-06-23 15:08:00 +02:00
minimons 48f087dc6e 11: Remove temporary SelfTest code 2026-06-23 12:57:03 +02:00
7 changed files with 106 additions and 161 deletions
@@ -13,6 +13,7 @@ public final class AlarmConfigurationParser {
public static List<PriceAlarmDefinition> 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<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) { for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = lines.get(lineNumber - 1); String line = lines.get(lineNumber - 1);
@@ -59,8 +60,8 @@ public final class AlarmConfigurationParser {
BigDecimal target = parseTarget(cursor.nextToken("target")); BigDecimal target = parseTarget(cursor.nextToken("target"));
AlarmTrigger trigger = AlarmTrigger.valueOf( TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger").toUpperCase(Locale.ROOT) cursor.nextToken("trigger")
); );
AlarmSeverity severity = AlarmSeverity.valueOf( AlarmSeverity severity = AlarmSeverity.valueOf(
@@ -81,7 +82,8 @@ public final class AlarmConfigurationParser {
asset, asset,
direction, direction,
target, target,
trigger, triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(),
severity, severity,
note note
); );
@@ -109,6 +111,50 @@ public final class AlarmConfigurationParser {
return target; return target;
} }
private static TriggerConfiguration parseTrigger(String triggerText) {
String normalized = triggerText.toUpperCase(Locale.ROOT);
if (normalized.equals("ONETIME")) {
return new TriggerConfiguration(AlarmTrigger.ONETIME, 0);
}
if (normalized.equals("PERSISTENT")) {
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0);
}
if (normalized.startsWith("PERSISTENT:")) {
String graceText = normalized.substring("PERSISTENT:".length());
if (graceText.isEmpty()) {
throw new IllegalArgumentException("Missing persistent grace period: " + triggerText);
}
ΩsecondsΩ gracePeriodSeconds;
try {
gracePeriodSeconds = Integer.parseInt(graceText);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Invalid persistent grace period: " + triggerText,
exception
);
}
if (gracePeriodSeconds < 0) {
throw new IllegalArgumentException(
"Persistent grace period cannot be negative: " + triggerText
);
}
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, gracePeriodSeconds);
}
if (normalized.startsWith("ONETIME:")) {
throw new IllegalArgumentException("ONETIME cannot have a grace period: " + triggerText);
}
throw new IllegalArgumentException("Unknown trigger: " + triggerText);
}
private static void validateTarget(BigDecimal target, String originalTargetStr) { private static void validateTarget(BigDecimal target, String originalTargetStr) {
if (target.compareTo(BigDecimal.ZERO) < 0) { if (target.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -274,8 +320,12 @@ public final class AlarmConfigurationParser {
} }
} }
private AlarmConfigurationParser() { private record TriggerConfiguration(
AlarmTrigger trigger,
ΩsecondsΩ gracePeriod
) {
} }
private static Map<String, String> constants = new LinkedHashMap<>(); private AlarmConfigurationParser() {
}
} }
@@ -48,25 +48,6 @@ public final class DovesAgPriceFeedDecoder {
); );
} }
public static void selfTest() {
byte[] data = new byte[ACCOUNT_SIZE];
System.arraycopy(AG_PRICE_FEED_DISCRIMINATOR, 0, data, 0, 8);
putUnsignedLongLittleEndian(data, PRICE_OFFSET, new BigInteger("123456789"));
data[EXPONENT_OFFSET] = (byte) -6;
ByteBuffer.wrap(data, TIMESTAMP_OFFSET, Long.BYTES)
.order(ByteOrder.LITTLE_ENDIAN)
.putLong(1_700_000_000L);
OraclePrice decoded = decode(JupiterPerpsAsset.SOL, data, 42L, "self-test");
if (decoded.priceUsd().compareTo(new BigDecimal("123.456789")) != 0) {
throw new IllegalStateException("Decoder self-test failed: " + decoded.priceUsd());
}
if (!decoded.oracleTime().equals(Instant.ofEpochSecond(1_700_000_000L))) {
throw new IllegalStateException("Timestamp self-test failed");
}
}
private static BigInteger readUnsignedLongLittleEndian(byte[] data, int offset) { private static BigInteger readUnsignedLongLittleEndian(byte[] data, int offset) {
byte[] positiveBigEndian = new byte[Long.BYTES + 1]; byte[] positiveBigEndian = new byte[Long.BYTES + 1];
for (int index = 0; index < Long.BYTES; index++) { for (int index = 0; index < Long.BYTES; index++) {
@@ -21,12 +21,6 @@ public final class JupiterPerpsAlarmImpl {
throw new IllegalStateException(errMsg, exception); throw new IllegalStateException(errMsg, exception);
} }
if (config.selfTest()) {
SelfTest.run();
System.out.println("All self-tests passed.");
return;
}
List<PriceAlarmDefinition> definitions; List<PriceAlarmDefinition> definitions;
try { try {
definitions = AlarmConfigurationParser.parse(config.alarmConfiguration()); definitions = AlarmConfigurationParser.parse(config.alarmConfiguration());
@@ -126,7 +120,6 @@ public final class JupiterPerpsAlarmImpl {
Options: Options:
--config=<path> Default: price-alarms.conf --config=<path> Default: price-alarms.conf
--ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com --ws=<url1,url2,...> Default: wss://api.mainnet-beta.solana.com
--self-test Test parser, alarm semantics and oracle decoder
Environment: Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path PRICE_ALARMS_CONFIG Alternative default configuration path
@@ -140,11 +133,9 @@ public final class JupiterPerpsAlarmImpl {
Path alarmConfiguration, Path alarmConfiguration,
List<URI> webSocketEndpoints, List<URI> webSocketEndpoints,
String pushoverToken, String pushoverToken,
String pushoverUserKey, String pushoverUserKey
boolean selfTest
) { ) {
private static Config parse(String[] args, Map<String, String> environment) { private static Config parse(String[] args, Map<String, String> environment) {
boolean selfTest = Arrays.asList(args).contains("--self-test");
Map<String, String> options = Arrays.stream(args) Map<String, String> options = Arrays.stream(args)
.filter(argument -> argument.startsWith("--") && argument.contains("=")) .filter(argument -> argument.startsWith("--") && argument.contains("="))
.map(argument -> argument.substring(2).split("=", 2)) .map(argument -> argument.substring(2).split("=", 2))
@@ -184,8 +175,7 @@ public final class JupiterPerpsAlarmImpl {
Path.of(configurationText), Path.of(configurationText),
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"))
selfTest
); );
} }
@@ -1,5 +1,7 @@
package com.r35157.jupiterperpsalarm.impl.ref; package com.r35157.jupiterperpsalarm.impl.ref;
import java.time.Instant;
public final class PriceAlarm { public final class PriceAlarm {
public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) { public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) {
@@ -25,16 +27,27 @@ public final class PriceAlarm {
previousReached = reached; previousReached = reached;
if (!enteredTriggeredSide) { if (!reached) {
return; return;
} }
if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) { if (definition.trigger() == AlarmTrigger.ONETIME) {
if (!enteredTriggeredSide || triggerCount > 0) {
return; return;
} }
triggerCount++; trigger(price);
action.trigger(price, definition); return;
}
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (enteredTriggeredSide || persistentGracePeriodHasPassed()) {
trigger(price);
}
return;
}
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
} }
public PriceAlarmDefinition definition() { public PriceAlarmDefinition definition() {
@@ -45,9 +58,32 @@ public final class PriceAlarm {
return triggerCount; return triggerCount;
} }
private boolean persistentGracePeriodHasPassed() {
if (lastTriggeredAt == null) {
return true;
}
ΩsecondsΩ gracePeriod = definition.triggerGracePeriod();
if (gracePeriod == 0) {
return true;
}
return !Instant.now().isBefore(
lastTriggeredAt.plusSeconds(gracePeriod)
);
}
private void trigger(OraclePrice price) {
triggerCount++;
lastTriggeredAt = Instant.now();
action.trigger(price, definition);
}
private final PriceAlarmDefinition definition; private final PriceAlarmDefinition definition;
private final AlarmAction action; private final AlarmAction action;
private Instant lastTriggeredAt;
private Boolean previousReached; private Boolean previousReached;
private long triggerCount; private long triggerCount;
} }
@@ -11,6 +11,7 @@ public record PriceAlarmDefinition(
PriceDirection direction, PriceDirection direction,
BigDecimal target, BigDecimal target,
AlarmTrigger trigger, AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity, AlarmSeverity severity,
String note String note
) { ) {
@@ -24,5 +25,11 @@ public record PriceAlarmDefinition(
if (target.signum() < 0) { if (target.signum() < 0) {
throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!"); throw new IllegalArgumentException("Target price cannot be negative (was: " + target.signum() + ")!");
} }
if (triggerGracePeriod < 0) {
throw new IllegalArgumentException(
"Trigger grace period cannot be negative: " + triggerGracePeriod
);
}
} }
} }
@@ -1,121 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
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 CRITICAL \"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() == AlarmSeverity.CRITICAL, "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(
123,
JupiterPerpsAsset.SOL,
direction,
new BigDecimal(target),
trigger,
AlarmSeverity.CRITICAL,
"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() {
}
}
@@ -0,0 +1,2 @@
package com.r35157.jupiterperpsalarm.impl.ref;