Compare commits
2 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| 050969ed16 | |||
| 48f087dc6e |
+55
-5
@@ -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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user