45: Add hysteresis support to CROSSING alarms

This commit is contained in:
2026-08-05 10:12:39 +02:00
parent bf492c9db1
commit 4cc14ceef5
7 changed files with 209 additions and 17 deletions
@@ -48,6 +48,84 @@ public final class AlarmConditionParser {
return new ComparisonPriceCondition(operator, target);
}
public static CrossingPriceCondition parseCrossing(String conditionExpression) {
String trimmedExpression = conditionExpression.trim();
int hysteresisSeparatorIndex = trimmedExpression.indexOf('/');
if (hysteresisSeparatorIndex != trimmedExpression.lastIndexOf('/')) {
throw new IllegalArgumentException(
"CROSSING condition must contain at most one '/': "
+ conditionExpression
);
}
String comparisonExpression = hysteresisSeparatorIndex < 0
? trimmedExpression
: trimmedExpression.substring(0, hysteresisSeparatorIndex);
PriceCondition parsedCondition = parse(comparisonExpression);
if (!(parsedCondition instanceof ComparisonPriceCondition comparison)
|| (comparison.operator() != PriceComparisonOperator.LESS_THAN
&& comparison.operator() != PriceComparisonOperator.GREATER_THAN)) {
throw new IllegalArgumentException(
"CROSSING requires a '<' or '>' condition: " + conditionExpression
);
}
BigDecimal hysteresis = hysteresisSeparatorIndex < 0
? BigDecimal.ZERO
: parseHysteresis(
trimmedExpression.substring(hysteresisSeparatorIndex + 1),
comparison.target()
);
return new CrossingPriceCondition(comparison, hysteresis);
}
private static BigDecimal parseHysteresis(
String hysteresisExpression,
BigDecimal target
) {
String trimmedExpression = hysteresisExpression.trim();
if (trimmedExpression.isEmpty()) {
throw new IllegalArgumentException("Missing CROSSING hysteresis");
}
if (trimmedExpression.contains("+") || trimmedExpression.contains("-")) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be specified without '+' or '-': "
+ hysteresisExpression
);
}
boolean percentage = trimmedExpression.endsWith("%");
String distanceExpression = percentage
? trimmedExpression.substring(0, trimmedExpression.length() - 1)
: trimmedExpression;
BigDecimal distance;
try {
distance = new BigDecimal(distanceExpression);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Invalid CROSSING hysteresis: " + hysteresisExpression,
exception
);
}
if (distance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be zero or positive: "
+ hysteresisExpression
);
}
return percentage
? target.multiply(distance).movePointLeft(2)
: distance;
}
private static PriceCondition parseRange(String rangeExpression) {
char lowerDelimiter = rangeExpression.charAt(0);
char upperDelimiter = rangeExpression.charAt(rangeExpression.length() - 1);
@@ -99,6 +99,12 @@ public final class AlarmConfigurationParser {
AlarmTrigger trigger
) {
if (trigger != AlarmTrigger.CROSSING) {
if (conditionExpression.contains("/")) {
throw new IllegalArgumentException(
"Hysteresis is only supported for CROSSING conditions: "
+ conditionExpression
);
}
return;
}
@@ -0,0 +1,62 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.math.BigDecimal;
import java.util.Objects;
public record CrossingPriceCondition(
ComparisonPriceCondition comparison,
BigDecimal hysteresis
) implements PriceCondition {
public CrossingPriceCondition {
Objects.requireNonNull(comparison, "comparison");
Objects.requireNonNull(hysteresis, "hysteresis");
if (comparison.operator() != PriceComparisonOperator.LESS_THAN
&& comparison.operator() != PriceComparisonOperator.GREATER_THAN) {
throw new IllegalArgumentException(
"CROSSING requires a '<' or '>' comparison"
);
}
if (hysteresis.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"CROSSING hysteresis must be zero or positive: " + hysteresis
);
}
}
@Override
public boolean matches(BigDecimal price) {
return comparison.matches(price);
}
public boolean matchesRearmCondition(BigDecimal price) {
BigDecimal rearmTarget = switch (comparison.operator()) {
case GREATER_THAN -> comparison.target().subtract(hysteresis);
case LESS_THAN -> comparison.target().add(hysteresis);
default -> throw new IllegalStateException(
"Unsupported CROSSING operator: " + comparison.operator()
);
};
return switch (comparison.operator()) {
case GREATER_THAN -> price.compareTo(rearmTarget) <= 0;
case LESS_THAN -> price.compareTo(rearmTarget) >= 0;
default -> throw new IllegalStateException(
"Unsupported CROSSING operator: " + comparison.operator()
);
};
}
@Override
public String expression() {
if (hysteresis.compareTo(BigDecimal.ZERO) == 0) {
return comparison.expression();
}
return comparison.expression()
+ "/"
+ hysteresis.stripTrailingZeros().toPlainString();
}
}
@@ -163,9 +163,15 @@ public final class JupiterPerpsAlarmImpl {
) {
for (PriceAlarmDefinition definition : definitions) {
try {
AlarmConditionParser.parse(
variableResolver.resolve(definition.conditionExpression())
String resolvedConditionExpression = variableResolver.resolve(
definition.conditionExpression()
);
if (definition.trigger() == AlarmTrigger.CROSSING) {
AlarmConditionParser.parseCrossing(resolvedConditionExpression);
} else {
AlarmConditionParser.parse(resolvedConditionExpression);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
"Invalid condition for alarm "
@@ -23,9 +23,13 @@ public final class PriceAlarm {
PriceCondition condition;
try {
condition = AlarmConditionParser.parse(
variableResolver.resolve(definition.conditionExpression())
String resolvedConditionExpression = variableResolver.resolve(
definition.conditionExpression()
);
condition = definition.trigger() == AlarmTrigger.CROSSING
? AlarmConditionParser.parseCrossing(resolvedConditionExpression)
: AlarmConditionParser.parse(resolvedConditionExpression);
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve condition for alarm %d: %s%n",
@@ -35,16 +39,17 @@ public final class PriceAlarm {
return;
}
if (definition.trigger() == AlarmTrigger.CROSSING) {
acceptCrossing(price, (CrossingPriceCondition) condition);
return;
}
boolean reached = condition.matches(price.priceUsd());
boolean enteredTriggeredSide = previousReached == null
? reached
: reached && !previousReached;
boolean crossedIntoTriggeredSide = previousReached != null
&& reached
&& !previousReached;
previousReached = reached;
if (!reached) {
@@ -61,13 +66,6 @@ public final class PriceAlarm {
return;
}
if (definition.trigger() == AlarmTrigger.CROSSING) {
if (crossedIntoTriggeredSide) {
trigger(price, condition);
}
return;
}
if (definition.trigger() == AlarmTrigger.PERSISTENT) {
if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) {
trigger(price, condition);
@@ -78,6 +76,30 @@ public final class PriceAlarm {
throw new IllegalStateException("Unsupported alarm trigger: " + definition.trigger());
}
private void acceptCrossing(
OraclePrice price,
CrossingPriceCondition condition
) {
boolean reached = condition.matches(price.priceUsd());
if (crossingArmed == null) {
crossingArmed = !reached;
return;
}
if (crossingArmed) {
if (reached) {
crossingArmed = false;
trigger(price, condition);
}
return;
}
if (condition.matchesRearmCondition(price.priceUsd())) {
crossingArmed = true;
}
}
public PriceAlarmDefinition definition() {
return definition;
}
@@ -122,6 +144,7 @@ public final class PriceAlarm {
private final AlarmAction action;
private Instant lastTriggeredAt;
private Boolean crossingArmed;
private Boolean previousReached;
private long triggerCount;
}