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:
```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:<seconds>`.
- `TRIGGER`: `ONETIME`, `CROSSING`, `PERSISTENT`, or `PERSISTENT:<duration>`.
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:<seconds>` 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:<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.
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
+18 -18
View File
@@ -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
@@ -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() {
}
}
@@ -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)
);
}
@@ -7,7 +7,7 @@ public record PriceAlarmDefinition(
JupiterPerpsAsset asset,
String conditionExpression,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod
ΩmilliSecondsΩ triggerGracePeriod
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
@@ -5,6 +5,6 @@ public record ResolvedPriceAlarm(
JupiterPerpsAsset asset,
PriceCondition condition,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod
ΩmilliSecondsΩ triggerGracePeriod
) {
}