From e1d691f2a74ff2f4e3eec7cb9053d1416a7707e71cbaa7174a68cfb8f60fd40d Mon Sep 17 00:00:00 2001 From: Minimons Date: Tue, 16 Jun 2026 00:16:52 +0200 Subject: [PATCH] Jupiter alarm extended with multi alarm and config file --- README_alarm.md | 90 ++++++++-- .../jupiterperpsalarm/AlarmAction.tjava | 4 +- .../AlarmConfigurationParser.tjava | 161 ++++++++++++++++++ .../jupiterperpsalarm/AlarmTrigger.tjava | 6 + .../AssetPriceAlarmMonitor.tjava | 79 +++++++++ .../CompositeAlarmAction.tjava | 5 +- .../ConsoleAlarmAction.tjava | 11 +- .../jupiterperpsalarm/JupiterPerpsAsset.tjava | 4 +- .../com/r35157/jupiterperpsalarm/Main.tjava | 131 ++++++++------ .../r35157/jupiterperpsalarm/PriceAlarm.tjava | 78 ++++----- .../PriceAlarmDefinition.tjava | 28 +++ .../PushoverAlarmAction.tjava | 11 +- .../r35157/jupiterperpsalarm/SelfTest.tjava | 119 +++++++++++++ 13 files changed, 600 insertions(+), 127 deletions(-) create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/AlarmConfigurationParser.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/AlarmTrigger.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/AssetPriceAlarmMonitor.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarmDefinition.tjava create mode 100644 src/main/tjava/com/r35157/jupiterperpsalarm/SelfTest.tjava diff --git a/README_alarm.md b/README_alarm.md index 35253ff..8d39272 100644 --- a/README_alarm.md +++ b/README_alarm.md @@ -1,9 +1,63 @@ # Jupiter Perps Price Alarm -A small Java 17 program that listens to Jupiter Perps' on-chain aggregated oracle account through Solana WebSocket `accountSubscribe`. +A small Java program that listens to Jupiter Perps' on-chain aggregated oracle accounts through Solana WebSocket `accountSubscribe`. 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. +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. + +## Java version + +The project uses the current JDK compiler but generates Java 17-compatible class files: + +```gradle +tasks.withType(JavaCompile).configureEach { + options.release = 17 +} +``` + +## Alarm configuration + +The default file is `price-alarms.conf`: + +```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!" +``` + +Supported values: + +- 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 `\"` + +`SEVERITY` and `NOTE` are parsed and retained in `PriceAlarmDefinition`, but are intentionally not used by the alarm actions yet. + +## Trigger behavior + +On the first received price after program start: + +- An already satisfied alarm triggers immediately. +- An unsatisfied alarm waits for the price to cross into its triggered side. + +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. + +After that: + +- `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. + +`ONETIME` state is currently kept in memory. Restarting the process arms the alarm again. + ## Build and test ```bash @@ -11,16 +65,32 @@ gradle classes gradle run --args='--self-test' ``` -## Monitor a SOL short liquidation threshold +The self-test covers: + +- binary oracle decoding +- configuration parsing +- initial satisfied alarm behavior +- `ONETIME` behavior +- `PERSISTENT` crossing behavior + +## Run + +Using the default `price-alarms.conf` in the working directory: ```bash -gradle run --args='--asset=SOL --target=175.00 --direction=above' +gradle run ``` -For a long position, liquidation is normally below the current price: +Using another file: ```bash -gradle run --args='--asset=SOL --target=120.00 --direction=below' +gradle run --args='--config=/path/to/price-alarms.conf' +``` + +The path can also be selected through: + +```bash +export PRICE_ALARMS_CONFIG='/path/to/price-alarms.conf' ``` ## Use two RPC WebSocket streams @@ -29,24 +99,24 @@ A single WebSocket/RPC provider is not a durable event log. For better resilienc ```bash export SOLANA_WS_URLS='wss://first-provider.example,wss://second-provider.example' -gradle run --args='--asset=SOL --target=175 --direction=above' +gradle run ``` -The same URL is converted from `wss://` to `https://` for initial and reconnect state retrieval. This works with the usual Solana RPC endpoint format, including API-key query parameters. +With two configured assets and two RPC endpoints, the program opens four WebSocket connections. ## Pushover emergency alarm ```bash export PUSHOVER_APP_TOKEN='...' export PUSHOVER_USER_KEY='...' -gradle run --args='--asset=SOL --target=175 --direction=above' +gradle run ``` -The program sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`. +The current implementation sends `priority=2`, `retry=30`, `expire=10800`, and `sound=persistent`. ## Important limitations - `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 your position was liquidated. For that, also subscribe to your Jupiter position account or relevant program transactions. +- 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. diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmAction.tjava index 2e7e015..e8cfeb4 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmAction.tjava @@ -1,8 +1,6 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; - @FunctionalInterface public interface AlarmAction { - void trigger(OraclePrice price, BigDecimal target, PriceDirection direction); + void trigger(OraclePrice price, PriceAlarmDefinition alarm); } diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmConfigurationParser.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmConfigurationParser.tjava new file mode 100644 index 0000000..0b2c32e --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmConfigurationParser.tjava @@ -0,0 +1,161 @@ +package com.r35157.jupiterperpsalarm; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class AlarmConfigurationParser { + + public static List parse(Path path) throws IOException { + List lines = Files.readAllLines(path); + List alarms = new ArrayList<>(); + + 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 { + alarms.add(parseLine(line)); + } catch (RuntimeException exception) { + throw new IllegalArgumentException( + path + ":" + lineNumber + ": " + exception.getMessage(), + exception + ); + } + } + + if (alarms.isEmpty()) { + throw new IllegalArgumentException("No alarms found in " + path); + } + + return List.copyOf(alarms); + } + + static PriceAlarmDefinition parseLine(String line) { + Cursor cursor = new Cursor(line); + + JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf( + cursor.nextToken("asset").toUpperCase(Locale.ROOT) + ); + PriceDirection direction = PriceDirection.valueOf( + cursor.nextToken("direction").toUpperCase(Locale.ROOT) + ); + + BigDecimal target = new BigDecimal(cursor.nextToken("target")); + AlarmTrigger trigger = AlarmTrigger.valueOf( + cursor.nextToken("trigger").toUpperCase(Locale.ROOT) + ); + int severity = Integer.parseInt(cursor.nextToken("severity")); + String note = cursor.nextQuotedString("note"); + + cursor.skipWhitespace(); + if (!cursor.atEnd() && cursor.current() != '#') { + throw new IllegalArgumentException( + "Unexpected text after note: " + cursor.remaining() + ); + } + + return new PriceAlarmDefinition( + asset, + direction, + target, + trigger, + severity, + note + ); + } + + 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 skipWhitespace() { + while (!atEnd() && Character.isWhitespace(current())) { + position++; + } + } + + private boolean atEnd() { + return position >= line.length(); + } + + private char current() { + return line.charAt(position); + } + + private String remaining() { + return line.substring(position); + } + + private final String line; + private int position; + } + + private AlarmConfigurationParser() { + } +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmTrigger.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmTrigger.tjava new file mode 100644 index 0000000..aa8951a --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/AlarmTrigger.tjava @@ -0,0 +1,6 @@ +package com.r35157.jupiterperpsalarm; + +public enum AlarmTrigger { + ONETIME, + PERSISTENT +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/AssetPriceAlarmMonitor.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/AssetPriceAlarmMonitor.tjava new file mode 100644 index 0000000..5d3da9b --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/AssetPriceAlarmMonitor.tjava @@ -0,0 +1,79 @@ +package com.r35157.jupiterperpsalarm; + +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class AssetPriceAlarmMonitor { + + public AssetPriceAlarmMonitor( + JupiterPerpsAsset asset, + List definitions, + AlarmAction action + ) { + this.asset = asset; + this.alarms = definitions.stream() + .map(definition -> { + if (definition.asset() != asset) { + throw new IllegalArgumentException( + "Alarm asset " + definition.asset() + " does not match monitor " + asset + ); + } + return new PriceAlarm(definition, action); + }) + .toList(); + + if (alarms.isEmpty()) { + throw new IllegalArgumentException("At least one alarm is required for " + asset); + } + } + + public synchronized void accept(OraclePrice price) { + if (price.asset() != asset) { + throw new IllegalArgumentException( + "Received " + price.asset() + " price for " + asset + " monitor" + ); + } + + String eventKey = price.rawPrice() + ":" + price.exponent() + ":" + + price.oracleTime().getEpochSecond(); + if (!recentEvents.add(eventKey)) { + return; + } + trimRecentEvents(); + + long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds(); + System.out.printf( + "%s %s=%s USD oracleAge=%ds slot=%d source=%s%n", + Instant.now(), + asset, + price.priceUsd().toPlainString(), + ageSeconds, + price.slot(), + price.source() + ); + + alarms.forEach(alarm -> alarm.accept(price)); + } + + public List alarms() { + return alarms; + } + + private void trimRecentEvents() { + while (recentEvents.size() > MAX_RECENT_EVENTS) { + Iterator iterator = recentEvents.iterator(); + iterator.next(); + iterator.remove(); + } + } + + private final JupiterPerpsAsset asset; + private final List alarms; + private final Set recentEvents = new LinkedHashSet<>(); + + private static final int MAX_RECENT_EVENTS = 512; +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/CompositeAlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/CompositeAlarmAction.tjava index 46dd4c0..cdd0cec 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/CompositeAlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/CompositeAlarmAction.tjava @@ -1,6 +1,5 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; import java.util.List; public final class CompositeAlarmAction implements AlarmAction { @@ -10,10 +9,10 @@ public final class CompositeAlarmAction implements AlarmAction { } @Override - public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) { + public void trigger(OraclePrice price, PriceAlarmDefinition alarm) { for (AlarmAction action : actions) { try { - action.trigger(price, target, direction); + action.trigger(price, alarm); } catch (RuntimeException exception) { System.err.println("Alarm action failed: " + exception.getMessage()); } diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/ConsoleAlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/ConsoleAlarmAction.tjava index 3d74fc4..f58587d 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/ConsoleAlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/ConsoleAlarmAction.tjava @@ -1,21 +1,20 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; - public final class ConsoleAlarmAction implements AlarmAction { @Override - public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) { + public void trigger(OraclePrice price, PriceAlarmDefinition alarm) { System.err.println(); System.err.println("============================================================"); System.err.printf( "ALARM: %s is %s USD; target %s %s USD%n", price.asset(), price.priceUsd().toPlainString(), - direction, - target.toPlainString() + alarm.direction(), + alarm.target().toPlainString() ); System.err.printf( - "Oracle time: %s, slot: %d, source: %s%n", + "Trigger: %s, oracle time: %s, slot: %d, source: %s%n", + alarm.trigger(), price.oracleTime(), price.slot(), price.source() diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/JupiterPerpsAsset.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/JupiterPerpsAsset.tjava index f8c03e1..c17a74e 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/JupiterPerpsAsset.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/JupiterPerpsAsset.tjava @@ -3,9 +3,7 @@ package com.r35157.jupiterperpsalarm; public enum JupiterPerpsAsset { SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"), ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"), - BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC"), - USDC("6Jp2xZUTWdDD2ZyUPRzeMdc6AFQ5K3pFgZxk2EijfjnM"), - USDT("Fgc93D641F8N2d1xLjQ4jmShuD3GE3BsCXA56KBQbF5u"); + BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC"); JupiterPerpsAsset(String oracleAccount) { this.oracleAccount = oracleAccount; diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/Main.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/Main.tjava index 2cf3f74..1819d30 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/Main.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/Main.tjava @@ -1,11 +1,11 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; import java.net.URI; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.EnumMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -23,8 +23,17 @@ public final class Main { } if (config.selfTest()) { - DovesAgPriceFeedDecoder.selfTest(); - System.out.println("Decoder self-test passed."); + SelfTest.run(); + System.out.println("All self-tests passed."); + return; + } + + List definitions; + try { + definitions = AlarmConfigurationParser.parse(config.alarmConfiguration()); + } catch (Exception exception) { + System.err.println("Could not load alarm configuration: " + exception.getMessage()); + System.exit(2); return; } @@ -45,60 +54,92 @@ public final class Main { } AlarmAction action = new CompositeAlarmAction(actions); - PriceAlarm alarm = new PriceAlarm( - config.asset(), - config.target(), - config.direction(), - action - ); + Map> definitionsByAsset = + groupByAsset(definitions); - List clients = config.webSocketEndpoints().stream() - .map(endpoint -> new OracleWebSocketClient(endpoint, config.asset(), alarm::accept)) - .toList(); + Map monitors = new EnumMap<>( + JupiterPerpsAsset.class + ); + definitionsByAsset.forEach((asset, assetDefinitions) -> monitors.put( + asset, + new AssetPriceAlarmMonitor(asset, assetDefinitions, action) + )); + + List clients = new ArrayList<>(); + for (Map.Entry entry : monitors.entrySet()) { + for (URI endpoint : config.webSocketEndpoints()) { + clients.add(new OracleWebSocketClient( + endpoint, + entry.getKey(), + entry.getValue()::accept + )); + } + } Runtime.getRuntime().addShutdownHook(new Thread( () -> clients.forEach(OracleWebSocketClient::close), "shutdown" )); - System.out.printf( - "Monitoring Jupiter Perps %s oracle. Alarm when price is %s %s USD.%n", - config.asset(), - config.direction(), - config.target().toPlainString() - ); - System.out.println("Oracle account: " + config.asset().oracleAccount()); - System.out.println("RPC streams: " + config.webSocketEndpoints().size()); + System.out.println("Alarm configuration: " + config.alarmConfiguration().toAbsolutePath()); + System.out.println("Loaded alarms: " + definitions.size()); + definitionsByAsset.forEach((asset, assetDefinitions) -> { + System.out.printf( + " %s: %d alarm(s), oracle account %s%n", + asset, + assetDefinitions.size(), + asset.oracleAccount() + ); + assetDefinitions.forEach(definition -> System.out.printf( + " %s %s USD, %s, severity=%d%n", + definition.direction(), + definition.target().toPlainString(), + definition.trigger(), + definition.severity() + )); + }); + System.out.println("RPC endpoints per asset: " + config.webSocketEndpoints().size()); + System.out.println("Total WebSocket connections: " + clients.size()); clients.forEach(OracleWebSocketClient::start); new CountDownLatch(1).await(); } + private static Map> groupByAsset( + List definitions + ) { + Map> result = new EnumMap<>( + JupiterPerpsAsset.class + ); + for (PriceAlarmDefinition definition : definitions) { + result.computeIfAbsent(definition.asset(), ignored -> new ArrayList<>()) + .add(definition); + } + result.replaceAll((asset, alarms) -> List.copyOf(alarms)); + return result; + } + private static void printUsage() { System.err.println(""" Usage: - gradle run --args='--asset=SOL --target=175.00 --direction=above' + gradle run + gradle run --args='--config=/path/to/price-alarms.conf' Options: - --asset=SOL|ETH|BTC Default: SOL - --target= Required - --direction=above|below Default: above + --config= Default: price-alarms.conf --ws= Default: wss://api.mainnet-beta.solana.com - --self-test Test the binary decoder and exit + --self-test Test parser, alarm semantics and oracle decoder 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 - - For a SOL short liquidation warning, use --direction=above. """); } private record Config( - JupiterPerpsAsset asset, - BigDecimal target, - PriceDirection direction, + Path alarmConfiguration, List webSocketEndpoints, String pushoverToken, String pushoverUserKey, @@ -115,26 +156,14 @@ public final class Main { (first, second) -> second )); - JupiterPerpsAsset asset = JupiterPerpsAsset.valueOf( - options.getOrDefault("asset", "SOL").toUpperCase(Locale.ROOT) - ); - - BigDecimal target = null; - if (!selfTest) { - String targetText = options.get("target"); - if (targetText == null || targetText.isBlank()) { - throw new IllegalArgumentException("Missing required --target="); - } - target = new BigDecimal(targetText); - if (target.signum() <= 0) { - throw new IllegalArgumentException("Target price must be positive"); - } + String configurationText = options.get("config"); + if (configurationText == null || configurationText.isBlank()) { + configurationText = environment.getOrDefault( + "PRICE_ALARMS_CONFIG", + "price-alarms.conf" + ); } - PriceDirection direction = PriceDirection.valueOf( - options.getOrDefault("direction", "above").toUpperCase(Locale.ROOT) - ); - String endpointText = options.get("ws"); if (endpointText == null || endpointText.isBlank()) { endpointText = environment.getOrDefault( @@ -154,9 +183,7 @@ public final class Main { } return new Config( - asset, - target, - direction, + Path.of(configurationText), endpoints, blankToNull(environment.get("PUSHOVER_APP_TOKEN")), blankToNull(environment.get("PUSHOVER_USER_KEY")), diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarm.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarm.tjava index d5dea53..e39c0ec 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarm.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarm.tjava @@ -1,65 +1,53 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; -import java.time.Duration; -import java.time.Instant; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - public final class PriceAlarm { - public PriceAlarm( - JupiterPerpsAsset asset, - BigDecimal target, - PriceDirection direction, - AlarmAction action - ) { - this.asset = asset; - this.target = target; - this.direction = direction; + public PriceAlarm(PriceAlarmDefinition definition, AlarmAction action) { + this.definition = definition; this.action = action; } public synchronized void accept(OraclePrice price) { - String eventKey = price.rawPrice() + ":" + price.exponent() + ":" + - price.oracleTime().getEpochSecond(); - if (!recentEvents.add(eventKey)) { - return; + if (price.asset() != definition.asset()) { + throw new IllegalArgumentException( + "Received " + price.asset() + " price for " + definition.asset() + " alarm" + ); } - trimRecentEvents(); - long ageSeconds = Duration.between(price.oracleTime(), Instant.now()).getSeconds(); - System.out.printf( - "%s %s=%s USD oracleAge=%ds slot=%d source=%s%n", - Instant.now(), - asset, - price.priceUsd().toPlainString(), - ageSeconds, - price.slot(), - price.source() + boolean reached = definition.direction().reached( + price.priceUsd(), + definition.target() ); - if (direction.reached(price.priceUsd(), target) && triggered.compareAndSet(false, true)) { - action.trigger(price, target, direction); + boolean enteredTriggeredSide = previousReached == null + ? reached + : reached && !previousReached; + + previousReached = reached; + + if (!enteredTriggeredSide) { + return; } + + if (definition.trigger() == AlarmTrigger.ONETIME && triggerCount > 0) { + return; + } + + triggerCount++; + action.trigger(price, definition); } - private void trimRecentEvents() { - while (recentEvents.size() > MAX_RECENT_EVENTS) { - Iterator iterator = recentEvents.iterator(); - iterator.next(); - iterator.remove(); - } + public PriceAlarmDefinition definition() { + return definition; } - private final JupiterPerpsAsset asset; - private final BigDecimal target; - private final PriceDirection direction; + public synchronized long triggerCount() { + return triggerCount; + } + + private final PriceAlarmDefinition definition; private final AlarmAction action; - private final AtomicBoolean triggered = new AtomicBoolean(); - private final Set recentEvents = new LinkedHashSet<>(); - private static final int MAX_RECENT_EVENTS = 512; + private Boolean previousReached; + private long triggerCount; } diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarmDefinition.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarmDefinition.tjava new file mode 100644 index 0000000..192ca2f --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/PriceAlarmDefinition.tjava @@ -0,0 +1,28 @@ +package com.r35157.jupiterperpsalarm; + +import java.math.BigDecimal; +import java.util.Objects; + +public record PriceAlarmDefinition( + JupiterPerpsAsset asset, + PriceDirection direction, + BigDecimal target, + AlarmTrigger trigger, + int severity, + String note +) { + public PriceAlarmDefinition { + Objects.requireNonNull(asset, "asset"); + Objects.requireNonNull(direction, "direction"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(trigger, "trigger"); + Objects.requireNonNull(note, "note"); + + if (target.signum() <= 0) { + throw new IllegalArgumentException("Target price must be positive"); + } + if (severity < 0) { + throw new IllegalArgumentException("Severity must be zero or positive"); + } + } +} diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/PushoverAlarmAction.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/PushoverAlarmAction.tjava index 70c5d0d..78277c2 100644 --- a/src/main/tjava/com/r35157/jupiterperpsalarm/PushoverAlarmAction.tjava +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/PushoverAlarmAction.tjava @@ -1,6 +1,5 @@ package com.r35157.jupiterperpsalarm; -import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -17,18 +16,20 @@ public final class PushoverAlarmAction implements AlarmAction { } @Override - public void trigger(OraclePrice price, BigDecimal target, PriceDirection direction) { + public void trigger(OraclePrice price, PriceAlarmDefinition alarm) { String title = "Jupiter Perps " + price.asset() + " alarm"; String message = String.format( "%s is %s USD. Target: %s %s USD. Oracle time: %s. Slot: %d.", price.asset(), price.priceUsd().toPlainString(), - direction, - target.toPlainString(), + alarm.direction(), + alarm.target().toPlainString(), price.oracleTime(), price.slot() ); + // Severity and note are intentionally parsed and retained in the model, + // but are not used by Pushover yet. String body = form("token", applicationToken) + "&" + form("user", userKey) + "&" + form("title", title) + "&" + @@ -38,7 +39,7 @@ public final class PushoverAlarmAction implements AlarmAction { HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI) .timeout(Duration.ofSeconds(15)) .header("Content-Type", "application/x-www-form-urlencoded") - .header("User-Agent", "jupiter-perps-price-alarm/1.0") + .header("User-Agent", "jupiter-perps-price-alarm/1.1") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); diff --git a/src/main/tjava/com/r35157/jupiterperpsalarm/SelfTest.tjava b/src/main/tjava/com/r35157/jupiterperpsalarm/SelfTest.tjava new file mode 100644 index 0000000..32c149e --- /dev/null +++ b/src/main/tjava/com/r35157/jupiterperpsalarm/SelfTest.tjava @@ -0,0 +1,119 @@ +package com.r35157.jupiterperpsalarm; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public final class SelfTest { + + public static void run() { + DovesAgPriceFeedDecoder.selfTest(); + configurationParserTest(); + oneTimeAlarmTest(); + persistentAlarmTest(); + initialTriggerTest(); + } + + private static void configurationParserTest() { + PriceAlarmDefinition alarm = AlarmConfigurationParser.parseLine( + "SOL ABOVE 75.7 ONETIME 2 \"ALARM: Risiko for likvidering!\"" + ); + + require(alarm.asset() == JupiterPerpsAsset.SOL, "Asset parsing failed"); + require(alarm.direction() == PriceDirection.ABOVE, "Direction parsing failed"); + require(alarm.target().compareTo(new BigDecimal("75.7")) == 0, "Target parsing failed"); + require(alarm.trigger() == AlarmTrigger.ONETIME, "Trigger parsing failed"); + require(alarm.severity() == 2, "Severity parsing failed"); + require(alarm.note().equals("ALARM: Risiko for likvidering!"), "Note parsing failed"); + } + + private static void oneTimeAlarmTest() { + List triggeredPrices = new ArrayList<>(); + PriceAlarm alarm = new PriceAlarm( + definition(PriceDirection.ABOVE, "100", AlarmTrigger.ONETIME), + (price, definition) -> triggeredPrices.add(price.priceUsd()) + ); + + accept(alarm, "90"); + accept(alarm, "100"); + accept(alarm, "110"); + accept(alarm, "90"); + accept(alarm, "101"); + + require(triggeredPrices.equals(List.of(new BigDecimal("100"))), "ONETIME semantics failed"); + } + + private static void persistentAlarmTest() { + List triggeredPrices = new ArrayList<>(); + PriceAlarm alarm = new PriceAlarm( + definition(PriceDirection.BELOW, "100", AlarmTrigger.PERSISTENT), + (price, definition) -> triggeredPrices.add(price.priceUsd()) + ); + + accept(alarm, "110"); + accept(alarm, "99"); + accept(alarm, "98"); + accept(alarm, "101"); + accept(alarm, "100"); + + require( + triggeredPrices.equals(List.of(new BigDecimal("99"), new BigDecimal("100"))), + "PERSISTENT crossing semantics failed" + ); + } + + private static void initialTriggerTest() { + List triggeredPrices = new ArrayList<>(); + PriceAlarm alarm = new PriceAlarm( + definition(PriceDirection.ABOVE, "100", AlarmTrigger.PERSISTENT), + (price, definition) -> triggeredPrices.add(price.priceUsd()) + ); + + accept(alarm, "105"); + accept(alarm, "106"); + + require( + triggeredPrices.equals(List.of(new BigDecimal("105"))), + "Initial satisfied alarm did not trigger exactly once" + ); + } + + private static PriceAlarmDefinition definition( + PriceDirection direction, + String target, + AlarmTrigger trigger + ) { + return new PriceAlarmDefinition( + JupiterPerpsAsset.SOL, + direction, + new BigDecimal(target), + trigger, + 2, + "ignored for now" + ); + } + + private static void accept(PriceAlarm alarm, String price) { + BigDecimal decimal = new BigDecimal(price); + alarm.accept(new OraclePrice( + JupiterPerpsAsset.SOL, + decimal.unscaledValue().abs(), + -decimal.scale(), + decimal, + Instant.ofEpochSecond(1_700_000_000L), + 1L, + "self-test" + )); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + + private SelfTest() { + } +}