45: Add hysteresis support to CROSSING alarms
This commit is contained in:
+15
-2
@@ -224,12 +224,14 @@ On the first accepted oracle price after startup, an already-satisfied `ONETIME`
|
||||
- Triggers when the condition changes from not satisfied to satisfied.
|
||||
- The first accepted price establishes the initial state and never triggers the alarm.
|
||||
- Remaining inside the condition does not trigger again.
|
||||
- Leaving the condition re-arms the alarm, so the next entry triggers again.
|
||||
- Without hysteresis, leaving the condition re-arms the alarm, so the next entry triggers again.
|
||||
- An optional hysteresis can be appended to the condition as `/H` or `/P%`.
|
||||
- With hysteresis, the alarm remains disarmed until the price reaches the re-arm boundary.
|
||||
- A grace period is not supported; `CROSSING:1m` is rejected.
|
||||
- Only the strict directional comparisons `<` and `>` are supported.
|
||||
- `=`, `<=`, `>=`, and range conditions are rejected during configuration parsing.
|
||||
|
||||
The condition determines the crossing direction. For example:
|
||||
The condition determines the crossing direction. Without hysteresis:
|
||||
|
||||
```text
|
||||
19 SOL >{{SOL_LONG_ENTRY_PRICE}} CROSSING
|
||||
@@ -238,6 +240,17 @@ The condition determines the crossing direction. For example:
|
||||
|
||||
Alarm 19 triggers when the price moves from at or below the entry price to above it. Alarm 20 triggers when the price moves from at or above the entry price to below it. Landing exactly on the entry price is not a crossing.
|
||||
|
||||
Hysteresis prevents repeated triggers when the price fluctuates around the target:
|
||||
|
||||
```text
|
||||
21 SOL >{{SOL_LONG_ENTRY_PRICE}}/0.25 CROSSING
|
||||
22 SOL <{{SOL_LONG_ENTRY_PRICE}}/0.25% CROSSING
|
||||
```
|
||||
|
||||
Alarm 21 triggers above the entry price and re-arms only at or below `entry price - 0.25`. Alarm 22 triggers below the entry price and re-arms only at or above `entry price + 0.25%`.
|
||||
|
||||
Percentage hysteresis is calculated from the fully resolved comparison target. Hysteresis is an unsigned distance, so a leading `+` or `-` is rejected. Hysteresis is supported only for `CROSSING` alarms.
|
||||
|
||||
### `PERSISTENT`
|
||||
|
||||
- Triggers immediately on the first accepted matching price.
|
||||
|
||||
@@ -48,3 +48,7 @@
|
||||
16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
|
||||
17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
|
||||
18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
|
||||
|
||||
# CROSSING with hysteresis
|
||||
# Triggers above the entry price and re-arms at or below entry price minus 0.25%
|
||||
#19 SOL >{{SOL_LONG_ENTRY_PRICE}}/0.25% CROSSING
|
||||
|
||||
@@ -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