34: Combine alarm direction and target into one condition

This commit is contained in:
2026-08-05 10:12:38 +02:00
parent d33dff7091
commit 7266a8c2dd
15 changed files with 574 additions and 154 deletions
@@ -0,0 +1,91 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public final class AlarmConditionParser {
private static final String RANGE_SEPARATOR = "-->";
private AlarmConditionParser() {
}
public static PriceCondition parse(String conditionExpression) {
String trimmedExpression = conditionExpression.trim();
if (trimmedExpression.startsWith("[") || trimmedExpression.startsWith("(")) {
return parseRange(trimmedExpression);
}
PriceComparisonOperator operator;
int operatorLength;
if (trimmedExpression.startsWith("<=")) {
operator = PriceComparisonOperator.LESS_THAN_OR_EQUAL;
operatorLength = 2;
} else if (trimmedExpression.startsWith(">=")) {
operator = PriceComparisonOperator.GREATER_THAN_OR_EQUAL;
operatorLength = 2;
} else if (trimmedExpression.startsWith("<")) {
operator = PriceComparisonOperator.LESS_THAN;
operatorLength = 1;
} else if (trimmedExpression.startsWith(">")) {
operator = PriceComparisonOperator.GREATER_THAN;
operatorLength = 1;
} else {
throw new IllegalArgumentException(
"Unknown price condition operator: " + conditionExpression
);
}
String targetExpression = trimmedExpression.substring(operatorLength).trim();
if (targetExpression.isEmpty()) {
throw new IllegalArgumentException(
"Missing target in price condition: " + conditionExpression
);
}
BigDecimal target = AlarmTargetParser.parse(targetExpression);
return new ComparisonPriceCondition(operator, target);
}
private static PriceCondition parseRange(String rangeExpression) {
char lowerDelimiter = rangeExpression.charAt(0);
char upperDelimiter = rangeExpression.charAt(rangeExpression.length() - 1);
if (upperDelimiter != ')' && upperDelimiter != ']') {
throw new IllegalArgumentException(
"Range must end with ')' or ']': " + rangeExpression
);
}
String rangeBody = rangeExpression.substring(1, rangeExpression.length() - 1);
int separatorIndex = rangeBody.indexOf(RANGE_SEPARATOR);
if (separatorIndex < 0 || separatorIndex != rangeBody.lastIndexOf(RANGE_SEPARATOR)) {
throw new IllegalArgumentException(
"Range must contain exactly one '" + RANGE_SEPARATOR + "': " + rangeExpression
);
}
String lowerTargetExpression = rangeBody.substring(0, separatorIndex).trim();
String upperTargetExpression = rangeBody
.substring(separatorIndex + RANGE_SEPARATOR.length())
.trim();
if (lowerTargetExpression.isEmpty() || upperTargetExpression.isEmpty()) {
throw new IllegalArgumentException(
"Range must contain both a lower and an upper target: " + rangeExpression
);
}
BigDecimal lowerTarget = AlarmTargetParser.parse(lowerTargetExpression);
BigDecimal upperTarget = AlarmTargetParser.parse(upperTargetExpression);
return new RangePriceCondition(
lowerTarget,
lowerDelimiter == '[',
upperTarget,
upperDelimiter == ']'
);
}
}
@@ -52,11 +52,7 @@ public final class AlarmConfigurationParser {
cursor.nextToken("asset").toUpperCase(Locale.ROOT)
);
PriceDirection direction = PriceDirection.valueOf(
cursor.nextToken("direction").toUpperCase(Locale.ROOT)
);
String targetExpression = cursor.nextToken("target");
String conditionExpression = parseConditionExpression(cursor);
TriggerConfiguration triggerConfiguration = parseTrigger(
cursor.nextToken("trigger")
@@ -78,8 +74,7 @@ public final class AlarmConfigurationParser {
return new PriceAlarmDefinition(
id,
asset,
direction,
targetExpression,
conditionExpression,
triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(),
severity,
@@ -87,6 +82,21 @@ public final class AlarmConfigurationParser {
);
}
private static String parseConditionExpression(Cursor cursor) {
String conditionExpression = cursor.nextToken("condition");
if (conditionExpression.equals("<")
|| conditionExpression.equals("<=")
|| conditionExpression.equals(">")
|| conditionExpression.equals(">=")) {
throw new IllegalArgumentException(
"Condition must not contain whitespace: " + conditionExpression
);
}
return conditionExpression;
}
private static TriggerConfiguration parseTrigger(String triggerText) {
String normalized = triggerText.toUpperCase(Locale.ROOT);
@@ -0,0 +1,25 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record ComparisonPriceCondition(
PriceComparisonOperator operator,
BigDecimal target
) implements PriceCondition {
public ComparisonPriceCondition {
Objects.requireNonNull(operator, "operator");
Objects.requireNonNull(target, "target");
}
@Override
public boolean matches(BigDecimal price) {
return operator.matches(price, target);
}
@Override
public String expression() {
return operator.symbol() + target.toPlainString();
}
}
@@ -6,11 +6,10 @@ public final class ConsoleAlarmAction implements AlarmAction {
System.err.println();
System.err.println("============================================================");
System.err.printf(
"ALARM: %s is %s USD; target %s %s USD%n",
"ALARM: %s is %s USD; condition %s USD%n",
price.asset(),
price.priceUsd().toPlainString(),
alarm.direction(),
alarm.target().toPlainString()
alarm.condition().expression()
);
System.err.printf(
"Trigger: %s, oracle time: %s, slot: %d, source: %s%n",
@@ -68,6 +68,10 @@ public final class JupiterPerpsAlarmImpl {
entryPriceVariableRefresher.refresh();
System.out.println("Fetching done.");
System.out.print("Validating alarm conditions... ");
validateAlarmConditions(definitions, variableResolver);
System.out.println("Done.");
entryPriceVariableRefresher.startPeriodicRefresh();
JupiterPerpsEntryPriceVariableRefreshWatcher entryPriceVariableRefreshWatcher =
@@ -141,9 +145,8 @@ public final class JupiterPerpsAlarmImpl {
asset.oracleAccount()
);
assetDefinitions.forEach(definition -> System.out.printf(
" %s %s USD, %s, severity=%s%n",
definition.direction(),
definition.targetExpression(),
" %s USD, %s, severity=%s%n",
definition.conditionExpression(),
definition.trigger(),
definition.severity()
));
@@ -155,6 +158,29 @@ public final class JupiterPerpsAlarmImpl {
new CountDownLatch(1).await();
}
private static void validateAlarmConditions(
List<PriceAlarmDefinition> definitions,
AlarmVariableResolver variableResolver
) {
for (PriceAlarmDefinition definition : definitions) {
try {
AlarmConditionParser.parse(
variableResolver.resolve(definition.conditionExpression())
);
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"Invalid condition for alarm "
+ definition.id()
+ " ("
+ definition.conditionExpression()
+ "): "
+ exception.getMessage(),
exception
);
}
}
}
private static Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> groupByAsset(
List<PriceAlarmDefinition> definitions
) {
@@ -1,6 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.time.Instant;
public final class PriceAlarm {
@@ -22,24 +21,21 @@ public final class PriceAlarm {
);
}
BigDecimal target;
PriceCondition condition;
try {
target = AlarmTargetParser.parse(
variableResolver.resolve(definition.targetExpression())
condition = AlarmConditionParser.parse(
variableResolver.resolve(definition.conditionExpression())
);
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve target for alarm %d: %s%n",
"Could not resolve condition for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
boolean reached = definition.direction().reached(
price.priceUsd(),
target
);
boolean reached = condition.matches(price.priceUsd());
boolean enteredTriggeredSide = previousReached == null
? reached
@@ -57,13 +53,13 @@ public final class PriceAlarm {
return;
}
trigger(price, target);
trigger(price, condition);
return;
}
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price, target);
trigger(price, condition);
}
return;
}
@@ -95,7 +91,7 @@ public final class PriceAlarm {
);
}
private void trigger(OraclePrice price, BigDecimal target) {
private void trigger(OraclePrice price, PriceCondition condition) {
triggerCount++;
lastTriggeredAt = Instant.now();
@@ -115,8 +111,7 @@ public final class PriceAlarm {
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
definition.id(),
definition.asset(),
definition.direction(),
target,
condition,
definition.trigger(),
definition.triggerGracePeriod(),
definition.severity(),
@@ -7,8 +7,7 @@ import java.util.Objects;
public record PriceAlarmDefinition(
int id,
JupiterPerpsAsset asset,
PriceDirection direction,
String targetExpression,
String conditionExpression,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
@@ -16,14 +15,13 @@ public record PriceAlarmDefinition(
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(targetExpression, "targetExpression");
Objects.requireNonNull(conditionExpression, "conditionExpression");
Objects.requireNonNull(trigger, "trigger");
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note");
if (targetExpression.isBlank()) {
throw new IllegalArgumentException("Target expression cannot be blank");
if (conditionExpression.isBlank()) {
throw new IllegalArgumentException("Condition expression cannot be blank");
}
if (triggerGracePeriod < 0) {
@@ -0,0 +1,42 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public enum PriceComparisonOperator {
LESS_THAN("<") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) < 0;
}
},
LESS_THAN_OR_EQUAL("<=") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) <= 0;
}
},
GREATER_THAN(">") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) > 0;
}
},
GREATER_THAN_OR_EQUAL(">=") {
@Override
public boolean matches(BigDecimal price, BigDecimal target) {
return price.compareTo(target) >= 0;
}
};
PriceComparisonOperator(String symbol) {
this.symbol = symbol;
}
public abstract boolean matches(BigDecimal price, BigDecimal target);
public String symbol() {
return symbol;
}
private final String symbol;
}
@@ -0,0 +1,10 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public interface PriceCondition {
boolean matches(BigDecimal price);
String expression();
}
@@ -1,20 +0,0 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
public enum PriceDirection {
ABOVE {
@Override
public boolean reached(BigDecimal price, BigDecimal target) {
return price.compareTo(target) >= 0;
}
},
BELOW {
@Override
public boolean reached(BigDecimal price, BigDecimal target) {
return price.compareTo(target) <= 0;
}
};
public abstract boolean reached(BigDecimal price, BigDecimal target);
}
@@ -71,14 +71,13 @@ public final class PushoverAlarmAction implements AlarmAction {
private static String createMessage(OraclePrice price, ResolvedPriceAlarm alarm) {
return String.format(
"%d - %s: %s%n%n%s is %s USD.%nTarget: %s %s USD.%nOracle time: %s.%nSlot: %d.",
"%d - %s: %s%n%n%s is %s USD.%nCondition: %s USD.%nOracle time: %s.%nSlot: %d.",
alarm.id(),
alarm.severity(),
alarm.note(),
price.asset(),
price.priceUsd().toPlainString(),
alarm.direction(),
alarm.target().toPlainString(),
alarm.condition().expression(),
price.oracleTime(),
price.slot()
);
@@ -0,0 +1,54 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record RangePriceCondition(
BigDecimal lowerTarget,
boolean lowerInclusive,
BigDecimal upperTarget,
boolean upperInclusive
) implements PriceCondition {
public RangePriceCondition {
Objects.requireNonNull(lowerTarget, "lowerTarget");
Objects.requireNonNull(upperTarget, "upperTarget");
if (lowerTarget.compareTo(upperTarget) > 0) {
throw new IllegalArgumentException(
"Range lower target must not exceed upper target: "
+ lowerTarget.toPlainString()
+ "-->"
+ upperTarget.toPlainString()
);
}
}
@Override
public boolean matches(BigDecimal price) {
int lowerComparison = price.compareTo(lowerTarget);
int upperComparison = price.compareTo(upperTarget);
boolean matchesLower = lowerInclusive
? lowerComparison >= 0
: lowerComparison > 0;
boolean matchesUpper = upperInclusive
? upperComparison <= 0
: upperComparison < 0;
return matchesLower && matchesUpper;
}
@Override
public String expression() {
char lowerDelimiter = lowerInclusive ? '[' : '(';
char upperDelimiter = upperInclusive ? ']' : ')';
return lowerDelimiter
+ lowerTarget.toPlainString()
+ "-->"
+ upperTarget.toPlainString()
+ upperDelimiter;
}
}
@@ -1,16 +1,14 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.math.BigDecimal;
public record ResolvedPriceAlarm(
int id,
JupiterPerpsAsset asset,
PriceDirection direction,
BigDecimal target,
PriceCondition condition,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
) {
}
}