diff --git a/README_alarm.md b/README_alarm.md index 2ab1dfe..1aed01c 100644 --- a/README_alarm.md +++ b/README_alarm.md @@ -131,8 +131,8 @@ ID ASSET CONDITION TRIGGER Example: ```text -4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 -5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 +4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m +5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h ``` Supported column values: @@ -140,7 +140,7 @@ Supported column values: - `ID`: integer alarm identifier. IDs should be unique. Action files use this ID to select alarms. - `ASSET`: `SOL`, `ETH`, or `BTC`. - `CONDITION`: a comparison or range expression. It must be one token with no whitespace. -- `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:`. +- `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:`. A trailing comment is allowed after a variable value or trigger. @@ -225,7 +225,7 @@ On the first accepted oracle price after startup, an already-satisfied `ONETIME` - 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. -- A grace period is not supported; `CROSSING:` is rejected. +- A grace period is not supported; `CROSSING:1m` is rejected. The condition determines the crossing direction. For example: @@ -240,9 +240,24 @@ Alarm 19 triggers when the price enters the profitable side from below. Alarm 20 - Triggers immediately on the first accepted matching price. - With no grace period, it can trigger for every accepted matching price event. -- `PERSISTENT:` limits repeated triggering to at most once per configured interval while the condition remains satisfied. +- `PERSISTENT:` limits repeated triggering to at most once per configured interval while the condition remains satisfied. - Leaving and re-entering a condition does not bypass the grace period measured from the previous trigger. +A duration is a non-negative whole number followed by exactly one suffix: + +| Suffix | Unit | +|---|---| +| `ms` | millisecond | +| `s` | second | +| `m` | minute | +| `h` | hour | +| `d` | day | +| `w` | week | +| `M` | month (30 days) | +| `y` | year (365 days) | + +Suffixes are case-sensitive, so `m` means minute and `M` means month. Combined durations such as `1w3d` are not supported. + Duplicate price events received from redundant RPC endpoints are suppressed using the raw price, exponent, and oracle timestamp. Up to 512 recent event keys are retained per asset monitor. ## Dynamic price-variable refresh diff --git a/conf/alarms.conf.example b/conf/alarms.conf.example index 05cc7d7..8e37772 100644 --- a/conf/alarms.conf.example +++ b/conf/alarms.conf.example @@ -20,31 +20,31 @@ ################################################################################################ # SOL SHORT -1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 -2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 -3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 +1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m +2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h +3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h # SOL LONG -4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 -5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 -6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 +4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m +5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h +6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h # ETH SHORT -7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 -8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 -9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 +7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m +8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h +9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h # ETH LONG -10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:60 -11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 -12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 +10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:1m +11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h +12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h # BTC SHORT -13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 -14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 -15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 +13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m +14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h +15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h # BTC LONG -16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:60 -17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 -18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 +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 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 c540faa..aea6dee 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava @@ -4,6 +4,8 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class AlarmConfigurationParser { @@ -91,41 +93,24 @@ public final class AlarmConfigurationParser { String normalized = triggerText.toUpperCase(Locale.ROOT); if (normalized.equals("ONETIME")) { - return new TriggerConfiguration(AlarmTrigger.ONETIME, 0); + return new TriggerConfiguration(AlarmTrigger.ONETIME, 0L); } if (normalized.equals("CROSSING")) { - return new TriggerConfiguration(AlarmTrigger.CROSSING, 0); + return new TriggerConfiguration(AlarmTrigger.CROSSING, 0L); } if (normalized.equals("PERSISTENT")) { - return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0); + return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0L); } if (normalized.startsWith("PERSISTENT:")) { - String graceText = normalized.substring("PERSISTENT:".length()); + String graceText = triggerText.substring("PERSISTENT:".length()); - if (graceText.isEmpty()) { - throw new IllegalArgumentException("Missing persistent grace period: " + triggerText); - } - - ΩsecondsΩ gracePeriodSeconds; - try { - gracePeriodSeconds = Integer.parseInt(graceText); - } catch (NumberFormatException exception) { - throw new IllegalArgumentException( - "Invalid persistent grace period: " + triggerText, - exception - ); - } - - if (gracePeriodSeconds < 0) { - throw new IllegalArgumentException( - "Persistent grace period cannot be negative: " + triggerText - ); - } - - return new TriggerConfiguration(AlarmTrigger.PERSISTENT, gracePeriodSeconds); + return new TriggerConfiguration( + AlarmTrigger.PERSISTENT, + parsePersistentGracePeriod(graceText) + ); } if (normalized.startsWith("ONETIME:")) { @@ -139,6 +124,51 @@ public final class AlarmConfigurationParser { throw new IllegalArgumentException("Unknown trigger: " + triggerText); } + private static ΩmilliSecondsΩ parsePersistentGracePeriod(String graceText) { + Matcher matcher = PERSISTENT_GRACE_PERIOD_PATTERN.matcher(graceText); + + if (!matcher.matches()) { + throw invalidPersistentGracePeriod(graceText); + } + + try { + long value = Long.parseLong(matcher.group(1)); + long millisecondsPerUnit = switch (matcher.group(2)) { + case "ms" -> 1L; + case "s" -> 1_000L; + case "m" -> 60_000L; + case "h" -> 3_600_000L; + case "d" -> 86_400_000L; + case "w" -> 604_800_000L; + case "M" -> 2_592_000_000L; + case "y" -> 31_536_000_000L; + default -> throw new AssertionError("Unsupported grace period suffix"); + }; + + return Math.multiplyExact(value, millisecondsPerUnit); + } catch (NumberFormatException | ArithmeticException exception) { + throw invalidPersistentGracePeriod(graceText, exception); + } + } + + private static IllegalArgumentException invalidPersistentGracePeriod( + String graceText + ) { + return invalidPersistentGracePeriod(graceText, null); + } + + private static IllegalArgumentException invalidPersistentGracePeriod( + String graceText, + Exception cause + ) { + return new IllegalArgumentException( + "Invalid persistent grace period '" + graceText + + "'. Expected a non-negative whole number followed by one of: " + + "ms, s, m, h, d, w, M, y", + cause + ); + } + private static final class Cursor { private Cursor(String line) { this.line = line; @@ -245,10 +275,13 @@ public final class AlarmConfigurationParser { private record TriggerConfiguration( AlarmTrigger trigger, - ΩsecondsΩ gracePeriod + ΩmilliSecondsΩ gracePeriod ) { } + private static final Pattern PERSISTENT_GRACE_PERIOD_PATTERN = + Pattern.compile("(\\d+)(ms|s|m|h|d|w|M|y)"); + private AlarmConfigurationParser() { } } 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 591eff7..f6684dc 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava @@ -91,14 +91,14 @@ public final class PriceAlarm { return true; } - ΩsecondsΩ gracePeriod = definition.triggerGracePeriod(); + ΩmilliSecondsΩ gracePeriod = definition.triggerGracePeriod(); if (gracePeriod == 0) { return true; } return !Instant.now().isBefore( - lastTriggeredAt.plusSeconds(gracePeriod) + lastTriggeredAt.plusMillis(gracePeriod) ); } diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava index a10fa84..6042e4a 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava @@ -7,7 +7,7 @@ public record PriceAlarmDefinition( JupiterPerpsAsset asset, String conditionExpression, AlarmTrigger trigger, - ΩsecondsΩ triggerGracePeriod + ΩmilliSecondsΩ triggerGracePeriod ) { public PriceAlarmDefinition { Objects.requireNonNull(asset, "asset"); diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava index 2665d0b..a9edd8b 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava @@ -5,6 +5,6 @@ public record ResolvedPriceAlarm( JupiterPerpsAsset asset, PriceCondition condition, AlarmTrigger trigger, - ΩsecondsΩ triggerGracePeriod + ΩmilliSecondsΩ triggerGracePeriod ) { }