46: Support human-readable time units for PERSISTENT alarm grace periods

This commit is contained in:
2026-08-05 10:12:39 +02:00
parent 5afac819d8
commit f5c449fe23
6 changed files with 101 additions and 53 deletions
+20 -5
View File
@@ -131,8 +131,8 @@ ID ASSET CONDITION TRIGGER
Example: Example:
```text ```text
4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
``` ```
Supported column values: 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. - `ID`: integer alarm identifier. IDs should be unique. Action files use this ID to select alarms.
- `ASSET`: `SOL`, `ETH`, or `BTC`. - `ASSET`: `SOL`, `ETH`, or `BTC`.
- `CONDITION`: a comparison or range expression. It must be one token with no whitespace. - `CONDITION`: a comparison or range expression. It must be one token with no whitespace.
- `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:<seconds>`. - `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:<duration>`.
A trailing comment is allowed after a variable value or trigger. 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. - The first accepted price establishes the initial state and never triggers the alarm.
- Remaining inside the condition does not trigger again. - Remaining inside the condition does not trigger again.
- Leaving the condition re-arms the alarm, so the next entry triggers again. - Leaving the condition re-arms the alarm, so the next entry triggers again.
- A grace period is not supported; `CROSSING:<seconds>` is rejected. - A grace period is not supported; `CROSSING:1m` is rejected.
The condition determines the crossing direction. For example: 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. - Triggers immediately on the first accepted matching price.
- With no grace period, it can trigger for every accepted matching price event. - With no grace period, it can trigger for every accepted matching price event.
- `PERSISTENT:<seconds>` limits repeated triggering to at most once per configured interval while the condition remains satisfied. - `PERSISTENT:<duration>` 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. - 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. 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 ## Dynamic price-variable refresh
+18 -18
View File
@@ -20,31 +20,31 @@
################################################################################################ ################################################################################################
# SOL SHORT # SOL SHORT
1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# SOL LONG # SOL LONG
4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
# ETH SHORT # ETH SHORT
7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# ETH LONG # ETH LONG
10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:60 10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
# BTC SHORT # BTC SHORT
13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:1m
14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:1h
15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:1h
# BTC LONG # BTC LONG
16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:60 16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:1m
17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:1h
18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:1h
@@ -4,6 +4,8 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class AlarmConfigurationParser { public final class AlarmConfigurationParser {
@@ -91,41 +93,24 @@ public final class AlarmConfigurationParser {
String normalized = triggerText.toUpperCase(Locale.ROOT); String normalized = triggerText.toUpperCase(Locale.ROOT);
if (normalized.equals("ONETIME")) { if (normalized.equals("ONETIME")) {
return new TriggerConfiguration(AlarmTrigger.ONETIME, 0); return new TriggerConfiguration(AlarmTrigger.ONETIME, 0L);
} }
if (normalized.equals("CROSSING")) { if (normalized.equals("CROSSING")) {
return new TriggerConfiguration(AlarmTrigger.CROSSING, 0); return new TriggerConfiguration(AlarmTrigger.CROSSING, 0L);
} }
if (normalized.equals("PERSISTENT")) { if (normalized.equals("PERSISTENT")) {
return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0); return new TriggerConfiguration(AlarmTrigger.PERSISTENT, 0L);
} }
if (normalized.startsWith("PERSISTENT:")) { if (normalized.startsWith("PERSISTENT:")) {
String graceText = normalized.substring("PERSISTENT:".length()); String graceText = triggerText.substring("PERSISTENT:".length());
if (graceText.isEmpty()) { return new TriggerConfiguration(
throw new IllegalArgumentException("Missing persistent grace period: " + triggerText); AlarmTrigger.PERSISTENT,
} parsePersistentGracePeriod(graceText)
);
Ω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);
} }
if (normalized.startsWith("ONETIME:")) { if (normalized.startsWith("ONETIME:")) {
@@ -139,6 +124,51 @@ public final class AlarmConfigurationParser {
throw new IllegalArgumentException("Unknown trigger: " + triggerText); 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 static final class Cursor {
private Cursor(String line) { private Cursor(String line) {
this.line = line; this.line = line;
@@ -245,10 +275,13 @@ public final class AlarmConfigurationParser {
private record TriggerConfiguration( private record TriggerConfiguration(
AlarmTrigger trigger, 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() { private AlarmConfigurationParser() {
} }
} }
@@ -91,14 +91,14 @@ public final class PriceAlarm {
return true; return true;
} }
ΩsecondsΩ gracePeriod = definition.triggerGracePeriod(); ΩmilliSecondsΩ gracePeriod = definition.triggerGracePeriod();
if (gracePeriod == 0) { if (gracePeriod == 0) {
return true; return true;
} }
return !Instant.now().isBefore( return !Instant.now().isBefore(
lastTriggeredAt.plusSeconds(gracePeriod) lastTriggeredAt.plusMillis(gracePeriod)
); );
} }
@@ -7,7 +7,7 @@ public record PriceAlarmDefinition(
JupiterPerpsAsset asset, JupiterPerpsAsset asset,
String conditionExpression, String conditionExpression,
AlarmTrigger trigger, AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod ΩmilliSecondsΩ triggerGracePeriod
) { ) {
public PriceAlarmDefinition { public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset"); Objects.requireNonNull(asset, "asset");
@@ -5,6 +5,6 @@ public record ResolvedPriceAlarm(
JupiterPerpsAsset asset, JupiterPerpsAsset asset,
PriceCondition condition, PriceCondition condition,
AlarmTrigger trigger, AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod ΩmilliSecondsΩ triggerGracePeriod
) { ) {
} }