45: Add hysteresis support to CROSSING alarms
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user