35: Separate alarm definitions from action configuration
This commit is contained in:
+92
-42
@@ -1,10 +1,10 @@
|
||||
# Jupiter Perps Price Alarm
|
||||
|
||||
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.
|
||||
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 independently configured console, Pushover, and Jupiter position-increase actions.
|
||||
|
||||
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.
|
||||
|
||||
> **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.
|
||||
> **Important:** This application is not necessarily notification-only. Every active entry in `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf` can sign and submit a real Jupiter Perps transaction. See [Automatic position increase](#automatic-position-increase) before running it.
|
||||
|
||||
## Startup flow
|
||||
|
||||
@@ -15,10 +15,10 @@ The application performs the following steps before it starts monitoring prices:
|
||||
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.
|
||||
6. Parse and validate the three action configuration files.
|
||||
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.
|
||||
Startup fails before WebSockets are opened if an alarm condition or action configuration contains an unresolved variable or invalid value.
|
||||
|
||||
## Requirements and build
|
||||
|
||||
@@ -60,7 +60,7 @@ java --enable-preview \
|
||||
--config=conf/alarms.conf
|
||||
```
|
||||
|
||||
The repository contains [the example configuration](conf/alarms.conf.example). Keep the real configuration outside version control if it contains operational wallet details.
|
||||
The repository contains example files for the alarm configuration and every action configuration. The real `conf/*.conf` files are ignored by Git; keep credentials and operational wallet details out of version control.
|
||||
|
||||
### Command-line options and environment variables
|
||||
|
||||
@@ -68,26 +68,42 @@ The repository contains [the example configuration](conf/alarms.conf.example). K
|
||||
|---|---|---|---|
|
||||
| Alarm configuration | `--config=<path>` | `PRICE_ALARMS_CONFIG` | `price-alarms.conf` |
|
||||
| Solana WebSocket endpoints | `--ws=<url1,url2,...>` | `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 command-line option takes precedence over its environment variable.
|
||||
|
||||
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
|
||||
## Configuration files
|
||||
|
||||
Blank lines and lines beginning with `#` are ignored. The file contains variable definitions and alarm definitions.
|
||||
The application uses four configuration files:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `alarms.conf` | Variables and price-alarm definitions |
|
||||
| `alarmaction_Console.conf` | Alarm IDs written to the console |
|
||||
| `alarmaction_Pushover.conf` | Pushover credentials and notifications |
|
||||
| `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf` | Jupiter wallet, signer, and position increases |
|
||||
|
||||
The three action files are resolved in the same directory as the file supplied through `--config`. All four files are required at startup, although an action can contain no alarm IDs. Blank lines and lines beginning with `#` are ignored.
|
||||
|
||||
Example files:
|
||||
|
||||
- [alarms.conf.example](conf/alarms.conf.example)
|
||||
- [alarmaction_Console.conf.example](conf/alarmaction_Console.conf.example)
|
||||
- [alarmaction_Pushover.conf.example](conf/alarmaction_Pushover.conf.example)
|
||||
- [alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf.example](conf/alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf.example)
|
||||
|
||||
## Shared variables
|
||||
|
||||
### Variable definitions
|
||||
|
||||
A variable definition consists of a name and a value:
|
||||
Variable definitions live in `alarms.conf`. A definition consists of a name and a single-token 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}}`.
|
||||
Variable names must use uppercase letters, digits, and underscores. User-defined variables can be referenced from alarm conditions and action configuration values 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:
|
||||
|
||||
@@ -100,33 +116,35 @@ Variable names must use uppercase letters, digits, and underscores. Variables ca
|
||||
{{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.
|
||||
The refresher replaces user-supplied variables with these names when position data is fetched. Only variables for positions returned by Jupiter are populated. Startup validation fails if a condition or action value references a variable that is unavailable after the initial position fetch.
|
||||
|
||||
### Alarm definitions
|
||||
Alarm conditions, Pushover notes, and Jupiter position-increase rows are resolved again when an alarm triggers, so refreshed values take effect without restarting the application. Pushover credentials and severity plus the Jupiter wallet and signer are resolved during startup.
|
||||
|
||||
Each alarm is one line with six columns:
|
||||
## Alarm definitions
|
||||
|
||||
Each alarm is one line with four columns:
|
||||
|
||||
```text
|
||||
ID ASSET CONDITION TRIGGER SEVERITY "NOTE"
|
||||
ID ASSET CONDITION TRIGGER
|
||||
```
|
||||
|
||||
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"
|
||||
4 SOL <={{SOL_LONG_LIQ_PRICE}}+1% PERSISTENT:60
|
||||
5 SOL ({{SOL_LONG_LIQ_PRICE}}+1%-->{{SOL_LONG_LIQ_PRICE}}+3%] PERSISTENT:3600
|
||||
```
|
||||
|
||||
Supported column values:
|
||||
|
||||
- `ID`: integer alarm identifier. IDs should be unique. IDs `4` and `5` have special transaction behavior in the current implementation.
|
||||
- `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`, `PERSISTENT`, or `PERSISTENT:<seconds>`.
|
||||
- `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.
|
||||
A trailing comment is allowed after a variable value or trigger.
|
||||
|
||||
An alarm can be listed in zero, one, or several action files. Absence means that action ignores the alarm; absence from all action files means that the condition is monitored but produces no action.
|
||||
|
||||
## Condition expressions
|
||||
|
||||
@@ -230,15 +248,34 @@ When a dynamic price variable changes, the application logs its old and new valu
|
||||
|
||||
## Alarm actions
|
||||
|
||||
Actions are executed sequentially through `CompositeAlarmAction`. A runtime failure in one action is logged so the remaining actions can still run.
|
||||
|
||||
### Console
|
||||
|
||||
Console output is always enabled. It includes the asset, current oracle price, resolved condition, trigger type, oracle time, slot, and source endpoint.
|
||||
`alarmaction_Console.conf` contains one alarm ID per line:
|
||||
|
||||
Actions are executed sequentially through `CompositeAlarmAction`. A runtime failure in one action is logged so the remaining actions can still run.
|
||||
```text
|
||||
1
|
||||
4
|
||||
5
|
||||
```
|
||||
|
||||
Only listed alarms are written to the console. The output includes the asset, current oracle price, resolved condition, trigger type, oracle time, slot, and source endpoint. Duplicate or non-integer IDs are rejected. An empty file disables console output for all alarms.
|
||||
|
||||
### Pushover
|
||||
|
||||
Pushover is enabled when both `PUSHOVER_APP_TOKEN` and `PUSHOVER_USER_KEY` are configured.
|
||||
`alarmaction_Pushover.conf` contains the credentials followed by zero or more notification definitions:
|
||||
|
||||
```text
|
||||
APPLICATION_TOKEN <pushover-application-token>
|
||||
USER_KEY <pushover-user-key>
|
||||
|
||||
# ID SEVERITY "NOTE"
|
||||
4 CRITICAL "🚨 SOL Long add 1.1x"
|
||||
5 INFO "🌱 SOL Long add 3x"
|
||||
```
|
||||
|
||||
The application token and user/group key are required. Only listed alarm IDs send a Pushover notification. Notes must be quoted and support `\n`, `\r`, `\t`, `\"`, and `\\` escapes.
|
||||
|
||||
| Severity | Pushover parameters |
|
||||
|---|---|
|
||||
@@ -249,26 +286,33 @@ Pushover is enabled when both `PUSHOVER_APP_TOKEN` and `PUSHOVER_USER_KEY` are c
|
||||
| `SILENT` | `priority=-1` |
|
||||
| `GHOST` | `priority=-2` |
|
||||
|
||||
Pushover requests are sent asynchronously with a 15-second request timeout. Rejected requests and asynchronous failures are logged.
|
||||
Credentials, severity, and notes may reference shared variables. Credentials and severity are resolved during startup; notes are also resolved for every trigger so refreshed variables take effect. 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:
|
||||
`alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf` contains the wallet and signer followed by zero or more position increases:
|
||||
|
||||
```text
|
||||
vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf
|
||||
WALLET_ID {{JUPITER_PERPS_WALLET}}
|
||||
SIGNER_KEY_NAME <jup-key-name>
|
||||
|
||||
# ID ASSET DIRECTION COLLATERAL_USDC SIZE_DELTA_USD MAX_SLIPPAGE_BPS
|
||||
4 SOL LONG 0.25 2.50 200
|
||||
```
|
||||
|
||||
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.
|
||||
Each active row can submit a real transaction when its alarm triggers. Supported values are:
|
||||
|
||||
- `ASSET`: `SOL`, `ETH`, or `BTC`.
|
||||
- `DIRECTION`: `LONG` or `SHORT`.
|
||||
- `COLLATERAL_USDC`: greater than zero.
|
||||
- `SIZE_DELTA_USD`: zero or greater.
|
||||
- `MAX_SLIPPAGE_BPS`: from `0` to `10000`.
|
||||
|
||||
Wallet, signer, asset, direction, amounts, and slippage may reference shared variables. Wallet and signer are resolved during startup. Every transaction row is validated during startup and resolved again at trigger time, allowing refreshed variables to change the transaction parameters without a restart.
|
||||
|
||||
For a configured ID, the action builds a Jupiter position-increase transaction, signs it by running `jup sign -f json --key <SIGNER_KEY_NAME> --tx <serialized-transaction>`, verifies that the returned signer matches `WALLET_ID`, and submits the signed transaction. IDs absent from this file never perform a transaction.
|
||||
|
||||
The example transaction rows are commented out. Do not activate a row until its alarm condition, wallet, signer, asset, direction, amounts, and slippage have been reviewed.
|
||||
|
||||
## RPC redundancy and reconnect behavior
|
||||
|
||||
@@ -289,7 +333,7 @@ Each connection:
|
||||
|
||||
## Validation and errors
|
||||
|
||||
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.
|
||||
Structural configuration errors include the file path and line number. After the initial Jupiter position fetch, every condition is resolved and parsed. The action files are then parsed and validated before WebSockets are opened.
|
||||
|
||||
Startup validation rejects, among other things:
|
||||
|
||||
@@ -299,9 +343,13 @@ Startup validation rejects, among other things:
|
||||
- malformed ranges or multiple `-->` separators;
|
||||
- missing range targets;
|
||||
- negative target values or percentages;
|
||||
- ranges whose lower target exceeds the upper target.
|
||||
- ranges whose lower target exceeds the upper target;
|
||||
- missing action credentials or wallet settings;
|
||||
- duplicate action IDs;
|
||||
- invalid Pushover severities;
|
||||
- invalid Jupiter assets, directions, amounts, or slippage.
|
||||
|
||||
At runtime, a later resolution/parsing failure is logged for the affected alarm and that price event is skipped for that alarm.
|
||||
At runtime, a later condition resolution/parsing failure skips that price event for the affected alarm. A later action-variable failure is logged by the action dispatcher, and the remaining actions are still attempted.
|
||||
|
||||
## Current limitations
|
||||
|
||||
@@ -310,6 +358,8 @@ At runtime, a later resolution/parsing failure is logged for the affected alarm
|
||||
- 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.
|
||||
- Configuration files are read only during startup; editing an action file requires a restart.
|
||||
- Action IDs are not cross-validated against `alarms.conf`; an unknown ID is accepted but never triggered.
|
||||
- Pushover credentials and severity plus the Jupiter wallet and signer are resolved only during startup. Dynamic re-resolution applies to conditions, Pushover notes, and Jupiter transaction rows.
|
||||
- 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.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Alarm ids handled by ConsoleAlarmAction
|
||||
##########################################
|
||||
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
6
|
||||
7
|
||||
8
|
||||
9
|
||||
10
|
||||
11
|
||||
12
|
||||
13
|
||||
14
|
||||
15
|
||||
16
|
||||
17
|
||||
18
|
||||
@@ -0,0 +1,11 @@
|
||||
# Wallet and signer used for position-increase transactions
|
||||
################################################################
|
||||
WALLET_ID {{JUPITER_PERPS_WALLET}}
|
||||
SIGNER_KEY_NAME <SIGNER>
|
||||
|
||||
|
||||
# Id Asset Direction CollateralUSDC SizeDeltaUSD MaxSlippageBPS
|
||||
############################################################################
|
||||
# Values may reference {{VARIABLES}} defined in alarms.conf.
|
||||
#4 SOL LONG 0.25 2.50 200
|
||||
#5 SOL LONG 0.25 6.25 200
|
||||
@@ -0,0 +1,39 @@
|
||||
# Pushover credentials
|
||||
##########################################
|
||||
# Values and notes may reference {{VARIABLES}} defined in alarms.conf.
|
||||
APPLICATION_TOKEN <pushover-application-token>
|
||||
USER_KEY <pushover-user-key>
|
||||
|
||||
|
||||
# Id Severity Note
|
||||
##########################################
|
||||
|
||||
# SOL SHORT
|
||||
1 CRITICAL "🚨 SOL Short add 1.1x"
|
||||
2 INFO "🌱 SOL Short add 3x"
|
||||
3 INFO "💵 SOL Short close"
|
||||
|
||||
# SOL LONG
|
||||
4 CRITICAL "🚨 SOL Long add 1.1x"
|
||||
5 INFO "🌱 SOL Long add 3x"
|
||||
6 INFO "💵 SOL Long close"
|
||||
|
||||
# ETH SHORT
|
||||
7 CRITICAL "🚨 ETH Short add 1.1x"
|
||||
8 INFO "🌱 ETH Short add 3x"
|
||||
9 INFO "💵 ETH Short close"
|
||||
|
||||
# ETH LONG
|
||||
10 CRITICAL "🚨 ETH Long add 1.1x"
|
||||
11 INFO "🌱 ETH Long add 3x"
|
||||
12 INFO "💵 ETH Long close"
|
||||
|
||||
# BTC SHORT
|
||||
13 CRITICAL "🚨 BTC Short add 1.1x"
|
||||
14 INFO "🌱 BTC Short add 3x"
|
||||
15 INFO "💵 BTC Short close"
|
||||
|
||||
# BTC LONG
|
||||
16 CRITICAL "🚨 BTC Long add 1.1x"
|
||||
17 INFO "🌱 BTC Long add 3x"
|
||||
18 INFO "💵 BTC Long close"
|
||||
+20
-20
@@ -16,35 +16,35 @@
|
||||
#{{BTC_SHORT_LIQ_PRICE}} 63275.97
|
||||
|
||||
|
||||
# Id Asset Condition Trigger Severity Note
|
||||
#################################################################################################################################
|
||||
# Id Asset Condition Trigger
|
||||
################################################################################################
|
||||
|
||||
# SOL SHORT
|
||||
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"
|
||||
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
|
||||
|
||||
# SOL LONG
|
||||
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"
|
||||
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
|
||||
|
||||
# ETH SHORT
|
||||
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"
|
||||
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
|
||||
|
||||
# ETH LONG
|
||||
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"
|
||||
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
|
||||
|
||||
# BTC SHORT
|
||||
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"
|
||||
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
|
||||
|
||||
# BTC LONG
|
||||
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"
|
||||
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
|
||||
|
||||
+2
-56
@@ -1,7 +1,5 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -58,16 +56,10 @@ public final class AlarmConfigurationParser {
|
||||
cursor.nextToken("trigger")
|
||||
);
|
||||
|
||||
AlarmSeverity severity = AlarmSeverity.valueOf(
|
||||
cursor.nextToken("severity").toUpperCase()
|
||||
);
|
||||
|
||||
String note = cursor.nextQuotedString("note");
|
||||
|
||||
cursor.skipWhitespace();
|
||||
if (!cursor.atEnd() && cursor.current() != '#') {
|
||||
throw new IllegalArgumentException(
|
||||
"Unexpected text after note: " + cursor.remaining()
|
||||
"Unexpected text after trigger: " + cursor.remaining()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,9 +68,7 @@ public final class AlarmConfigurationParser {
|
||||
asset,
|
||||
conditionExpression,
|
||||
triggerConfiguration.trigger(),
|
||||
triggerConfiguration.gracePeriod(),
|
||||
severity,
|
||||
note
|
||||
triggerConfiguration.gracePeriod()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,50 +149,6 @@ public final class AlarmConfigurationParser {
|
||||
return line.substring(start, position);
|
||||
}
|
||||
|
||||
private String nextQuotedString(String fieldName) {
|
||||
skipWhitespace();
|
||||
if (atEnd() || current() != '"') {
|
||||
throw new IllegalArgumentException(
|
||||
"Missing quoted " + fieldName + "; expected \"...\""
|
||||
);
|
||||
}
|
||||
position++;
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean escaped = false;
|
||||
|
||||
while (!atEnd()) {
|
||||
char character = current();
|
||||
position++;
|
||||
|
||||
if (escaped) {
|
||||
result.append(switch (character) {
|
||||
case 'n' -> '\n';
|
||||
case 'r' -> '\r';
|
||||
case 't' -> '\t';
|
||||
case '"' -> '"';
|
||||
case '\\' -> '\\';
|
||||
default -> character;
|
||||
});
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '"') {
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
result.append(character);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
|
||||
}
|
||||
|
||||
private void skipWhitespace() {
|
||||
while (!atEnd() && Character.isWhitespace(current())) {
|
||||
position++;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
public interface ConfiguredAlarmAction extends AlarmAction {
|
||||
String getConfigurationFileName();
|
||||
}
|
||||
@@ -1,8 +1,32 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
public final class ConsoleAlarmAction implements AlarmAction {
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ConsoleAlarmAction implements ConfiguredAlarmAction {
|
||||
public ConsoleAlarmAction(Path configurationDirectory) throws IOException {
|
||||
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
|
||||
alarmIds = parseAlarmIds(
|
||||
configurationDirectory.resolve(getConfigurationFileName())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationFileName() {
|
||||
return "alarmaction_Console.conf";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
|
||||
if (!alarmIds.contains(alarm.id())) {
|
||||
return;
|
||||
}
|
||||
|
||||
System.err.println();
|
||||
System.err.println("============================================================");
|
||||
System.err.printf(
|
||||
@@ -21,4 +45,45 @@ public final class ConsoleAlarmAction implements AlarmAction {
|
||||
System.err.println("============================================================");
|
||||
System.err.println();
|
||||
}
|
||||
|
||||
private static Set<Integer> parseAlarmIds(Path path) throws IOException {
|
||||
List<String> lines = Files.readAllLines(path);
|
||||
Set<Integer> result = new LinkedHashSet<>();
|
||||
|
||||
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
|
||||
String line = removeComment(lines.get(lineNumber - 1)).trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (line.chars().anyMatch(Character::isWhitespace)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected one alarm id, but found: " + line
|
||||
);
|
||||
}
|
||||
|
||||
int alarmId = Integer.parseInt(line);
|
||||
if (!result.add(alarmId)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Duplicate alarm id: " + alarmId
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
path + ":" + lineNumber + ": " + exception.getMessage(),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
|
||||
private static String removeComment(String line) {
|
||||
int commentStart = line.indexOf('#');
|
||||
return commentStart < 0 ? line : line.substring(0, commentStart);
|
||||
}
|
||||
|
||||
private final Set<Integer> alarmIds;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
||||
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
|
||||
import com.r35157.libs.objcache.ObjectCache;
|
||||
@@ -82,26 +80,22 @@ public final class JupiterPerpsAlarmImpl {
|
||||
|
||||
entryPriceVariableRefreshWatcher.start();
|
||||
|
||||
List<AlarmAction> actions = new ArrayList<>();
|
||||
actions.add(new ConsoleAlarmAction());
|
||||
SolanaBlockChain sbc = new SolanaBlockChainImpl();
|
||||
JupiterPerpsService jupiter = new AnchorIdlJupiterPerpsServiceImpl(sbc);
|
||||
ΩSolanaWalletIdΩ walletId = "vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf";
|
||||
SolanaWallet wallet = new SolanaWalletImpl(walletId, "evelyn-prod", sbc);
|
||||
actions.add(new JupiterPerpsPositionIncreaseAlarmAction(wallet, jupiter));
|
||||
Path alarmConfigurationDirectory = config.alarmConfiguration()
|
||||
.toAbsolutePath()
|
||||
.getParent();
|
||||
|
||||
if (config.pushoverToken() != null && config.pushoverUserKey() != null) {
|
||||
List<AlarmAction> actions = new ArrayList<>();
|
||||
actions.add(new ConsoleAlarmAction(alarmConfigurationDirectory));
|
||||
actions.add(new JupiterPerpsPositionIncreaseAlarmAction(
|
||||
alarmConfigurationDirectory,
|
||||
solanaBlockChain,
|
||||
jupiterPerpsService,
|
||||
variableResolver
|
||||
));
|
||||
actions.add(new PushoverAlarmAction(
|
||||
config.pushoverToken(),
|
||||
config.pushoverUserKey()
|
||||
alarmConfigurationDirectory,
|
||||
variableResolver
|
||||
));
|
||||
System.out.println("Pushover emergency alarm is enabled.");
|
||||
} else {
|
||||
System.out.println(
|
||||
"Pushover is disabled. Set PUSHOVER_APP_TOKEN and " +
|
||||
"PUSHOVER_USER_KEY to enable it."
|
||||
);
|
||||
}
|
||||
|
||||
AlarmAction action = new CompositeAlarmAction(actions);
|
||||
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> definitionsByAsset =
|
||||
@@ -145,10 +139,9 @@ public final class JupiterPerpsAlarmImpl {
|
||||
asset.oracleAccount()
|
||||
);
|
||||
assetDefinitions.forEach(definition -> System.out.printf(
|
||||
" %s USD, %s, severity=%s%n",
|
||||
" %s USD, %s%n",
|
||||
definition.conditionExpression(),
|
||||
definition.trigger(),
|
||||
definition.severity()
|
||||
definition.trigger()
|
||||
));
|
||||
});
|
||||
System.out.println("RPC endpoints per asset: " + config.webSocketEndpoints().size());
|
||||
@@ -208,16 +201,12 @@ public final class JupiterPerpsAlarmImpl {
|
||||
Environment:
|
||||
PRICE_ALARMS_CONFIG Alternative default configuration path
|
||||
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
|
||||
PUSHOVER_APP_TOKEN Pushover application token
|
||||
PUSHOVER_USER_KEY Pushover user/group key
|
||||
""");
|
||||
}
|
||||
|
||||
private record Config(
|
||||
Path alarmConfiguration,
|
||||
List<URI> webSocketEndpoints,
|
||||
String pushoverToken,
|
||||
String pushoverUserKey
|
||||
List<URI> webSocketEndpoints
|
||||
) {
|
||||
private static Config parse(String[] args, Map<String, String> environment) {
|
||||
Map<String, String> options = Arrays.stream(args)
|
||||
@@ -257,15 +246,9 @@ public final class JupiterPerpsAlarmImpl {
|
||||
|
||||
return new Config(
|
||||
Path.of(configurationText),
|
||||
endpoints,
|
||||
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
|
||||
blankToNull(environment.get("PUSHOVER_USER_KEY"))
|
||||
endpoints
|
||||
);
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
private JupiterPerpsAlarmImpl() {
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
public enum JupiterPerpsAsset {
|
||||
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
|
||||
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
|
||||
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC");
|
||||
SOL(
|
||||
"FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh",
|
||||
"So11111111111111111111111111111111111111112"
|
||||
),
|
||||
ETH(
|
||||
"AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1",
|
||||
"7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
|
||||
),
|
||||
BTC(
|
||||
"hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC",
|
||||
"3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh"
|
||||
);
|
||||
|
||||
JupiterPerpsAsset(String oracleAccount) {
|
||||
JupiterPerpsAsset(
|
||||
String oracleAccount,
|
||||
ΩSPLMintAddressΩ tradedTokenMint
|
||||
) {
|
||||
this.oracleAccount = oracleAccount;
|
||||
this.tradedTokenMint = tradedTokenMint;
|
||||
}
|
||||
|
||||
public String oracleAccount() {
|
||||
return oracleAccount;
|
||||
}
|
||||
|
||||
public ΩSPLMintAddressΩ tradedTokenMint() {
|
||||
return tradedTokenMint;
|
||||
}
|
||||
|
||||
private final String oracleAccount;
|
||||
private final ΩSPLMintAddressΩ tradedTokenMint;
|
||||
}
|
||||
|
||||
+46
-26
@@ -1,43 +1,64 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
|
||||
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
|
||||
import com.r35157.libs.solana.SolanaBlockChain;
|
||||
import com.r35157.libs.solana.SolanaSignedTransaction;
|
||||
import com.r35157.libs.solana.SolanaUnsignedTransaction;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class JupiterPerpsPositionIncreaseAlarmAction implements AlarmAction {
|
||||
public JupiterPerpsPositionIncreaseAlarmAction(SolanaWallet wallet, JupiterPerpsService jupiter) {
|
||||
this.wallet = wallet;
|
||||
this.jupiter = jupiter;
|
||||
public final class JupiterPerpsPositionIncreaseAlarmAction implements ConfiguredAlarmAction {
|
||||
public JupiterPerpsPositionIncreaseAlarmAction(
|
||||
Path configurationDirectory,
|
||||
SolanaBlockChain solanaBlockChain,
|
||||
JupiterPerpsService jupiter,
|
||||
AlarmVariableResolver variableResolver
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
|
||||
Objects.requireNonNull(solanaBlockChain, "solanaBlockChain");
|
||||
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
|
||||
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
|
||||
|
||||
configuration = JupiterPerpsPositionIncreaseAlarmActionConfigurationParser.parse(
|
||||
configurationDirectory.resolve(getConfigurationFileName()),
|
||||
variableResolver
|
||||
);
|
||||
wallet = new SolanaWalletImpl(
|
||||
configuration.walletId(),
|
||||
configuration.signerKeyName(),
|
||||
solanaBlockChain
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationFileName() {
|
||||
return "alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
|
||||
ΩUSDCAmountΩ inputTokenAmount = new ΩUSDCAmountΩ("0.25");
|
||||
ΩUSDCAmountΩ sizeUsdDelta;
|
||||
|
||||
if(alarm.id() == 4) {
|
||||
// Critical
|
||||
sizeUsdDelta = new ΩUSDCAmountΩ("2.50"); // Leverage: 10x
|
||||
} else if(alarm.id() == 5) {
|
||||
// Info
|
||||
sizeUsdDelta = new ΩUSDCAmountΩ("6.25"); // Leverage: 25x
|
||||
} else {
|
||||
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease definition =
|
||||
configuration.positionIncreases().get(alarm.id());
|
||||
if (definition == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JupiterPerpsPositionIncreaseAlarmActionConfiguration.ResolvedPositionIncrease
|
||||
positionIncrease = definition.resolve(variableResolver);
|
||||
|
||||
SolanaUnsignedTransaction utrans =
|
||||
jupiter.buildPositionIncreaseTransaction(
|
||||
wallet.getAddress(),
|
||||
SOL_MINT,
|
||||
JupiterPerpsPositionDirection.LONG,
|
||||
inputTokenAmount,
|
||||
sizeUsdDelta,
|
||||
200
|
||||
positionIncrease.asset().tradedTokenMint(),
|
||||
positionIncrease.direction(),
|
||||
positionIncrease.collateralUsd(),
|
||||
positionIncrease.sizeUsdDelta(),
|
||||
positionIncrease.maxSlippageBps()
|
||||
);
|
||||
SolanaSignedTransaction strans = wallet.signTransaction(utrans);
|
||||
ΩSolanaTransactionSignatureΩ signature = jupiter.executePositionIncreaseTransaction(strans);
|
||||
@@ -47,9 +68,8 @@ public final class JupiterPerpsPositionIncreaseAlarmAction implements AlarmActio
|
||||
}
|
||||
}
|
||||
|
||||
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
|
||||
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
|
||||
|
||||
private SolanaWallet wallet;
|
||||
private JupiterPerpsService jupiter;
|
||||
private final JupiterPerpsPositionIncreaseAlarmActionConfiguration configuration;
|
||||
private final SolanaWallet wallet;
|
||||
private final JupiterPerpsService jupiter;
|
||||
private final AlarmVariableResolver variableResolver;
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record JupiterPerpsPositionIncreaseAlarmActionConfiguration(
|
||||
ΩSolanaWalletIdΩ walletId,
|
||||
String signerKeyName,
|
||||
Map<Integer, PositionIncrease> positionIncreases
|
||||
) {
|
||||
public JupiterPerpsPositionIncreaseAlarmActionConfiguration {
|
||||
Objects.requireNonNull(walletId, "walletId");
|
||||
Objects.requireNonNull(signerKeyName, "signerKeyName");
|
||||
positionIncreases = Map.copyOf(positionIncreases);
|
||||
|
||||
if (walletId.isBlank()) {
|
||||
throw new IllegalArgumentException("Wallet id cannot be blank");
|
||||
}
|
||||
if (signerKeyName.isBlank()) {
|
||||
throw new IllegalArgumentException("Signer key name cannot be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public record PositionIncrease(
|
||||
String assetExpression,
|
||||
String directionExpression,
|
||||
String collateralUsdExpression,
|
||||
String sizeUsdDeltaExpression,
|
||||
String maxSlippageBpsExpression
|
||||
) {
|
||||
public PositionIncrease {
|
||||
requireNonBlank(assetExpression, "asset");
|
||||
requireNonBlank(directionExpression, "direction");
|
||||
requireNonBlank(collateralUsdExpression, "collateral");
|
||||
requireNonBlank(sizeUsdDeltaExpression, "position size delta");
|
||||
requireNonBlank(maxSlippageBpsExpression, "maximum slippage");
|
||||
}
|
||||
|
||||
public ResolvedPositionIncrease resolve(AlarmVariableResolver variableResolver) {
|
||||
Objects.requireNonNull(variableResolver, "variableResolver");
|
||||
|
||||
return new ResolvedPositionIncrease(
|
||||
JupiterPerpsAsset.valueOf(
|
||||
variableResolver.resolve(assetExpression).toUpperCase(Locale.ROOT)
|
||||
),
|
||||
JupiterPerpsPositionDirection.valueOf(
|
||||
variableResolver.resolve(directionExpression)
|
||||
.toUpperCase(Locale.ROOT)
|
||||
),
|
||||
new ΩUSDCAmountΩ(variableResolver.resolve(collateralUsdExpression)),
|
||||
new ΩUSDCAmountΩ(variableResolver.resolve(sizeUsdDeltaExpression)),
|
||||
Integer.parseInt(variableResolver.resolve(maxSlippageBpsExpression))
|
||||
);
|
||||
}
|
||||
|
||||
private static void requireNonBlank(String value, String fieldName) {
|
||||
Objects.requireNonNull(value, fieldName);
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException(fieldName + " cannot be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record ResolvedPositionIncrease(
|
||||
JupiterPerpsAsset asset,
|
||||
JupiterPerpsPositionDirection direction,
|
||||
ΩUSDCAmountΩ collateralUsd,
|
||||
ΩUSDCAmountΩ sizeUsdDelta,
|
||||
int maxSlippageBps
|
||||
) {
|
||||
public ResolvedPositionIncrease {
|
||||
Objects.requireNonNull(asset, "asset");
|
||||
Objects.requireNonNull(direction, "direction");
|
||||
Objects.requireNonNull(collateralUsd, "collateralUsd");
|
||||
Objects.requireNonNull(sizeUsdDelta, "sizeUsdDelta");
|
||||
|
||||
if (collateralUsd.signum() <= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Collateral must be greater than zero: " + collateralUsd
|
||||
);
|
||||
}
|
||||
if (sizeUsdDelta.signum() < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Position size delta cannot be negative: " + sizeUsdDelta
|
||||
);
|
||||
}
|
||||
if (maxSlippageBps < 0 || maxSlippageBps > 10_000) {
|
||||
throw new IllegalArgumentException(
|
||||
"Maximum slippage must be between 0 and 10000 basis points: "
|
||||
+ maxSlippageBps
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class JupiterPerpsPositionIncreaseAlarmActionConfigurationParser {
|
||||
public static JupiterPerpsPositionIncreaseAlarmActionConfiguration parse(
|
||||
Path path,
|
||||
AlarmVariableResolver variableResolver
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(variableResolver, "variableResolver");
|
||||
List<String> lines = Files.readAllLines(path);
|
||||
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
|
||||
positionIncreases = new LinkedHashMap<>();
|
||||
ΩSolanaWalletIdΩ walletId = null;
|
||||
String signerKeyName = null;
|
||||
|
||||
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
|
||||
String line = removeComment(lines.get(lineNumber - 1)).trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String[] columns = line.split("\\s+");
|
||||
String firstColumn = columns[0].toUpperCase(Locale.ROOT);
|
||||
|
||||
if (firstColumn.equals("WALLET_ID")) {
|
||||
requireColumnCount(columns, 2, "WALLET_ID value");
|
||||
if (walletId != null) {
|
||||
throw new IllegalArgumentException("Duplicate WALLET_ID");
|
||||
}
|
||||
walletId = variableResolver.resolve(columns[1]);
|
||||
} else if (firstColumn.equals("SIGNER_KEY_NAME")) {
|
||||
requireColumnCount(columns, 2, "SIGNER_KEY_NAME value");
|
||||
if (signerKeyName != null) {
|
||||
throw new IllegalArgumentException("Duplicate SIGNER_KEY_NAME");
|
||||
}
|
||||
signerKeyName = variableResolver.resolve(columns[1]);
|
||||
} else {
|
||||
parsePositionIncrease(
|
||||
columns,
|
||||
positionIncreases,
|
||||
variableResolver
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
path + ":" + lineNumber + ": " + exception.getMessage(),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (walletId == null) {
|
||||
throw new IllegalArgumentException("Missing WALLET_ID in " + path);
|
||||
}
|
||||
if (signerKeyName == null) {
|
||||
throw new IllegalArgumentException("Missing SIGNER_KEY_NAME in " + path);
|
||||
}
|
||||
|
||||
return new JupiterPerpsPositionIncreaseAlarmActionConfiguration(
|
||||
walletId,
|
||||
signerKeyName,
|
||||
positionIncreases
|
||||
);
|
||||
}
|
||||
|
||||
private static void parsePositionIncrease(
|
||||
String[] columns,
|
||||
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
|
||||
positionIncreases,
|
||||
AlarmVariableResolver variableResolver
|
||||
) {
|
||||
requireColumnCount(
|
||||
columns,
|
||||
6,
|
||||
"alarm id, asset, direction, collateral, size delta, and maximum slippage"
|
||||
);
|
||||
|
||||
int alarmId = Integer.parseInt(columns[0]);
|
||||
if (positionIncreases.containsKey(alarmId)) {
|
||||
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
|
||||
}
|
||||
|
||||
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease positionIncrease =
|
||||
new JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease(
|
||||
columns[1],
|
||||
columns[2],
|
||||
columns[3],
|
||||
columns[4],
|
||||
columns[5]
|
||||
);
|
||||
|
||||
positionIncrease.resolve(variableResolver);
|
||||
positionIncreases.put(alarmId, positionIncrease);
|
||||
}
|
||||
|
||||
private static void requireColumnCount(
|
||||
String[] columns,
|
||||
int expected,
|
||||
String expectedColumns
|
||||
) {
|
||||
if (columns.length != expected) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected " + expectedColumns + ", but found " + columns.length + " column(s)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static String removeComment(String line) {
|
||||
int commentStart = line.indexOf('#');
|
||||
return commentStart < 0 ? line : line.substring(0, commentStart);
|
||||
}
|
||||
|
||||
private JupiterPerpsPositionIncreaseAlarmActionConfigurationParser() {
|
||||
}
|
||||
}
|
||||
@@ -95,27 +95,12 @@ public final class PriceAlarm {
|
||||
triggerCount++;
|
||||
lastTriggeredAt = Instant.now();
|
||||
|
||||
String note;
|
||||
|
||||
try {
|
||||
note = variableResolver.resolve(definition.note());
|
||||
} catch (RuntimeException exception) {
|
||||
System.err.printf(
|
||||
"Could not resolve note for alarm %d: %s%n",
|
||||
definition.id(),
|
||||
exception.getMessage()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
|
||||
definition.id(),
|
||||
definition.asset(),
|
||||
condition,
|
||||
definition.trigger(),
|
||||
definition.triggerGracePeriod(),
|
||||
definition.severity(),
|
||||
note
|
||||
definition.triggerGracePeriod()
|
||||
);
|
||||
|
||||
action.trigger(price, resolvedAlarm);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record PriceAlarmDefinition(
|
||||
@@ -9,16 +7,12 @@ public record PriceAlarmDefinition(
|
||||
JupiterPerpsAsset asset,
|
||||
String conditionExpression,
|
||||
AlarmTrigger trigger,
|
||||
ΩsecondsΩ triggerGracePeriod,
|
||||
AlarmSeverity severity,
|
||||
String note
|
||||
ΩsecondsΩ triggerGracePeriod
|
||||
) {
|
||||
public PriceAlarmDefinition {
|
||||
Objects.requireNonNull(asset, "asset");
|
||||
Objects.requireNonNull(conditionExpression, "conditionExpression");
|
||||
Objects.requireNonNull(trigger, "trigger");
|
||||
Objects.requireNonNull(severity, "severity");
|
||||
Objects.requireNonNull(note, "note");
|
||||
|
||||
if (conditionExpression.isBlank()) {
|
||||
throw new IllegalArgumentException("Condition expression cannot be blank");
|
||||
|
||||
@@ -2,31 +2,57 @@ package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PushoverAlarmAction implements AlarmAction {
|
||||
public final class PushoverAlarmAction implements ConfiguredAlarmAction {
|
||||
|
||||
public PushoverAlarmAction(String applicationToken, String userKey) {
|
||||
this.applicationToken = applicationToken;
|
||||
this.userKey = userKey;
|
||||
public PushoverAlarmAction(
|
||||
Path configurationDirectory,
|
||||
AlarmVariableResolver variableResolver
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
|
||||
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
|
||||
configuration = PushoverAlarmActionConfigurationParser.parse(
|
||||
configurationDirectory.resolve(getConfigurationFileName()),
|
||||
variableResolver
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationFileName() {
|
||||
return "alarmaction_Pushover.conf";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
|
||||
String title = "Jupiter Perps " + price.asset() + " alarm";
|
||||
String message = createMessage(price, alarm);
|
||||
PushoverAlarmActionConfiguration.Notification notification =
|
||||
configuration.notifications().get(alarm.id());
|
||||
if (notification == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String body = form("token", applicationToken) + "&" +
|
||||
form("user", userKey) + "&" +
|
||||
String title = "Jupiter Perps " + price.asset() + " alarm";
|
||||
String message = createMessage(
|
||||
price,
|
||||
alarm,
|
||||
notification.severity(),
|
||||
variableResolver.resolve(notification.note())
|
||||
);
|
||||
|
||||
String body = form("token", configuration.applicationToken()) + "&" +
|
||||
form("user", configuration.userKey()) + "&" +
|
||||
form("title", title) + "&" +
|
||||
form("message", message) + "&" +
|
||||
createPushoverSeverityParameters(alarm.severity());
|
||||
createPushoverSeverityParameters(notification.severity());
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
@@ -48,7 +74,7 @@ public final class PushoverAlarmAction implements AlarmAction {
|
||||
response.body()
|
||||
);
|
||||
} else {
|
||||
System.out.println("Pushover alarm sent: " + alarm.severity());
|
||||
System.out.println("Pushover alarm sent: " + notification.severity());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -69,12 +95,17 @@ public final class PushoverAlarmAction implements AlarmAction {
|
||||
};
|
||||
}
|
||||
|
||||
private static String createMessage(OraclePrice price, ResolvedPriceAlarm alarm) {
|
||||
private static String createMessage(
|
||||
OraclePrice price,
|
||||
ResolvedPriceAlarm alarm,
|
||||
AlarmSeverity severity,
|
||||
String note
|
||||
) {
|
||||
return String.format(
|
||||
"%d - %s: %s%n%n%s is %s USD.%nCondition: %s USD.%nOracle time: %s.%nSlot: %d.",
|
||||
alarm.id(),
|
||||
alarm.severity(),
|
||||
alarm.note(),
|
||||
severity,
|
||||
note,
|
||||
price.asset(),
|
||||
price.priceUsd().toPlainString(),
|
||||
alarm.condition().expression(),
|
||||
@@ -84,8 +115,8 @@ public final class PushoverAlarmAction implements AlarmAction {
|
||||
}
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private final String applicationToken;
|
||||
private final String userKey;
|
||||
private final PushoverAlarmActionConfiguration configuration;
|
||||
private final AlarmVariableResolver variableResolver;
|
||||
|
||||
private static final URI PUSHOVER_URI =
|
||||
URI.create("https://api.pushover.net/1/messages.json");
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PushoverAlarmActionConfiguration(
|
||||
String applicationToken,
|
||||
String userKey,
|
||||
Map<Integer, Notification> notifications
|
||||
) {
|
||||
public PushoverAlarmActionConfiguration {
|
||||
Objects.requireNonNull(applicationToken, "applicationToken");
|
||||
Objects.requireNonNull(userKey, "userKey");
|
||||
notifications = Map.copyOf(notifications);
|
||||
|
||||
if (applicationToken.isBlank()) {
|
||||
throw new IllegalArgumentException("Application token cannot be blank");
|
||||
}
|
||||
if (userKey.isBlank()) {
|
||||
throw new IllegalArgumentException("User key cannot be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public record Notification(
|
||||
AlarmSeverity severity,
|
||||
String note
|
||||
) {
|
||||
public Notification {
|
||||
Objects.requireNonNull(severity, "severity");
|
||||
Objects.requireNonNull(note, "note");
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PushoverAlarmActionConfigurationParser {
|
||||
public static PushoverAlarmActionConfiguration parse(
|
||||
Path path,
|
||||
AlarmVariableResolver variableResolver
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(variableResolver, "variableResolver");
|
||||
List<String> lines = Files.readAllLines(path);
|
||||
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications =
|
||||
new LinkedHashMap<>();
|
||||
String applicationToken = null;
|
||||
String userKey = null;
|
||||
|
||||
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
|
||||
String line = lines.get(lineNumber - 1);
|
||||
String trimmed = line.trim();
|
||||
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Cursor cursor = new Cursor(line);
|
||||
String firstToken = cursor.nextToken("setting or alarm id");
|
||||
|
||||
switch (firstToken.toUpperCase(Locale.ROOT)) {
|
||||
case "APPLICATION_TOKEN" -> {
|
||||
if (applicationToken != null) {
|
||||
throw new IllegalArgumentException("Duplicate APPLICATION_TOKEN");
|
||||
}
|
||||
applicationToken = variableResolver.resolve(
|
||||
cursor.nextToken("application token")
|
||||
);
|
||||
cursor.requireEndOrComment();
|
||||
}
|
||||
case "USER_KEY" -> {
|
||||
if (userKey != null) {
|
||||
throw new IllegalArgumentException("Duplicate USER_KEY");
|
||||
}
|
||||
userKey = variableResolver.resolve(
|
||||
cursor.nextToken("user key")
|
||||
);
|
||||
cursor.requireEndOrComment();
|
||||
}
|
||||
default -> parseNotification(
|
||||
firstToken,
|
||||
cursor,
|
||||
notifications,
|
||||
variableResolver
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
path + ":" + lineNumber + ": " + exception.getMessage(),
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (applicationToken == null) {
|
||||
throw new IllegalArgumentException("Missing APPLICATION_TOKEN in " + path);
|
||||
}
|
||||
if (userKey == null) {
|
||||
throw new IllegalArgumentException("Missing USER_KEY in " + path);
|
||||
}
|
||||
|
||||
return new PushoverAlarmActionConfiguration(
|
||||
applicationToken,
|
||||
userKey,
|
||||
notifications
|
||||
);
|
||||
}
|
||||
|
||||
private static void parseNotification(
|
||||
String alarmIdText,
|
||||
Cursor cursor,
|
||||
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications,
|
||||
AlarmVariableResolver variableResolver
|
||||
) {
|
||||
int alarmId;
|
||||
try {
|
||||
alarmId = Integer.parseInt(alarmIdText);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException(
|
||||
"Expected APPLICATION_TOKEN, USER_KEY, or alarm id: " + alarmIdText,
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
AlarmSeverity severity = AlarmSeverity.valueOf(
|
||||
variableResolver.resolve(cursor.nextToken("severity"))
|
||||
.toUpperCase(Locale.ROOT)
|
||||
);
|
||||
String note = cursor.nextQuotedString("note");
|
||||
variableResolver.resolve(note);
|
||||
cursor.requireEndOrComment();
|
||||
|
||||
PushoverAlarmActionConfiguration.Notification previous = notifications.putIfAbsent(
|
||||
alarmId,
|
||||
new PushoverAlarmActionConfiguration.Notification(severity, note)
|
||||
);
|
||||
if (previous != null) {
|
||||
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Cursor {
|
||||
private Cursor(String line) {
|
||||
this.line = line;
|
||||
}
|
||||
|
||||
private String nextToken(String fieldName) {
|
||||
skipWhitespace();
|
||||
if (atEnd()) {
|
||||
throw new IllegalArgumentException("Missing " + fieldName);
|
||||
}
|
||||
|
||||
int start = position;
|
||||
while (!atEnd() && !Character.isWhitespace(current())) {
|
||||
position++;
|
||||
}
|
||||
return line.substring(start, position);
|
||||
}
|
||||
|
||||
private String nextQuotedString(String fieldName) {
|
||||
skipWhitespace();
|
||||
if (atEnd() || current() != '"') {
|
||||
throw new IllegalArgumentException(
|
||||
"Missing quoted " + fieldName + "; expected \"...\""
|
||||
);
|
||||
}
|
||||
position++;
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
boolean escaped = false;
|
||||
|
||||
while (!atEnd()) {
|
||||
char character = current();
|
||||
position++;
|
||||
|
||||
if (escaped) {
|
||||
result.append(switch (character) {
|
||||
case 'n' -> '\n';
|
||||
case 'r' -> '\r';
|
||||
case 't' -> '\t';
|
||||
case '"' -> '"';
|
||||
case '\\' -> '\\';
|
||||
default -> character;
|
||||
});
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '"') {
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
result.append(character);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
|
||||
}
|
||||
|
||||
private void requireEndOrComment() {
|
||||
skipWhitespace();
|
||||
if (!atEnd() && current() != '#') {
|
||||
throw new IllegalArgumentException(
|
||||
"Unexpected text: " + line.substring(position)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void skipWhitespace() {
|
||||
while (!atEnd() && Character.isWhitespace(current())) {
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean atEnd() {
|
||||
return position >= line.length();
|
||||
}
|
||||
|
||||
private char current() {
|
||||
return line.charAt(position);
|
||||
}
|
||||
|
||||
private final String line;
|
||||
private int position;
|
||||
}
|
||||
|
||||
private PushoverAlarmActionConfigurationParser() {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
package com.r35157.jupiterperpsalarm.impl.ref;
|
||||
|
||||
import com.r35157.jupiterperpsalarm.AlarmSeverity;
|
||||
|
||||
public record ResolvedPriceAlarm(
|
||||
int id,
|
||||
JupiterPerpsAsset asset,
|
||||
PriceCondition condition,
|
||||
AlarmTrigger trigger,
|
||||
ΩsecondsΩ triggerGracePeriod,
|
||||
AlarmSeverity severity,
|
||||
String note
|
||||
ΩsecondsΩ triggerGracePeriod
|
||||
) {
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user