From 4cc14ceef59ad5eeb4ff19cd1110dd2fb9651d56 Mon Sep 17 00:00:00 2001 From: Minimons Date: Mon, 27 Jul 2026 12:25:23 +0200 Subject: [PATCH] 45: Add hysteresis support to CROSSING alarms --- README_alarm.md | 17 +++- conf/alarms.conf.example | 4 + .../impl/ref/AlarmConditionParser.tjava | 78 +++++++++++++++++++ .../impl/ref/AlarmConfigurationParser.tjava | 6 ++ .../impl/ref/CrossingPriceCondition.tjava | 62 +++++++++++++++ .../impl/ref/JupiterPerpsAlarmImpl.tjava | 10 ++- .../impl/ref/PriceAlarm.tjava | 49 ++++++++---- 7 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/CrossingPriceCondition.tjava diff --git a/README_alarm.md b/README_alarm.md index 0373d15..9b46841 100644 --- a/README_alarm.md +++ b/README_alarm.md @@ -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. diff --git a/conf/alarms.conf.example b/conf/alarms.conf.example index 8e37772..3a63ad7 100644 --- a/conf/alarms.conf.example +++ b/conf/alarms.conf.example @@ -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 diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava index 5022d7f..befdfd3 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava @@ -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); diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava index a0541c4..29b99cd 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava @@ -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; } diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/CrossingPriceCondition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/CrossingPriceCondition.tjava new file mode 100644 index 0000000..ff1e5f6 --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/CrossingPriceCondition.tjava @@ -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(); + } +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava index f881508..e3bc2db 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava @@ -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 " diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava index f6684dc..294472b 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava @@ -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; }