From 7266a8c2dd5bebf5dd53f6f70edffe6b51af58af Mon Sep 17 00:00:00 2001 From: Minimons Date: Mon, 20 Jul 2026 14:56:44 +0200 Subject: [PATCH] 34: Combine alarm direction and target into one condition --- README_alarm.md | 343 ++++++++++++++---- conf/alarms.conf.example | 38 +- .../impl/ref/AlarmConditionParser.tjava | 91 +++++ .../impl/ref/AlarmConfigurationParser.tjava | 24 +- .../impl/ref/ComparisonPriceCondition.tjava | 25 ++ .../impl/ref/ConsoleAlarmAction.tjava | 5 +- .../impl/ref/JupiterPerpsAlarmImpl.tjava | 32 +- .../impl/ref/PriceAlarm.tjava | 23 +- .../impl/ref/PriceAlarmDefinition.tjava | 10 +- .../impl/ref/PriceComparisonOperator.tjava | 42 +++ .../impl/ref/PriceCondition.tjava | 10 + .../impl/ref/PriceDirection.tjava | 20 - .../impl/ref/PushoverAlarmAction.tjava | 5 +- .../impl/ref/RangePriceCondition.tjava | 54 +++ .../impl/ref/ResolvedPriceAlarm.tjava | 6 +- 15 files changed, 574 insertions(+), 154 deletions(-) create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ComparisonPriceCondition.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceComparisonOperator.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceCondition.tjava delete mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceDirection.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/RangePriceCondition.tjava diff --git a/README_alarm.md b/README_alarm.md index c011e15..151b0e7 100644 --- a/README_alarm.md +++ b/README_alarm.md @@ -1,122 +1,315 @@ # Jupiter Perps Price Alarm -A small Java program that listens to Jupiter Perps' on-chain aggregated oracle accounts through Solana WebSocket `accountSubscribe`. +The Jupiter Perps Price Alarm monitors the Jupiter Perps aggregated oracle accounts for SOL, ETH, and BTC. It evaluates configurable price conditions and dispatches matching alarms to the console, an optional Pushover account, and the currently hard-coded Jupiter position-increase action. -It does **not** poll once per second. Every account update observed by the connected RPC node is decoded immediately. The program reconnects automatically, performs an initial/reconnect state fetch, and can connect to multiple independent RPC endpoints for redundancy. +Oracle prices are received through Solana WebSocket `accountSubscribe` using `processed` commitment. The application does not poll prices once per second. It also fetches the current oracle account state whenever a WebSocket connection opens, reconnects automatically, and supports multiple RPC endpoints for redundancy. -The program supports any number of alarms for SOL, ETH, and BTC. Only one oracle stream is created per configured asset and RPC endpoint, regardless of how many alarms use that asset. +> **Important:** This application is not currently notification-only. Alarm IDs `4` and `5` can sign and submit real Jupiter Perps position-increase transactions. See [Automatic position increase](#automatic-position-increase) before running it. -## Java version +## Startup flow -The project uses the current JDK compiler but generates Java 17-compatible class files: +The application performs the following steps before it starts monitoring prices: -```gradle -tasks.withType(JavaCompile).configureEach { - options.release = 17 -} +1. Parse command-line options and the alarm configuration file. +2. Fetch the wallet's current open Jupiter Perps positions. +3. Populate entry-price and liquidation-price variables. +4. Resolve and validate every alarm condition. +5. Start the periodic variable refresher and file-trigger watcher. +6. Configure console, automatic Jupiter, and optional Pushover actions. +7. Open one oracle WebSocket connection per configured asset and RPC endpoint. + +Startup fails before WebSockets and alarm actions are started if a condition contains an unresolved variable, invalid syntax, or an invalid range. + +## Requirements and build + +The project uses: + +- JDK 25 +- Gradle 9.3.1 through the Gradle wrapper +- Java preview features +- Detag-generated Java sources from `src/main/tjava` + +Compile the project with: + +```bash +./gradlew classes ``` -## Alarm configuration +To create the application JAR and collect runtime dependencies in `build/libs`: -The default file is `price-alarms.conf`: +```bash +./gradlew prepareLibs +``` + +There is currently no alarm-specific automated self-test task. + +## Running the alarm application + +The alarm entry point is: ```text -# Asset Direction Target TRIGGER SEVERITY NOTE -################################################################################################## -SOL ABOVE 75.7 ONETIME 2 "ALARM: Risiko for Perps Solana short LIKVIDERING!" -SOL BELOW 60.8 ONETIME 2 "ALARM: Risiko for Perps Solana long LIKVIDERING!" -SOL BELOW 71.4 ONETIME 2 "ALARM: Risiko for Solana Raydium LΓ…N LIKVIDERING!" -ETH ABOVE 1848.41 ONETIME 2 "ALARM: Risiko for Perps Ethereum short LIKVIDERING!" -ETH BELOW 1789 ONETIME 1 "OK: Perps Ethereum short er lukket!" +com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl ``` -Supported values: +The Gradle application plugin currently points at the Nenjim Hub main class, so `./gradlew run` does **not** directly start the alarm application. Run `JupiterPerpsAlarmImpl` from the IDE, or run the prepared JAR and dependencies explicitly: -- Asset: `SOL`, `ETH`, or `BTC` -- Direction: `ABOVE` or `BELOW` -- Trigger: `ONETIME` or `PERSISTENT` -- Target: positive decimal USD price -- Severity: zero or positive integer -- Note: quoted text; escaped quotes can be written as `\"` +```bash +java --enable-preview \ + -cp "build/libs/*" \ + com.r35157.jupiterperpsalarm.impl.ref.JupiterPerpsAlarmImpl \ + --config=conf/alarms.conf +``` -`SEVERITY` and `NOTE` are parsed and retained in `PriceAlarmDefinition`, but are intentionally not used by the alarm actions yet. +The repository contains [the example configuration](conf/alarms.conf.example). Keep the real configuration outside version control if it contains operational wallet details. + +### Command-line options and environment variables + +| Purpose | Command-line option | Environment variable | Default | +|---|---|---|---| +| Alarm configuration | `--config=` | `PRICE_ALARMS_CONFIG` | `price-alarms.conf` | +| Solana WebSocket endpoints | `--ws=` | `SOLANA_WS_URLS` | `wss://api.mainnet-beta.solana.com` | +| Pushover application token | β€” | `PUSHOVER_APP_TOKEN` | Disabled | +| Pushover user/group key | β€” | `PUSHOVER_USER_KEY` | Disabled | + +The command-line option takes precedence over its environment variable. Pushover is enabled only when both Pushover variables are non-blank. + +The variable-refresh file watcher uses the configuration file's parent directory. Until that is changed, use a configuration path with an explicit directory component, such as `conf/alarms.conf` or `./price-alarms.conf`, rather than relying on the bare default filename. + +## Configuration format + +Blank lines and lines beginning with `#` are ignored. The file contains variable definitions and alarm definitions. + +### Variable definitions + +A variable definition consists of a name and a value: + +```text +{{JUPITER_PERPS_WALLET}} vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf +``` + +Variable names must use uppercase letters, digits, and underscores. Variables can be referenced in conditions and notes as `{{NAME}}`. + +`JUPITER_PERPS_WALLET` is required for the initial and periodic Jupiter position fetches. For open SOL, BTC, and ETH positions, the refresher supplies these dynamic variables: + +```text +{{SOL_LONG_ENTRY_PRICE}} {{SOL_LONG_LIQ_PRICE}} +{{SOL_SHORT_ENTRY_PRICE}} {{SOL_SHORT_LIQ_PRICE}} +{{BTC_LONG_ENTRY_PRICE}} {{BTC_LONG_LIQ_PRICE}} +{{BTC_SHORT_ENTRY_PRICE}} {{BTC_SHORT_LIQ_PRICE}} +{{ETH_LONG_ENTRY_PRICE}} {{ETH_LONG_LIQ_PRICE}} +{{ETH_SHORT_ENTRY_PRICE}} {{ETH_SHORT_LIQ_PRICE}} +``` + +Only variables for positions returned by Jupiter are populated. Startup validation fails if an alarm references a variable that is unavailable after the initial position fetch. + +### Alarm definitions + +Each alarm is one line with six columns: + +```text +ID ASSET CONDITION TRIGGER SEVERITY "NOTE" +``` + +Example: + +```text +4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 SOL Long add 1.1x" +5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 INFO "🌱 SOL Long add 3x" +``` + +Supported column values: + +- `ID`: integer alarm identifier. IDs should be unique. IDs `4` and `5` have special transaction behavior in the current implementation. +- `ASSET`: `SOL`, `ETH`, or `BTC`. +- `CONDITION`: a comparison or range expression. It must be one token with no whitespace. +- `TRIGGER`: `ONETIME`, `PERSISTENT`, or `PERSISTENT:`. +- `SEVERITY`: `EMERGENCY`, `CRITICAL`, `WARN`, `INFO`, `SILENT`, or `GHOST`. +- `NOTE`: quoted text. Supported escapes include `\n`, `\r`, `\t`, `\"`, and `\\`. + +A trailing comment is allowed after a variable value or the quoted note. + +## Condition expressions + +A condition is evaluated against the current oracle price in USD. Conditions are resolved again for every accepted price event, so periodically refreshed entry and liquidation prices take effect without restarting the application. + +The complete condition must not contain whitespace. + +### Target expressions + +Each target can be: + +```text +75.50 +{{SOL_LONG_LIQ_PRICE}} +{{SOL_LONG_LIQ_PRICE}}+1% +{{SOL_SHORT_LIQ_PRICE}}-3% +``` + +`BASE+P%` adds `P` percent of `BASE`; `BASE-P%` subtracts it. The base, percentage, and resulting target must be zero or positive. General arithmetic expressions are not supported. + +### Comparisons + +| Syntax | Matches when | +|---|---| +| `X` | `price > X` | +| `>=X` | `price >= X` | + +Examples: + +```text +<={{SOL_LONG_LIQ_PRICE}}+1% +>={{SOL_SHORT_LIQ_PRICE}}-1% +``` + +### Ranges + +Ranges use `-->` between a lower and an upper target. Parentheses exclude a boundary; square brackets include it. + +| Syntax | Matches when | +|---|---| +| `(A-->B)` | `A < price < B` | +| `[A-->B)` | `A <= price < B` | +| `(A-->B]` | `A < price <= B` | +| `[A-->B]` | `A <= price <= B` | + +The lower resolved target must not exceed the upper resolved target. + +Example of two adjacent, non-overlapping long-position conditions: + +```text +<={{SOL_LONG_LIQ_PRICE}}+1% +({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] +``` + +At exactly `LIQ+1%`, only the first condition matches. The corresponding short-position split is: + +```text +>={{SOL_SHORT_LIQ_PRICE}}-1% +[{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) +``` ## Trigger behavior -On the first received price after program start: +On the first accepted oracle price after startup or reconnect, an already-satisfied alarm can trigger immediately. -- An already satisfied alarm triggers immediately. -- An unsatisfied alarm waits for the price to cross into its triggered side. +### `ONETIME` -Alarm state is retained across WebSocket reconnects within the same process. If the price moves from the safe side to the triggered side during a connection outage, the first price received after reconnect will therefore trigger the alarm. +- Triggers on the first accepted price that satisfies the condition. +- Triggers at most once during the current process lifetime. +- Leaving and re-entering the condition does not re-arm it. +- Restarting the application re-arms it because alarm state is held only in memory. -After that: +### `PERSISTENT` -- `ONETIME` triggers only once during the current program run. -- `PERSISTENT` triggers each time the price crosses from the safe side into the triggered side. -- Remaining on the triggered side does not repeatedly fire the alarm. +- 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. +- Leaving and re-entering a condition does not bypass the grace period measured from the previous trigger. -`ONETIME` state is currently kept in memory. Restarting the process arms the alarm again. +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. -## Build and test +## Dynamic price-variable refresh + +Entry-price and liquidation-price variables are refreshed: + +- once during startup, before condition validation; +- every 60 seconds in a daemon task; +- when `jupiter-perps-alarm-var.refresh` is created in the configuration directory. + +For example: ```bash -gradle classes -gradle run --args='--self-test' +touch conf/jupiter-perps-alarm-var.refresh ``` -The self-test covers: +The watcher detects creation of the file, refreshes the variables, and then deletes the trigger file. -- binary oracle decoding -- configuration parsing -- initial satisfied alarm behavior -- `ONETIME` behavior -- `PERSISTENT` crossing behavior +When a dynamic price variable changes, the application logs its old and new value. -## Run +## Alarm actions -Using the default `price-alarms.conf` in the working directory: +### Console -```bash -gradle run +Console output is always enabled. It includes the asset, current oracle price, resolved condition, trigger type, oracle time, slot, and source endpoint. + +Actions are executed sequentially through `CompositeAlarmAction`. A runtime failure in one action is logged so the remaining actions can still run. + +### Pushover + +Pushover is enabled when both `PUSHOVER_APP_TOKEN` and `PUSHOVER_USER_KEY` are configured. + +| Severity | Pushover parameters | +|---|---| +| `EMERGENCY` | `priority=2`, `retry=30`, `expire=10800`, `sound=persistent` | +| `CRITICAL` | `priority=1`, `sound=spacealarm` | +| `WARN` | `priority=0`, `sound=siren` | +| `INFO` | `priority=0` | +| `SILENT` | `priority=-1` | +| `GHOST` | `priority=-2` | + +Pushover requests are sent asynchronously with a 15-second request timeout. Rejected requests and asynchronous failures are logged. + +### Automatic position increase + +The current reference implementation always registers `JupiterPerpsPositionIncreaseAlarmAction`. It performs no transaction for most alarms, but alarm IDs `4` and `5` have hard-coded live behavior: + +| Alarm ID | Asset/direction | USDC collateral | Position-size delta | Maximum slippage | +|---|---|---:|---:|---:| +| `4` | SOL long | `0.25` | `2.50` USD | `200` bps | +| `5` | SOL long | `0.25` | `6.25` USD | `200` bps | + +For these IDs, the action builds a Jupiter position-increase transaction, signs it by running the external `jup sign` command with key name `evelyn-prod`, verifies that the returned signer matches the hard-coded wallet address, and submits the signed transaction. + +The hard-coded wallet address is: + +```text +vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf ``` -Using another file: +Changing which alarm uses ID `4` or `5` does not change this action's hard-coded SOL-long behavior. Treat those IDs as operationally significant, and do not run the application unless the wallet, signer key, amounts, slippage, and conditions have been reviewed. -```bash -gradle run --args='--config=/path/to/price-alarms.conf' -``` +## RPC redundancy and reconnect behavior -The path can also be selected through: - -```bash -export PRICE_ALARMS_CONFIG='/path/to/price-alarms.conf' -``` - -## Use two RPC WebSocket streams - -A single WebSocket/RPC provider is not a durable event log. For better resilience, provide two independent endpoints: +Provide multiple comma-separated WebSocket endpoints for redundancy: ```bash export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example' -gradle run ``` -With two configured assets and two RPC endpoints, the program opens four WebSocket connections. +The application creates one WebSocket connection for every configured asset/endpoint combination. With three configured assets and two endpoints, it creates six connections. -## Pushover emergency alarm +Each connection: -```bash -export PUSHOVER_APP_TOKEN='...' -export PUSHOVER_USER_KEY='...' -gradle run -``` +- subscribes with `processed` commitment and Base64 account encoding; +- fetches the current account state after opening; +- sends a heartbeat every 20 seconds; +- reconnects with exponential delays from 1 to 30 seconds. -The current implementation sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`. +## Validation and errors -## Important limitations +Structural configuration errors include the file path and line number. After the initial Jupiter position fetch, every condition is resolved and parsed before periodic refresh, WebSockets, and alarm actions are started. -- `processed` is intentionally used for minimum delay, but a processed update may belong to a fork that is later abandoned. -- Solana PubSub is not guaranteed delivery. Two independent RPC streams reduce, but do not eliminate, the risk of missing an update. -- The alarm reports the Jupiter Perps oracle price. It does not prove that a specific position was liquidated. -- This is an alerting aid, not a substitute for placing an on-platform stop-loss or reducing leverage. +Startup validation rejects, among other things: + +- unknown or unavailable variables; +- missing or unknown comparison operators; +- conditions split by whitespace; +- malformed ranges or multiple `-->` separators; +- missing range targets; +- negative target values or percentages; +- ranges whose lower target exceeds the upper target. + +At runtime, a later resolution/parsing failure is logged for the affected alarm and that price event is skipped for that alarm. + +## Current limitations + +- `processed` commitment minimizes delay but an observed update may belong to a fork that is later abandoned. +- Solana PubSub is not a durable event log and does not guarantee delivery. Multiple independent endpoints reduce, but do not eliminate, this risk. +- Alarm state and grace-period timestamps are kept only in memory and reset on restart. +- Dynamic variables exist only for currently returned open positions. +- Target expressions support only a decimal value with an optional single percentage adjustment; they are not a general expression language. +- The automatic position-increase action is hard-coded to one wallet, signer key, asset, direction, and two alarm IDs. +- A bare default configuration path has no parent directory for the refresh watcher; use an explicit path such as `./price-alarms.conf` or `conf/alarms.conf`. +- This application is a monitoring and automation aid, not a substitute for independent risk controls. \ No newline at end of file diff --git a/conf/alarms.conf.example b/conf/alarms.conf.example index 50080bf..2e2a226 100644 --- a/conf/alarms.conf.example +++ b/conf/alarms.conf.example @@ -16,35 +16,35 @@ #{{BTC_SHORT_LIQ_PRICE}} 63275.97 -# Id Asset Direction Target Trigger Severity Note +# Id Asset Condition Trigger Severity Note ################################################################################################################################# # SOL SHORT -1 SOL ABOVE {{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 SOL Short add 1.1x" -2 SOL ABOVE {{SOL_SHORT_LIQ_PRICE}}-3% PERSISTENT:3600 INFO "🌱 SOL Short add 3x" -3 SOL BELOW {{SOL_SHORT_ENTRY_PRICE}}-3% PERSISTENT:3600 INFO "πŸ’΅ SOL Short close" +1 SOL >={{SOL_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 SOL Short add 1.1x" +2 SOL [{{SOL_SHORT_LIQ_PRICE}}-3%-->{{SOL_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 INFO "🌱 SOL Short add 3x" +3 SOL <={{SOL_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 INFO "πŸ’΅ SOL Short close" # SOL LONG -4 SOL BELOW {{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 SOL Long add 1.1x" -5 SOL BELOW {{SOL_LONG_LIQ_PRICE}}+3% PERSISTENT:3600 INFO "🌱 SOL Long add 3x" -6 SOL ABOVE {{SOL_LONG_ENTRY_PRICE}}+3% PERSISTENT:3600 INFO "πŸ’΅ SOL Long close" +4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 SOL Long add 1.1x" +5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 INFO "🌱 SOL Long add 3x" +6 SOL >={{SOL_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 INFO "πŸ’΅ SOL Long close" # ETH SHORT -7 ETH ABOVE {{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 ETH Short add 1.1x" -8 ETH ABOVE {{ETH_SHORT_LIQ_PRICE}}-3% PERSISTENT:3600 INFO "🌱 ETH Short add 3x" -9 ETH BELOW {{ETH_SHORT_ENTRY_PRICE}}-3% PERSISTENT:3600 INFO "πŸ’΅ ETH Short close" +7 ETH >={{ETH_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 ETH Short add 1.1x" +8 ETH [{{ETH_SHORT_LIQ_PRICE}}-3%-->{{ETH_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 INFO "🌱 ETH Short add 3x" +9 ETH <={{ETH_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 INFO "πŸ’΅ ETH Short close" # ETH LONG -10 ETH BELOW {{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 ETH Long add 1.1x" -11 ETH BELOW {{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:3600 INFO "🌱 ETH Long add 3x" -12 ETH ABOVE {{ETH_LONG_ENTRY_PRICE}}+3% PERSISTENT:3600 INFO "πŸ’΅ ETH Long close" +10 ETH <={{ETH_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 ETH Long add 1.1x" +11 ETH ({{ETH_LONG_LIQ_PRICE}}+1%-->{{ETH_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 INFO "🌱 ETH Long add 3x" +12 ETH >={{ETH_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 INFO "πŸ’΅ ETH Long close" # BTC SHORT -13 BTC ABOVE {{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 BTC Short add 1.1x" -14 BTC ABOVE {{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:3600 INFO "🌱 BTC Short add 3x" -15 BTC BELOW {{BTC_SHORT_ENTRY_PRICE}}-3% PERSISTENT:3600 INFO "πŸ’΅ BTC Short close" +13 BTC >={{BTC_SHORT_LIQ_PRICE}}-1% PERSISTENT:60 CRITICAL "🚨 BTC Short add 1.1x" +14 BTC [{{BTC_SHORT_LIQ_PRICE}}-3%-->{{BTC_SHORT_LIQ_PRICE}}-1%) PERSISTENT:3600 INFO "🌱 BTC Short add 3x" +15 BTC <={{BTC_SHORT_ENTRY_PRICE}}-5% PERSISTENT:3600 INFO "πŸ’΅ BTC Short close" # BTC LONG -16 BTC BELOW {{BTC_LONG_LIQ_PRICE}}+0.5% PERSISTENT:60 CRITICAL "🚨 BTC Long add 1.1x" -17 BTC BELOW {{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:3600 INFO "🌱 BTC Long add 3x" -18 BTC ABOVE {{BTC_LONG_ENTRY_PRICE}}+3% PERSISTENT:3600 INFO "πŸ’΅ BTC Long close" \ No newline at end of file +16 BTC <={{BTC_LONG_LIQ_PRICE}}+1% PERSISTENT:60 CRITICAL "🚨 BTC Long add 1.1x" +17 BTC ({{BTC_LONG_LIQ_PRICE}}+1%-->{{BTC_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600 INFO "🌱 BTC Long add 3x" +18 BTC >={{BTC_LONG_ENTRY_PRICE}}+5% PERSISTENT:3600 INFO "πŸ’΅ BTC Long close" diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava new file mode 100644 index 0000000..5022d7f --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConditionParser.tjava @@ -0,0 +1,91 @@ +package com.r35157.jupiterperpsalarm.impl.ref; + +import java.math.BigDecimal; + +public final class AlarmConditionParser { + + private static final String RANGE_SEPARATOR = "-->"; + + private AlarmConditionParser() { + } + + public static PriceCondition parse(String conditionExpression) { + String trimmedExpression = conditionExpression.trim(); + + if (trimmedExpression.startsWith("[") || trimmedExpression.startsWith("(")) { + return parseRange(trimmedExpression); + } + + PriceComparisonOperator operator; + int operatorLength; + + if (trimmedExpression.startsWith("<=")) { + operator = PriceComparisonOperator.LESS_THAN_OR_EQUAL; + operatorLength = 2; + } else if (trimmedExpression.startsWith(">=")) { + operator = PriceComparisonOperator.GREATER_THAN_OR_EQUAL; + operatorLength = 2; + } else if (trimmedExpression.startsWith("<")) { + operator = PriceComparisonOperator.LESS_THAN; + operatorLength = 1; + } else if (trimmedExpression.startsWith(">")) { + operator = PriceComparisonOperator.GREATER_THAN; + operatorLength = 1; + } else { + throw new IllegalArgumentException( + "Unknown price condition operator: " + conditionExpression + ); + } + + String targetExpression = trimmedExpression.substring(operatorLength).trim(); + if (targetExpression.isEmpty()) { + throw new IllegalArgumentException( + "Missing target in price condition: " + conditionExpression + ); + } + + BigDecimal target = AlarmTargetParser.parse(targetExpression); + return new ComparisonPriceCondition(operator, target); + } + + private static PriceCondition parseRange(String rangeExpression) { + char lowerDelimiter = rangeExpression.charAt(0); + char upperDelimiter = rangeExpression.charAt(rangeExpression.length() - 1); + + if (upperDelimiter != ')' && upperDelimiter != ']') { + throw new IllegalArgumentException( + "Range must end with ')' or ']': " + rangeExpression + ); + } + + String rangeBody = rangeExpression.substring(1, rangeExpression.length() - 1); + int separatorIndex = rangeBody.indexOf(RANGE_SEPARATOR); + + if (separatorIndex < 0 || separatorIndex != rangeBody.lastIndexOf(RANGE_SEPARATOR)) { + throw new IllegalArgumentException( + "Range must contain exactly one '" + RANGE_SEPARATOR + "': " + rangeExpression + ); + } + + String lowerTargetExpression = rangeBody.substring(0, separatorIndex).trim(); + String upperTargetExpression = rangeBody + .substring(separatorIndex + RANGE_SEPARATOR.length()) + .trim(); + + if (lowerTargetExpression.isEmpty() || upperTargetExpression.isEmpty()) { + throw new IllegalArgumentException( + "Range must contain both a lower and an upper target: " + rangeExpression + ); + } + + BigDecimal lowerTarget = AlarmTargetParser.parse(lowerTargetExpression); + BigDecimal upperTarget = AlarmTargetParser.parse(upperTargetExpression); + + return new RangePriceCondition( + lowerTarget, + lowerDelimiter == '[', + upperTarget, + upperDelimiter == ']' + ); + } +} 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 37ffda1..f5ab202 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/AlarmConfigurationParser.tjava @@ -52,11 +52,7 @@ public final class AlarmConfigurationParser { cursor.nextToken("asset").toUpperCase(Locale.ROOT) ); - PriceDirection direction = PriceDirection.valueOf( - cursor.nextToken("direction").toUpperCase(Locale.ROOT) - ); - - String targetExpression = cursor.nextToken("target"); + String conditionExpression = parseConditionExpression(cursor); TriggerConfiguration triggerConfiguration = parseTrigger( cursor.nextToken("trigger") @@ -78,8 +74,7 @@ public final class AlarmConfigurationParser { return new PriceAlarmDefinition( id, asset, - direction, - targetExpression, + conditionExpression, triggerConfiguration.trigger(), triggerConfiguration.gracePeriod(), severity, @@ -87,6 +82,21 @@ public final class AlarmConfigurationParser { ); } + private static String parseConditionExpression(Cursor cursor) { + String conditionExpression = cursor.nextToken("condition"); + + if (conditionExpression.equals("<") + || conditionExpression.equals("<=") + || conditionExpression.equals(">") + || conditionExpression.equals(">=")) { + throw new IllegalArgumentException( + "Condition must not contain whitespace: " + conditionExpression + ); + } + + return conditionExpression; + } + private static TriggerConfiguration parseTrigger(String triggerText) { String normalized = triggerText.toUpperCase(Locale.ROOT); diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ComparisonPriceCondition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ComparisonPriceCondition.tjava new file mode 100644 index 0000000..3fe566f --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ComparisonPriceCondition.tjava @@ -0,0 +1,25 @@ +package com.r35157.jupiterperpsalarm.impl.ref; + +import java.math.BigDecimal; +import java.util.Objects; + +public record ComparisonPriceCondition( + PriceComparisonOperator operator, + BigDecimal target +) implements PriceCondition { + + public ComparisonPriceCondition { + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(target, "target"); + } + + @Override + public boolean matches(BigDecimal price) { + return operator.matches(price, target); + } + + @Override + public String expression() { + return operator.symbol() + target.toPlainString(); + } +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ConsoleAlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ConsoleAlarmAction.tjava index c228fb8..1ac1746 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ConsoleAlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ConsoleAlarmAction.tjava @@ -6,11 +6,10 @@ public final class ConsoleAlarmAction implements AlarmAction { System.err.println(); System.err.println("============================================================"); System.err.printf( - "ALARM: %s is %s USD; target %s %s USD%n", + "ALARM: %s is %s USD; condition %s USD%n", price.asset(), price.priceUsd().toPlainString(), - alarm.direction(), - alarm.target().toPlainString() + alarm.condition().expression() ); System.err.printf( "Trigger: %s, oracle time: %s, slot: %d, source: %s%n", 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 2c8c5e0..ac93990 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/JupiterPerpsAlarmImpl.tjava @@ -68,6 +68,10 @@ public final class JupiterPerpsAlarmImpl { entryPriceVariableRefresher.refresh(); System.out.println("Fetching done."); + System.out.print("Validating alarm conditions... "); + validateAlarmConditions(definitions, variableResolver); + System.out.println("Done."); + entryPriceVariableRefresher.startPeriodicRefresh(); JupiterPerpsEntryPriceVariableRefreshWatcher entryPriceVariableRefreshWatcher = @@ -141,9 +145,8 @@ public final class JupiterPerpsAlarmImpl { asset.oracleAccount() ); assetDefinitions.forEach(definition -> System.out.printf( - " %s %s USD, %s, severity=%s%n", - definition.direction(), - definition.targetExpression(), + " %s USD, %s, severity=%s%n", + definition.conditionExpression(), definition.trigger(), definition.severity() )); @@ -155,6 +158,29 @@ public final class JupiterPerpsAlarmImpl { new CountDownLatch(1).await(); } + private static void validateAlarmConditions( + List definitions, + AlarmVariableResolver variableResolver + ) { + for (PriceAlarmDefinition definition : definitions) { + try { + AlarmConditionParser.parse( + variableResolver.resolve(definition.conditionExpression()) + ); + } catch (RuntimeException exception) { + throw new IllegalArgumentException( + "Invalid condition for alarm " + + definition.id() + + " (" + + definition.conditionExpression() + + "): " + + exception.getMessage(), + exception + ); + } + } + } + private static Map> groupByAsset( List definitions ) { 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 7275183..34874a6 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarm.tjava @@ -1,6 +1,5 @@ package com.r35157.jupiterperpsalarm.impl.ref; -import java.math.BigDecimal; import java.time.Instant; public final class PriceAlarm { @@ -22,24 +21,21 @@ public final class PriceAlarm { ); } - BigDecimal target; + PriceCondition condition; try { - target = AlarmTargetParser.parse( - variableResolver.resolve(definition.targetExpression()) + condition = AlarmConditionParser.parse( + variableResolver.resolve(definition.conditionExpression()) ); } catch (RuntimeException exception) { System.err.printf( - "Could not resolve target for alarm %d: %s%n", + "Could not resolve condition for alarm %d: %s%n", definition.id(), exception.getMessage() ); return; } - boolean reached = definition.direction().reached( - price.priceUsd(), - target - ); + boolean reached = condition.matches(price.priceUsd()); boolean enteredTriggeredSide = previousReached == null ? reached @@ -57,13 +53,13 @@ public final class PriceAlarm { return; } - trigger(price, target); + trigger(price, condition); return; } if (definition.trigger() == AlarmTrigger.PERSISTENT) { if (lastTriggeredAt == null || persistentGracePeriodHasPassed()) { - trigger(price, target); + trigger(price, condition); } return; } @@ -95,7 +91,7 @@ public final class PriceAlarm { ); } - private void trigger(OraclePrice price, BigDecimal target) { + private void trigger(OraclePrice price, PriceCondition condition) { triggerCount++; lastTriggeredAt = Instant.now(); @@ -115,8 +111,7 @@ public final class PriceAlarm { ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm( definition.id(), definition.asset(), - definition.direction(), - target, + condition, definition.trigger(), definition.triggerGracePeriod(), definition.severity(), 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 8047d0b..b567f0b 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceAlarmDefinition.tjava @@ -7,8 +7,7 @@ import java.util.Objects; public record PriceAlarmDefinition( int id, JupiterPerpsAsset asset, - PriceDirection direction, - String targetExpression, + String conditionExpression, AlarmTrigger trigger, Ξ©secondsΞ© triggerGracePeriod, AlarmSeverity severity, @@ -16,14 +15,13 @@ public record PriceAlarmDefinition( ) { public PriceAlarmDefinition { Objects.requireNonNull(asset, "asset"); - Objects.requireNonNull(direction, "direction"); - Objects.requireNonNull(targetExpression, "targetExpression"); + Objects.requireNonNull(conditionExpression, "conditionExpression"); Objects.requireNonNull(trigger, "trigger"); Objects.requireNonNull(severity, "severity"); Objects.requireNonNull(note, "note"); - if (targetExpression.isBlank()) { - throw new IllegalArgumentException("Target expression cannot be blank"); + if (conditionExpression.isBlank()) { + throw new IllegalArgumentException("Condition expression cannot be blank"); } if (triggerGracePeriod < 0) { diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceComparisonOperator.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceComparisonOperator.tjava new file mode 100644 index 0000000..720007c --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceComparisonOperator.tjava @@ -0,0 +1,42 @@ +package com.r35157.jupiterperpsalarm.impl.ref; + +import java.math.BigDecimal; + +public enum PriceComparisonOperator { + LESS_THAN("<") { + @Override + public boolean matches(BigDecimal price, BigDecimal target) { + return price.compareTo(target) < 0; + } + }, + LESS_THAN_OR_EQUAL("<=") { + @Override + public boolean matches(BigDecimal price, BigDecimal target) { + return price.compareTo(target) <= 0; + } + }, + GREATER_THAN(">") { + @Override + public boolean matches(BigDecimal price, BigDecimal target) { + return price.compareTo(target) > 0; + } + }, + GREATER_THAN_OR_EQUAL(">=") { + @Override + public boolean matches(BigDecimal price, BigDecimal target) { + return price.compareTo(target) >= 0; + } + }; + + PriceComparisonOperator(String symbol) { + this.symbol = symbol; + } + + public abstract boolean matches(BigDecimal price, BigDecimal target); + + public String symbol() { + return symbol; + } + + private final String symbol; +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceCondition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceCondition.tjava new file mode 100644 index 0000000..06d60f4 --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceCondition.tjava @@ -0,0 +1,10 @@ +package com.r35157.jupiterperpsalarm.impl.ref; + +import java.math.BigDecimal; + +public interface PriceCondition { + + boolean matches(BigDecimal price); + + String expression(); +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceDirection.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceDirection.tjava deleted file mode 100644 index 98ca5af..0000000 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PriceDirection.tjava +++ /dev/null @@ -1,20 +0,0 @@ -package com.r35157.jupiterperpsalarm.impl.ref; - -import java.math.BigDecimal; - -public enum PriceDirection { - ABOVE { - @Override - public boolean reached(BigDecimal price, BigDecimal target) { - return price.compareTo(target) >= 0; - } - }, - BELOW { - @Override - public boolean reached(BigDecimal price, BigDecimal target) { - return price.compareTo(target) <= 0; - } - }; - - public abstract boolean reached(BigDecimal price, BigDecimal target); -} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PushoverAlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PushoverAlarmAction.tjava index 0f42790..e8cba2e 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PushoverAlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/PushoverAlarmAction.tjava @@ -71,14 +71,13 @@ public final class PushoverAlarmAction implements AlarmAction { private static String createMessage(OraclePrice price, ResolvedPriceAlarm alarm) { return String.format( - "%d - %s: %s%n%n%s is %s USD.%nTarget: %s %s USD.%nOracle time: %s.%nSlot: %d.", + "%d - %s: %s%n%n%s is %s USD.%nCondition: %s USD.%nOracle time: %s.%nSlot: %d.", alarm.id(), alarm.severity(), alarm.note(), price.asset(), price.priceUsd().toPlainString(), - alarm.direction(), - alarm.target().toPlainString(), + alarm.condition().expression(), price.oracleTime(), price.slot() ); diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/RangePriceCondition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/RangePriceCondition.tjava new file mode 100644 index 0000000..937e7c6 --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/RangePriceCondition.tjava @@ -0,0 +1,54 @@ +package com.r35157.jupiterperpsalarm.impl.ref; + +import java.math.BigDecimal; +import java.util.Objects; + +public record RangePriceCondition( + BigDecimal lowerTarget, + boolean lowerInclusive, + BigDecimal upperTarget, + boolean upperInclusive +) implements PriceCondition { + + public RangePriceCondition { + Objects.requireNonNull(lowerTarget, "lowerTarget"); + Objects.requireNonNull(upperTarget, "upperTarget"); + + if (lowerTarget.compareTo(upperTarget) > 0) { + throw new IllegalArgumentException( + "Range lower target must not exceed upper target: " + + lowerTarget.toPlainString() + + "-->" + + upperTarget.toPlainString() + ); + } + } + + @Override + public boolean matches(BigDecimal price) { + int lowerComparison = price.compareTo(lowerTarget); + int upperComparison = price.compareTo(upperTarget); + + boolean matchesLower = lowerInclusive + ? lowerComparison >= 0 + : lowerComparison > 0; + + boolean matchesUpper = upperInclusive + ? upperComparison <= 0 + : upperComparison < 0; + + return matchesLower && matchesUpper; + } + + @Override + public String expression() { + char lowerDelimiter = lowerInclusive ? '[' : '('; + char upperDelimiter = upperInclusive ? ']' : ')'; + + return lowerDelimiter + + lowerTarget.toPlainString() + + "-->" + + upperTarget.toPlainString() + + upperDelimiter; + } +} 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 502f4e3..35f4b34 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/impl/ref/ResolvedPriceAlarm.tjava @@ -1,16 +1,14 @@ package com.r35157.jupiterperpsalarm.impl.ref; import com.r35157.jupiterperpsalarm.AlarmSeverity; -import java.math.BigDecimal; public record ResolvedPriceAlarm( int id, JupiterPerpsAsset asset, - PriceDirection direction, - BigDecimal target, + PriceCondition condition, AlarmTrigger trigger, Ξ©secondsΞ© triggerGracePeriod, AlarmSeverity severity, String note ) { -} \ No newline at end of file +}