From 5b62bd8d12b882f6a8ff74cc00bbe5e5a693ff3dae5ea036d5825ea4306a999b Mon Sep 17 00:00:00 2001 From: Minimons Date: Tue, 16 Jun 2026 19:59:48 +0200 Subject: [PATCH] Added classes from old Cauldron (NON TESTED / BROKEN!) --- build.gradle.kts | 17 +- debug.sh | 1 + src/main/docker/run.sh | 1 + src/main/tjava/crypto/r35157/RingBuffer.tjava | 35 + src/main/tjava/crypto/r35157/SemVer.tjava | 13 + src/main/tjava/crypto/r35157/TimeTools.tjava | 322 ++++ .../r35157/assetaz/hub/AssetAZHub.tjava | 27 + .../crypto/r35157/cauldron/AssetAZ.tjava | 23 + .../r35157/cauldron/CheckBoxTreeCell.tjava | 27 + .../r35157/cauldron/FormatterUtils.tjava | 33 + .../cauldron/NetworthChartController.tjava | 314 ++++ .../cauldron/PriceChartController.tjava | 271 +++ .../crypto/r35157/cauldron/PriceFetcher.tjava | 109 ++ .../tjava/crypto/r35157/cauldron/Ticker.tjava | 100 ++ .../crypto/r35157/cauldron/TickerItem.tjava | 11 + .../r35157/cauldron/TickerService.tjava | 167 ++ .../crypto/r35157/cauldron/TimeUtils.tjava | 15 + .../crypto/r35157/cauldron/Version.tjava | 6 + .../r35157/cauldron/afets/BusinessLogic.tjava | 19 + .../cauldron/afets/BusinessLogicImpl.tjava | 51 + .../r35157/cauldron/afets/Configuration.tjava | 175 ++ .../cauldron/afets/EarningsStatistics.tjava | 7 + .../cauldron/afets/EquilibriumStatus.tjava | 9 + .../crypto/r35157/cauldron/afets/Evelyn.tjava | 1497 +++++++++++++++++ .../r35157/cauldron/afets/EventWatcher.tjava | 63 + .../cauldron/afets/FundingFeeStatus.tjava | 10 + .../cauldron/afets/FutureBasePrices.tjava | 14 + .../afets/InvalidAPIKeyException.tjava | 7 + .../cauldron/afets/MEXCFuturesPlugin.tjava | 274 +++ .../r35157/cauldron/afets/MEXCOpenType.tjava | 21 + .../r35157/cauldron/afets/MEXCOrderType.tjava | 24 + .../cauldron/afets/MEXCPositionSide.tjava | 23 + .../cauldron/afets/MEXCProtocolErrors.tjava | 114 ++ .../cauldron/afets/MEXCSpotPlugin.tjava | 141 ++ .../r35157/cauldron/afets/MEXCType.tjava | 25 + .../cauldron/afets/NextCloseableInfo.tjava | 18 + .../r35157/cauldron/afets/Portfolio.tjava | 208 +++ .../r35157/cauldron/afets/Position.tjava | 20 + .../cauldron/afets/PositionSideAction.tjava | 8 + .../cauldron/afets/PositionStatistics.tjava | 17 + .../r35157/cauldron/afets/PositionType.tjava | 17 + .../cauldron/afets/PositionValues.tjava | 18 + .../r35157/cauldron/afets/ProfitLoss.tjava | 9 + .../r35157/cauldron/afets/PushNotifier.tjava | 68 + .../cauldron/afets/ScoreCalculator.tjava | 16 + .../cauldron/afets/ScoreCalculatorImpl.tjava | 42 + .../cauldron/afets/ScoreCalculatorInput.tjava | 57 + .../cauldron/afets/ServiceResponse.tjava | 6 + .../afets/ThousandsSeparatorFormatter.tjava | 14 + .../r35157/cauldron/afets/TimeOuts.tjava | 154 ++ .../afets/accountdetails/AccountDetails.tjava | 16 + .../AccountDetailsResponse.tjava | 9 + .../MEXCAccountDetailsService.tjava | 8 + .../MEXCAccountDetailsServiceImpl.tjava | 39 + ...ngMEXCAccountDetailsServiceDecorator.tjava | 59 + .../contractdetails/ContractDetailsItem.tjava | 74 + .../ContractDetailsResponse.tjava | 9 + .../contractdetails/MEXCContractDetails.tjava | 59 + .../MEXCContractDetailsDeserializer.tjava | 203 +++ .../contractdetails/RiskLimitCustom.tjava | 9 + .../fundingrate/FundingRatesResponse.tjava | 9 + .../fundingrate/MEXCFundingRateItem.tjava | 13 + .../afets/fundingrate/MEXCFundingRates.tjava | 73 + .../MEXCFundingRatesDeserializer.tjava | 38 + .../afets/gami/GlobalAssetMetadataIndex.tjava | 94 ++ .../gami/GlobalAssetMetadataIndexItem.tjava | 12 + .../afets/leverage/LeverageInfoResponse.tjava | 9 + .../afets/leverage/MEXCLeverageInfo.tjava | 43 + .../MEXCLeverageInfoDeserializer.tjava | 42 + .../afets/leverage/MEXCLeverageInfoItem.tjava | 15 + .../afets/nenjim/NenjimObjectRepository.tjava | 188 +++ .../afets/ticker/FuturesTickerResponse.tjava | 9 + .../cauldron/afets/ticker/MEXCTicker.tjava | 58 + .../afets/ticker/MEXCTickerDeserializer.tjava | 31 + .../afets/ticker/MEXCTickerItem.tjava | 11 + .../cauldron/afets/ticker/PositionItem.tjava | 36 + .../afets/ticker/PositionResponse.tjava | 9 + .../r35157/nenjim/DirectoryClassLoader.tjava | 41 + .../r35157/nenjim/DockerSwarmSecrets.tjava | 56 + .../nenjim/EncryptedFileProperties.tjava | 60 + .../r35157/nenjim/ImmutableProperties.tjava | 9 + .../r35157/nenjim/NenjimCLIAdminClient.tjava | 5 + .../crypto/r35157/nenjim/NenjimContext.tjava | 4 + .../crypto/r35157/nenjim/NenjimHub.tjava | 25 + .../r35157/nenjim/NenjimHubClient.tjava | 4 + .../crypto/r35157/nenjim/NenjimHubImpl.tjava | 93 + .../nenjim/NenjimHubRPCAdminAdapter.tjava | 24 + .../nenjim/NenjimHubRPCAdminBridge.tjava | 4 + .../nenjim/NenjimHubRestAdminAdapter.tjava | 23 + .../nenjim/NenjimHubRestAdminBridge.tjava | 4 + .../nenjim/NenjimHubSocketAdminAdapter.tjava | 116 ++ .../nenjim/NenjimHubSocketAdminBridge.tjava | 4 + .../nenjim/NenjimHubSocketAdminClient.tjava | 74 + .../crypto/r35157/nenjim/NenjimProcess.tjava | 10 + .../crypto/r35157/nenjim/NenjimTestTool.tjava | 245 +++ .../crypto/r35157/nenjim/ProjectConfig.tjava | 14 + .../r35157/nenjim/SodaTaskManager.tjava | 25 + .../crypto/r35157/nenjim/SuwimoHub.tjava | 23 + .../nenjim/SystemEnvironmentProperties.tjava | 27 + .../scorevisualizer/ScoreVisualizerApp.tjava | 209 +++ 100 files changed, 6910 insertions(+), 2 deletions(-) create mode 100644 src/main/tjava/crypto/r35157/RingBuffer.tjava create mode 100644 src/main/tjava/crypto/r35157/SemVer.tjava create mode 100644 src/main/tjava/crypto/r35157/TimeTools.tjava create mode 100644 src/main/tjava/crypto/r35157/assetaz/hub/AssetAZHub.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/AssetAZ.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/CheckBoxTreeCell.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/FormatterUtils.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/NetworthChartController.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/PriceChartController.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/PriceFetcher.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/Ticker.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/TickerItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/TickerService.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/TimeUtils.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/Version.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogic.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogicImpl.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/Configuration.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/EarningsStatistics.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/EquilibriumStatus.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/Evelyn.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/EventWatcher.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/FundingFeeStatus.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/FutureBasePrices.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/InvalidAPIKeyException.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCFuturesPlugin.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCOpenType.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCOrderType.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCPositionSide.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCProtocolErrors.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCSpotPlugin.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/MEXCType.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/NextCloseableInfo.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/Portfolio.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/Position.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/PositionSideAction.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/PositionStatistics.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/PositionType.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/PositionValues.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ProfitLoss.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/PushNotifier.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculator.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorImpl.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorInput.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ServiceResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ThousandsSeparatorFormatter.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/TimeOuts.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetails.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetailsResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsService.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsServiceImpl.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/RetryingMEXCAccountDetailsServiceDecorator.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetails.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetailsDeserializer.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/RiskLimitCustom.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/FundingRatesResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRateItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRates.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRatesDeserializer.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndex.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndexItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/leverage/LeverageInfoResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfo.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoDeserializer.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/nenjim/NenjimObjectRepository.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/FuturesTickerResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTicker.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerDeserializer.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionItem.tjava create mode 100644 src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionResponse.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/DirectoryClassLoader.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/DockerSwarmSecrets.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/EncryptedFileProperties.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/ImmutableProperties.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimCLIAdminClient.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimContext.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHub.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubClient.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubImpl.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminAdapter.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminBridge.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminAdapter.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminBridge.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminAdapter.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminBridge.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminClient.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimProcess.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/NenjimTestTool.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/ProjectConfig.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/SodaTaskManager.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/SuwimoHub.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/SystemEnvironmentProperties.tjava create mode 100644 src/main/tjava/crypto/r35157/nenjim/scorevisualizer/ScoreVisualizerApp.tjava diff --git a/build.gradle.kts b/build.gradle.kts index 6737111..85311d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ if (version == "UNSET" && gradle.startParameter.taskNames.any { it.startsWith("p application { mainClass.set("com.r35157.nenjim.hubd.impl.ref.Main") + applicationDefaultJvmArgs = listOf("--enable-preview") } repositories { @@ -43,9 +44,13 @@ dependencies { runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0") runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0") - implementation("org.slf4j:slf4j-api:2.0.18") implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") implementation("com.fazecast:jSerialComm:2.11.4") + implementation("com.google.code.gson:gson:2.14.0") + implementation("commons-codec:commons-codec:1.22.0") + implementation("org.apache.commons:commons-collections4:4.5.0") + implementation("org.apache.commons:commons-lang3:3.20.0") + implementation("org.slf4j:slf4j-api:2.0.18") } java { @@ -54,7 +59,15 @@ java { tasks.withType().configureEach { options.release.set(25) - options.compilerArgs.addAll(listOf("-Xmaxerrs", "1")) + + options.compilerArgs.addAll( + listOf( + "--enable-preview", + //"-Xlint:deprecation", + //"-Xlint:unchecked", + "-Xmaxerrs", "1" + ) + ) } val generatedDetagMain = layout.buildDirectory.dir("generated/sources/detag/main/java") diff --git a/debug.sh b/debug.sh index f39db2a..4731ebd 100755 --- a/debug.sh +++ b/debug.sh @@ -17,6 +17,7 @@ DEBUG_SUSPEND="${DEBUG_SUSPEND:-y}" echo "Starting NenjimHub in DEBUG mode on port ${DEBUG_PORT} (suspend=${DEBUG_SUSPEND})" exec java \ + --enable-preview \ "-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND},address=*:${DEBUG_PORT}" \ -Dlog4j.configurationFile=conf/log4j2.xml \ -cp "$CLASSPATH" \ diff --git a/src/main/docker/run.sh b/src/main/docker/run.sh index 1c2de87..a524fd7 100755 --- a/src/main/docker/run.sh +++ b/src/main/docker/run.sh @@ -12,6 +12,7 @@ fi CLASSPATH=$(IFS=:; echo "${jars[*]}") exec java \ + --enable-preview \ -Dlog4j.configurationFile=conf/log4j2.xml \ -cp "$CLASSPATH" \ com.r35157.nenjim.hubd.impl.ref.Main diff --git a/src/main/tjava/crypto/r35157/RingBuffer.tjava b/src/main/tjava/crypto/r35157/RingBuffer.tjava new file mode 100644 index 0000000..2907346 --- /dev/null +++ b/src/main/tjava/crypto/r35157/RingBuffer.tjava @@ -0,0 +1,35 @@ +package crypto.r35157; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +public class RingBuffer { + public RingBuffer(int capacity) { + this.capacity = capacity; + this.buffer = new ArrayDeque<>(capacity); + } + + public void add(String command) { + if (getSize() == capacity) { + buffer.removeFirst(); + } + buffer.addLast(command); + } + + public List getAll() { + return new ArrayList<>(buffer); + } + + public int getSize() { + return buffer.size(); + } + + public int getCapacity() { + return capacity; + } + + private final int capacity; + private final Deque buffer; +} diff --git a/src/main/tjava/crypto/r35157/SemVer.tjava b/src/main/tjava/crypto/r35157/SemVer.tjava new file mode 100644 index 0000000..2aa7c24 --- /dev/null +++ b/src/main/tjava/crypto/r35157/SemVer.tjava @@ -0,0 +1,13 @@ +package crypto.r35157; + +public class SemVer { + public SemVer(String semVer) { + this.semVer = semVer; + } + + public String getSemVer() { + return semVer; + } + + private String semVer; +} diff --git a/src/main/tjava/crypto/r35157/TimeTools.tjava b/src/main/tjava/crypto/r35157/TimeTools.tjava new file mode 100644 index 0000000..77589ac --- /dev/null +++ b/src/main/tjava/crypto/r35157/TimeTools.tjava @@ -0,0 +1,322 @@ +package crypto.r35157; + +import java.time.Duration; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.ArrayList; +import java.util.List; + +public class TimeTools { + public static String formatLaterTime(long millis) { + LocalTime now = LocalTime.now(); + Duration duration = Duration.ofMillis(millis); + LocalTime futureTime = now.plus(duration); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss"); + String timeStr = futureTime.format(formatter); + + return timeStr; + } + + /** + * Converts a time string into milliseconds. + * Supported units: + * y = year (average Gregorian year = 365.2425 days) + * M = month (average Gregorian month = 30.436875 days) + * w = week (7 days) + * d = day + * h = hour + * m = minute + * s = second + * + * Note: The conversion for years and months is approximate. + * + * @param timeString the input time string (e.g., "1w3d" or "1h40m") + * @return the total time in milliseconds + * @throws IllegalArgumentException if an unsupported unit is encountered + */ + public static long parseTimeToMillis(String timeString) { + long millis = 0L; + // Regular expression to match sequences like "1y", "3d", "40m", etc. + Pattern pattern = Pattern.compile("(\\d+)([yMwdhms])"); + Matcher matcher = pattern.matcher(timeString); + + // Base conversion factors: + final long millisPerSecond = 1000L; + final long millisPerMinute = millisPerSecond * 60; + final long millisPerHour = millisPerMinute * 60; + final long millisPerDay = millisPerHour * 24; + final long millisPerWeek = millisPerDay * 7; + // Average values for Gregorian calendar + final double millisPerYear = 365.2425 * millisPerDay; + final double millisPerMonth = millisPerYear / 12; + + while (matcher.find()) { + long value = Long.parseLong(matcher.group(1)); + String unit = matcher.group(2); + + switch(unit) { + case "y": + millis += (long)(value * millisPerYear); + break; + case "M": + millis += (long)(value * millisPerMonth); + break; + case "w": + millis += value * millisPerWeek; + break; + case "d": + millis += value * millisPerDay; + break; + case "h": + millis += value * millisPerHour; + break; + case "m": + millis += value * millisPerMinute; + break; + case "s": + millis += value * millisPerSecond; + break; + default: + throw new IllegalArgumentException("Invalid time unit: " + unit); + } + } + + return millis; + } + + /** + * Formats a duration (in milliseconds) using up to maxElements most-significant parts. + * Rounding is applied to the last displayed element. If rounding occurs or any lower parts + * are dropped, the output is prefixed with "≈". + * + * Examples: + * formatTimeFromMillis(691200000L, 1) returns "8 days" + * formatTimeFromMillis(691200001L, 1) returns "≈8 days" + * formatTimeFromMillis(691200001L, 2) returns "8 days and 1 millisecond" + * formatTimeFromMillis(183856012L) returns "2 days, 3 hours, 4 minutes, 16 seconds, 12 milliseconds" + * formatTimeFromMillis(183856012L, 1) returns "≈2 days" + * formatTimeFromMillis(183856012L, 3) returns "≈2 days, 3 hours, 4 minutes" + * formatTimeFromMillis(285000L, 2) returns "4 minutes and 45 seconds" + * formatTimeFromMillis(285000L, 1) returns "≈5 minutes" + * + * Years and months are included if the duration is long enough. + * + * @param totalMs the total duration in milliseconds. + * @param maxElements maximum number of time parts to show (must be >= 1) + * @return the formatted time string. + */ + public static String formatTimeFromMillis(long totalMs, int maxElements) { + if (maxElements < 1) { + throw new IllegalArgumentException("maxElements must be at least 1"); + } + + // Compute each unit in order. + long years = (long)(totalMs / MILLIS_PER_YEAR); + long remainder = totalMs - (long)(years * MILLIS_PER_YEAR); + + long months = (long)(remainder / MILLIS_PER_MONTH); + remainder -= (long)(months * MILLIS_PER_MONTH); + + long days = remainder / MILLIS_PER_DAY; + remainder %= MILLIS_PER_DAY; + long hours = remainder / MILLIS_PER_HOUR; + remainder %= MILLIS_PER_HOUR; + long minutes = remainder / MILLIS_PER_MINUTE; + remainder %= MILLIS_PER_MINUTE; + long seconds = remainder / MILLIS_PER_SECOND; + long milliseconds = remainder % MILLIS_PER_SECOND; + + // Breakdown values in order: years, months, days, hours, minutes, seconds, milliseconds. + long[] values = new long[] { years, months, days, hours, minutes, seconds, milliseconds }; + double[] factors = new double[] { MILLIS_PER_YEAR, MILLIS_PER_MONTH, MILLIS_PER_DAY, + MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, 1 }; + + // Determine the first nonzero unit (if all zero, use the smallest unit). + int startIndex = 0; + while (startIndex < NUM_UNITS && values[startIndex] == 0) { + startIndex++; + } + if (startIndex == NUM_UNITS) { + startIndex = NUM_UNITS - 1; + } + + // Build a list of nonzero components starting from startIndex. + List components = new ArrayList<>(); + for (int i = startIndex; i < NUM_UNITS; i++) { + if (values[i] != 0) { + components.add(new Component(i, values[i])); + } + } + // Ensure at least one component is present. + if (components.isEmpty()) { + components.add(new Component(NUM_UNITS - 1, 0)); + } + + // If the caller wants to show all components (or more than available), join them exactly. + if (maxElements >= components.size()) { + return joinComponents(components, false); + } + + // Otherwise, show (maxElements - 1) components exactly, and the last one rounded. + List resultComponents = new ArrayList<>(); + for (int i = 0; i < maxElements - 1; i++) { + resultComponents.add(components.get(i)); + } + int lastUnitIndex = components.get(maxElements - 1).unitIndex; + + // Sum the contribution (in ms) of all units more significant than the last displayed unit. + double msAccounted = 0; + for (int i = startIndex; i < lastUnitIndex; i++) { + msAccounted += values[i] * factors[i]; + } + + // Compute the remaining ms for the last unit. + double valueExact = (totalMs - msAccounted) / factors[lastUnitIndex]; + long roundedValue = Math.round(valueExact); + + // Mark approximation if any lower parts were dropped or if rounding changed the exact value. + boolean approx = (components.size() > maxElements) || (Math.abs(valueExact - Math.floor(valueExact)) > 1e-9); + resultComponents.add(new Component(lastUnitIndex, roundedValue)); + + return joinComponents(resultComponents, approx); + } + + /** + * Overload that returns the full breakdown (all nonzero parts). + */ + public static String formatTimeFromMillis(long totalMs) { + return formatTimeFromMillis(totalMs, NUM_UNITS); + } + + /** + * Overloaded method that converts the entire duration into a single unit (as specified by the enum). + * For example: + * formatTimeFromMillis(400L * MILLIS_PER_DAY, TimeUnit.HOURS) returns "9600 hours" + * formatTimeFromMillis(400L * MILLIS_PER_DAY - 1, TimeUnit.HOURS) returns "≈9600 hours" + * + * @param totalMs the total duration in milliseconds. + * @param unit the target time unit. + * @return the formatted string representing the duration in the given unit. + */ + public static String formatTimeFromMillis(long totalMs, TimeUnit unit) { + double factor; + switch(unit) { + case YEARS: + factor = MILLIS_PER_YEAR; + break; + case MONTHS: + factor = MILLIS_PER_MONTH; + break; + case DAYS: + factor = MILLIS_PER_DAY; + break; + case HOURS: + factor = MILLIS_PER_HOUR; + break; + case MINUTES: + factor = MILLIS_PER_MINUTE; + break; + case SECONDS: + factor = MILLIS_PER_SECOND; + break; + case MILLISECONDS: + factor = 1; + break; + default: + throw new IllegalArgumentException("Unsupported time unit: " + unit); + } + double exactValue = totalMs / factor; + long roundedValue = Math.round(exactValue); + boolean approx = (Math.abs(exactValue - roundedValue) > 1e-9); + return (approx ? "≈" : "") + unit.format(roundedValue); + } + + // Joins the list of components into a single string. + // If approx is true, the string is prefixed with "≈". + private static String joinComponents(List comps, boolean approx) { + List parts = new ArrayList<>(); + for (Component comp : comps) { + parts.add(comp.value + " " + unitName(comp.unitIndex, comp.value)); + } + String result; + if (parts.size() == 1) { + result = parts.get(0); + } else if (parts.size() == 2) { + result = parts.get(0) + " and " + parts.get(1); + } else { + result = String.join(", ", parts); + } + return approx ? "≈" + result : result; + } + + // Returns the proper singular or plural name for each unit based on its index. + private static String unitName(int unitIndex, long value) { + String result = switch (unitIndex) { + case 0 -> value == 1 ? "year" : "years"; + case 1 -> value == 1 ? "month" : "months"; + case 2 -> value == 1 ? "day" : "days"; + case 3 -> value == 1 ? "hour" : "hours"; + case 4 -> value == 1 ? "minute" : "minutes"; + case 5 -> value == 1 ? "second" : "seconds"; + case 6 -> value == 1 ? "millisecond" : "milliseconds"; + default -> ""; + }; + return result; + } + + private static class Component { + int unitIndex; + long value; + Component(int unitIndex, long value) { + this.unitIndex = unitIndex; + this.value = value; + } + } + + /** + * Enum representing time units with singular and plural names. + */ + enum TimeUnit { + YEARS("year", "years"), + MONTHS("month", "months"), + DAYS("day", "days"), + HOURS("hour", "hours"), + MINUTES("minute", "minutes"), + SECONDS("second", "seconds"), + MILLISECONDS("millisecond", "milliseconds"); + + private final String singular; + private final String plural; + + TimeUnit(String singular, String plural) { + this.singular = singular; + this.plural = plural; + } + + /** + * Formats the value with the appropriate singular or plural name. + * @param value the numeric value + * @return a formatted string, e.g. "1 day" or "5 days" + */ + public String format(long value) { + return value + " " + (value == 1 ? singular : plural); + } + } + + // Base units (using long for fixed units). + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = MILLIS_PER_SECOND * 60; + private static final long MILLIS_PER_HOUR = MILLIS_PER_MINUTE * 60; + private static final long MILLIS_PER_DAY = MILLIS_PER_HOUR * 24; + + // Approximate values for months and years (as doubles). + private static final double MILLIS_PER_YEAR = 365.2425 * MILLIS_PER_DAY; + private static final double MILLIS_PER_MONTH = MILLIS_PER_YEAR / 12; + + // The order of units from most-significant to least: + // 0: years, 1: months, 2: days, 3: hours, 4: minutes, 5: seconds, 6: milliseconds. + private static final int NUM_UNITS = 7; +} diff --git a/src/main/tjava/crypto/r35157/assetaz/hub/AssetAZHub.tjava b/src/main/tjava/crypto/r35157/assetaz/hub/AssetAZHub.tjava new file mode 100644 index 0000000..43b66b1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/assetaz/hub/AssetAZHub.tjava @@ -0,0 +1,27 @@ +package crypto.r35157.assetaz.hub; + +import crypto.r35157.nenjim.NenjimContext; +import crypto.r35157.nenjim.NenjimHub; +import crypto.r35157.nenjim.NenjimProcess; + +public class AssetAZHub implements NenjimProcess { + @Override + public void run() throws Exception { + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "AssetAZ Hub"; + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/AssetAZ.tjava b/src/main/tjava/crypto/r35157/cauldron/AssetAZ.tjava new file mode 100644 index 0000000..7a35ed8 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/AssetAZ.tjava @@ -0,0 +1,23 @@ +package crypto.r35157.cauldron; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.stage.Stage; + +import java.io.IOException; + +public class AssetAZ extends Application { + @Override + public void start(Stage stage) throws IOException { + FXMLLoader fxmlLoader = new FXMLLoader(AssetAZ.class.getResource("Structure.fxml")); + Scene scene = new Scene(fxmlLoader.load(), 1200, 980); + stage.setTitle("AssetAZ"); + stage.setScene(scene); + stage.show(); + } + + public static void main(String[] args) { + launch(); + } +} \ No newline at end of file diff --git a/src/main/tjava/crypto/r35157/cauldron/CheckBoxTreeCell.tjava b/src/main/tjava/crypto/r35157/cauldron/CheckBoxTreeCell.tjava new file mode 100644 index 0000000..d38219d --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/CheckBoxTreeCell.tjava @@ -0,0 +1,27 @@ +package crypto.r35157.cauldron; + +import javafx.scene.control.CheckBox; +import javafx.scene.control.TreeCell; + +public class CheckBoxTreeCell extends TreeCell { + private CheckBox checkBox; + + public CheckBoxTreeCell() { + checkBox = new CheckBox(); + checkBox.setDisable(true); // Disable the checkbox so it only changes when the item is selected + setGraphic(checkBox); + } + + @Override + public void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + setText(null); + setGraphic(null); + } else { + setText(item); + setGraphic(checkBox); + } + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/FormatterUtils.tjava b/src/main/tjava/crypto/r35157/cauldron/FormatterUtils.tjava new file mode 100644 index 0000000..db6e593 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/FormatterUtils.tjava @@ -0,0 +1,33 @@ +package crypto.r35157.cauldron; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + +public class FormatterUtils { + public static String formatPercentage(double val) { + DecimalFormatSymbols symbols = new DecimalFormatSymbols(Locale.getDefault()); + symbols.setGroupingSeparator('.'); + symbols.setDecimalSeparator(','); + + // Create a DecimalFormat object with a pattern + DecimalFormat df = new DecimalFormat("#,##0.00", symbols); + + // Format the double value + String formattedValue = df.format(val * 100) + "%"; + return formattedValue; + } + + public static String formatAmount(double val) { + DecimalFormatSymbols symbols = new DecimalFormatSymbols(Locale.getDefault()); + symbols.setGroupingSeparator('.'); + symbols.setDecimalSeparator(','); + + // Create a DecimalFormat object with a pattern + DecimalFormat df = new DecimalFormat("#,##0.00", symbols); + + // Format the double value + String formattedValue = df.format(val); + return formattedValue; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/NetworthChartController.tjava b/src/main/tjava/crypto/r35157/cauldron/NetworthChartController.tjava new file mode 100644 index 0000000..a9c5e86 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/NetworthChartController.tjava @@ -0,0 +1,314 @@ +package crypto.r35157.cauldron; + +import javafx.application.Platform; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.chart.CategoryAxis; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.StackedAreaChart; +import javafx.scene.chart.XYChart; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.util.Callback; + +import java.text.NumberFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static crypto.r35157.cauldron.FormatterUtils.formatAmount; +import static crypto.r35157.cauldron.FormatterUtils.formatPercentage; +import static crypto.r35157.cauldron.Ticker.getTickerItems; +import static crypto.r35157.cauldron.TimeUtils.ONE_DAY; +import static crypto.r35157.cauldron.TimeUtils.formatter_hhmm; +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; +import static java.util.concurrent.TimeUnit.MINUTES; + +public class NetworthChartController { + + private void startChartUpdater() { + ScheduledExecutorService executorService = newSingleThreadScheduledExecutor(); + Runnable task = this::updateUI; + executorService.scheduleAtFixedRate(task, 0, POLL_INTERVAL, POLL_TIMEUNIT); + } + + /* This synchronized as both the scheduler and manual UI changes can result in the UI gets updated */ + private synchronized void updateUI() { + long currentTimeStamp = System.currentTimeMillis(); + List tickerItems_solana; + List tickerItems_ethereum; + List tickerItems_bitcoincash; + List tickerItems_monero; + List tickerItems_usdc; + List tickerItems_bitcoin; + + try { + tickerItems_solana = getTickerItems(ASSETID_SOLANA, currentTimeStamp - displayPeriod, currentTimeStamp); + tickerItems_ethereum = getTickerItems(ASSETID_ETHEREUM, currentTimeStamp - displayPeriod, currentTimeStamp); + tickerItems_bitcoincash = getTickerItems(ASSETID_BITCOINCASH, currentTimeStamp - displayPeriod, currentTimeStamp); + tickerItems_monero = getTickerItems(ASSETID_MONERO, currentTimeStamp - displayPeriod, currentTimeStamp); + tickerItems_usdc = getTickerItems(ASSETID_USDC, currentTimeStamp - displayPeriod, currentTimeStamp); + tickerItems_bitcoin = getTickerItems(ASSETID_BITCOIN, currentTimeStamp - displayPeriod, currentTimeStamp); + } catch(Exception e) { + e.printStackTrace(); + tickerItems_solana = Collections.emptyList(); + tickerItems_ethereum = Collections.emptyList(); + tickerItems_bitcoincash = Collections.emptyList(); + tickerItems_monero = Collections.emptyList(); + tickerItems_usdc = Collections.emptyList(); + tickerItems_bitcoin = Collections.emptyList(); + } + + if (!tickerItems_solana.isEmpty()) { + double latestSOLPrice = getLastPrice(tickerItems_solana); +// double latestETHPrice = getLastPrice(tickerItems_ethereum); +// double latestBCHPrice = getLastPrice(tickerItems_bitcoincash); +// double latestXMRPrice = getLastPrice(tickerItems_monero); +// double latestUSDCPrice = getLastPrice(tickerItems_usdc); +// double latestBTCPrice = getLastPrice(tickerItems_bitcoin); + +// Number price_sol = prices.get("solana"); +// Number price_btc = prices.get("bitcoin"); +// Number price_ethereum = prices.get("ethereum"); +// Number price_bitcoincash = prices.get("bitcoin-cash"); +// Number price_usdollarcoin = prices.get("usd-coin"); +// Number price_monero = prices.get("monero"); +// Number price_fiatdkk = 1.0d; + + //Number value_skyld_maria = balance_liability_maria.doubleValue() * price_btc.doubleValue(); + //Number value_skyld_emil = balance_liability_emil.doubleValue() * price_monero.doubleValue(); + //Number value_skyld_sofie = balance_liability_sofie.doubleValue() * price_monero.doubleValue(); + Number value_sol = balance_solana.doubleValue() * latestSOLPrice; + //Number value_btc = balance_bitcoin.doubleValue() * price_btc.doubleValue(); + //Number value_ethereum = balance_ethereum.doubleValue() * price_ethereum.doubleValue(); + //Number value_bitcoincash = balance_bitcoincash.doubleValue() * price_bitcoincash.doubleValue(); + //Number value_usdollarcoin = balance_usdollarcoin.doubleValue() * price_usdollarcoin.doubleValue(); + //Number value_monero = balance_monero.doubleValue() * price_monero.doubleValue(); + //Number value_fiatdkk = balance_fiatdkk.doubleValue() * price_fiatdkk.doubleValue(); + + Number totalValue = + // value_skyld_maria.doubleValue() + // + value_skyld_emil.doubleValue() + // + value_skyld_sofie.doubleValue() + value_sol.doubleValue(); + // + value_btc.doubleValue() + // + value_ethereum.doubleValue() + // + value_bitcoincash.doubleValue() + // + value_usdollarcoin.doubleValue() + // + value_monero.doubleValue() + // + value_fiatdkk.doubleValue(); + + boolean addChangeDetailsToTitle = !series_asset_fiatdkk.getData().isEmpty(); + final String title = calculateTitle(totalValue.doubleValue(), addChangeDetailsToTitle); + final List finalTickerItems_solana = tickerItems_solana; + + Platform.runLater(() -> { + // Add Solana + for(TickerItem tickerItem : finalTickerItems_solana) { + timeStampForLastValueOnChart = tickerItem.timestamp(); + Instant instant = Instant.ofEpochMilli(timeStampForLastValueOnChart); + LocalDateTime ts = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + String tsTxt = ts.format(formatter_hhmm); + Number val = balance_solana.doubleValue() * tickerItem.price(); + seriesData_asset_solana.add(new XYChart.Data<>(tsTxt, val)); + } + + chart.setTitle(title); + }); + } + } + + private String calculateTitle(double totalValue, boolean addChangeDetails) { + String title = "Networth: " + formatAmount(totalValue) + " DKK"; + + if(addChangeDetails) { + double historicTotal = calculateInitialNetworthTotal(); + double change = totalValue - historicTotal; + title += " ("; + title += (change > 0) ? "+" : ""; + title += formatAmount(change) + " DKK, "; + + double pct = change / historicTotal; + title += (pct > 0) ? "+" : ""; + title += formatPercentage(pct) + ")"; + } + + return title; + } + + private double calculateInitialNetworthTotal() { + double total = + series_liability_maria.getData().getFirst().getYValue().doubleValue() + + series_liability_emil.getData().getFirst().getYValue().doubleValue() + + series_liability_sofie.getData().getFirst().getYValue().doubleValue() + + series_asset_solana.getData().getFirst().getYValue().doubleValue() + + series_asset_bitcoin.getData().getFirst().getYValue().doubleValue() + + series_asset_ethereum.getData().getFirst().getYValue().doubleValue() + + series_asset_bitcoincash.getData().getFirst().getYValue().doubleValue() + + series_asset_usdollarcoin.getData().getFirst().getYValue().doubleValue() + + series_asset_monero.getData().getFirst().getYValue().doubleValue() + + series_asset_fiatdkk.getData().getFirst().getYValue().doubleValue(); + + return total; + } + + private double getLastPrice(List tickerItems) { + double price = 0; + TickerItem item = tickerItems.getLast(); + if(item != null) { + price = item.price(); + } + return price; + } + + @FXML + private void initialize() { + treeView_accounts.setShowRoot(false); + TreeItem rootItem = new TreeItem<>(); + rootItem.setExpanded(true); + + TreeItem item1 = new TreeItem<>("Assets"); + TreeItem item2 = new TreeItem<>("Liabilities"); + + rootItem.getChildren().addAll(item1, item2); + + treeView_accounts.setRoot(rootItem); + treeView_accounts.setCellFactory(new Callback, TreeCell>() { + @Override + public TreeCell call(TreeView param) { + return new CheckBoxTreeCell(); + } + }); + + chart.setCreateSymbols(false); + + axisY.setUpperBound(50000); + axisY.setLowerBound(-15000); + axisY.setTickUnit(5000); + axisY.setLabel("DKK"); + axisY.setTickLabelFormatter(new NumberAxis.DefaultFormatter(axisY) { + private final NumberFormat formatter; + + { + // Create a custom locale for Denmark + Locale danishLocale = new Locale("da", "DK"); + formatter = NumberFormat.getInstance(danishLocale); + formatter.setMinimumFractionDigits(2); + formatter.setMaximumFractionDigits(2); + } + + @Override + public String toString(Number object) { + return formatter.format(object); + } + }); + axisY.setAutoRanging(false); + + series_asset_solana = new XYChart.Series<>(); + series_asset_solana.setName("Solana"); + series_asset_bitcoin = new XYChart.Series<>(); + series_asset_bitcoin.setName("Bitcoin"); + series_asset_ethereum = new XYChart.Series<>(); + series_asset_ethereum.setName("Ethereum"); + series_asset_bitcoincash = new XYChart.Series<>(); + series_asset_bitcoincash.setName("Bitcoin Cash"); + series_asset_usdollarcoin = new XYChart.Series<>(); + series_asset_usdollarcoin.setName("USD Coin"); + series_asset_monero = new XYChart.Series<>(); + series_asset_monero.setName("Monero"); + series_asset_fiatdkk = new XYChart.Series<>(); + series_asset_fiatdkk.setName("Fiat DKK"); + series_liability_maria = new XYChart.Series<>(); + series_liability_maria.setName("Skyld til Maria"); + series_liability_emil = new XYChart.Series<>(); + series_liability_emil.setName("Skyld til Emil"); + series_liability_sofie = new XYChart.Series<>(); + series_liability_sofie.setName("Skyld til Sofie"); + + seriesData_asset_solana = series_asset_solana.getData(); + + ObservableList> chartData = chart.getData(); + + chartData.addAll( + //series_liability_maria, + //series_liability_emil, + //series_liability_sofie, + series_asset_solana + //series_asset_fiatdkk, + //series_asset_bitcoin, + //series_asset_ethereum, + //series_asset_bitcoincash, + //series_asset_usdollarcoin, + //series_asset_monero + ); + + this.displayPeriod = ONE_DAY; + this.timeStampForLastValueOnChart = 0L; + + startChartUpdater(); + } + + @FXML + private TreeView treeView_accounts; + + @FXML + private StackedAreaChart chart; + + @FXML + private CategoryAxis axisX; + + @FXML + private NumberAxis axisY; + + private static final TimeUnit POLL_TIMEUNIT = MINUTES; + private static final int POLL_INTERVAL = 15; + + private XYChart.Series series_asset_solana; + private XYChart.Series series_asset_bitcoin; + private XYChart.Series series_asset_ethereum; + private XYChart.Series series_asset_bitcoincash; + private XYChart.Series series_asset_usdollarcoin; + private XYChart.Series series_asset_monero; + private XYChart.Series series_asset_fiatdkk; + private XYChart.Series series_liability_maria; + private XYChart.Series series_liability_sofie; + private XYChart.Series series_liability_emil; + + private ObservableList> seriesData_asset_solana; + private ObservableList> seriesData_asset_bitcoin; + private ObservableList> seriesData_asset_ethereum; + private ObservableList> seriesData_asset_bitcoincash; + private ObservableList> seriesData_asset_usdollarcoin; + private ObservableList> seriesData_asset_monero; + private ObservableList> seriesData_asset_fiatdkk; + private ObservableList> seriesData_liability_maria; + private ObservableList> seriesData_liability_sofie; + private ObservableList> seriesData_liability_emil; + + private final Number balance_solana = 43.415906951d; + private final Number balance_bitcoin = 0.00016755d; + private final Number balance_ethereum = 0.00233255d; + private final Number balance_bitcoincash = 0.0069836d; + private final Number balance_usdollarcoin = 0.931486d; + private final Number balance_monero = 0.00213335d; + private final Number balance_fiatdkk = 14000d; + + private final Number balance_liability_maria = -0.00887165; + private final Number balance_liability_sofie = -3.57; + private final Number balance_liability_emil = -5.78; + + private final String ASSETID_SOLANA = "solana"; + private final String ASSETID_ETHEREUM = "ethereum"; + private final String ASSETID_BITCOINCASH = "bitcoin-cash"; + private final String ASSETID_MONERO = "monero"; + private final String ASSETID_USDC = "usd-coin"; + private final String ASSETID_BITCOIN = "bitcoin"; + private final String ASSETID_DKK = "fiatdkk"; + + private long displayPeriod; + private long timeStampForLastValueOnChart; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/PriceChartController.tjava b/src/main/tjava/crypto/r35157/cauldron/PriceChartController.tjava new file mode 100644 index 0000000..a0ad989 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/PriceChartController.tjava @@ -0,0 +1,271 @@ +package crypto.r35157.cauldron; + +import javafx.application.Platform; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; + +import java.text.NumberFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static crypto.r35157.cauldron.FormatterUtils.formatAmount; +import static crypto.r35157.cauldron.FormatterUtils.formatPercentage; +import static crypto.r35157.cauldron.Ticker.getTickerItems; +import static crypto.r35157.cauldron.TimeUtils.*; +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; +import static java.util.concurrent.TimeUnit.MINUTES; + +public class PriceChartController { + + private void startChartUpdater() { + ScheduledExecutorService executorService = newSingleThreadScheduledExecutor(); + Runnable task = this::updateUI; + executorService.scheduleAtFixedRate(task, 0, POLL_INTERVAL, POLL_TIMEUNIT); + } + + /* This synchronized as both the scheduler and manual UI changes can result in the UI gets updated */ + private synchronized void updateUI() { + long currentTimeStamp = System.currentTimeMillis(); + List tickerItems; + + try { + tickerItems = getTickerItems( + ASSETID__SOLANA, + currentTimeStamp - displayPeriod, + currentTimeStamp + ); + } catch(Exception e) { + e.printStackTrace(); + tickerItems = Collections.emptyList(); + } + + if (!tickerItems.isEmpty()) { + double latestPrice = getLastPrice(tickerItems); + final List finalTickerItems = tickerItems; + + Platform.runLater(() -> { + seriesData.clear(); + // TODO: This update could be done a bit cheaper instead of just clearing everything and recreating it all. + // There might be a little issue in identifying the points as in seriesData we have only human readable + // timestamp and not the unix epoch. + for(TickerItem tickerItem : finalTickerItems) { + timeStampForLastPriceOnChart = tickerItem.timestamp(); + Instant instant = Instant.ofEpochMilli(timeStampForLastPriceOnChart); + LocalDateTime ts = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + String tsTxt = ts.format(formatter_hhmm); + seriesData.add(new XYChart.Data<>(tsTxt, tickerItem.price())); + } + + setChartLineColor(latestPrice); + + boolean addChangeDetailsToTitle = seriesData.size() > 1; + final String title = calculateTitle(latestPrice, addChangeDetailsToTitle); + chart.setTitle(title); + + updatePriceYAxisBounds(); + }); + } + } + + private void setChartLineColor(double latestPrice) { + final String color; + if(seriesData.size() < 2) { + color = "black"; + } else { + double initialPrice = getInitialPrice(); + if(latestPrice == initialPrice) { + color = "black"; + } else { + color = (latestPrice > initialPrice) ? "darkgreen" : "darkred"; + } + } + + series.getNode().lookup(".chart-series-line").setStyle("-fx-stroke: " + color + ";"); + } + + private String calculateTitle(double price, boolean addChangeDetails) { + String title = "Solana price: " + formatAmount(price) + " DKK"; + + if (addChangeDetails) { + double historicPrice = getInitialPrice(); + double change = price - historicPrice; + title += " ("; + title += (change > 0) ? "+" : ""; + title += formatAmount(change) + " DKK, "; + + double pct = change / historicPrice; + title += (pct > 0) ? "+" : ""; + title += formatPercentage(pct) + ")"; + } + + return title; + } + + private double getInitialPrice() { + return seriesData.getFirst().getYValue().doubleValue(); + } + + private double getLastPrice(List tickerItems) { + double price = 0; + TickerItem item = tickerItems.getLast(); + if(item != null) { + price = item.price(); + } + return price; + } + + private void updatePriceYAxisBounds() { + double lowerBound = Double.MAX_VALUE; + double upperBound = Double.MIN_VALUE; + + for (XYChart.Data data : seriesData) { + double value = data.getYValue().doubleValue(); + + if (value < lowerBound) { + lowerBound = value; + } + + if (value > upperBound) { + upperBound = value; + } + } + + if(upperBound == lowerBound) { + upperBound += upperBound * 0.05; + lowerBound -= lowerBound * 0.05; + } else { + upperBound = upperBound + (upperBound - lowerBound) * 0.05; + lowerBound = lowerBound - (upperBound - lowerBound) * 0.05; + } + + double range = upperBound - lowerBound; + axisY.setTickUnit(range / 6); + axisY.setUpperBound(upperBound); + axisY.setLowerBound(lowerBound); + } + + private void temporarilyLockSomeUIDueToUnimplementation() { + menuButton_baseAssetType.setText("Fiat"); + menuButton_baseAssetType.setDisable(true); + + menuButton_quoteAssetType.setText("Crypto"); + menuButton_quoteAssetType.setDisable(true); + + menuButton_quoteDataSource.setText("Coin Gecko"); + menuButton_quoteDataSource.setDisable(true); + + menuButton_baseAssetId.setText("DKK - Danske Kroner"); + menuButton_baseAssetId.setDisable(true); + + menuButton_quoteAssetId.setText("SOL - Solana"); + menuButton_quoteAssetId.setDisable(true); + } + + @FXML + private void initialize() { + chart.setCreateSymbols(false); + chart.setLegendVisible(false); + + axisY.setLabel("DKK"); + axisY.setTickLabelFormatter(new NumberAxis.DefaultFormatter(axisY) { + private final NumberFormat formatter; + + { + // Create a custom locale for Denmark + Locale danishLocale = new Locale("da", "DK"); + formatter = NumberFormat.getInstance(danishLocale); + formatter.setMinimumFractionDigits(2); + formatter.setMaximumFractionDigits(2); + } + + @Override + public String toString(Number object) { + return formatter.format(object); + } + }); + axisY.setAutoRanging(false); + + series = new XYChart.Series<>(); + series.setName("Solana"); + + seriesData = series.getData(); + + ObservableList> chartData = chart.getData(); + chartData.add(series); + + this.displayPeriod = ONE_DAY; + this.timeStampForLastPriceOnChart = 0L; + this.menuButton_period.setText("24 Hours"); + + temporarilyLockSomeUIDueToUnimplementation(); + + startChartUpdater(); + } + + @FXML + private void periodChanged(ActionEvent event) { + MenuItem selectedItem = (MenuItem) event.getSource(); + String text = selectedItem.getText(); + + menuButton_period.setText(text); + + switch (text) { + case "24 Hours" -> this.displayPeriod = ONE_DAY; + case "1 Week" -> this.displayPeriod = ONE_WEEK; + case "1 Month" -> this.displayPeriod = ONE_MONTH; + case "3 Months" -> this.displayPeriod = ONE_QUARTER; + case "1 Year" -> this.displayPeriod = ONE_YEAR; + case "5 Years" -> this.displayPeriod = FIVE_YEARS; + case "10 Years" -> this.displayPeriod = ONE_DECADE; + case "All" -> this.displayPeriod = Long.MAX_VALUE; + } + + updateUI(); + } + + @FXML + private LineChart chart; + + @FXML + private NumberAxis axisY; + + @FXML + private MenuButton menuButton_period; + + @FXML + private MenuButton menuButton_baseAssetType; + + @FXML + private MenuButton menuButton_quoteAssetType; + + @FXML + private MenuButton menuButton_quoteDataSource; + + @FXML + private MenuButton menuButton_baseAssetId; + + @FXML + private MenuButton menuButton_quoteAssetId; + + private static final TimeUnit POLL_TIMEUNIT = MINUTES; + private static final int POLL_INTERVAL = 15; + + private XYChart.Series series; + private ObservableList> seriesData; + + private final String ASSETID__SOLANA = "solana"; + private long displayPeriod; + private long timeStampForLastPriceOnChart; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/PriceFetcher.tjava b/src/main/tjava/crypto/r35157/cauldron/PriceFetcher.tjava new file mode 100644 index 0000000..7dcbb55 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/PriceFetcher.tjava @@ -0,0 +1,109 @@ +package crypto.r35157.cauldron; + +//import org.json.JSONObject; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; + +public class PriceFetcher { + public static void main(String[] args) throws Exception { + new PriceFetcher(); + Thread.currentThread().join(); + } + + public PriceFetcher() { + File file = new File(PRICE_FILENAME); + boolean isFileEmpty = !file.exists() || file.length() == 0; + + ScheduledExecutorService executorService = newSingleThreadScheduledExecutor(); + + // Initializing BufferedWriter outside the try-with-resources block to manage it manually + BufferedWriter bw; + try { + FileWriter fw = new FileWriter(PRICE_FILENAME, true); + bw = new BufferedWriter(fw); + + if (isFileEmpty) { + bw.write(HEADER); + bw.newLine(); + } + + // Flushing the header line + bw.flush(); + + BufferedWriter finalBw = bw; // Needed to access bw inside the Runnable + Runnable task = () -> { + try { + ZonedDateTime now = ZonedDateTime.now(); + long timestamp = System.currentTimeMillis(); + String humanReadableTimestamp = now.format(formatter); + String sourceId = SOURCEID_COINGECKO; + Map prices = getPrices(); + + for(String assetId : prices.keySet()) { + double price = prices.get(assetId).doubleValue(); + finalBw.write(timestamp + ";" + humanReadableTimestamp + ";" + sourceId + ";" + "fiatdkk_" + assetId + ";" + price); + finalBw.newLine(); + } + + finalBw.flush(); + } catch (Exception e) { + e.printStackTrace(); + } + }; + + executorService.scheduleAtFixedRate(task, 0, POLL_INTERVAL, POLL_TIMEUNIT); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private Map getPrices() throws Exception { + String urlString = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana,monero,bitcoin-cash,usd-coin&vs_currencies=dkk"; + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + // Read the response + BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream())); + String inputLine; + StringBuilder content = new StringBuilder(); + while ((inputLine = in.readLine()) != null) { + content.append(inputLine); + } + in.close(); + + // Parse JSON response + /* + JSONObject jsonResponse = new JSONObject(content.toString()); + + // Map response to a simple HashMap + Map cryptoPrices = new HashMap<>(); + for (String crypto : jsonResponse.keySet()) { + JSONObject cryptoData = jsonResponse.getJSONObject(crypto); + double price = cryptoData.getDouble("dkk"); + cryptoPrices.put(crypto, price); + } + + return cryptoPrices; + */ + return null; + } + + private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss z"); + private static final String PRICE_FILENAME = "prices.csv"; + private static final String HEADER = "# Epoch ; Human readable timestamp ; Source ID ; Pair (for example fiatdkk_bitcoin) ; Price"; + + private static final String SOURCEID_COINGECKO = "CoinGecko"; + private static final TimeUnit POLL_TIMEUNIT = TimeUnit.MINUTES; + private static final int POLL_INTERVAL = 15; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/Ticker.tjava b/src/main/tjava/crypto/r35157/cauldron/Ticker.tjava new file mode 100644 index 0000000..311579e --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/Ticker.tjava @@ -0,0 +1,100 @@ +package crypto.r35157.cauldron; + +import org.jetbrains.annotations.NotNull; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.*; + +public class Ticker { + public static @NotNull List getTickerItems( + long startTimeStamp, + long stopTimeStamp + ) { + return getTickerItems((Collection) null, startTimeStamp, stopTimeStamp); + } + + public static @NotNull List getTickerItems( + String assetId, + long startTimeStamp, + long stopTimeStamp + ) { + return getTickerItems(Set.of(assetId), startTimeStamp, stopTimeStamp); + } + + public static @NotNull List getTickerItems( + Collection assetIds, + long startTimeStamp, + long stopTimeStamp + ) { + List tickerItems = new ArrayList<>(); + + try (BufferedReader br = new BufferedReader(new FileReader(FILEPATH_PRICES))) { + String line; + + while ((line = br.readLine()) != null) { + if (line.startsWith("#")) { + continue; + } + + int commentIndex = line.indexOf('#'); + if (commentIndex != -1) { + line = line.substring(0, commentIndex); + } + + String[] fields = line.split(";"); + + for (int i = 0; i < fields.length; i++) { + fields[i] = fields[i].trim(); + } + + long timestamp = Long.parseLong(fields[FIELD__EPOCH]); + + if(timestamp < startTimeStamp || timestamp > stopTimeStamp) { + // Outside requested period + continue; + } + + String source = fields[FIELD__SOURCE_ID]; + String humanReadableTimestamp = fields[FIELD__HUMAN_READABLE_TIMESTAMP]; + String pair = fields[FIELD__PAIR]; + int index = pair.indexOf("_"); + String base = pair.substring(0, index); + String quote = pair.substring(index + 1); + + if(assetIds != null && !assetIds.isEmpty() && !assetIds.contains(quote)) { + continue; + } + + double price = Double.parseDouble(fields[FIELD__PRICE]); + + TickerItem tickerItem = new TickerItem( + timestamp, + humanReadableTimestamp, + source, + base, + quote, + price + ); + + tickerItems.add(tickerItem); + } + } catch(IOException ioe) { + // TODO: Make handling of error more robust! + ioe.printStackTrace(); + } + + tickerItems.sort(Comparator.comparingLong(TickerItem::timestamp)); + + return tickerItems; + } + + private static final int FIELD__EPOCH = 0; + private static final int FIELD__HUMAN_READABLE_TIMESTAMP = 1; + private static final int FIELD__SOURCE_ID = 2; + private static final int FIELD__PAIR = 3; + private static final int FIELD__PRICE = 4; + + private static final String FILEPATH_PRICES = "prices.csv"; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/TickerItem.tjava b/src/main/tjava/crypto/r35157/cauldron/TickerItem.tjava new file mode 100644 index 0000000..9fcc80b --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/TickerItem.tjava @@ -0,0 +1,11 @@ +package crypto.r35157.cauldron; + +public record TickerItem( + long timestamp, + String humanReadableTimestamp, + String source, + String base, + String quote, + double price +) { +} diff --git a/src/main/tjava/crypto/r35157/cauldron/TickerService.tjava b/src/main/tjava/crypto/r35157/cauldron/TickerService.tjava new file mode 100644 index 0000000..dbcd951 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/TickerService.tjava @@ -0,0 +1,167 @@ +package crypto.r35157.cauldron; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.logging.*; +import java.io.IOException; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpExchange; +//import org.json.JSONObject; + +public class TickerService { + + public static void main(String[] args) throws Exception { + configureLogging(); + + logger.info("Initializing TickerService:"); + logger.info(" Starting embedded SQLLite..."); + configureDatabase(); + + logger.info(" Validating schema..."); + validateSchema(); + + logger.info(" Starting HTTP Service on port 8123..."); + configureHTTPService(); + + logger.info("Ready to service!"); + } + + private static void validateSchema() throws SQLException { + boolean exist = doTableExist(dbConnection, "ticks"); + if(exist == false) { + createTable(dbConnection); + } + } + + private static boolean doTableExist(Connection dbConnection, String tableName) throws SQLException { + String sql = "SELECT name FROM sqlite_master WHERE type='table' AND name=?"; + + PreparedStatement pstmt = dbConnection.prepareStatement(sql); + pstmt.setString(1, tableName); + ResultSet rs = pstmt.executeQuery(); + + return rs.next(); + } + + private static void configureLogging() { + try { + Formatter logFormatter = new CustomLogFormatter(); + + // Remove the default console handler + Logger rootLogger = Logger.getLogger(""); + rootLogger.removeHandler(rootLogger.getHandlers()[0]); + + // Add a custom console handler + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setLevel(Level.ALL); + consoleHandler.setFormatter(logFormatter); + rootLogger.addHandler(consoleHandler); + + // Add a file handler + FileHandler fileHandler = new FileHandler("TickerService.log", true); + fileHandler.setLevel(Level.ALL); + fileHandler.setFormatter(logFormatter); + rootLogger.addHandler(fileHandler); + + } catch (IOException e) { + logger.severe("Failed to set up logging: " + e.getMessage()); + } + } + + private static void configureDatabase() throws SQLException { + dbConnection = DriverManager.getConnection(DB_URL); + } + + private static void createTable(Connection dbConnection) throws SQLException { + String sql = "CREATE TABLE ticks (" + + "epoch INTEGER NOT NULL," + + "baseAssetId TEXT NOT NULL," + + "quoteAssetId TEXT NOT NULL," + + "price REAL NOT NULL," + + "PRIMARY KEY (epoch, baseAssetId, quoteAssetId)" + + ");"; + + Statement stmt = dbConnection.createStatement(); + stmt.execute(sql); + } + + private static void insertStockPrice(Connection conn, String symbol, double price) throws SQLException { + String sql = "INSERT INTO stock_prices(symbol, price) VALUES(?, ?)"; + + try (PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, symbol); + pstmt.setDouble(2, price); + pstmt.executeUpdate(); + } + } + + private static void retrieveStockPrices(Connection conn, String symbol) { + String sql = "SELECT id, symbol, price, timestamp FROM stock_prices WHERE symbol = ?"; + + try (PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, symbol); + ResultSet rs = pstmt.executeQuery(); + + while (rs.next()) { + System.out.println(rs.getInt("id") + "\t" + + rs.getString("symbol") + "\t" + + rs.getDouble("price") + "\t" + + rs.getString("timestamp")); + } + } catch (SQLException e) { + System.out.println(e.getMessage()); + } + } + + private static void configureHTTPService() throws Exception{ + HttpServer server = HttpServer.create(new InetSocketAddress(PORT), BACKLOG); + server.createContext("/ticker", new MyHandler()); + server.setExecutor(null); // Creates a default executor + server.start(); + } + + static class CustomLogFormatter extends Formatter { + @Override + public String format(LogRecord record) { + return String.format("%1$s [%2$s] %3$s%n", + record.getLevel().getName(), + record.getLoggerName(), + record.getMessage()); + } + } + + static class MyHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + // TODO: Reenable when import is fixed + /* + JSONObject json = new JSONObject(); + json.put("message", "Hello, World!"); + String response = json.toString(); + + exchange.getResponseHeaders().set("Content-type", RESPONSE_MIMETYPE); + exchange.sendResponseHeaders(HTTP_OK, response.length()); + OutputStream os = exchange.getResponseBody(); + os.write(response.getBytes()); + + os.close(); + */ + } + } + + private static final Logger logger = Logger.getLogger(TickerService.class.getName()); + private static Connection dbConnection; + + private static final int PORT = 8123; + private static final int BACKLOG = 10; + private static final String RESPONSE_MIMETYPE = "application/json"; + private static final int HTTP_OK = 200; + private static final String DB_URL = "jdbc:sqlite:ticker.db"; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/TimeUtils.tjava b/src/main/tjava/crypto/r35157/cauldron/TimeUtils.tjava new file mode 100644 index 0000000..30393c6 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/TimeUtils.tjava @@ -0,0 +1,15 @@ +package crypto.r35157.cauldron; + +import java.time.format.DateTimeFormatter; + +public class TimeUtils { + public static final DateTimeFormatter formatter_hhmm = DateTimeFormatter.ofPattern("HH:mm"); + public static final long ONE_DAY = 1000 * 60 * 60 * 24; + public static final long ONE_WEEK = ONE_DAY * 7; + public static final long ONE_MONTH = ONE_DAY * 31; + public static final long ONE_QUARTER = ONE_MONTH * 3; + public static final long ONE_HALFYEAR = ONE_MONTH * 6; + public static final long ONE_YEAR = (long)(ONE_DAY * 365.2425f); + public static final long FIVE_YEARS = ONE_YEAR * 5; + public static final long ONE_DECADE = ONE_DAY * 10; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/Version.tjava b/src/main/tjava/crypto/r35157/cauldron/Version.tjava new file mode 100644 index 0000000..2a413b1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/Version.tjava @@ -0,0 +1,6 @@ +package crypto.r35157.cauldron; + +public class Version { + public static final String VERSION_CAULDRON = "trunk"; + public static final String VERSION_EVELYN = "trunk"; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogic.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogic.tjava new file mode 100644 index 0000000..4ab25a5 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogic.tjava @@ -0,0 +1,19 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public interface BusinessLogic { + BigDecimal calculateContractValueToTrade( + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration, + BigDecimal positionValue, + BigDecimal currentPrice, + BigDecimal tokensPerContract, + int leverage, + BigDecimal maxTradePctOfFullPosition + ); + + BigDecimal min(BigDecimal a, BigDecimal b); + + BigDecimal max(BigDecimal a, BigDecimal b); +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogicImpl.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogicImpl.tjava new file mode 100644 index 0000000..e823b29 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/BusinessLogicImpl.tjava @@ -0,0 +1,51 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +import static java.math.BigDecimal.ZERO; +import static java.math.RoundingMode.HALF_UP; + +public class BusinessLogicImpl implements BusinessLogic { + @Override + public BigDecimal calculateContractValueToTrade( + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration, + BigDecimal positionValue, + BigDecimal currentPrice, + BigDecimal tokensPerContract, + int leverage, + BigDecimal maxTradePctOfFullPosition + ) { + BigDecimal valueToTrade = maxMarginAmountToTradePerIteration.multiply(BigDecimal.valueOf(leverage)); + BigDecimal valueOfOneContract = currentPrice.multiply(tokensPerContract); + + BigDecimal maxTradePctOfFullPositionPct = maxTradePctOfFullPosition.divide(new BigDecimal(100), 4, HALF_UP); + if (maxTradePctOfFullPositionPct.compareTo(ZERO) > 0) { + // Add soft limit + BigDecimal softLimit = positionValue.multiply(maxTradePctOfFullPositionPct); + valueToTrade = min(softLimit, valueToTrade); + } + + // Test if we are allowed to trade this size of contracts + if (valueOfOneContract.compareTo(valueToTrade) > 0) { + // We cannot afford even one single contract - try stretch + BigDecimal maxStretchContractValueToTrade = maxStretchMarginAmountToTradePerIteration.multiply(BigDecimal.valueOf(leverage)); + if (valueOfOneContract.compareTo(maxStretchContractValueToTrade) <= 0) { + // With 'stretch' we can afford, at least, one single contract - but limit to ONE contract ONLY! + valueToTrade = valueOfOneContract; + } + } + + return valueToTrade; + } + + @Override + public BigDecimal min(BigDecimal a, BigDecimal b) { + return a.compareTo(b) < 0 ? a : b; + } + + @Override + public BigDecimal max(BigDecimal a, BigDecimal b) { + return a.compareTo(b) < 0 ? b : a; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/Configuration.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/Configuration.tjava new file mode 100644 index 0000000..0844806 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/Configuration.tjava @@ -0,0 +1,175 @@ +package crypto.r35157.cauldron.afets; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static crypto.r35157.TimeTools.parseTimeToMillis; + +public class Configuration { + + public Configuration(@NotNull String configurationFilename) { + this.configurationFilename = configurationFilename; + } + + public BigDecimal maxSpendPctOfFullPosition() { + return maxSpendPctOfFullPosition; + } + + public long exchangeTransactionDelay() { + return exchangeTransactionDelay; + } + + public BigDecimal expensiveFundingFeeThreshold() { + return expensiveFundingFeeThreshold; + } + + public int minimumEmptyPositionsToRestart() { + return minimumEmptyPositionsToRestart; + } + + public BigDecimal minimumPctEarningForLongs() { + return minimumPctEarningForLongs; + } + + public BigDecimal minimumPctEarningForShorts() { + return minimumPctEarningForShorts; + } + + public BigDecimal maxMarginAmountToTradePerIteration() { + return maxMarginAmountToTradePerIteration; + } + + public BigDecimal maxStretchMarginAmountToTradePerIteration() { + return maxStretchMarginAmountToTradePerIteration; + } + + public boolean executeOpen() { + return executeOpen; + } + + public BigDecimal adjustedScoreTooBigPositionsPenaltyCoefficient() { + return adjustedScoreTooBigPositionsPenaltyCoefficient; + } + + public boolean executeClose() { + return executeClose; + } + + public boolean enableNotification() { + return enableNotification; + } + + public boolean printFundingFees() { + return printFundingFees; + } + + public boolean printEquilibriumStatus() { + return printEquilibriumStatus; + } + + public boolean printPositionStatus() { + return printPositionStatus; + } + + public boolean printProfitLoss() { + return printProfitLoss; + } + + public boolean restartPositions() { + return restartPositions; + } + + public long getIterationTime() { + return iterationTime; + } + + public long getTimeOut() { + return timeOut; + } + + public boolean detectAndPrepareForBrandNewPositions() { + return detectAndPrepareForBrandNewPositions; + } + + /** + * Update this configuration from the configuration file. + * @throws IOException if the configuration file cannot be read. + */ + public void update() throws IOException { + Path configFile = Paths.get(configurationFilename); + List lines = Files.readAllLines(configFile); + + for (String line : lines) { + line = line.trim(); + + // Skip empty lines or separator lines. + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + // Assume key and value are separated by whitespace. + String[] parts = line.split("\\s+"); + if (parts.length < 2) { + continue; // or throw an exception if file format must be strict + } + + String key = parts[0]; + String value = parts[1]; + + switch (key) { + case "IterationTime" -> iterationTime = parseTimeToMillis(value); + case "TimeOut" -> timeOut = parseTimeToMillis(value); + case "DetectAndPrepareForBrandNewPositions" -> detectAndPrepareForBrandNewPositions = Boolean.parseBoolean(value); + case "RestartPositions" -> restartPositions = Boolean.parseBoolean(value); + case "PrintEquilibriumStatus" -> printEquilibriumStatus = Boolean.parseBoolean(value); + case "PrintPositionStatus" -> printPositionStatus = Boolean.parseBoolean(value); + case "PrintProfitLoss" -> printProfitLoss = Boolean.parseBoolean(value); + case "PrintFundingFees" -> printFundingFees = Boolean.parseBoolean(value); + case "ExecuteOpen" -> executeOpen = Boolean.parseBoolean(value); + case "ExecuteClose" -> executeClose = Boolean.parseBoolean(value); + case "EnableNotification" -> enableNotification = Boolean.parseBoolean(value); + case "MaxMarginAmountToTradePerIteration" -> maxMarginAmountToTradePerIteration = new BigDecimal(value); + case "MaxStretchMarginAmountToTradePerIteration" -> maxStretchMarginAmountToTradePerIteration = new BigDecimal(value); + case "MinimumPctEarningForLongs" -> minimumPctEarningForLongs = new BigDecimal(value); + case "MinimumPctEarningForShorts" -> minimumPctEarningForShorts = new BigDecimal(value); + case "MinimumEmptyPositionsToRestart" -> minimumEmptyPositionsToRestart = Integer.parseInt(value); + case "ExpensiveFundingFeeThreshold" -> expensiveFundingFeeThreshold = new BigDecimal(value); + case "ExchangeTransactionDelay" -> exchangeTransactionDelay = Long.parseLong(value); + case "MaxSpendPctOfFullPosition" -> maxSpendPctOfFullPosition = new BigDecimal(value); + case "PriceDiscoveryDuration" -> priceDiscoveryDuration = parseTimeToMillis(value); + case "AdjustedScoreTooBigPositionsPenaltyCoefficient" -> adjustedScoreTooBigPositionsPenaltyCoefficient = new BigDecimal(value); + default -> System.out.println("WARNING! Configuration key '" + key + "' unknown! Skipping!"); + } + } + } + + private final String configurationFilename; + + private long iterationTime; + private long timeOut; + private boolean detectAndPrepareForBrandNewPositions; + private boolean restartPositions; + private boolean printEquilibriumStatus; + private boolean printPositionStatus; + private boolean printProfitLoss; + private boolean printFundingFees; + private boolean executeOpen; + private boolean executeClose; + private boolean enableNotification; + private BigDecimal maxMarginAmountToTradePerIteration; + private BigDecimal maxStretchMarginAmountToTradePerIteration; + private BigDecimal minimumPctEarningForLongs; + private BigDecimal minimumPctEarningForShorts; + private int minimumEmptyPositionsToRestart; + private BigDecimal expensiveFundingFeeThreshold; + private long exchangeTransactionDelay; + private BigDecimal maxSpendPctOfFullPosition; + private long priceDiscoveryDuration; + private BigDecimal adjustedScoreTooBigPositionsPenaltyCoefficient; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/EarningsStatistics.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/EarningsStatistics.tjava new file mode 100644 index 0000000..76e7d79 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/EarningsStatistics.tjava @@ -0,0 +1,7 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public class EarningsStatistics { + BigDecimal totalEarnings; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/EquilibriumStatus.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/EquilibriumStatus.tjava new file mode 100644 index 0000000..51f3aa1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/EquilibriumStatus.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record EquilibriumStatus( + BigDecimal longEquilibrium, + BigDecimal shortEquilibrium, + BigDecimal availableEquilibrium +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/Evelyn.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/Evelyn.tjava new file mode 100644 index 0000000..1dee6d3 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/Evelyn.tjava @@ -0,0 +1,1497 @@ +package crypto.r35157.cauldron.afets; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import crypto.r35157.TimeTools; +import crypto.r35157.cauldron.afets.accountdetails.AccountDetails; +import crypto.r35157.cauldron.afets.accountdetails.MEXCAccountDetailsService; +import crypto.r35157.cauldron.afets.contractdetails.ContractDetailsItem; +import crypto.r35157.cauldron.afets.contractdetails.MEXCContractDetails; +import crypto.r35157.cauldron.afets.fundingrate.MEXCFundingRateItem; +import crypto.r35157.cauldron.afets.fundingrate.MEXCFundingRates; +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndex; +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndexItem; +import crypto.r35157.cauldron.afets.leverage.MEXCLeverageInfo; +import crypto.r35157.cauldron.afets.leverage.MEXCLeverageInfoItem; +import crypto.r35157.cauldron.afets.nenjim.NenjimObjectRepository; +import crypto.r35157.cauldron.afets.ticker.MEXCTicker; +import crypto.r35157.cauldron.afets.ticker.MEXCTickerItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.text.DecimalFormat; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +import static crypto.r35157.cauldron.Version.VERSION_EVELYN; +import static crypto.r35157.cauldron.afets.PositionType.marginLong; +import static crypto.r35157.cauldron.afets.PositionType.marginShort; +import static java.math.BigDecimal.ONE; +import static java.math.BigDecimal.ZERO; +import static java.math.RoundingMode.DOWN; +import static java.math.RoundingMode.HALF_UP; + +/* + * Evelyn - the Anti-Fragile Equilibrium Trading Strategy + */ +public class Evelyn { + public static void main(String[] args) { + new Evelyn(); + } + + public Evelyn() { + log.info("Starting Evelyn " + VERSION_EVELYN); + log.debug(" Configuration:"); + + try { + nor = new NenjimObjectRepository(); // In the future we will NOT create this. It should be given to us from Nenjim. + + configuration = nor.getConfiguration(); + pushNotifier = nor.getPushNotifier(); + gami = nor.getGlobalAssetMetadataIndex(); + ticker = nor.getTicker(); + contractDetails = nor.getContractDetails(); + timeOuts = nor.getTimeOuts(); + portfolio = nor.getPortfolio(); + mexcFuturesPlugin = nor.getMexcFuturesPlugin(); + mexcFundingRates = nor.getMexcFundingRates(); + mexcLeverageInfo = nor.getMexcLeverageInfo(); + scoreCalculator = nor.getScoreCalculator(); + accountDetailsService = nor.getAccountDetails(); + pushNotification("Evelyn started!"); + + execute(); + } catch(Exception e) { + String message = "Evelyn STOPPED due to an exception: '" + e.getMessage() + "'"; + pushNotification(message); + log.error(message, e); + } + + System.exit(1); + } + + private void pushNotification(String message) { + if(configuration.enableNotification()) { + pushNotifier.push(message); + } + } + + private void execute() throws Exception { + log.info(" Iteration time: {}", TimeTools.formatTimeFromMillis(configuration.getIterationTime())); + + while (true) { + final String laterTime = TimeTools.formatLaterTime(configuration.getIterationTime()); + log.info("Sleeping until {}...", laterTime); + + try { + Thread.sleep(configuration.getIterationTime()); + } catch(InterruptedException e) { + log.info("Worker thread has been woken up but the signaller - running iteration now."); + } + + updateLocalCache(); + + if(configuration.detectAndPrepareForBrandNewPositions()) { + detectAndPrepareForBrandNewPositions(); + } + + final List positionValuesList = calculatePositionValues(portfolio.getPositions()); + final PositionStatistics positionStatistics = calculatePositionStatistics(positionValuesList); + + if(configuration.printPositionStatus()) { + logPositionStatistics(positionStatistics); + } + + final AccountDetails accountDetails; + final BigDecimal marginAvailable; + + try { + accountDetails = accountDetailsService.getDetails(); + marginAvailable = accountDetails.availableOpen(); + } catch(Exception e) { + log.error("ERROR: Getting account details from Exchange - skipping iteration (" + e.getMessage() + ")"); + continue; + } + + final BigDecimal totalLeverageAllPositions = calculateAverageLeverageForAllPositions(positionValuesList); + final BigDecimal availableOpenLeveraged = marginAvailable.multiply(totalLeverageAllPositions); + + if(configuration.printEquilibriumStatus()) { + final EquilibriumStatus equilibriumStatus = calculateEquilibriumStatus( + positionStatistics, + marginAvailable, + totalLeverageAllPositions + ); + + logEquilibriumStatus(equilibriumStatus, marginAvailable, availableOpenLeveraged, totalLeverageAllPositions); + } + + if(configuration.printProfitLoss()) { + final ProfitLoss profitLoss = calculateProfitAndLoss(positionStatistics); + logProfitAndLoss(profitLoss); + } + + if(configuration.printFundingFees()) { + final FundingFeeStatus fundingFeeStatus = calculateFundingFeeStats(positionValuesList); + printFundingFeeStats(fundingFeeStatus); + } + + PositionSideAction action = decideAction( + availableOpenLeveraged, + positionStatistics + ); + + BigDecimal maxMarginAmountToTradePerIteration = configuration.maxMarginAmountToTradePerIteration(); + BigDecimal maxStretchMarginAmountToTradePerIteration = configuration.maxStretchMarginAmountToTradePerIteration(); + handleNextAction( + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration, + action, + positionValuesList + ); + + if(configuration.restartPositions()) { + final Map positionsToRestart = detectPositionsToRestart(); + int minimumEmptyPositionsToRestart = configuration.minimumEmptyPositionsToRestart(); + restartPositions(positionsToRestart, minimumEmptyPositionsToRestart, positionValuesList); + } + } + } + + private BigDecimal calculateAverageLeverageForAllPositions(List positionValuesList) { + BigDecimal totalMarginValue = BigDecimal.ZERO; + BigDecimal totalPositionValue = BigDecimal.ZERO; + + for(PositionValues positionValues : positionValuesList) { + BigDecimal positionValue = positionValues.positionValue(); + BigDecimal marginValue = positionValues.marginValue(); + + totalMarginValue = totalMarginValue.add(marginValue); + totalPositionValue = totalPositionValue.add(positionValue); + } + + BigDecimal averageLeverage = totalPositionValue.divide(totalMarginValue, 20, HALF_UP); + + return averageLeverage; + } + + private boolean handleNextAction( + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration, + PositionSideAction action, + List positionValuesList + ) throws Exception { + boolean didTrade = false; + + switch(action) { + case OpenLong -> { + if(configuration.executeOpen()) { + didTrade = handleNextContractOpen( + positionValuesList, + PositionSideAction.OpenLong, + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration + ); + } + } + case OpenShort -> { + if(configuration.executeOpen()) { + didTrade = handleNextContractOpen( + positionValuesList, + PositionSideAction.OpenShort, + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration + ); + } + } + case CloseLong -> { + if(configuration.executeClose()) { + didTrade = handleNextContractClose( + positionValuesList, + PositionSideAction.CloseLong, + maxMarginAmountToTradePerIteration + ); + } + } + case CloseShort -> { + if(configuration.executeClose()) { + didTrade = handleNextContractClose( + positionValuesList, + PositionSideAction.CloseShort, + maxMarginAmountToTradePerIteration + ); + } + } + } + + return didTrade; + } + + private PositionSideAction decideAction(BigDecimal availableLeveragedMargin, PositionStatistics positionStatistics) { + PositionSideAction action; + BigDecimal totalPositionsLongs = positionStatistics.totalLongPositionsValue(); + BigDecimal totalPositionsShorts = positionStatistics.totalShortPositionsValue(); + + if (totalPositionsLongs.compareTo(totalPositionsShorts) > 0 + && totalPositionsLongs.compareTo(availableLeveragedMargin) > 0) { + // Position size of all LONG positions is greater that both shorts and available margin - Sell some... + action = PositionSideAction.CloseLong; + } else if (totalPositionsShorts.compareTo(totalPositionsLongs) > 0 + && totalPositionsShorts.compareTo(availableLeveragedMargin) > 0) { + // Position size of all SHORT positions is greater that both longs and available margin - Sell some... + action = PositionSideAction.CloseShort; + } else { + // Available Margin HAS to be biggest now, so we have to open... but what? + action = (totalPositionsLongs.compareTo(totalPositionsShorts) > 0) + ? PositionSideAction.OpenShort + : PositionSideAction.OpenLong; + } + + return action; + } + + /* + private List decideActions( + BigDecimal amountToSpend, + BigDecimal availableLeveragedMargin, + PositionStatus positionStatus + ) { + BigDecimal totalPositionValuesLongs = positionStatus.totalLongPositionsValue(); + BigDecimal totalPositionValuesShorts = positionStatus.totalShortPositionsValue(); + + final BigDecimal grandTotal = availableLeveragedMargin + .add(totalPositionValuesLongs) + .add(totalPositionValuesShorts); + final BigDecimal average = grandTotal.divide(BigDecimal.valueOf(3), HALF_UP); + + BigDecimal newTotalPositionValuesLongs; + BigDecimal newTotalPositionValuesShorts; + BigDecimal newAvailableLeveragedMargin; + BigDecimal longDistance; + BigDecimal shortDistance; + BigDecimal availDistance; + BigDecimal distanceScore; + + HashMap sortMap = new HashMap<>(); + + // Calculate distanceScore if doing OpenLong + newTotalPositionValuesLongs = totalPositionValuesLongs.add(amountToSpend); + newTotalPositionValuesShorts = totalPositionValuesShorts; + newAvailableLeveragedMargin = availableLeveragedMargin.subtract(amountToSpend); + longDistance = average.subtract(newTotalPositionValuesLongs).abs(); + shortDistance = average.subtract(newTotalPositionValuesShorts).abs(); + availDistance = average.subtract(newAvailableLeveragedMargin).abs(); + distanceScore = longDistance.add(shortDistance).add(availDistance); + sortMap.put(PositionSideAction.OpenLong, distanceScore); + + // Calculate distanceScore if doing OpenShort + newTotalPositionValuesLongs = totalPositionValuesLongs; + newTotalPositionValuesShorts = totalPositionValuesShorts.add(amountToSpend); + newAvailableLeveragedMargin = availableLeveragedMargin.subtract(amountToSpend); + longDistance = average.subtract(newTotalPositionValuesLongs).abs(); + shortDistance = average.subtract(newTotalPositionValuesShorts).abs(); + availDistance = average.subtract(newAvailableLeveragedMargin).abs(); + distanceScore = longDistance.add(shortDistance).add(availDistance); + sortMap.put(PositionSideAction.OpenShort, distanceScore); + + // Calculate distanceScore if doing CloseLong + newTotalPositionValuesLongs = totalPositionValuesLongs.subtract(amountToSpend); + newTotalPositionValuesShorts = totalPositionValuesShorts; + newAvailableLeveragedMargin = availableLeveragedMargin.add(amountToSpend); + longDistance = average.subtract(newTotalPositionValuesLongs).abs(); + shortDistance = average.subtract(newTotalPositionValuesShorts).abs(); + availDistance = average.subtract(newAvailableLeveragedMargin).abs(); + distanceScore = longDistance.add(shortDistance).add(availDistance); + sortMap.put(PositionSideAction.CloseLong, distanceScore); + + // Calculate distanceScore if doing CloseShort + newTotalPositionValuesLongs = totalPositionValuesLongs; + newTotalPositionValuesShorts = totalPositionValuesShorts.subtract(amountToSpend); + newAvailableLeveragedMargin = availableLeveragedMargin.add(amountToSpend); + longDistance = average.subtract(newTotalPositionValuesLongs).abs(); + shortDistance = average.subtract(newTotalPositionValuesShorts).abs(); + availDistance = average.subtract(newAvailableLeveragedMargin).abs(); + distanceScore = longDistance.add(shortDistance).add(availDistance); + sortMap.put(PositionSideAction.CloseShort, distanceScore); + + // Sort the distanceScores in ascending order + List actions = sortMap.entrySet() + .stream() + .sorted(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .collect(Collectors.toList()) + .reversed(); + + return actions; + } + */ + + private void updateLocalCache() throws Exception { + configuration.update(); + contractDetails.updateAll(); + gami.updateAll(); + portfolio.updateAll(); + ticker.updateAll(); + mexcFundingRates.updateAll(); + } + + private Map detectPositionsToRestart() { + List positions = portfolio.getPositions(); + Map positionsToRestart = new HashMap<>(); + + for (Position position : positions) { + String symbol = position.gamiId().symbolMEXC(); + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + + BigDecimal contractSize = contractDetailsItem.contractSize(); + BigDecimal balanceTokens = position.balance(); + + int balanceContracts = balanceTokens.divide(contractSize, 20, HALF_UP).intValue(); + + if (balanceContracts <= 1) { // We do not let the position go to zero as we want to keep the statistics which is lost if we close the position totally + BigDecimal price = ticker.getPrice(symbol).lastPrice(); + BigDecimal valueOfOneContract = contractSize.multiply(price); + int leverage = position.leverage(); + + int quantityOfContractsWeCanTradeWithinLimit = calculateNumberOfContractsToTradeWithinLimit(valueOfOneContract, leverage); + + if (quantityOfContractsWeCanTradeWithinLimit > 0) { + positionsToRestart.put(position, quantityOfContractsWeCanTradeWithinLimit); + } else { + PositionType type = position.type(); + BigDecimal minimumRequired = valueOfOneContract.divide(BigDecimal.valueOf(leverage), 20, HALF_UP); + String minimumRequiredStr = df4.format(minimumRequired); + + String msg = "Skipping restart of " + symbol + "/" + type + + " as the lot value is outside the limits, even after leverage and stretch! " + + "Increase leverage or increase 'MaxMarginAmountToTradePerIteration' " + + "to atleast " + minimumRequiredStr + " - or consider using 'MaxStretchMarginAmountToTradePerIteration'"; + log.info(msg); + } + } + } + return positionsToRestart; + } + + record RestartCandidate ( + + ) { + + } + + private int calculateNumberOfContractsToTradeWithinLimit(BigDecimal valueOfOneContract, int leverage) { + int quantityOfContractWeCanAfford = 0; + BigDecimal maxMarginAmount = configuration.maxMarginAmountToTradePerIteration(); + BigDecimal maxContractPrice = maxMarginAmount.multiply(BigDecimal.valueOf(leverage)); + + if(valueOfOneContract.compareTo(maxContractPrice) < 0) { + quantityOfContractWeCanAfford = maxContractPrice + .divideToIntegralValue(valueOfOneContract) + .intValue(); + } else { + // We could not afford a single contract - try stretch + BigDecimal maxStretchMarginAmount = configuration.maxStretchMarginAmountToTradePerIteration(); + BigDecimal maxStretchContractPrice = maxStretchMarginAmount.multiply(BigDecimal.valueOf(leverage)); + if(valueOfOneContract.compareTo(maxStretchContractPrice) < 0) { + // We can afford at least one contract under stretch - but ONLY ONE ALLOWED UNDER STRETCH + quantityOfContractWeCanAfford = 1; + } + } + + return quantityOfContractWeCanAfford; + } + + private PositionValues getPositionValue(Position position, List positionValuesList) { + for(PositionValues value : positionValuesList) { + if(value.position().equals(position)) { + return value; + } + } + + return null; + } + + private void restartPositions( + Map positionsToRestart, + int minimumEmptyPositionsToRestart, + List positionValuesList + ) throws Exception { + boolean tooFewToRestart = (positionsToRestart.size() < minimumEmptyPositionsToRestart); + if(tooFewToRestart) { + return; + } + + final AccountDetails accountDetails = accountDetailsService.getDetails(); + BigDecimal available = accountDetails.availableOpen(); + + log.info("Trying to restart {} positions...", positionsToRestart.size()); + if(available.compareTo(ZERO) <= 0) { + log.warn("WARNING: Currently not enough available margin to restart any positions!"); + return; + } + + for (Position position : positionsToRestart.keySet()) { + String symbol = position.gamiId().symbolMEXC(); + int quantityToTradeContracts = positionsToRestart.get(position); + + PositionType type = position.type(); + int leverage = position.leverage(); + + MEXCPositionSide side = (type == PositionType.marginLong) ? MEXCPositionSide.OpenLong : MEXCPositionSide.OpenShort; + + String msg = " Restarting " + symbol + "/" + type + "(" + leverage + "x) - opening " + + quantityToTradeContracts + " contract(s)..."; + log.info(msg); + + GlobalAssetMetadataIndexItem indexItem = gami.getByMEXCSymbol(symbol); + + if(indexItem == null) { + String message = "ERROR: '" + symbol + "' not found in GAMI - Skipping!"; + pushNotification(message); + log.error(message); + continue; + } + + UUID gamiId = position.gamiId().gamiId(); + GlobalAssetMetadataIndexItem metaData = gami.getById(gamiId); + + PositionValues positionValues = getPositionValue(position, positionValuesList); + BigDecimal price = positionValues.currentPrice(); + + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + BigDecimal numberOfTokensPerContract = contractDetailsItem.contractSize(); + BigDecimal contractValue = numberOfTokensPerContract.multiply(price); + final BigDecimal marginPricePerContract = contractValue.divide(new BigDecimal(leverage), 4, HALF_UP); + + final BigDecimal marginPriceForOpening = marginPricePerContract.multiply(BigDecimal.valueOf(quantityToTradeContracts)); + available = accountDetails.availableOpen(); + + if(available.compareTo(marginPriceForOpening) <= 0) { + String message = "WARNING: Available margin too low to open '" + metaData.symbolMEXC() + + "'. Have $" + df4.format(available) + + ", needing $" + df4.format(marginPriceForOpening) + + " - skipping until next iteration!"; + log.warn(message); + continue; + } + + String json = mexcFuturesPlugin.trade(side, quantityToTradeContracts, symbol, leverage); + + if(json == null) { + String message = "ERROR: Could not trade '" + symbol + "' - Empty reply from MEXC"; + pushNotification(message); + log.error(message); + continue; + } + + if(!json.contains("success\":true")) { + MEXCProtocolErrors error = mapErrorMessage(json); + String message = "ERROR: Could not trade '" + symbol + "'! (" + error.toString() + ")"; + pushNotification(message); + log.error(message + ": " + json); + continue; + } + + UUID uuid = indexItem.gamiId(); + + PositionType positionType = position.type(); + timeOuts.putInTimeout(uuid, positionType, symbol); + + long exchangeTransactionDelay = configuration.exchangeTransactionDelay(); + + Thread.sleep(exchangeTransactionDelay); + } + + updateLocalCache(); + } + + private MEXCProtocolErrors mapErrorMessage(String json) { + if(json == null || json.isEmpty()) { + return null; + } + + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + int code = obj.get("code").getAsInt(); + + return MEXCProtocolErrors.fromErrorCode(code); + } + + private FundingFeeStatus calculateFundingFeeStats(List positionValuesList) { + BigDecimal totalFeesToPay = BigDecimal.ZERO; + BigDecimal totalFeesToReceive = BigDecimal.ZERO; + List feeWarnings = new ArrayList<>(); + + for(PositionValues positionValues : positionValuesList) { + String mexcSymbol = positionValues.position().gamiId().symbolMEXC(); + MEXCFundingRateItem fundingRateItem = mexcFundingRates.getFundingRate(mexcSymbol); + + PositionType type = positionValues.position().type(); + BigDecimal fundingRate = fundingRateItem.fundingRate(); + + if(fundingRate.compareTo(BigDecimal.ZERO) != 0) { + BigDecimal fee = positionValues.positionValue().multiply(fundingRate).abs(); + + BigDecimal expensiveFundingFeeThreshold = configuration.expensiveFundingFeeThreshold(); + if(fee.compareTo(expensiveFundingFeeThreshold) >= 0) { + feeWarnings.add("WARNING: Expensive funding rate $" + df2.format(fee) + + " for " + mexcSymbol + "(" + type + ")"); + } + + if (type == marginLong) { + if (fundingRate.compareTo(BigDecimal.ZERO) > 0) { + // Positive rate: Long traders will pay short traders + totalFeesToPay = totalFeesToPay.add(fee); + } else { + // Negative rate: Short traders will pay long traders + totalFeesToReceive = totalFeesToReceive.add(fee); + } + } else { + if (fundingRate.compareTo(BigDecimal.ZERO) > 0) { + // Positive rate: Long traders will pay short traders + totalFeesToReceive = totalFeesToReceive.add(fee); + } else { + // Negative rate: Short traders will pay long traders + totalFeesToPay = totalFeesToPay.add(fee); + } + } + } + } + + FundingFeeStatus feeStatus = new FundingFeeStatus( + feeWarnings, + totalFeesToPay, + totalFeesToReceive + ); + + return feeStatus; + } + + private void printFundingFeeStats(FundingFeeStatus fundingFeeStatus) { + BigDecimal totalFeesToPay = fundingFeeStatus.totalFeesToPay(); + BigDecimal totalFeesToReceive = fundingFeeStatus.totalFeesToReceive(); + + for(String warning : fundingFeeStatus.feeWarnings()) { + log.info(" {}", warning); + } + + log.info("Funding fees (pay / receive):"); + log.info(" ${} / ${}", df4.format(totalFeesToPay), df4.format(totalFeesToReceive)); + } + + private ProfitLoss calculateProfitAndLoss(PositionStatistics positionStatistics) { + BigDecimal pnlOnLongs = positionStatistics.totalLongPositionsValue() + .subtract(positionStatistics.totalLongBaseValue()); + BigDecimal pnlOnShorts = positionStatistics.totalShortBaseValue() + .subtract(positionStatistics.totalShortPositionsValue()); + BigDecimal pnlUnrealized = pnlOnLongs.add(pnlOnShorts); + + ProfitLoss profitLoss = new ProfitLoss( + pnlOnLongs, + pnlOnShorts, + pnlUnrealized + ); + + return profitLoss; + } + + private PositionStatistics calculatePositionStatistics(List positionValuesList) { + BigDecimal totalPositionsLongs = ZERO; + BigDecimal totalPositionsShorts = ZERO; + BigDecimal totalBaseLongs = ZERO; + BigDecimal totalBaseShorts = ZERO; + BigDecimal totalMarginLong = ZERO; + BigDecimal totalMarginShort = ZERO; + int totalNumberOfLongPositions = 0; + int totalNumberOfShortPositions = 0; + + for (PositionValues positionValues : positionValuesList) { + Position position = positionValues.position(); + PositionType type = position.type(); + + BigDecimal positionValue = positionValues.positionValue(); + BigDecimal baseValue = positionValues.baseValue(); + BigDecimal marginValue = positionValues.marginValue(); + + if (marginLong == type) { + totalPositionsLongs = totalPositionsLongs.add(positionValue); + totalBaseLongs = totalBaseLongs.add(baseValue); + totalMarginLong = totalMarginLong.add(marginValue); + totalNumberOfLongPositions++; + } else { + totalPositionsShorts = totalPositionsShorts.add(positionValue); + totalBaseShorts = totalBaseShorts.add(baseValue); + totalMarginShort = totalMarginShort.add(marginValue); + totalNumberOfShortPositions++; + } + } + + BigDecimal avgLongPositionSize = totalPositionsLongs.divide(BigDecimal.valueOf(totalNumberOfLongPositions), + 20, HALF_UP); + BigDecimal avgShortPositionSize = totalPositionsShorts.divide(BigDecimal.valueOf(totalNumberOfShortPositions), + 20, HALF_UP); + + PositionStatistics positionStatistics = new PositionStatistics( + totalNumberOfLongPositions, + totalNumberOfShortPositions, + totalPositionsLongs, + totalPositionsShorts, + totalBaseLongs, + totalBaseShorts, + totalMarginLong, + totalMarginShort, + avgLongPositionSize, + avgShortPositionSize + ); + + return positionStatistics; + } + + private void logProfitAndLoss(ProfitLoss profitLoss) { + log.info("Profit and Loss (longs + short = unrealized pnl):"); + String msg = " $" + df2.format(profitLoss.pnlOnLongs()) + + " + $" + df2.format(profitLoss.pnlOnShorts()) + + " = $" + df2.format(profitLoss.pnlUnrealized()); + log.info(msg); + } + + + private EquilibriumStatus calculateEquilibriumStatus( + PositionStatistics positionStatistics, + BigDecimal marginAvailable, + BigDecimal totalLeverageAllPositions + ) { + BigDecimal totalLongPositionsValue = positionStatistics.totalLongPositionsValue(); + BigDecimal totalShortPositionsValue = positionStatistics.totalShortPositionsValue(); + BigDecimal notionalAvailable = marginAvailable.multiply(totalLeverageAllPositions); + + BigDecimal grandTotal = notionalAvailable.add(totalLongPositionsValue).add(totalShortPositionsValue); + + BigDecimal longs = totalLongPositionsValue.divide(grandTotal, 4, HALF_UP); + BigDecimal shorts = totalShortPositionsValue.divide(grandTotal, 4, HALF_UP); + BigDecimal leveragedAvailable = notionalAvailable.divide(grandTotal, 4, HALF_UP); + + EquilibriumStatus equilibriumStatus = new EquilibriumStatus( + longs, + shorts, + leveragedAvailable + ); + + return equilibriumStatus; + } + + private void logEquilibriumStatus( + EquilibriumStatus equilibriumStatus, + BigDecimal availableMargin, + BigDecimal availableLeveragedMargin, + BigDecimal leverage + ) { + BigDecimal hundred = new BigDecimal(100); + + log.info("Equilibrium status:"); + String longEquilibriumStr = df2.format(equilibriumStatus.longEquilibrium().multiply(hundred)); + String shortEquilibriumStr = df2.format(equilibriumStatus.shortEquilibrium().multiply(hundred)); + String availableEquilibriumStr = df2.format(equilibriumStatus.availableEquilibrium().multiply(hundred)); + + log.info(" Long: {}%", longEquilibriumStr); + log.info(" Short: {}%", shortEquilibriumStr); + log.info(" Available: {}% (${} / ${} - leverage: {}x)", + availableEquilibriumStr, + df2.format(availableMargin), + df2.format(availableLeveragedMargin), + df2.format(leverage)); + } + + private void logPositionStatistics(PositionStatistics positionStatistics) { + BigDecimal totalLongPositionsValue = positionStatistics.totalLongPositionsValue(); + BigDecimal totalLongBaseValue = positionStatistics.totalLongBaseValue(); + BigDecimal totalLongMarginValue = positionStatistics.totalLongMarginValue(); + + BigDecimal totalShortPositionsValue = positionStatistics.totalShortPositionsValue(); + BigDecimal totalShortBaseValue = positionStatistics.totalShortBaseValue(); + BigDecimal totalShortMarginValue = positionStatistics.totalShortMarginValue(); + + BigDecimal avgLongPostionSize = positionStatistics.totalAvgLongPositionSize(); + BigDecimal avgShortPositionSize = positionStatistics.totalAvgShortPositionSize(); + + log.info("Position statistics:"); + + log.info(" Number of positions: longs:{} / shorts:{} / total:{}", + positionStatistics.totalNumberOfLongPositions(), + positionStatistics.totalNumberOfShortPositions(), + positionStatistics.totalNumberOfLongPositions() + positionStatistics.totalNumberOfShortPositions()); + + log.info(" Total position value: longs:${} / shorts:${} / total:${}", + df2.format(totalLongPositionsValue), + df2.format(totalShortPositionsValue), + df2.format(totalLongPositionsValue.add(totalShortPositionsValue))); + + log.info(" Total base value: longs:${} / shorts:${} / total:${}", + df2.format(totalLongBaseValue), + df2.format(totalShortBaseValue), + df2.format(totalLongBaseValue.add(totalShortBaseValue))); + + log.info(" Total margin value: longs:${} / shorts:${} / total:${}", + df2.format(totalLongMarginValue), + df2.format(totalShortMarginValue), + df2.format(totalLongMarginValue.add(totalShortMarginValue))); + + log.info(" Average position size: longs:${} / shorts:${}", + df2.format(avgLongPostionSize), + df2.format(avgShortPositionSize)); + } + + private List calculatePositionValues(List allPositions) { + List positionAmounts = new ArrayList<>(); + + for (Position position : allPositions) { + String localSymbol = position.gamiId().symbolMEXC(); + + MEXCTickerItem item = ticker.getPrice(localSymbol); + if (item == null) { + log.info(" {} not found in ticker - skipping!", localSymbol); + continue; + } + + BigDecimal currentPrice = item.lastPrice(); + BigDecimal basePrice = position.basePrice(); + BigDecimal balanceInTokens = position.balance(); + int leverage = position.leverage(); + BigDecimal positionValue = balanceInTokens.multiply(currentPrice); + BigDecimal baseValue = balanceInTokens.multiply(basePrice); + BigDecimal marginValue = baseValue.divide(BigDecimal.valueOf(leverage), 20, HALF_UP); + + PositionValues pv = new PositionValues( + position, + currentPrice, + basePrice, + balanceInTokens, + leverage, + positionValue, + baseValue, + marginValue + ); + + positionAmounts.add(pv); + } + + return positionAmounts; + } + + private List calculateClosingScores( + List positionValuesList, + BigDecimal maxMarginAmountToTradePerIteration + ) { + List nextCloseableList = new ArrayList<>(); + + for(PositionValues positionValues : positionValuesList) { + BigDecimal currentPrice = positionValues.currentPrice(); + BigDecimal basePrice = positionValues.basePrice(); + Position position = positionValues.position(); + + NextCloseableInfo nextCloseableInfo = calculateClosingScore( + maxMarginAmountToTradePerIteration, + currentPrice, + basePrice, + position + ); + + nextCloseableList.add(nextCloseableInfo); + } + + return nextCloseableList; + } + + private NextCloseableInfo calculateClosingScore( + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal currentPrice, + BigDecimal basePrice, + Position position + ) { + PositionType positionType = position.type(); + String symbol = position.gamiId().symbolMEXC(); + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + + BigDecimal rawScore; + BigDecimal priceDiff; + BigDecimal earningPct; + + if(positionType == marginLong) { + rawScore = currentPrice.divide(basePrice,20, HALF_UP); + priceDiff = currentPrice.subtract(basePrice); + earningPct = currentPrice.divide(basePrice,20, HALF_UP).subtract(ONE); + } else { + rawScore = basePrice.divide(currentPrice,20, HALF_UP); + priceDiff = basePrice.subtract(currentPrice); + earningPct = ONE.subtract(currentPrice.divide(basePrice, 20, HALF_UP)); + } + + // TODO: EVE-51: Implement score adjustments for closing too + BigDecimal adjustedScore = rawScore; + + BigDecimal contractSize = contractDetailsItem.contractSize(); + BigDecimal balanceTokens = position.balance(); + int balanceContracts = balanceTokens.divide(contractSize, 20, HALF_UP).intValue(); + BigDecimal earning = priceDiff.multiply(contractSize); + + int leverage = position.leverage(); + BigDecimal valueOfOneContract = currentPrice.multiply(contractSize); + BigDecimal marginPriceOfAContract = valueOfOneContract.divide(BigDecimal.valueOf(leverage), 20, HALF_UP); + + int quantityOfContractsInTradeForMarginAmount = maxMarginAmountToTradePerIteration.divideToIntegralValue(marginPriceOfAContract).intValue(); + + if(quantityOfContractsInTradeForMarginAmount == 0) { + // A single contract has a higher value than Evelyn.conf/MaxUnleveragedAmountToTradePerIteration. But it is ok to just close one then. + // Because we can always close one - means that we can ignore stretch + quantityOfContractsInTradeForMarginAmount = 1; + } + + int quantityToTradeContracts = quantityOfContractsInTradeForMarginAmount; + + if(balanceContracts > 1 && quantityToTradeContracts >= balanceContracts) { + quantityToTradeContracts = balanceContracts - 1; // We will not sell everything out. We want to keep one contract to keep the statistics. + } + + BigDecimal quantityToTradeTokens = contractSize.multiply(BigDecimal.valueOf(quantityToTradeContracts)); + + NextCloseableInfo nextCloseableInfo = new NextCloseableInfo( + position, + quantityToTradeTokens, + quantityToTradeContracts, + positionType, + earning, + earningPct, + rawScore, + adjustedScore, + balanceTokens, + balanceContracts, + leverage + ); + + return nextCloseableInfo; + } + + private boolean handleNextContractClose( + List positionValuesList, + PositionSideAction action, + BigDecimal maxMarginAmountToTradePerIteration + ) throws Exception { + int left = positionValuesList.size(); + int totalNum = left; + int num; + + List candidates = filterOkType(positionValuesList, action); + num = candidates.size(); + int wrongTypeNum = left - num; + left -= wrongTypeNum; + + candidates = filterQuarantined(candidates); + num = candidates.size(); + int quarantinedNum = left - num; + left -= quarantinedNum; + + List closeableCandidatesInfo = calculateClosingScores( + candidates, + maxMarginAmountToTradePerIteration + ); + + closeableCandidatesInfo = filterNonEmptyPositions(closeableCandidatesInfo); + num = closeableCandidatesInfo.size(); + int balanceOfOneNum = left - num; + left -= balanceOfOneNum; + + closeableCandidatesInfo = filterBelowCloseScoreLimit(closeableCandidatesInfo); + num = closeableCandidatesInfo.size(); + int badScoreNum = left - num; + left -= badScoreNum; + + int closeableNum = closeableCandidatesInfo.size(); + + // Sort descending (according to adjustedScore) + closeableCandidatesInfo.sort(Comparator.comparing(o -> ((NextCloseableInfo) o).adjustedScore()).reversed()); + + String msg = action + " candidates: Total:" + totalNum + + " - wrongType:" + wrongTypeNum + + " - quarantined:" + quarantinedNum + + " - badScore:" + badScoreNum + + " - balanceOfOne:" + balanceOfOneNum + + " = closeable:" + closeableNum; + log.info(msg); + + if (closeableCandidatesInfo.isEmpty()) { + log.info(" Nothing closeable!"); + return false; + } + + NextCloseableInfo nextCloseableInfo = closeableCandidatesInfo.get(0); + performClose(nextCloseableInfo); + + // Do NOT put this position into time-out as it is OK to keep liquidating it on each iteration + return true; + } + + private List filterOkType(List positionValuesList, PositionSideAction action) { + List result = new ArrayList<>(); + + for (PositionValues positionValue : positionValuesList) { + PositionType positionType = positionValue.position().type(); + + boolean doAdd = switch (action) { + case OpenLong, CloseLong -> positionType == marginLong; + case OpenShort, CloseShort -> positionType == marginShort; + }; + + if (doAdd) { + result.add(positionValue); + } + } + + return result; + } + + private boolean handleNextContractOpen( + List positionValuesList, + PositionSideAction action, + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration + ) throws Exception { + int left = positionValuesList.size(); + int totalNum = left; + int num; + + List candidates = filterOkType(positionValuesList, action); + num = candidates.size(); + int wrongTypeNum = left - num; + left -= wrongTypeNum; + + int totalNumberOfPositionsOfThisType = left; + + candidates = filterQuarantined(candidates); + num = candidates.size(); + int quarantinedNum = left - num; + left -= quarantinedNum; + + candidates = filterTimedOuts(candidates); + num = candidates.size(); + int timedOutNum = left - num; + left -= timedOutNum; + + // TODO: EVE-50: Re-enable maxOwnable +//candidates = filterMaxOwnable(nonTimedOut); +//num = nonMaxOwnable.size(); + int maxOwnableNum = 0; +//int maxOwnableNum = left - num; +//left -= maxOwnableNum; + + final List futureBasePrices = calculateAllFutureBasePrices( + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration, + candidates, + totalNumberOfPositionsOfThisType + ); + + final List goodScore = filterBelowAdjustedScoreLimit(futureBasePrices); + num = goodScore.size(); + int badScoreNum = left - num; + left -= badScoreNum; + + final List openable = goodScore; + int openableNum = openable.size(); + + // Sort descending - according to adjustedScore + openable.sort(Comparator.comparing(o -> ((FutureBasePrices) o).adjustedScore()).reversed()); + + String msg = action + " candidates: Total:" + totalNum + + " - wrongType:" + wrongTypeNum + + " - quarantined:" + quarantinedNum + + " - timedOut:" + timedOutNum + + " - maxOwnable:" + maxOwnableNum + + " - badScore:" + badScoreNum + + " = openable:" + openableNum; + log.info(msg); + + if (openable.isEmpty()) { + log.info(" Nothing openable!"); + return false; + } + + FutureBasePrices nextBuyInfo = openable.get(0); + performOpen(nextBuyInfo); + + // Put this position in timeout for a while + Position position = nextBuyInfo.position(); + UUID uuid = position.gamiId().gamiId(); + PositionType positionType = position.type(); + String symbol = position.gamiId().symbolMEXC(); + timeOuts.putInTimeout(uuid, positionType, symbol); + + return true; + } + + private void detectAndPrepareForBrandNewPositions() throws Exception { + Map remoteSymbols = new HashMap<>(); + for(String symbol : ticker.getAllSymbols()) { + if(symbol.endsWith("_USDT")) { + if(symbol.equals("USDC_USDT")) { // We do not want to buy that! + continue; + } + remoteSymbols.put(symbol + "/long", symbol + "/long"); // We do not need the actual value - only the symbols + remoteSymbols.put(symbol + "/short", symbol + "/short"); // We do not need the actual value - only the symbols + } + } + + List positions = portfolio.getPositions(); + for(Position position : positions) { + String symbol = position.gamiId().symbolMEXC() + "/" + position.type().toString(); + remoteSymbols.remove(symbol); + } + + if(remoteSymbols.isEmpty()) { + return; + } + + log.info("Detected {} new symbols! Preparing to start (remember to add the following entries into Positions.json)...", + remoteSymbols.size()); + + for(String positionId : remoteSymbols.keySet()) { + int positionTypeInt; + MEXCPositionSide side; + + if(positionId.endsWith("/long")) { + side = MEXCPositionSide.OpenLong; + positionTypeInt = 1; // Long + } else { + side = MEXCPositionSide.OpenShort; + positionTypeInt = 2; // Short + } + + int index = positionId.indexOf('/'); + String symbol = positionId.substring(0, index); + + MEXCLeverageInfoItem leverageInfo = mexcLeverageInfo.getLeverageInfo(symbol, positionTypeInt); + int leverage = leverageInfo.leverage(); + + int contractsToOpen = 2; + log.info(" Initializing position by opening {} contracts on '{}'", contractsToOpen, positionId); + String json = mexcFuturesPlugin.trade(side, 2, symbol, leverage); + + if(json == null || !json.contains("success\":true")) { + String message = "ERROR: Could not trade '" + symbol + "'"; + pushNotification(message); + log.error(message + ": " + json); + continue; + } + + long exchangeTransactionDelay = configuration.exchangeTransactionDelay(); + Thread.sleep(exchangeTransactionDelay); + } + + updateLocalCache(); + } + + private List filterQuarantined(List positionValuesList) { + List result = new ArrayList<>(); + + for (PositionValues positionValues : positionValuesList) { + Position position = positionValues.position(); + + if (position.quarantined()) { + continue; + } + + result.add(positionValues); + } + + return result; + } + + private List filterTimedOuts(List positionValuesList) { + List result = new ArrayList<>(); + + for(PositionValues positionValues : positionValuesList) { + Position position = positionValues.position(); + UUID gamiId = position.gamiId().gamiId(); + PositionType positionType = position.type(); + boolean isInTimeOut = timeOuts.isInTimeOut(gamiId, positionType); + + if(isInTimeOut) { + continue; + } + + result.add(positionValues); + } + + return result; + } + + private List filterBelowAdjustedScoreLimit(List futureBasePricesList) { + List filteredList = new ArrayList<>(); + + for (FutureBasePrices futureBasePrices : futureBasePricesList) { + BigDecimal adjustedScore = futureBasePrices.adjustedScore(); + + if (adjustedScore.compareTo(ONE) <= 0) { + log.debug(" {}: Score:{} / Adjusted score:{} - Below limit!", + futureBasePrices.position().gamiId().symbolMEXC(), + futureBasePrices.rawScore(), + adjustedScore + ); + continue; + } else { + log.debug(" {}: Score:{} / Adjusted score:{} - Accepted!", + futureBasePrices.position().gamiId().symbolMEXC(), + futureBasePrices.rawScore(), + adjustedScore + ); + filteredList.add(futureBasePrices); + } + } + + return filteredList; + } + + private List filterNonEmptyPositions(List nextCloseableInfoList) { + List filteredList = new ArrayList<>(); + + for (NextCloseableInfo nextCloseableInfo : nextCloseableInfoList) { + if (nextCloseableInfo.balanceContracts() < 2) { + // Do not close everything on the position. Keep one so that statistics are not reset + continue; + } + + filteredList.add(nextCloseableInfo); + } + + return filteredList; + } + + private List filterBelowCloseScoreLimit(List nextCloseableInfoList) { + List filteredList = new ArrayList<>(); + + for (NextCloseableInfo nextCloseableInfo : nextCloseableInfoList) { + BigDecimal score = nextCloseableInfo.adjustedScore(); + + BigDecimal minimumPctEarning = (nextCloseableInfo.positionType() == marginLong) + ? configuration.minimumPctEarningForLongs() + : configuration.minimumPctEarningForShorts(); + + BigDecimal limit = BigDecimal.ONE.add(minimumPctEarning.divide(BigDecimal.valueOf(100), 20, HALF_UP)); + if (score.compareTo(limit) < 0) { + continue; + } + + filteredList.add(nextCloseableInfo); + } + + return filteredList; + } + + private List filterMaxOwnable(List futureBasePricesList) { + List filteredList = new ArrayList<>(); + + for (FutureBasePrices futureBasePrices : futureBasePricesList) { + Position position = futureBasePrices.position(); + + BigDecimal max = position.maxTotal(); + if (max != null && position.balance().compareTo(max) >= 0) { + // Max has been reached - skip this! + continue; + } + + filteredList.add(futureBasePrices); + } + + return filteredList; + } + + private void performClose(NextCloseableInfo nextCloseableInfo) throws Exception { + String time = getFormattedTime(); + + UUID gamiId = nextCloseableInfo.position().gamiId().gamiId(); + GlobalAssetMetadataIndexItem metaData = gami.getById(gamiId); + int balanceContracts = nextCloseableInfo.balanceContracts(); + + BigDecimal quantityToTradeTokens = nextCloseableInfo.quantityToTradeTokens(); + int quantityToTradeContracts = nextCloseableInfo.quantityToTradeContracts(); + BigDecimal earning = nextCloseableInfo.earning().multiply(BigDecimal.valueOf(quantityToTradeContracts)); + String earningStr = df4.format(earning); + int leverage = nextCloseableInfo.leverage(); + BigDecimal earningPct = nextCloseableInfo.earningPct().multiply(BigDecimal.valueOf(100)); + BigDecimal earningPctLeveraged = earningPct.multiply(BigDecimal.valueOf(leverage)); + PositionType positionType = nextCloseableInfo.positionType(); + + String msg = " " + time + " - " + metaData.name() + "/" + metaData.symbolMEXC() + + ": Close " + quantityToTradeContracts + "/" + balanceContracts + " " + positionType + " contract(s) (" + + quantityToTradeTokens + " tokens). " + "Earning: $" + earningStr + + " (%" + df2.format(earningPctLeveraged) + " x" + leverage + ")"; + log.info(msg); + + MEXCPositionSide side = (positionType == PositionType.marginLong) + ? MEXCPositionSide.CloseLong + : MEXCPositionSide.CloseShort; + + String jsonResult = mexcFuturesPlugin.trade(side, quantityToTradeContracts, metaData.symbolMEXC()); + + if(jsonResult == null || !jsonResult.contains("success\":true")) { + String message = "ERROR: Could not trade!"; + pushNotification(message); + log.error(message + ": " + jsonResult); + } + } + + private String getFormattedTime() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); + ZonedDateTime nowCET = ZonedDateTime.now(ZoneId.of("CET")); + String time = formatter.format(nowCET); + return time; + } + + private void performOpen(FutureBasePrices nextOpenInfo) throws Exception { + PositionType type = nextOpenInfo.type(); + Position position = nextOpenInfo.position(); + UUID gamiId = position.gamiId().gamiId(); + GlobalAssetMetadataIndexItem metaData = gami.getById(gamiId); + BigDecimal quantityTokens = nextOpenInfo.quantityAbleToBuy(); + BigDecimal targetPrice = nextOpenInfo.futureBasePrice(); + BigDecimal rawScore = nextOpenInfo.rawScore(); + BigDecimal adjustedScore = nextOpenInfo.adjustedScore(); + String time = getFormattedTime(); + + String symbol = position.gamiId().symbolMEXC(); + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + BigDecimal contractSize = contractDetailsItem.contractSize(); + int quantityToTradeContracts = quantityTokens.divide(contractSize, 0, HALF_UP).intValue(); + + BigDecimal price = nextOpenInfo.currentPrice(); + BigDecimal spendAmountLeveraged = quantityTokens.multiply(price); + int leverage = nextOpenInfo.position().leverage(); + final BigDecimal marginPrice = spendAmountLeveraged.divide(new BigDecimal(leverage), 4, HALF_UP); + + String msg = time + " - " + metaData.name() + "/" + metaData.symbolMEXC() + + ": Open " + quantityToTradeContracts + " " + type + " contract(s) (" + quantityTokens + " tokens), " + + "Spending $" + df4.format(marginPrice) + " / $" + df4.format(spendAmountLeveraged) + + " to reach base price $" + targetPrice.toPlainString() + + " - Score (raw/adjusted): " + df4.format(rawScore) + " / " + df4.format(adjustedScore) + "."; + log.info(msg); + + MEXCPositionSide side = (type == PositionType.marginLong) + ? MEXCPositionSide.OpenLong + : MEXCPositionSide.OpenShort; + + final BigDecimal marginPriceForOpening = marginPrice.multiply(BigDecimal.valueOf(quantityToTradeContracts)); + final AccountDetails accountDetails = accountDetailsService.getDetails(); + final BigDecimal available = accountDetails.availableOpen(); + + if(available.compareTo(marginPriceForOpening) <= 0) { + String message = "WARNING: Available margin too low to open '" + metaData.symbolMEXC() + + "'. Have $" + df4.format(available) + + ", needing $" + df4.format(marginPriceForOpening) + + " - skipping until next iteration!"; + log.warn(message); + } + + String json = mexcFuturesPlugin.trade(side, quantityToTradeContracts, metaData.symbolMEXC()); + + if(json == null) { + String message = "ERROR: Could not trade '" + symbol + "' - Empty reply from MEXC"; + pushNotification(message); + log.error(message); + } + + if(!json.contains("success\":true")) { + MEXCProtocolErrors error = mapErrorMessage(json); + String errorStr = (error == null) + ? "" + : " (" + error + ")"; + String message = "ERROR: Could not trade '" + symbol + "'!" + errorStr; + pushNotification(message); + log.error(message + ": " + json); + } + } + + private static BigDecimal roundToNearestMultiple(BigDecimal number, BigDecimal multiple) { + BigDecimal divided = number.divide(multiple, 0, HALF_UP); + return divided.multiply(multiple); + } + + private List calculateAllFutureBasePrices( + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration, + List positionValuesList, + int totalNumberOfPositionsOfThisType + ) { + if(positionValuesList == null || positionValuesList.isEmpty()) { + return List.of(); + } + + List futureBasePrices = new ArrayList<>(); + + BigDecimal totalPositionsValue = ZERO; + int numberOfPositions = positionValuesList.size(); + + for(PositionValues positionValues : positionValuesList) { + totalPositionsValue = totalPositionsValue.add(positionValues.positionValue()); + } + + BigDecimal averagePositionValue = totalPositionsValue.divide( + BigDecimal.valueOf(numberOfPositions), 4, HALF_UP); + + for(PositionValues positionValues : positionValuesList) { + FutureBasePrices fap = calculateFutureBasePrices( + positionValues, + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration, + averagePositionValue, + totalNumberOfPositionsOfThisType + ); + futureBasePrices.add(fap); + } + + return futureBasePrices; + } + + private FutureBasePrices calculateFutureBasePrices( + PositionValues positionValues, + BigDecimal maxMarginAmountToTradePerIteration, + BigDecimal maxStretchMarginAmountToTradePerIteration, + BigDecimal averagePositionValue, + int totalNumberOfPositions + ) { + Position position = positionValues.position(); + String symbol = position.gamiId().symbolMEXC(); + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + BigDecimal contractSize = contractDetailsItem.contractSize(); + BigDecimal positionValue = positionValues.positionValue(); + BigDecimal currentTokenNotationPrice = positionValues.currentPrice(); + int leverage = positionValues.leverage(); + + BigDecimal amountNotionalToSpend = bl.calculateContractValueToTrade( + maxMarginAmountToTradePerIteration, + maxStretchMarginAmountToTradePerIteration, + positionValue, + currentTokenNotationPrice, + contractSize, + leverage, + configuration.maxSpendPctOfFullPosition() + ); + + BigDecimal quantityAbleToTrade = amountNotionalToSpend.divide( + currentTokenNotationPrice, + 20, + DOWN + ).setScale(position.numberOfDecimalsInTrade(), DOWN); + quantityAbleToTrade = roundToNearestMultiple(quantityAbleToTrade, contractSize); + + BigDecimal contractsAbleToTrade = quantityAbleToTrade.divideToIntegralValue(contractSize); + + BigDecimal futureTokenBaseNotationPrice = calculateFutureBasePrice( + position.balance(), + currentTokenNotationPrice, + positionValues.basePrice(), + quantityAbleToTrade + ); + + PositionType positionType = position.type(); + BigDecimal currentTokenBaseNotationPrice = positionValues.basePrice(); + + ScoreCalculatorInput input = new ScoreCalculatorInput( + currentTokenBaseNotationPrice, + futureTokenBaseNotationPrice, + currentTokenNotationPrice, + amountNotionalToSpend, + totalNumberOfPositions, + contractSize, + BigDecimal.valueOf(leverage), + positionType, + averagePositionValue + ); + + BigDecimal rawScore = scoreCalculator.calculateScore(input); + + BigDecimal penaltyCoefficient = configuration.adjustedScoreTooBigPositionsPenaltyCoefficient(); + BigDecimal adjustedScore = calculateAdjustedScore( + rawScore, + positionValue, + averagePositionValue, + penaltyCoefficient + ); + + FutureBasePrices fap = new FutureBasePrices( + position, + positionType, + quantityAbleToTrade, + contractsAbleToTrade, + futureTokenBaseNotationPrice, + rawScore, + adjustedScore, + currentTokenNotationPrice + ); + + return fap; + } + + private static BigDecimal calculateAdjustedScore( + BigDecimal rawScore, + BigDecimal positionValue, + BigDecimal averagePositionValue, + BigDecimal penaltyCoefficient + ) { + if (positionValue.compareTo(averagePositionValue) <= 0) { + // Only those positions that are larger than the average should be penalized. + return rawScore; + } + + final BigDecimal K = penaltyCoefficient; // Use 1.0 for "halve at double size" + + BigDecimal positionRatio = positionValue.divide(averagePositionValue, MC); + BigDecimal diff = positionRatio.subtract(BigDecimal.ONE, MC); + BigDecimal diffSquared = diff.multiply(diff, MC); + BigDecimal denominator = BigDecimal.ONE.add(K.multiply(diffSquared, MC), MC); + BigDecimal adjustedScore = rawScore.divide(denominator, MC); + + return adjustedScore; + } + + private BigDecimal calculateFutureBasePrice( + final BigDecimal currentTokenBalance, + final BigDecimal currentTokenPrice, + final BigDecimal currentTokenBasePrice, + final BigDecimal tokenQuantityAbleToBuy + ) { + BigDecimal futureTokenBalance = currentTokenBalance.add(tokenQuantityAbleToBuy); + BigDecimal totalValueForTrade = tokenQuantityAbleToBuy.multiply(currentTokenPrice); + BigDecimal currentPositionBaseValue = currentTokenBasePrice.multiply(currentTokenBalance); + BigDecimal futurePositionBaseValue = currentPositionBaseValue.add(totalValueForTrade); + BigDecimal futureTokenBasePrice = futurePositionBaseValue.divide(futureTokenBalance, MC); + + return futureTokenBasePrice; + } + + private static final Logger log = LoggerFactory.getLogger(Evelyn.class); + + private static final MathContext MC = new MathContext(20, HALF_UP); + private static final BigDecimal K = new BigDecimal("0.01", MC); // Constant in punishment algo + + private static final DecimalFormat df0 = new DecimalFormat("#,##0"); + private static final DecimalFormat df2 = new DecimalFormat("#,##0.00"); + private static final DecimalFormat df4 = new DecimalFormat("#,##0.0000"); + + private final BusinessLogic bl = new BusinessLogicImpl(); + + private NenjimObjectRepository nor = null; + private GlobalAssetMetadataIndex gami = null; + private TimeOuts timeOuts = null; + private MEXCTicker ticker = null; + private MEXCContractDetails contractDetails = null; + private MEXCFundingRates mexcFundingRates = null; + private MEXCLeverageInfo mexcLeverageInfo = null; + private Portfolio portfolio = null; + private MEXCFuturesPlugin mexcFuturesPlugin = null; + private ScoreCalculator scoreCalculator = null; + private PushNotifier pushNotifier = null; + private Configuration configuration = null; + private MEXCAccountDetailsService accountDetailsService = null; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/EventWatcher.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/EventWatcher.tjava new file mode 100644 index 0000000..2691596 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/EventWatcher.tjava @@ -0,0 +1,63 @@ +package crypto.r35157.cauldron.afets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.*; +import static java.nio.file.StandardWatchEventKinds.*; + +public class EventWatcher { + public EventWatcher(Thread workerThread, String directoryName) throws IllegalArgumentException { + this.workerThread = workerThread; + this.directory = Paths.get(directoryName); + + if(!(Files.exists(directory) && Files.isDirectory(directory))) { + String error = "'" + directory.toAbsolutePath().normalize() + "' is not a directory or do not exist!"; + throw new IllegalArgumentException(error); + } + } + + public void start() throws IOException { + log.info("Register watch on '$EVELYN_HOME/tmp/iteration.signal' to signal thread '" + workerThread.getName() + + "' to start an iteration."); + + WatchService watchService = FileSystems.getDefault().newWatchService(); + directory.register(watchService, ENTRY_CREATE); + + Thread.ofVirtual().start(() -> { + try { + while (true) { + WatchKey key = watchService.take(); // Blocking + + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + Path filename = (Path) event.context(); + + if (kind == ENTRY_CREATE && "iteration.signal".equals(filename.toString())) { + log.info("EventWatcher found 'tmp/iteration.signal' file " + + "- sleeping worker thread will be waken now (and the signal file will be deleted)."); + Path fullPath = directory.resolve(filename); + try { + Files.deleteIfExists(fullPath); + } catch (IOException e) { + log.error("ERROR: Could NOT delete signal file 'tmp/iteration.signal'! (" + + e.getMessage() + ")"); + } + + workerThread.interrupt(); + } + } + key.reset(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + private static final Logger log = LoggerFactory.getLogger(EventWatcher.class); + + private final Path directory; + private final Thread workerThread; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/FundingFeeStatus.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/FundingFeeStatus.tjava new file mode 100644 index 0000000..44cd40a --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/FundingFeeStatus.tjava @@ -0,0 +1,10 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; +import java.util.List; + +public record FundingFeeStatus( + List feeWarnings, // TODO: Well... Presentation here is not too nice :-/ + BigDecimal totalFeesToPay, + BigDecimal totalFeesToReceive +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/FutureBasePrices.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/FutureBasePrices.tjava new file mode 100644 index 0000000..d8c225c --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/FutureBasePrices.tjava @@ -0,0 +1,14 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record FutureBasePrices( + Position position, + PositionType type, + BigDecimal quantityAbleToBuy, + BigDecimal contractsAbleToBuy, + BigDecimal futureBasePrice, + BigDecimal rawScore, + BigDecimal adjustedScore, + BigDecimal currentPrice +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/InvalidAPIKeyException.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/InvalidAPIKeyException.tjava new file mode 100644 index 0000000..0422674 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/InvalidAPIKeyException.tjava @@ -0,0 +1,7 @@ +package crypto.r35157.cauldron.afets; + +public class InvalidAPIKeyException extends RuntimeException { + public InvalidAPIKeyException(String message) { + super(message); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCFuturesPlugin.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCFuturesPlugin.tjava new file mode 100644 index 0000000..98c8de1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCFuturesPlugin.tjava @@ -0,0 +1,274 @@ +package crypto.r35157.cauldron.afets; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MEXCFuturesPlugin { + public MEXCFuturesPlugin(String webKey, String userAgent) { + this.httpClient = HttpClient.newHttpClient(); + this.gson = new GsonBuilder().create(); + + baseHeaders = new HashMap<>(); + baseHeaders.put("User-Agent", userAgent); + + setWebKey(webKey); + } + + public void setWebKey(String webKey) { + if(!webKey.equals(this.webKey)) { + if(this.webKey == null) { + log.debug("Setting WebKey!"); + } else { + log.info("WebKey change detected - updating!"); + } + + this.webKey = webKey; + baseHeaders.put("Authorization", webKey); + baseHeaders.put("Cookie", "u_id=" + webKey); + } else { + log.debug("WebKey unchanged - skipping update!"); + } + } + + // Place order (market and limit) + public String placeOrder(Object obj) throws IOException, InterruptedException { + return sendPost("/order/create", obj, true); + } + + // Get orders trigger + public ServiceResponse getOrdersTrigger() throws IOException, InterruptedException { + // No payload provided in the Python code example. + return sendGet("/planorder/list/orders?states=1", null, false); + } + + // Get open positions + public ServiceResponse getOpenPositions() throws IOException, InterruptedException { + return sendGet("/position/open_positions", null, false); + } + + public ServiceResponse getLeverage(String symbol) throws IOException, InterruptedException { + return sendGet("/position/leverage?symbol=" + symbol, null, false); + } + + public ServiceResponse getAccountDetails(String symbol) throws IOException, InterruptedException { + return sendGet("/account/asset/" + symbol, null, false); + } + + // Close order trigger – note: Python wraps obj in a list. + public String closeOrderTrigger(Object obj) throws IOException, InterruptedException { + Object payload = List.of(obj); + return sendPost("/planorder/cancel", payload, true); + } + + // Close all orders trigger (no payload) + public String closeAllOrdersTrigger() throws IOException, InterruptedException { + Map payload = new HashMap<>(); + return sendPost("/planorder/cancel_all", payload, true); + } + + // Open order trigger (for plan orders) + public String openOrderTrigger(Object obj) throws IOException, InterruptedException { + return sendPost("/planorder/place/v2", obj, true); + } + + // Get open orders limit + public ServiceResponse getOpenOrdersLimit() throws IOException, InterruptedException { + return sendGet("/order/list/open_orders", null, false); + } + + // Add Stop Loss / Take Profit for limit orders + public String addSTTPForLimit(Object obj) throws IOException, InterruptedException { + return sendPost("/stoporder/change_price", obj, true); + } + + // Close all limits + public String closeAllLimits() throws IOException, InterruptedException { + Map payload = new HashMap<>(); + return sendPost("/order/cancel_all", payload, true); + } + + public String trade(MEXCPositionSide side, int numOfContracts, String symbol) throws Exception { + return trade(side, numOfContracts, symbol, null); + } + + //MEXCPositionSide sideId = (side == PositionType.marginLong) + // ? MEXCPositionSide.OpenLong + // : MEXCPositionSide.OpenShort; + + public String trade(MEXCPositionSide side, int numOfContracts, String symbol, Integer leverage) throws Exception { + int vol = numOfContracts; // Number of contracts to trade + MEXCOpenType openType = MEXCOpenType.Cross; + MEXCType type = MEXCType.MarketOrders; + MEXCOrderType orderType = MEXCOrderType.MarketOrders; + + // Example order object (for place order) + Map orderObj = new HashMap<>(); + orderObj.put("symbol", symbol); + orderObj.put("side", "" + side.getValue()); + orderObj.put("openType", "" + openType.getValue()); + orderObj.put("type", "" + type.getValue()); + orderObj.put("orderType", "" + orderType.getValue()); + orderObj.put("vol", vol); + + if(leverage != null) { + orderObj.put("leverage", leverage); + } + + //orderObj.put("price", 2); + orderObj.put("priceProtect", "0"); + + // Call open_order_trigger with a sample object similar to obj3 in Python + //Map openOrderObj = new HashMap<>(); + //openOrderObj.put("executeCycle", 3); + //openOrderObj.put("leverage", 100); + //openOrderObj.put("openType", 1); + //openOrderObj.put("orderType", 1); + //openOrderObj.put("positionMode", 1); + //openOrderObj.put("price", "30"); + //openOrderObj.put("priceProtect", "0"); + //openOrderObj.put("side", 1); + //openOrderObj.put("symbol", "TONCOIN_USDT"); + //openOrderObj.put("trend", 1); + //openOrderObj.put("triggerPrice", "10"); + //openOrderObj.put("triggerType", 1); + //openOrderObj.put("vol", 1); + + + String openOrderResponse = placeOrder(orderObj); + + return openOrderResponse; + } + + private static String md5(String value) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] hashBytes = md.digest(value.getBytes(StandardCharsets.UTF_8)); + // Convert to hex string + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new RuntimeException("MD5 algorithm not found", ex); + } + } + + private Map mexcCrypto(Object obj) { + // Current time in ms as string. + String dateNow = String.valueOf(Instant.now().toEpochMilli()); + // Compute g = md5(key + dateNow) and take substring starting at index 7. + String g = md5(webKey + dateNow).substring(7); + // Convert the object to JSON with no extra spaces. + // Gson's default toJson produces compact JSON. + String s = gson.toJson(obj); + // sign = md5(dateNow + s + g) + String sign = md5(dateNow + s + g); + Map result = new HashMap<>(); + result.put("time", dateNow); + result.put("sign", sign); + return result; + } + + private String sendPost(String endpoint, Object payload, boolean signRequest) throws IOException, InterruptedException { + String url = BASE_URL + endpoint; + String jsonPayload = gson.toJson(payload); + Map headers = new HashMap<>(baseHeaders); + headers.put("Content-type", "application/json"); + + if (signRequest) { + Map signature = mexcCrypto(payload); + headers.put("x-mxc-sign", signature.get("sign")); + headers.put("x-mxc-nonce", signature.get("time")); + } + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)); + headers.forEach(builder::header); + + HttpRequest request = builder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + String responseTxt = response.body(); + return responseTxt; + } + + private ServiceResponse sendGet(String endpoint, Object payload, boolean signRequest) throws IOException, InterruptedException { + // For GET, if a payload is provided, assume it is converted to query parameters. + String url = BASE_URL + endpoint; + if (payload != null) { + // Assuming payload is a Map + @SuppressWarnings("unchecked") + Map paramMap = (Map) payload; + if (!paramMap.isEmpty()) { + String query = buildQueryString(paramMap); + url += "?" + query; + } + } + Map headers = new HashMap<>(baseHeaders); + headers.put("Content-type", "application/json"); + + if (signRequest && payload != null) { + Map signature = mexcCrypto(payload); + headers.put("x-mxc-sign", signature.get("sign")); + headers.put("x-mxc-nonce", signature.get("time")); + } + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET(); + headers.forEach(builder::header); + + HttpRequest request = builder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + ServiceResponse sr = new ServiceResponse(response.statusCode(), response.body()); + return sr; + } + + private String buildQueryString(Map params) { + StringBuilder sb = new StringBuilder(); + params.entrySet().stream().sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + if (sb.length() > 0) sb.append("&"); + sb.append(urlEncode(entry.getKey())) + .append("=") + .append(urlEncode(entry.getValue().toString())); + }); + return sb.toString(); + } + + private String urlEncode(String s) { + try { + return URLEncoder.encode(s, StandardCharsets.UTF_8.toString()) + .replaceAll("\\+", "%20"); + } catch (Exception e) { + throw new RuntimeException("UTF-8 encoding not supported", e); + } + } + + private static final String BASE_URL = "https://futures.mexc.com/api/v1/private"; + + private static final Logger log = LoggerFactory.getLogger(MEXCFuturesPlugin.class); + + private String webKey; + private final Map baseHeaders; + private final HttpClient httpClient; + private final Gson gson; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOpenType.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOpenType.tjava new file mode 100644 index 0000000..51ece54 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOpenType.tjava @@ -0,0 +1,21 @@ +package crypto.r35157.cauldron.afets; + +public enum MEXCOpenType { + Isolated(1), + Cross(2); + + private final int value; + + MEXCOpenType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOrderType.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOrderType.tjava new file mode 100644 index 0000000..c7f7c14 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCOrderType.tjava @@ -0,0 +1,24 @@ +package crypto.r35157.cauldron.afets; + +public enum MEXCOrderType { + LimitOrder(1), + PostOnlyMaker(2), + CloseOrCancelInstantly(3), + CloseOrCancelCompletely(4), + MarketOrders(5); + + private final int value; + + MEXCOrderType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCPositionSide.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCPositionSide.tjava new file mode 100644 index 0000000..16f86a4 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCPositionSide.tjava @@ -0,0 +1,23 @@ +package crypto.r35157.cauldron.afets; + +public enum MEXCPositionSide { + OpenLong(1), + CloseShort(2), + OpenShort(3), + CloseLong(4); + + private final int value; + + MEXCPositionSide(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCProtocolErrors.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCProtocolErrors.tjava new file mode 100644 index 0000000..7f09b10 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCProtocolErrors.tjava @@ -0,0 +1,114 @@ +package crypto.r35157.cauldron.afets; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public enum MEXCProtocolErrors { + operationSucceeded(0, "Operate succeed", true), + publicAbnormal(9999, "Public abnormal", false), + internalError(500, "Internal error", false), + systemBusy(501, "System busy", false), + unauthorized(401, "Unauthorized", true), + apiKeyExpired(402, "Api_key expired", true), + IpNotOnWhitelist(406, "Accessed IP is not in the whitelist", true), + unknownSource(506, "Unknown source of request", true), + excessiveRequestFrequency(510, "Excessive frequency of requests", false), + endpointInaccessible(511, "Endpoint inaccessible", true), + invalidRequest(513, "Invalid request(for open api serves time more or less than 10s)", false), + parameterError(600, "Parameter error", true), + dataDecodingError(601, "Data decoding error", true), + verifyFailed(602, "Verify failed", true), + repeatedRequests(603, "Repeated requests", false), + accountReadPermissionRequired(701, "Account read permission is required", true), + accpuntModifyPermissionRequired(702, "Account modify permission is required", true), + tradeInformationReadPermissionRequired(703, "Trade information read permission is required", true), + transactionInformationModifyPermissionRequired(704, "Transaction information modify permission is required", true), + accountDoesNotExist(1000, "Account does not exist", true), + contractDoesNotExist(1001, "Contract does not exist", true), + contractNotActivated(1002, "Contract not activated", true), + errorRiskLimitLevel(1003, "Error in risk limit level", true), + amountError(1004, "Amount error", true), + wrongOrderDirection(2001, "Wrong order direction", true), + wrongOrderType1(2002, "Wrong opening type", true), + overpriced(2003, "Overpriced to pay", true), + lowPriceForSelling(2004, "Low-price for selling", true), + balanceInsufficient(2005, "Balance insufficient", true), + leverageRatioError(2006, "Leverage ratio error", true), + orderPriceError(2007, "Order price error", true), + insufficientQuantity(2008, "The quantity is insufficient", true), + nonexistentPosition(2009, "Positions do not exist or have been closed", true), + orderQuanityError(2011, "Order quantity error", true), + cancelOrdersOverMaxLimit(2013, "Cancel orders over maximum limit", true), + batchQuantityExceedsLimit(2014, "The quantity of batch order exceeds the limit", true), + priceOrQuantityAccuracyError(2015, "Price or quantity accuracy error", true), + triggerVolumeOverMax(2016, "Trigger volume over the maximum", true), + exceedingMaxAvailableMargin(2018, "Exceeding the maximum available margin", true), + activeOpenPosition(2019, "There is an active open position", true), + leverageNotConsistentWithPosition(2021, "The single leverage is not consistent with the existing position leverage", true), + wrongOrderType2(2022, "Wrong position type", true), + positionsOverMaxLeverage(2023, "There are positions over the maximum leverage", true), + ordersOverMaxLeverage(2024, "There are orders with leverage over the maximum", true), + positionsOverMaxAllowable(2025, "The holding positions is over the maximum allowable positions", true), + leverageModificationForCrossNotAllowed(2026, "Modification of leverage is not supported for cross", true), + onlyOneCrossOrIsolatedInSameDirection(2027, "There is only one cross or isolated in the same direction", true), + maxOrderQuantityExceeded(2028, "The maximum order quantity is exceeded", true), + orderTypeError(2029, "Error order type", true), + externalOrderIdTooLong(2030, "External order ID is too long (Max. 32 bits )", true), + positionExceedsRiskLimit(2031, "The allowable holding position exceed the current risk limit", true), + orderPriceLessThanLongPositionForcedLiquidationPrice(2032, "Order price is less than long position force liquidate price", true), + orderPriceMoreThanShortPositionForcedLiquidationPrice(2033, "Order price is more than short position force liquidate price", true), + batchQueryQuantityLimitExceeded(2034, "The batch query quantity limit is exceeded", true), + unsupportedMarketPriceTier(2035, "Unsupported market price tier", true), + triggerPriceTypeError(3001, "Trigger price type error", true), + triggerTypeError(3002, "Trigger type error", true), + executiveCycleError(3003, "Executive cycle error", true), + triggerPriceError(3004, "Trigger price error", true), + unsupportedCurrency(4001, "Unsupported currency", true), + orderLimitReached(2036, "The orders more than the limit, please contact customer service", true), + transactionPostTooFast(2037, "Frequent transactions, please try it later", false), + positionLimitReached(2038, "The maximum allowable position quantity is exceeded, please contact customer service!", true), + takePriceAndStopLossError(5001, "The take-price and the stop-loss price cannot be none at the same time", true), + stopLimitOrderDoNotExist(5002, "The Stop-Limit order does not exist or has closed", true), + takeProfitStopLossPriceSettingError(5003, "Take-profit and stop-loss price setting is wrong", true), + takeProfitStopLossOrderVolumeError(5004, "The take-profit and stop-loss order volume is more than the holding positions can be liquidated", true), + tradeForbidden(6001, "Trading forbidden", true), + openForbidden(6002, "Open forbidden", true), + timeRangeError(6003, "Time range error", true), + tradingPairAndStatusMissing(6004, "The trading pair and status should be fill in", true), + tradingPairNotAvailable(6005, "The trading pair is not available", true); + + MEXCProtocolErrors(int errorCode, String description, boolean persistent) { + this.errorCode = errorCode; + this.description = description; + this.persistent = persistent; + } + + public int getErrorCode() { + return errorCode; + } + + public String getDescription() { + return description; + } + + public boolean isPersistent() { + return persistent; + } + + @Override + public String toString() { + return errorCode + ": " + description; + } + + private static final Map ERROR_CODE_MAP = + Arrays.stream(values()).collect(Collectors.toMap(MEXCProtocolErrors::getErrorCode, e -> e)); + + public static MEXCProtocolErrors fromErrorCode(int code) { + return ERROR_CODE_MAP.get(code); + } + + private final int errorCode; + private final String description; + private final boolean persistent; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCSpotPlugin.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCSpotPlugin.tjava new file mode 100644 index 0000000..a66e9f4 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCSpotPlugin.tjava @@ -0,0 +1,141 @@ +package crypto.r35157.cauldron.afets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +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.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.collections4.MapUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MEXCSpotPlugin { + public MEXCSpotPlugin(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + public String getPositions() throws IOException, InterruptedException { + HttpClient client = HttpClient.newHttpClient(); + + // For a GET request, if there are query parameters, add them to a map. + // In this example, we assume there are no extra query parameters. + Map queryParams = new HashMap<>(); + // If needed, add parameters: + // queryParams.put("param1", "value1"); + + // Create the query string (if there are any parameters) + String requestParam = MEXCSpotPlugin.getRequestParamString(queryParams); + + // Build the complete URL (append query parameters if not empty) + String fullUrl = API_OPENPOSITIONS; + if (!requestParam.isEmpty()) { + fullUrl += "?" + requestParam; + } + + // Create a request timestamp + String reqTime = String.valueOf(System.currentTimeMillis()); + + // Create a SignVo for generating the signature. + // For GET requests, the requestParam string is the query string (sorted, URL-encoded). + MEXCSpotPlugin.SignVo signVo = new MEXCSpotPlugin.SignVo(reqTime, accessKey, secretKey, requestParam); + String signature = MEXCSpotPlugin.sign(signVo); + + // Build the HttpRequest with the required headers + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(fullUrl)) + .header("Content-Type", "application/json") + .header("ApiKey", accessKey) + .header("Request-Time", reqTime) + .header("Signature", signature) + .GET() + .build(); + + // Send the request and get the response + HttpResponse httpResp = client.send(request, HttpResponse.BodyHandlers.ofString()); + String responseJson = httpResp.body(); + + return responseJson; + } + + /** + * Gets the get request parameter string + * + * @param param get/delete Request parameters map + */ + public static String getRequestParamString(Map param) { + if (MapUtils.isEmpty(param)) { + return ""; + } + StringBuilder sb = new StringBuilder(1024); + SortedMap map = new TreeMap<>(param); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + String value = StringUtils.isBlank(entry.getValue()) ? "" : entry.getValue(); + sb.append(key).append('=').append(urlEncode(value)).append('&'); + } + sb.deleteCharAt(sb.length() - 1); + return sb.toString(); + } + + public static String urlEncode(String s) { + try { + return URLEncoder.encode(s, "UTF-8").replaceAll("\\+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("UTF-8 encoding not supported!"); + } + } + + public static String sign(SignVo signVo) { + String requestParam = (signVo.requestParam == null) ? "" : signVo.requestParam; + + String str = signVo.accessKey + signVo.reqTime + requestParam; + return actualSignature(str, signVo.secretKey); + } + + public static String actualSignature(String inputStr, String key) { + Mac hmacSha256; + try { + hmacSha256 = Mac.getInstance("HmacSHA256"); + SecretKeySpec secKey = + new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmacSha256.init(secKey); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("No such algorithm: " + e.getMessage()); + } catch (InvalidKeyException e) { + throw new RuntimeException("Invalid key: " + e.getMessage()); + } + byte[] hash = hmacSha256.doFinal(inputStr.getBytes(StandardCharsets.UTF_8)); + return Hex.encodeHexString(hash); + } + + public record SignVo( + String reqTime, + String accessKey, + String secretKey, + String requestParam // Get the request parameters are sorted in dictionary order, with & concatenated strings, POST should be a JSON string + ) {} + + private static final Logger log = LoggerFactory.getLogger(MEXCSpotPlugin.class); + + private static final String API_BASEURL = "https://contract.mexc.com/api/v1/"; + private static final String API_PRIVATGEURL = API_BASEURL + "private/"; + private static final String API_OPENPOSITIONS = API_PRIVATGEURL + "position/open_positions"; + + private final String accessKey; + private final String secretKey; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/MEXCType.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCType.tjava new file mode 100644 index 0000000..27237dc --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/MEXCType.tjava @@ -0,0 +1,25 @@ +package crypto.r35157.cauldron.afets; + +public enum MEXCType { + PriceLimitOrder(1), + PostOnlyMaker(2), + TransactOrCancelInstantly(3), + TransactCompletelyOrCancelCompletely(4), + MarketOrders(5), + ConvertMarketPriceToCurrentPrice(6); + + private final int value; + + MEXCType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/NextCloseableInfo.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/NextCloseableInfo.tjava new file mode 100644 index 0000000..ab923f0 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/NextCloseableInfo.tjava @@ -0,0 +1,18 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record NextCloseableInfo( + Position position, + BigDecimal quantityToTradeTokens, + int quantityToTradeContracts, + PositionType positionType, + BigDecimal earning, + BigDecimal earningPct, + BigDecimal rawScore, + BigDecimal adjustedScore, + BigDecimal balanceTokens, + int balanceContracts, + int leverage +) { +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/Portfolio.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/Portfolio.tjava new file mode 100644 index 0000000..1600555 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/Portfolio.tjava @@ -0,0 +1,208 @@ +package crypto.r35157.cauldron.afets; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import crypto.r35157.cauldron.afets.contractdetails.ContractDetailsItem; +import crypto.r35157.cauldron.afets.contractdetails.MEXCContractDetails; +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndex; +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndexItem; +import crypto.r35157.cauldron.afets.ticker.PositionItem; +import crypto.r35157.cauldron.afets.ticker.PositionResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.*; + +import static crypto.r35157.cauldron.afets.PositionType.marginLong; +import static crypto.r35157.cauldron.afets.PositionType.marginShort; + +public class Portfolio { + public Portfolio( + PushNotifier pushNotifier, + String positionsFilename, + GlobalAssetMetadataIndex gami, + MEXCSpotPlugin mexcSpotPlugin, + MEXCContractDetails contractDetails + ) { + this.pushNotifier = pushNotifier; + this.positionsFilename = positionsFilename; + this.gami = gami; + this.mexcSpotPlugin = mexcSpotPlugin; + this.contractDetails = contractDetails; + + gson = new GsonBuilder().create(); + } + + public List getPositions() { + return positions; + } + + public void updateAll() throws Exception { + List mexcPositionList; + + // TODO: Super ugly - will fix later + while(true) { + String mexcPositionJson = mexcSpotPlugin.getPositions(); + PositionResponse posResponse = gson.fromJson(mexcPositionJson, PositionResponse.class); + mexcPositionList = posResponse.data(); + + if(mexcPositionList != null && !mexcPositionList.isEmpty()) { + // Everything is OK - break out! + break; + } + + String errorMsg = "ERROR: mexcPositionList is null or empty (retry in 1 minute): " + mexcPositionJson; + log.error(errorMsg); + pushNotification(errorMsg); + + Thread.sleep(60 * 1000); + } + + Map mexcPositionMap = new HashMap<>(); + for (PositionItem item : mexcPositionList) { // THIS CAN THROW AN NPE + int positionType = item.positionType(); + String typeStr = positionType == 1 ? "long" : "short"; + String key = item.symbol() + "/" + typeStr; + if(mexcPositionMap.containsKey(key)) { + log.error("ERROR: " + key + " already in MEXC map! Skipping!"); + continue; + } + + mexcPositionMap.put(key, item); + } + + Map localPositionMap = new HashMap<>(); + List lines = Files.readAllLines(Paths.get(positionsFilename)); + + for(String origLine : lines) { + String line = origLine.trim(); + + // Skip if the line is empty or is a full comment line. + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + // Remove any trailing comment from the line (everything after a '#' character). + int commentIndex = line.indexOf('#'); + if (commentIndex != -1) { + line = line.substring(0, commentIndex).trim(); + } + // Skip the line if nothing is left. + if (line.isEmpty()) { + continue; + } + + // Split the line on one or more whitespace characters. + String[] parts = line.split("\\s+"); + + if (parts.length != 6) { + log.error("ERROR: Unparseable line in '" + positionsFilename + "' (" + origLine + ")"); + continue; + } + + // The file format is: + // Name, Type, StartTime, GAMIId, NumberOfDecimalsInTrade and MaxTotal + + boolean quarantined = false; + + int index = 0; + String name = parts[index]; // Unused here - just for identification in file + String typeStr = parts[++index]; + long startTime = Long.parseLong(parts[++index]); + String gamiIdStr = parts[++index]; + int numberOfDecimalsInTrade = Integer.parseInt(parts[++index]); + BigDecimal maxTotal = new BigDecimal(parts[++index]); + + UUID gamiId = UUID.fromString(gamiIdStr); + GlobalAssetMetadataIndexItem gamiIdItem = gami.getById(gamiId); + PositionType type = ("long".equalsIgnoreCase(typeStr)) ? marginLong : marginShort; + + Position position = new Position( + quarantined, + gamiIdItem, + type, + startTime, + null, + null, + null, + numberOfDecimalsInTrade, + null, + maxTotal + ); + + String key = position.gamiId().symbolMEXC() + "/" + position.type(); + + if(localPositionMap.containsKey(key)) { + log.error("ERROR: " + key + " seems to be defined twice in 'Positions.json' - Skipping duplicate one!"); + continue; + } + + localPositionMap.put(key, position); + } + + // Map the two piles of garbage together + positions = new ArrayList<>(); + for (Position localPos : localPositionMap.values()) { + String symbol = localPos.gamiId().symbolMEXC(); + + String key = symbol + "/" + localPos.type(); + PositionItem mexcPos = mexcPositionMap.get(key); + + if(mexcPos == null) { + log.warn(" WARNING: Cannot find position '" + key + "' from 'Position.json' on MEXC - skipping!"); + continue; + } + + ContractDetailsItem contractDetailsItem = contractDetails.getContractDetails(symbol); + BigDecimal contractSize = contractDetailsItem.contractSize(); + + BigDecimal basePrice = new BigDecimal(mexcPos.holdAvgPriceFullyScale()); + BigDecimal numOfContracts = new BigDecimal(mexcPos.holdVol()); + BigDecimal balance = numOfContracts.multiply(contractSize); + + Position mergedPos = new Position( + localPos.quarantined(), + localPos.gamiId(), + localPos.type(), + localPos.startTime(), + basePrice, + balance, + mexcPos.leverage(), + localPos.numberOfDecimalsInTrade(), + contractSize, + localPos.maxTotal() + ); + + positions.add(mergedPos); + } + + // Check for rogue position (positions that exist in MEXC but is not controlled by Evelyn) + for(String key : mexcPositionMap.keySet()) { + if(localPositionMap.containsKey(key) == false) { + log.warn(" WARNING: " + key + " is NOT controlled by Evelyn - Skipping!"); + } + } + + int a = 0; + } + + private void pushNotification(String message) { + if(pushNotifier != null) { + pushNotifier.push(message); + } + } + + private final GlobalAssetMetadataIndex gami; + private final MEXCSpotPlugin mexcSpotPlugin; + private final MEXCContractDetails contractDetails; + private final Gson gson; + private final String positionsFilename; + private final PushNotifier pushNotifier; + + private static final Logger log = LoggerFactory.getLogger(Portfolio.class); + + private List positions; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/Position.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/Position.tjava new file mode 100644 index 0000000..ecfc580 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/Position.tjava @@ -0,0 +1,20 @@ +package crypto.r35157.cauldron.afets; + +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndexItem; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigDecimal; + +public record Position( + boolean quarantined, + @NotNull GlobalAssetMetadataIndexItem gamiId, + @NotNull PositionType type, + long startTime, + @Nullable BigDecimal basePrice, + @Nullable BigDecimal balance, + @Nullable Integer leverage, + int numberOfDecimalsInTrade, + @Nullable BigDecimal tokensPerContract, + @Nullable BigDecimal maxTotal +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/PositionSideAction.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/PositionSideAction.tjava new file mode 100644 index 0000000..45584ca --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/PositionSideAction.tjava @@ -0,0 +1,8 @@ +package crypto.r35157.cauldron.afets; + +public enum PositionSideAction { + OpenLong, + CloseLong, + OpenShort, + CloseShort +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/PositionStatistics.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/PositionStatistics.tjava new file mode 100644 index 0000000..12931c2 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/PositionStatistics.tjava @@ -0,0 +1,17 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record PositionStatistics( + int totalNumberOfLongPositions, + int totalNumberOfShortPositions, + BigDecimal totalLongPositionsValue, + BigDecimal totalShortPositionsValue, + BigDecimal totalLongBaseValue, + BigDecimal totalShortBaseValue, + BigDecimal totalLongMarginValue, + BigDecimal totalShortMarginValue, + BigDecimal totalAvgLongPositionSize, + BigDecimal totalAvgShortPositionSize +) { +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/PositionType.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/PositionType.tjava new file mode 100644 index 0000000..610f7c5 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/PositionType.tjava @@ -0,0 +1,17 @@ +package crypto.r35157.cauldron.afets; + +public enum PositionType { + marginLong("long"), + marginShort("short"); + + PositionType(String humanReadableName) { + this.humanReadableName = humanReadableName; + } + + @Override + public String toString() { + return humanReadableName; + } + + private final String humanReadableName; +} \ No newline at end of file diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/PositionValues.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/PositionValues.tjava new file mode 100644 index 0000000..63e26ab --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/PositionValues.tjava @@ -0,0 +1,18 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record PositionValues( + Position position, + BigDecimal currentPrice, + BigDecimal basePrice, + BigDecimal balance, + int leverage, + BigDecimal positionValue, + BigDecimal baseValue, + BigDecimal marginValue +) { +} + + + diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ProfitLoss.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ProfitLoss.tjava new file mode 100644 index 0000000..9940ee0 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ProfitLoss.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public record ProfitLoss( + BigDecimal pnlOnLongs, + BigDecimal pnlOnShorts, + BigDecimal pnlUnrealized +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/PushNotifier.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/PushNotifier.tjava new file mode 100644 index 0000000..6dd43ff --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/PushNotifier.tjava @@ -0,0 +1,68 @@ +package crypto.r35157.cauldron.afets; + +import org.jetbrains.annotations.NotNull; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class PushNotifier { + public PushNotifier(@NotNull String token, @NotNull String user) { + this.token = token; + this.user = user; + } + + public void push(@NotNull String message) { + try { + // Build the POST data string with URL encoding + StringBuilder postData = new StringBuilder(); + postData.append("token=").append(URLEncoder.encode(token, UTF_8)); + postData.append("&user=").append(URLEncoder.encode(user, UTF_8)); + postData.append("&message=").append(URLEncoder.encode(message, UTF_8)); + byte[] postDataBytes = postData.toString().getBytes(UTF_8); + + // Create the connection to the Pushover API + URL url = new URL(ENDPOINT); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + conn.setRequestProperty("Content-Length", String.valueOf(postDataBytes.length)); + + // Write the POST data to the request body + try (OutputStream os = conn.getOutputStream()) { + os.write(postDataBytes); + } + + // Read the response from the API + int responseCode = conn.getResponseCode(); + InputStream is = (responseCode < HttpURLConnection.HTTP_BAD_REQUEST) + ? conn.getInputStream() : conn.getErrorStream(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(is, UTF_8)); + String line; + StringBuilder response = new StringBuilder(); + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + // Output the response code and body + //System.out.println("Response Code: " + responseCode); + //System.out.println("Response: " + response.toString()); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static final String ENDPOINT = "https://api.pushover.net/1/messages.json"; + + private final String token; + private final String user; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculator.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculator.tjava new file mode 100644 index 0000000..b66ff00 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculator.tjava @@ -0,0 +1,16 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +public interface ScoreCalculator { + /** + * Calculates a trade score based on the parameter inputs. Different implementations can use + * different strategies to arrive at a score. + * The only importance is that scores are later used to compare against each other, so scores + * from different implementations cannot be expected to work. + * + * @param input A collection of different values that can be helpful for the score calculation. + * @return The implementation-specific score + */ + BigDecimal calculateScore(ScoreCalculatorInput input); +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorImpl.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorImpl.tjava new file mode 100644 index 0000000..2fca178 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorImpl.tjava @@ -0,0 +1,42 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +import static crypto.r35157.cauldron.afets.PositionType.marginLong; +import static java.math.RoundingMode.HALF_UP; + +public class ScoreCalculatorImpl implements ScoreCalculator { + @Override + public BigDecimal calculateScore(ScoreCalculatorInput input) { + // Guard clause + if(input.futureTokenBaseNotationPrice.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("'futureTokenBaseNotationPrice' must be > 0"); + } + + if(input.currentTokenNotationPrice.compareTo(BigDecimal.ZERO) == 0) { + throw new ArithmeticException("'currentTokenNotationPrice' cannot be zero"); + } + + if(input.amountNotionalToSpend.compareTo(BigDecimal.ZERO) == 0) { + throw new ArithmeticException("Notional amount to spend cannot be zero"); + } + + // Correct calculation of units traded at actual market price + BigDecimal unitsToBeTraded = input.amountNotionalToSpend.divide(input.currentTokenNotationPrice, 20, HALF_UP); + + BigDecimal priceDiff = (input.type == marginLong) + ? input.currentTokenBaseNotationPrice.subtract(input.futureTokenBaseNotationPrice) + : input.futureTokenBaseNotationPrice.subtract(input.currentTokenBaseNotationPrice); + + // Profit improvement in absolute terms + BigDecimal profit = priceDiff.multiply(unitsToBeTraded); + + // Relative profit improvement per dollar spent + BigDecimal relativeProfit = profit.divide(input.amountNotionalToSpend, 20, HALF_UP); + + // Final intuitive score + BigDecimal score = relativeProfit.add(BigDecimal.ONE); + + return score; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorInput.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorInput.tjava new file mode 100644 index 0000000..007c05a --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ScoreCalculatorInput.tjava @@ -0,0 +1,57 @@ +package crypto.r35157.cauldron.afets; + +import java.math.BigDecimal; + +/** + * A data transfer object that encapsulates the input parameters required for score calculation. This class provides + * all the necessary data points that different implementations of a ScoreCalculator may use to compute a score. + * Users of this class can represent various aspects of a trading position, such as pricing, + * leverage, position type, and contract specifications. + */ +public class ScoreCalculatorInput { + /** + * Input parameters for the score calculation. Different implementations of ScoreCalculator can choose which of + * them to use or leave out of the score calculation. + * + * @param currentTokenBaseNotationPrice Current average entry notation price per token (before trade) + * @param futureTokenBaseNotationPrice New average entry notation price per token (after trade) + * @param currentTokenNotationPrice Current notation price per token (before trade) + * @param amountNotionalToSpend Notional amount to spend (not margin amount) + * @param totalNumberOfPositions The total number of positions of this type (longs or shorts) + * @param contractSize Number of tokens per contract (can be fractional, e.g., 0.1) + * @param leverage How much leverage is used - set this to ONE if leverage is not used. + * @param type Position type (long or short) + * @param averagePositionValue The average notation value size (= totalValue / totalNumberOfPositions) + */ + public ScoreCalculatorInput( + BigDecimal currentTokenBaseNotationPrice, + BigDecimal futureTokenBaseNotationPrice, + BigDecimal currentTokenNotationPrice, + BigDecimal amountNotionalToSpend, + int totalNumberOfPositions, + BigDecimal contractSize, + BigDecimal leverage, + PositionType type, + BigDecimal averagePositionValue + ) { + this.currentTokenNotationPrice = currentTokenNotationPrice; + this.futureTokenBaseNotationPrice = futureTokenBaseNotationPrice; + this.currentTokenBaseNotationPrice = currentTokenBaseNotationPrice; + this.amountNotionalToSpend = amountNotionalToSpend; + this.totalNumberOfPositions = totalNumberOfPositions; + this.contractSize = contractSize; + this.leverage = leverage; + this.type = type; + this.averagePositionValue = averagePositionValue; + } + + public BigDecimal currentTokenBaseNotationPrice; + public BigDecimal futureTokenBaseNotationPrice; + public BigDecimal currentTokenNotationPrice; + public BigDecimal amountNotionalToSpend; + public int totalNumberOfPositions; + public BigDecimal contractSize; + public BigDecimal leverage; + public PositionType type; + public BigDecimal averagePositionValue; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ServiceResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ServiceResponse.tjava new file mode 100644 index 0000000..7b44739 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ServiceResponse.tjava @@ -0,0 +1,6 @@ +package crypto.r35157.cauldron.afets; + +public record ServiceResponse( + int code, + String body +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ThousandsSeparatorFormatter.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ThousandsSeparatorFormatter.tjava new file mode 100644 index 0000000..9d605e8 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ThousandsSeparatorFormatter.tjava @@ -0,0 +1,14 @@ +package crypto.r35157.cauldron.afets; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; + +public class ThousandsSeparatorFormatter { + public static String formatWithJavaThousandsSeparator(int number) { + DecimalFormatSymbols symbols = new DecimalFormatSymbols(); + symbols.setGroupingSeparator('_'); + + DecimalFormat formatter = new DecimalFormat("#,###", symbols); + return formatter.format(number); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/TimeOuts.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/TimeOuts.tjava new file mode 100644 index 0000000..bf18d08 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/TimeOuts.tjava @@ -0,0 +1,154 @@ +package crypto.r35157.cauldron.afets; + +import crypto.r35157.TimeTools; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public class TimeOuts { + + public TimeOuts(@NotNull String filename, long duration) { + timeoutMap = new HashMap<>(); + commentsMap = new HashMap<>(); + + this.filename = filename; + this.duration = duration; + + log.info("Timeouts file: {}", filename); + + update(); + purge(); + + log.info(" Timeout: {} (Initialized with {} entries)", + TimeTools.formatTimeFromMillis(duration), timeoutMap.size()); + } + + private void update() { + File file = new File(filename); + + if (!file.exists()) { + timeoutMap.clear(); + commentsMap.clear(); + return; + } + + try (BufferedReader reader = new BufferedReader(new FileReader(filename))) { + String line; + String comment; + + while ((line = reader.readLine()) != null) { + // Skip empty lines or lines starting with # + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + int commentIndex = line.indexOf('#'); + if (commentIndex != -1) { + comment = line.substring(commentIndex + 1).trim(); + line = line.substring(0, commentIndex).trim(); + } else { + comment = null; + } + + String[] parts = line.split(" ", 2); + if (parts.length == 2) { + String key = parts[0]; + try { + timeoutMap.put(key, Long.parseLong(parts[1])); + if(comment != null && !comment.trim().isEmpty()) { + commentsMap.put(key, comment); + } + } catch (NumberFormatException e) { + log.error("Skipping invalid line: {}", line); + } + } + } + } catch (IOException e) { + log.error("ERROR: Could not update TimeOuts by reading from '" + filename + "' - " + e.getMessage()); + } + } + + private void persist() { + try (BufferedWriter writer = new BufferedWriter(new FileWriter(filename))) { + for (Map.Entry entry : timeoutMap.entrySet()) { + String line = entry.getKey() + " " + entry.getValue(); + String comment = commentsMap.get(entry.getKey()); + if (comment != null && !comment.trim().isEmpty()) { + line += " #" + comment; + } + writer.write(line); + writer.newLine(); + } + } catch (IOException e) { + log.error("ERROR: Could not persist TimeOuts by writing to '" + filename + "' - " + e.getMessage()); + } + } + + public boolean isInTimeOut(UUID uuid, PositionType positionType) { + purge(); + + String key = getKey(uuid, positionType); + return timeoutMap.containsKey(key); + } + + public void putInTimeout(UUID uuid, PositionType positionType, String comment) { + Long timeout = System.currentTimeMillis() + duration; + String key = getKey(uuid, positionType); + timeoutMap.put(key, timeout); + + if (comment != null && !comment.trim().isEmpty()) { + commentsMap.put(key, comment); + } + + persist(); + purge(); + } + + public void purge() { + long now = System.currentTimeMillis(); + + int numberOfElementsBeforePurge = timeoutMap.size(); + + // Store keys to be removed + Set keysToRemove = timeoutMap.entrySet() + .stream() + .filter(entry -> entry.getValue() <= now) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + + // Remove from both maps + keysToRemove.forEach(key -> { + timeoutMap.remove(key); + commentsMap.remove(key); + }); + + int numberOfElementsAfterPurge = timeoutMap.size(); + + int numberOfPurges = numberOfElementsBeforePurge - numberOfElementsAfterPurge; + + if(numberOfPurges > 0) { + log.info("Purged {} TimeOuts", numberOfPurges); + persist(); + } + } + + private String getKey(UUID uuid, PositionType positionType) { + return uuid.toString() + "/" + positionType.toString(); + } + + private static final Logger log = LoggerFactory.getLogger(TimeOuts.class); + + private final long duration; + private final String filename; + + private final Map timeoutMap; + private final Map commentsMap; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetails.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetails.tjava new file mode 100644 index 0000000..5b5aab2 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetails.tjava @@ -0,0 +1,16 @@ +package crypto.r35157.cauldron.afets.accountdetails; + +import java.math.BigDecimal; + +public record AccountDetails( + String currency, + BigDecimal positionMargin, + BigDecimal availableBalance, + BigDecimal cashBalance, + BigDecimal frozenBalance, + BigDecimal equity, + BigDecimal unrealized, + BigDecimal bonus, + BigDecimal availableCash, + BigDecimal availableOpen +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetailsResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetailsResponse.tjava new file mode 100644 index 0000000..61db9f2 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/AccountDetailsResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.accountdetails; + +import java.math.BigDecimal; + +public record AccountDetailsResponse( + boolean success, + BigDecimal code, + AccountDetails data +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsService.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsService.tjava new file mode 100644 index 0000000..6584553 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsService.tjava @@ -0,0 +1,8 @@ +package crypto.r35157.cauldron.afets.accountdetails; + +import crypto.r35157.cauldron.afets.InvalidAPIKeyException; +import org.jetbrains.annotations.NotNull; + +public interface MEXCAccountDetailsService { + @NotNull AccountDetails getDetails() throws InvalidAPIKeyException, Exception; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsServiceImpl.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsServiceImpl.tjava new file mode 100644 index 0000000..cd493b2 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/MEXCAccountDetailsServiceImpl.tjava @@ -0,0 +1,39 @@ +package crypto.r35157.cauldron.afets.accountdetails; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import crypto.r35157.cauldron.afets.InvalidAPIKeyException; +import crypto.r35157.cauldron.afets.MEXCFuturesPlugin; +import crypto.r35157.cauldron.afets.ServiceResponse; +import org.jetbrains.annotations.NotNull; + +public class MEXCAccountDetailsServiceImpl implements MEXCAccountDetailsService { + public MEXCAccountDetailsServiceImpl(MEXCFuturesPlugin mexFuturesPlugin, String symbol) { + this.mexFuturesPlugin = mexFuturesPlugin; + this.symbol = symbol; + + gson = new GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.BIG_DECIMAL) + .create(); + } + + public @NotNull AccountDetails getDetails() throws InvalidAPIKeyException, Exception { + ServiceResponse response = mexFuturesPlugin.getAccountDetails(symbol); + int code = response.code(); + + if(code == 200) { + String json = response.body(); + AccountDetailsResponse adr = gson.fromJson(json, AccountDetailsResponse.class); + return adr.data(); + } else if(code == 401) { + throw new InvalidAPIKeyException("API key invalid!"); + } else { + throw new Exception("Error getting account details (Error code: " + code + "): " + response.body()); + } + } + + private final MEXCFuturesPlugin mexFuturesPlugin; + private final Gson gson; + private final String symbol; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/RetryingMEXCAccountDetailsServiceDecorator.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/RetryingMEXCAccountDetailsServiceDecorator.tjava new file mode 100644 index 0000000..1ff3ec1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/accountdetails/RetryingMEXCAccountDetailsServiceDecorator.tjava @@ -0,0 +1,59 @@ +package crypto.r35157.cauldron.afets.accountdetails; + +import crypto.r35157.cauldron.afets.InvalidAPIKeyException; +import crypto.r35157.cauldron.afets.MEXCFuturesPlugin; +import crypto.r35157.nenjim.EncryptedFileProperties; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RetryingMEXCAccountDetailsServiceDecorator implements MEXCAccountDetailsService { + public RetryingMEXCAccountDetailsServiceDecorator( + MEXCFuturesPlugin mexFuturesPlugin, + MEXCAccountDetailsService accountDetailsService, + int maxNumberOfRetries, + EncryptedFileProperties secrets, + long sleepTime + ) { + this.mexFuturesPlugin = mexFuturesPlugin; + this.accountDetailsService = accountDetailsService; + this.maxNumberOfRetires = maxNumberOfRetries; + this.secrets = secrets; + this.sleepTime = sleepTime; + } + + public @NotNull AccountDetails getDetails() throws InvalidAPIKeyException, Exception { + boolean keyInvalidKnown = false; + + for(int i = 0; i < maxNumberOfRetires - 1; i++) { + try { + return accountDetailsService.getDetails(); + } catch(InvalidAPIKeyException e) { + if(keyInvalidKnown) { + throw e; + } else { + keyInvalidKnown = true; + log.error("ERROR: Invalid MEXC API key (" + e.getMessage() + ") - Updating and retrying..."); + secrets.refresh(); + String mexcWebKey = secrets.getProperty("assetaz_exchange_mexc_webkey"); + mexFuturesPlugin.setWebKey(mexcWebKey); + } + } catch(Exception e) { + log.error("ERROR: Getting account details (" + e.getMessage() + ") - Try " + + (i + 1) + "/" + maxNumberOfRetires + "), retrying in " + sleepTime + "ms..."); + } + + Thread.sleep(sleepTime); + } + + return accountDetailsService.getDetails(); + } + + private static final Logger log = LoggerFactory.getLogger(RetryingMEXCAccountDetailsServiceDecorator.class); + + private final MEXCFuturesPlugin mexFuturesPlugin; + private final MEXCAccountDetailsService accountDetailsService; + private final int maxNumberOfRetires; + private final EncryptedFileProperties secrets; + private final long sleepTime; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsItem.tjava new file mode 100644 index 0000000..d0b9783 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsItem.tjava @@ -0,0 +1,74 @@ +package crypto.r35157.cauldron.afets.contractdetails; + +import java.math.BigDecimal; +import java.util.List; + +public record ContractDetailsItem( + String symbol, + String displayName, + String displayNameEn, + int positionOpenType, + String baseCoin, + String quoteCoin, + String baseCoinName, + String quoteCoinName, + int futureType, + String settleCoin, + BigDecimal contractSize, + int minLeverage, + int maxLeverage, + int countryConfigContractMaxLeverage, + int priceScale, + int volScale, + int amountScale, + double priceUnit, + int volUnit, + long minVol, + long maxVol, + double bidLimitPriceRate, + double askLimitPriceRate, + double takerFeeRate, + double makerFeeRate, + double maintenanceMarginRate, + double initialMarginRate, + long riskBaseVol, + long riskIncrVol, + int riskLongShortSwitch, + BigDecimal riskIncrMmr, + BigDecimal riskIncrImr, + int riskLevelLimit, + double priceCoefficientVariation, + List indexOrigin, + int state, + boolean isNew, + boolean isHot, + boolean isHidden, + List conceptPlate, + List conceptPlateId, + String riskLimitType, + List maxNumOrders, + int marketOrderMaxLevel, + double marketOrderPriceLimitRate1, + double marketOrderPriceLimitRate2, + double triggerProtect, + int appraisal, + int showAppraisalCountdown, + int automaticDelivery, + boolean apiAllowed, + List depthStepList, + long limitMaxVol, + int threshold, + String baseCoinIconUrl, + int id, + String vid, + String baseCoinId, + long createTime, + long openingTime, + int openingCountdownOption, + boolean showBeforeOpen, + boolean isMaxLeverage, + boolean isZeroFeeRate, + String riskLimitMode, + List riskLimitCustom +) { +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsResponse.tjava new file mode 100644 index 0000000..b5309d9 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/ContractDetailsResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.contractdetails; + +import java.util.List; + +public record ContractDetailsResponse( + boolean success, + int code, + List data +) { } \ No newline at end of file diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetails.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetails.tjava new file mode 100644 index 0000000..0e4b919 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetails.tjava @@ -0,0 +1,59 @@ +package crypto.r35157.cauldron.afets.contractdetails; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import crypto.r35157.cauldron.afets.ticker.MEXCTickerItem; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class MEXCContractDetails { + public MEXCContractDetails() { + map = new HashMap<>(); + client = HttpClient.newHttpClient(); + + gson = new GsonBuilder() + .registerTypeAdapter(MEXCTickerItem.class, new MEXCContractDetailsDeserializer()) + .create(); + + request = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .GET() + .build(); + } + + public void updateAll() throws Exception { + HttpResponse httpResp = client.send(request, HttpResponse.BodyHandlers.ofString()); + String json = httpResp.body(); + ContractDetailsResponse resp = gson.fromJson(json, ContractDetailsResponse.class); + List items = resp.data(); + + for (ContractDetailsItem item : items) { + map.put(item.symbol(), item); + } + } + + public @Nullable ContractDetailsItem getContractDetails(@NotNull String symbol) { + ContractDetailsItem item = map.get(symbol); + return item; + } + + public Set getAllSymbols() { + return map.keySet(); + } + + private static final String API_URL = "https://contract.mexc.com/api/v1/contract/detail"; + + private final HttpClient client; + private final HttpRequest request; + private final Map map; + private final Gson gson; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetailsDeserializer.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetailsDeserializer.tjava new file mode 100644 index 0000000..2163b51 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/MEXCContractDetailsDeserializer.tjava @@ -0,0 +1,203 @@ +package crypto.r35157.cauldron.afets.contractdetails; + +import com.google.gson.*; + +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +public class MEXCContractDetailsDeserializer implements JsonDeserializer { + @Override + public ContractDetailsResponse deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + boolean success = jsonObject.get("success").getAsBoolean(); + int code = jsonObject.get("code").getAsInt(); + + List dataItems = new ArrayList<>(); + JsonArray dataArray = jsonObject.getAsJsonArray("data"); + for (JsonElement dataElement : dataArray) { + JsonObject itemObj = dataElement.getAsJsonObject(); + + String symbol = itemObj.get("symbol").getAsString(); + String displayName = itemObj.get("displayName").getAsString(); + String displayNameEn = itemObj.get("displayNameEn").getAsString(); + int positionOpenType = itemObj.get("positionOpenType").getAsInt(); + String baseCoin = itemObj.get("baseCoin").getAsString(); + String quoteCoin = itemObj.get("quoteCoin").getAsString(); + String baseCoinName = itemObj.get("baseCoinName").getAsString(); + String quoteCoinName = itemObj.get("quoteCoinName").getAsString(); + int futureType = itemObj.get("futureType").getAsInt(); + String settleCoin = itemObj.get("settleCoin").getAsString(); + BigDecimal contractSize = jsonObject.get("contractSize").getAsBigDecimal(); + int minLeverage = itemObj.get("minLeverage").getAsInt(); + int maxLeverage = itemObj.get("maxLeverage").getAsInt(); + int countryConfigContractMaxLeverage = itemObj.get("countryConfigContractMaxLeverage").getAsInt(); + int priceScale = itemObj.get("priceScale").getAsInt(); + int volScale = itemObj.get("volScale").getAsInt(); + int amountScale = itemObj.get("amountScale").getAsInt(); + double priceUnit = itemObj.get("priceUnit").getAsDouble(); + int volUnit = itemObj.get("volUnit").getAsInt(); + long minVol = itemObj.get("minVol").getAsLong(); + long maxVol = itemObj.get("maxVol").getAsLong(); + double bidLimitPriceRate = itemObj.get("bidLimitPriceRate").getAsDouble(); + double askLimitPriceRate = itemObj.get("askLimitPriceRate").getAsDouble(); + double takerFeeRate = itemObj.get("takerFeeRate").getAsDouble(); + double makerFeeRate = itemObj.get("makerFeeRate").getAsDouble(); + double maintenanceMarginRate = itemObj.get("maintenanceMarginRate").getAsDouble(); + double initialMarginRate = itemObj.get("initialMarginRate").getAsDouble(); + long riskBaseVol = itemObj.get("riskBaseVol").getAsLong(); + long riskIncrVol = itemObj.get("riskIncrVol").getAsLong(); + int riskLongShortSwitch = itemObj.get("riskLongShortSwitch").getAsInt(); + BigDecimal riskIncrMmr = itemObj.get("riskIncrMmr").getAsBigDecimal(); + BigDecimal riskIncrImr = itemObj.get("riskIncrImr").getAsBigDecimal(); + int riskLevelLimit = itemObj.get("riskLevelLimit").getAsInt(); + double priceCoefficientVariation = itemObj.get("priceCoefficientVariation").getAsDouble(); + + List indexOrigin = new ArrayList<>(); + JsonArray indexOriginArray = itemObj.getAsJsonArray("indexOrigin"); + for (JsonElement element : indexOriginArray) { + indexOrigin.add(element.getAsString()); + } + + int state = itemObj.get("state").getAsInt(); + boolean isNew = itemObj.get("isNew").getAsBoolean(); + boolean isHot = itemObj.get("isHot").getAsBoolean(); + boolean isHidden = itemObj.get("isHidden").getAsBoolean(); + + List conceptPlate = new ArrayList<>(); + JsonArray conceptPlateArray = itemObj.getAsJsonArray("conceptPlate"); + for (JsonElement element : conceptPlateArray) { + conceptPlate.add(element.getAsString()); + } + + List conceptPlateId = new ArrayList<>(); + JsonArray conceptPlateIdArray = itemObj.getAsJsonArray("conceptPlateId"); + for (JsonElement element : conceptPlateIdArray) { + conceptPlateId.add(element.getAsInt()); + } + + String riskLimitType = itemObj.get("riskLimitType").getAsString(); + + List maxNumOrders = new ArrayList<>(); + JsonArray maxNumOrdersArray = itemObj.getAsJsonArray("maxNumOrders"); + for (JsonElement element : maxNumOrdersArray) { + maxNumOrders.add(element.getAsInt()); + } + + int marketOrderMaxLevel = itemObj.get("marketOrderMaxLevel").getAsInt(); + double marketOrderPriceLimitRate1 = itemObj.get("marketOrderPriceLimitRate1").getAsDouble(); + double marketOrderPriceLimitRate2 = itemObj.get("marketOrderPriceLimitRate2").getAsDouble(); + double triggerProtect = itemObj.get("triggerProtect").getAsDouble(); + int appraisal = itemObj.get("appraisal").getAsInt(); + int showAppraisalCountdown = itemObj.get("showAppraisalCountdown").getAsInt(); + int automaticDelivery = itemObj.get("automaticDelivery").getAsInt(); + boolean apiAllowed = itemObj.get("apiAllowed").getAsBoolean(); + + List depthStepList = new ArrayList<>(); + JsonArray depthStepListArray = itemObj.getAsJsonArray("depthStepList"); + for (JsonElement element : depthStepListArray) { + depthStepList.add(element.getAsString()); + } + + int limitMaxVol = itemObj.get("limitMaxVol").getAsInt(); + int threshold = itemObj.get("threshold").getAsInt(); + String baseCoinIconUrl = itemObj.get("baseCoinIconUrl").getAsString(); + int id = itemObj.get("id").getAsInt(); + String vid = itemObj.get("vid").getAsString(); + String baseCoinId = itemObj.get("baseCoinId").getAsString(); + long createTime = itemObj.get("createTime").getAsLong(); + long openingTime = itemObj.get("openingTime").getAsLong(); + int openingCountdownOption = itemObj.get("openingCountdownOption").getAsInt(); + boolean showBeforeOpen = itemObj.get("showBeforeOpen").getAsBoolean(); + boolean isMaxLeverage = itemObj.get("isMaxLeverage").getAsBoolean(); + boolean isZeroFeeRate = itemObj.get("isZeroFeeRate").getAsBoolean(); + String riskLimitMode = itemObj.get("riskLimitMode").getAsString(); + + List riskLimitCustomList = new ArrayList<>(); + JsonArray riskLimitCustomArray = itemObj.getAsJsonArray("riskLimitCustom"); + for (JsonElement element : riskLimitCustomArray) { + JsonObject riskObj = element.getAsJsonObject(); + int level = riskObj.get("level").getAsInt(); + int maxVolVal = riskObj.get("maxVol").getAsInt(); + double mmr = riskObj.get("mmr").getAsDouble(); + double imr = riskObj.get("imr").getAsDouble(); + int maxLeverageVal = riskObj.get("maxLeverage").getAsInt(); + riskLimitCustomList.add(new RiskLimitCustom(level, maxVolVal, mmr, imr, maxLeverageVal)); + } + + ContractDetailsItem item = new ContractDetailsItem( + symbol, + displayName, + displayNameEn, + positionOpenType, + baseCoin, + quoteCoin, + baseCoinName, + quoteCoinName, + futureType, + settleCoin, + contractSize, + minLeverage, + maxLeverage, + countryConfigContractMaxLeverage, + priceScale, + volScale, + amountScale, + priceUnit, + volUnit, + minVol, + maxVol, + bidLimitPriceRate, + askLimitPriceRate, + takerFeeRate, + makerFeeRate, + maintenanceMarginRate, + initialMarginRate, + riskBaseVol, + riskIncrVol, + riskLongShortSwitch, + riskIncrMmr, + riskIncrImr, + riskLevelLimit, + priceCoefficientVariation, + indexOrigin, + state, + isNew, + isHot, + isHidden, + conceptPlate, + conceptPlateId, + riskLimitType, + maxNumOrders, + marketOrderMaxLevel, + marketOrderPriceLimitRate1, + marketOrderPriceLimitRate2, + triggerProtect, + appraisal, + showAppraisalCountdown, + automaticDelivery, + apiAllowed, + depthStepList, + limitMaxVol, + threshold, + baseCoinIconUrl, + id, + vid, + baseCoinId, + createTime, + openingTime, + openingCountdownOption, + showBeforeOpen, + isMaxLeverage, + isZeroFeeRate, + riskLimitMode, + riskLimitCustomList + ); + dataItems.add(item); + } + return new ContractDetailsResponse(success, code, dataItems); + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/RiskLimitCustom.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/RiskLimitCustom.tjava new file mode 100644 index 0000000..501df4d --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/contractdetails/RiskLimitCustom.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.contractdetails; + +public record RiskLimitCustom( + int level, + int maxVol, + double mmr, + double imr, + int maxLeverage +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/FundingRatesResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/FundingRatesResponse.tjava new file mode 100644 index 0000000..9a88fc1 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/FundingRatesResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.fundingrate; + +import java.util.List; + +public record FundingRatesResponse( + boolean success, + int code, + List data +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRateItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRateItem.tjava new file mode 100644 index 0000000..c6e5c34 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRateItem.tjava @@ -0,0 +1,13 @@ +package crypto.r35157.cauldron.afets.fundingrate; + +import java.math.BigDecimal; + +public record MEXCFundingRateItem( + String symbol, + BigDecimal fundingRate, + BigDecimal maxFundingRate, + BigDecimal minFundingRate, + int collectCycle, + long nextSettleTime, + long timestamp +){} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRates.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRates.tjava new file mode 100644 index 0000000..eb6bd4f --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRates.tjava @@ -0,0 +1,73 @@ + +package crypto.r35157.cauldron.afets.fundingrate; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MEXCFundingRates { + public MEXCFundingRates() { + fundingRateMap = new HashMap<>(); + client = HttpClient.newHttpClient(); + + gson = new GsonBuilder() + .registerTypeAdapter(MEXCFundingRateItem.class, new MEXCFundingRatesDeserializer()) + .create(); + + request = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .GET() + .build(); + } + + public void updateAll() throws Exception { + HttpResponse httpResp = client.send(request, HttpResponse.BodyHandlers.ofString()); + String fundingFeeJson = httpResp.body(); + + List fundingRateItems = mapFundingRates(fundingFeeJson); + refreshLocalFundingRateMap(fundingRateItems); + } + + private void refreshLocalFundingRateMap(List fundingRateItems) { + fundingRateMap.clear(); + for(MEXCFundingRateItem item : fundingRateItems) { + fundingRateMap.put(item.symbol(), item); + } + } + + private List mapFundingRates(String json) { + final FundingRatesResponse response; + final List items; + + try { + response = gson.fromJson(json, FundingRatesResponse.class); + items = response.data(); + } catch (Exception e) { + throw e; + } + + return items; + } + + public @Nullable MEXCFundingRateItem getFundingRate(@NotNull String localSymbol) { + MEXCFundingRateItem item = fundingRateMap.get(localSymbol); + return item; + } + + private static final String API_URL = "https://contract.mexc.com/api/v1/contract/funding_rate"; + + private final HttpClient client; + private final HttpRequest request; + private final Map fundingRateMap; + private final Gson gson; + +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRatesDeserializer.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRatesDeserializer.tjava new file mode 100644 index 0000000..e2be8ba --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/fundingrate/MEXCFundingRatesDeserializer.tjava @@ -0,0 +1,38 @@ +package crypto.r35157.cauldron.afets.fundingrate; + +import com.google.gson.*; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Type; +import java.math.BigDecimal; + +public class MEXCFundingRatesDeserializer implements JsonDeserializer { + @Override + public MEXCFundingRateItem deserialize( + @NotNull JsonElement json, + @NotNull Type typeOfT, + @NotNull JsonDeserializationContext context + ) throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String symbol = jsonObject.get("symbol").getAsString(); + BigDecimal fundingRate = jsonObject.get("fundingRate").getAsBigDecimal(); + BigDecimal maxFundingRate = jsonObject.get("maxFundingRate").getAsBigDecimal(); + BigDecimal minFundingRate = jsonObject.get("minFundingRate").getAsBigDecimal(); + int collectCycle = jsonObject.get("collectCycle").getAsInt(); + long nextSettleTime = jsonObject.get("nextSettleTime").getAsLong(); + long timestamp = jsonObject.get("timestamp").getAsLong(); + + MEXCFundingRateItem item = new MEXCFundingRateItem( + symbol, + fundingRate, + maxFundingRate, + minFundingRate, + collectCycle, + nextSettleTime, + timestamp + ); + + return item; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndex.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndex.tjava new file mode 100644 index 0000000..e9ee14d --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndex.tjava @@ -0,0 +1,94 @@ +package crypto.r35157.cauldron.afets.gami; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class GlobalAssetMetadataIndex { + public GlobalAssetMetadataIndex(String configFilename) { + this.configFilename = configFilename; + } + + public @Nullable GlobalAssetMetadataIndexItem getByMEXCSymbol(@NotNull String localSymbol) { + // TODO: Currently hardcoded to work with MEXC only + for(GlobalAssetMetadataIndexItem item : items) { + if(item.symbolMEXC().equals(localSymbol)) { + return item; + } + } + + return null; + } + + public @Nullable GlobalAssetMetadataIndexItem getById(@NotNull UUID uuid) { + for(GlobalAssetMetadataIndexItem item : items) { + if(item.gamiId().equals(uuid)) { + return item; + } + } + + return null; + } + + public void updateAll() throws Exception { + List items = new ArrayList<>(); + List lines = Files.readAllLines(Paths.get(configFilename)); + + for(String origLine : lines) { + String line = origLine.trim(); + + // Skip if the line is empty or is a full comment line. + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + // Remove any trailing comment from the line (everything after a '#' character). + int commentIndex = line.indexOf('#'); + if (commentIndex != -1) { + line = line.substring(0, commentIndex).trim(); + } + // Skip the line if nothing is left. + if (line.isEmpty()) { + continue; + } + + // Split the line on one or more whitespace characters. + String[] parts = line.split("\\s+"); + // Expecting at least 3 parts: Name, GamiId, and MexcSymbol. + if (parts.length != 3) { + log.error("ERROR: Unparseable line in '" + configFilename + "' (" + origLine + ")"); + continue; + } + + // The file format is: + // Name, GamiId, MexcSymbol + String name = parts[0]; + String gamiIdStr = parts[1]; + String mexcSymbol = parts[2]; + + GlobalAssetMetadataIndexItem item = new GlobalAssetMetadataIndexItem( + UUID.fromString(gamiIdStr), + name, + mexcSymbol + ); + items.add(item); + } + + this.items = items; + + // TODO: This is ugly - but lets just drop all those entries that do not exist on MEXC + items.removeIf(item -> item.symbolMEXC() == null); + } + + private static final Logger log = LoggerFactory.getLogger(GlobalAssetMetadataIndex.class); + + private final String configFilename; + private List items; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndexItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndexItem.tjava new file mode 100644 index 0000000..558a863 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/gami/GlobalAssetMetadataIndexItem.tjava @@ -0,0 +1,12 @@ +package crypto.r35157.cauldron.afets.gami; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +public record GlobalAssetMetadataIndexItem( + @NotNull UUID gamiId, + @NotNull String name, + @Nullable String symbolMEXC +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/leverage/LeverageInfoResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/LeverageInfoResponse.tjava new file mode 100644 index 0000000..82c92ff --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/LeverageInfoResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.leverage; + +import java.util.List; + +public record LeverageInfoResponse( + boolean success, + int code, + List data +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfo.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfo.tjava new file mode 100644 index 0000000..9fb21be --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfo.tjava @@ -0,0 +1,43 @@ +package crypto.r35157.cauldron.afets.leverage; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import crypto.r35157.cauldron.afets.MEXCFuturesPlugin; +import crypto.r35157.cauldron.afets.ServiceResponse; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.List; + +public class MEXCLeverageInfo { + public MEXCLeverageInfo(MEXCFuturesPlugin mexFuturesPlugin) { + this.mexFuturesPlugin = mexFuturesPlugin; + + gson = new GsonBuilder() + .registerTypeAdapter(MEXCLeverageInfoItem.class, new MEXCLeverageInfoDeserializer()) + .create(); + } + + public @Nullable MEXCLeverageInfoItem getLeverageInfo(@NotNull String symbol, int positionType) + throws IOException, InterruptedException { + ServiceResponse rs = mexFuturesPlugin.getLeverage(symbol); + String json = rs.body(); + LeverageInfoResponse leverageInfoResponse = gson.fromJson(json, LeverageInfoResponse.class); + List items = leverageInfoResponse.data(); + + MEXCLeverageInfoItem result = null; + + for(MEXCLeverageInfoItem item : items) { + if(positionType == item.positionType()) { + result = item; + break; + } + } + + return result; + } + + private final MEXCFuturesPlugin mexFuturesPlugin; + private final Gson gson; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoDeserializer.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoDeserializer.tjava new file mode 100644 index 0000000..20588e8 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoDeserializer.tjava @@ -0,0 +1,42 @@ +package crypto.r35157.cauldron.afets.leverage; + +import com.google.gson.*; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Type; +import java.math.BigDecimal; + +public class MEXCLeverageInfoDeserializer implements JsonDeserializer { + @Override + public MEXCLeverageInfoItem deserialize( + @NotNull JsonElement json, + @NotNull Type typeOfT, + @NotNull JsonDeserializationContext context + ) throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + int level = jsonObject.get("level").getAsInt(); + int maxVol = jsonObject.get("maxVol").getAsInt(); + BigDecimal mmr = jsonObject.get("mmr").getAsBigDecimal(); + BigDecimal imr = jsonObject.get("imr").getAsBigDecimal(); + int positionType = jsonObject.get("positionType").getAsInt(); + int openType = jsonObject.get("openType").getAsInt(); + int leverage = jsonObject.get("leverage").getAsInt(); + boolean limitBySys = jsonObject.get("limitBySys").getAsBoolean(); + BigDecimal currentMmr = jsonObject.get("currentMmr").getAsBigDecimal(); + + MEXCLeverageInfoItem item = new MEXCLeverageInfoItem( + level, + maxVol, + mmr, + imr, + positionType, + openType, + leverage, + limitBySys, + currentMmr + ); + + return item; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoItem.tjava new file mode 100644 index 0000000..a783bea --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/leverage/MEXCLeverageInfoItem.tjava @@ -0,0 +1,15 @@ +package crypto.r35157.cauldron.afets.leverage; + +import java.math.BigDecimal; + +public record MEXCLeverageInfoItem( + int level, + int maxVol, + BigDecimal mmr, + BigDecimal imr, + int positionType, + int openType, + int leverage, + boolean limitBySys, + BigDecimal currentMmr +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/nenjim/NenjimObjectRepository.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/nenjim/NenjimObjectRepository.tjava new file mode 100644 index 0000000..19a8b06 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/nenjim/NenjimObjectRepository.tjava @@ -0,0 +1,188 @@ +package crypto.r35157.cauldron.afets.nenjim; + +import crypto.r35157.cauldron.afets.*; +import crypto.r35157.cauldron.afets.accountdetails.MEXCAccountDetailsService; +import crypto.r35157.cauldron.afets.accountdetails.MEXCAccountDetailsServiceImpl; +import crypto.r35157.cauldron.afets.accountdetails.RetryingMEXCAccountDetailsServiceDecorator; +import crypto.r35157.cauldron.afets.contractdetails.MEXCContractDetails; +import crypto.r35157.cauldron.afets.fundingrate.MEXCFundingRates; +import crypto.r35157.cauldron.afets.gami.GlobalAssetMetadataIndex; +import crypto.r35157.cauldron.afets.leverage.MEXCLeverageInfo; +import crypto.r35157.cauldron.afets.ticker.MEXCTicker; +import crypto.r35157.nenjim.DockerSwarmSecrets; +import crypto.r35157.nenjim.ImmutableProperties; +import crypto.r35157.nenjim.EncryptedFileProperties; +import crypto.r35157.nenjim.SystemEnvironmentProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class NenjimObjectRepository { + public NenjimObjectRepository() throws Exception { + ImmutableProperties sysEnvProperties = new SystemEnvironmentProperties(); + evelynHomeDir = sysEnvProperties.getProperty("EVELYN_HOME"); + log.info("Evelyn home directory: " + evelynHomeDir); + + if(evelynHomeDir == null) { + log.error("ERROR: EVELYN_HOME environment variable is empty - set it to the directory where the evelyn configuration files are located!"); + System.exit(1); // TODO: This is incorrect as it takes down the whole Nenjim system!!! + } + + evelynConfigDir = evelynHomeDir + "/conf"; + evelynGamiFilename = evelynConfigDir + "/GlobalAssetMetadataIndex.conf"; + evelynConfigFilename = evelynConfigDir + "/Evelyn.conf"; + + evelynTmpDir = evelynHomeDir + "/tmp"; + evelynTimeoutFilename = evelynTmpDir + "/Timeouts.tmp"; + + ImmutableProperties dockerSwarmSecrets = new DockerSwarmSecrets(); + String nenjimMasterPassword = null; + try { + nenjimMasterPassword = dockerSwarmSecrets.getProperty("nenjim-secrets-password"); + if(nenjimMasterPassword == null) { + log.error("ERROR: Nenjim master password NOT set in Docker Swarm (key: 'nenjim-secrets-password')!"); + System.exit(1); // TODO: This is incorrect as it takes down the whole Nenjim system!!! + } + } catch (IOException e) { + log.error("ERROR: " + e.getMessage()); + System.exit(1); + } + + String secretsFile = evelynHomeDir + "/conf/secrets.enc"; + log.info("Secrets file: " + secretsFile); + secrets = new EncryptedFileProperties(secretsFile, nenjimMasterPassword); + log.info("Entries in secret file found: " + secrets.getSize()); + + String pushOverTokenEvelyn = secrets.getProperty("notifier_pushover_token_evelyn"); + + String pushOverUser = secrets.getProperty("notifier_pushover_user"); + pushNotifier = new PushNotifier(pushOverTokenEvelyn, pushOverUser); + + gami = new GlobalAssetMetadataIndex(evelynGamiFilename); + ticker = new MEXCTicker(); + contractDetails = new MEXCContractDetails(); + + String mexcAccessKey = secrets.getProperty("assetaz_exchange_mexc_access_key"); + + String mexcSecretKey = secrets.getProperty("assetaz_exchange_mexc_secret_key"); + mexcSpotPlugin = new MEXCSpotPlugin(mexcAccessKey, mexcSecretKey); + + configuration = new Configuration(evelynConfigFilename); + configuration.update(); + timeOuts = new TimeOuts(evelynTimeoutFilename, configuration.getTimeOut()); + String positionsFilename = evelynHomeDir + "/conf/Positions.conf"; + portfolio = new Portfolio(pushNotifier, positionsFilename, gami, mexcSpotPlugin, contractDetails); + + String mexcUserAgent = secrets.getProperty("assetaz_exchange_mexc_useragent"); + + String mexcWebKey = secrets.getProperty("assetaz_exchange_mexc_webkey"); + mexcFuturesPlugin = new MEXCFuturesPlugin(mexcWebKey, mexcUserAgent); + + mexcFundingRates = new MEXCFundingRates(); + mexcLeverageInfo = new MEXCLeverageInfo(mexcFuturesPlugin); + scoreCalculator = new ScoreCalculatorImpl(); + earningsStatistics = new EarningsStatistics(); + MEXCAccountDetailsService realAccountDetailsService = new MEXCAccountDetailsServiceImpl(mexcFuturesPlugin, "USDT"); + accountDetails = new RetryingMEXCAccountDetailsServiceDecorator( + mexcFuturesPlugin, + realAccountDetailsService, + 3, + secrets, + 3000 + ); + + try { + eventWatcher = new EventWatcher(Thread.currentThread(), evelynTmpDir); + eventWatcher.start(); + } catch(Exception e) { + log.error("ERROR: " + e.getMessage()); + System.exit(1); + } + } + + public Configuration getConfiguration() { + return configuration; + } + + public MEXCAccountDetailsService getAccountDetails() { + return accountDetails; + } + + public EarningsStatistics getEarningsStatistics() { + return earningsStatistics; + } + + public GlobalAssetMetadataIndex getGlobalAssetMetadataIndex() { + return gami; + } + + public MEXCTicker getTicker() { + return ticker; + } + + public MEXCContractDetails getContractDetails() { + return contractDetails; + } + + public MEXCSpotPlugin getMEXCSpotPlugin() { + return mexcSpotPlugin; + } + + public TimeOuts getTimeOuts() { + return timeOuts; + } + + public Portfolio getPortfolio() { + return portfolio; + } + + public MEXCFuturesPlugin getMexcFuturesPlugin() { + return mexcFuturesPlugin; + } + + public MEXCFundingRates getMexcFundingRates() { + return mexcFundingRates; + } + + public MEXCLeverageInfo getMexcLeverageInfo() { + return mexcLeverageInfo; + } + + public ScoreCalculator getScoreCalculator() { + return scoreCalculator; + } + + public PushNotifier getPushNotifier() { + return pushNotifier; + } + + public EncryptedFileProperties getSecrets() { + return secrets; + } + + private static final Logger log = LoggerFactory.getLogger(NenjimObjectRepository.class); + + private final EncryptedFileProperties secrets; + private final GlobalAssetMetadataIndex gami; + private final MEXCTicker ticker; + private final MEXCContractDetails contractDetails; + private final MEXCSpotPlugin mexcSpotPlugin; + private final TimeOuts timeOuts; + private final Portfolio portfolio; + private final MEXCFuturesPlugin mexcFuturesPlugin; + private final MEXCFundingRates mexcFundingRates; + private final MEXCLeverageInfo mexcLeverageInfo; + private final ScoreCalculator scoreCalculator; + private final PushNotifier pushNotifier; + private final EarningsStatistics earningsStatistics; + private final MEXCAccountDetailsService accountDetails; + private final Configuration configuration; + private final String evelynHomeDir; + private final String evelynConfigDir; + private final String evelynGamiFilename; + private final String evelynConfigFilename; + private final String evelynTimeoutFilename; + private final String evelynTmpDir; + private EventWatcher eventWatcher = null; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/FuturesTickerResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/FuturesTickerResponse.tjava new file mode 100644 index 0000000..6fec5d0 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/FuturesTickerResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.ticker; + +import java.util.List; + +public record FuturesTickerResponse( + boolean success, + int code, + List data +) { } diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTicker.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTicker.tjava new file mode 100644 index 0000000..561c370 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTicker.tjava @@ -0,0 +1,58 @@ +package crypto.r35157.cauldron.afets.ticker; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class MEXCTicker { + public MEXCTicker() { + tickerMap = new HashMap<>(); + client = HttpClient.newHttpClient(); + + gson = new GsonBuilder() + .registerTypeAdapter(MEXCTickerItem.class, new MEXCTickerDeserializer()) + .create(); + + request = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .GET() + .build(); + } + + public void updateAll() throws Exception { + HttpResponse httpResp = client.send(request, HttpResponse.BodyHandlers.ofString()); + String tickerJson = httpResp.body(); + FuturesTickerResponse tickerResp = gson.fromJson(tickerJson, FuturesTickerResponse.class); + List tickerItems = tickerResp.data(); + + for (MEXCTickerItem item : tickerItems) { + tickerMap.put(item.symbol(), item); + } + } + + public @Nullable MEXCTickerItem getPrice(@NotNull String symbol) { + MEXCTickerItem item = tickerMap.get(symbol); + return item; + } + + public Set getAllSymbols() { + return tickerMap.keySet(); + } + + private static final String API_URL = "https://contract.mexc.com/api/v1/contract/ticker"; + + private final HttpClient client; + private final HttpRequest request; + private final Map tickerMap; + private final Gson gson; +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerDeserializer.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerDeserializer.tjava new file mode 100644 index 0000000..2960820 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerDeserializer.tjava @@ -0,0 +1,31 @@ +package crypto.r35157.cauldron.afets.ticker; + +import com.google.gson.*; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Type; +import java.math.BigDecimal; + +public class MEXCTickerDeserializer implements JsonDeserializer { + @Override + public MEXCTickerItem deserialize( + @NotNull JsonElement json, + @NotNull Type typeOfT, + @NotNull JsonDeserializationContext context + ) throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String symbol = jsonObject.get("symbol").getAsString(); + BigDecimal currentPrice = jsonObject.get("lastPrice").getAsBigDecimal(); + + long now = System.currentTimeMillis(); + + MEXCTickerItem ticker = new MEXCTickerItem( + symbol, + currentPrice, + now + ); + + return ticker; + } +} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerItem.tjava new file mode 100644 index 0000000..c386149 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/MEXCTickerItem.tjava @@ -0,0 +1,11 @@ +package crypto.r35157.cauldron.afets.ticker; + +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; + +public record MEXCTickerItem( + @NotNull String symbol, + @NotNull BigDecimal lastPrice, + long timestamp +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionItem.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionItem.tjava new file mode 100644 index 0000000..6702a62 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionItem.tjava @@ -0,0 +1,36 @@ +package crypto.r35157.cauldron.afets.ticker; + +import java.util.List; + +public record PositionItem( + long positionId, + String symbol, + int positionType, // 1=Long, 2=Short + int openType, + int state, + double holdVol, + double frozenVol, + double closeVol, + double holdAvgPrice, + String holdAvgPriceFullyScale, + double openAvgPrice, + String openAvgPriceFullyScale, + double closeAvgPrice, + double liquidatePrice, + double oim, + double im, + double holdFee, + double realised, + int leverage, + double marginRatio, + long createTime, + long updateTime, + boolean autoAddIm, + int version, + double profitRatio, + double newOpenAvgPrice, + double newCloseAvgPrice, + double closeProfitLoss, + double fee, + List deductFeeList +) {} diff --git a/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionResponse.tjava b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionResponse.tjava new file mode 100644 index 0000000..d423e20 --- /dev/null +++ b/src/main/tjava/crypto/r35157/cauldron/afets/ticker/PositionResponse.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.cauldron.afets.ticker; + +import java.util.List; + +public record PositionResponse( + boolean success, + int code, + List data +) {} diff --git a/src/main/tjava/crypto/r35157/nenjim/DirectoryClassLoader.tjava b/src/main/tjava/crypto/r35157/nenjim/DirectoryClassLoader.tjava new file mode 100644 index 0000000..3588c02 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/DirectoryClassLoader.tjava @@ -0,0 +1,41 @@ +package crypto.r35157.nenjim; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +public class DirectoryClassLoader extends ClassLoader { + + private final Path rootDir; + private final Map> loadedClasses = new HashMap<>(); + + public DirectoryClassLoader(Path rootDir) { + this.rootDir = rootDir; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + if (loadedClasses.containsKey(name)) { + return loadedClasses.get(name); + } + + String relativePath = name.replace('.', '/') + ".class"; + File classFile = rootDir.resolve(relativePath).toFile(); + + if (!classFile.exists()) { + throw new ClassNotFoundException("Class file not found: " + classFile.getAbsolutePath()); + } + + try (FileInputStream fis = new FileInputStream(classFile)) { + byte[] bytes = fis.readAllBytes(); + Class clazz = defineClass(name, bytes, 0, bytes.length); + loadedClasses.put(name, clazz); + return clazz; + } catch (IOException e) { + throw new ClassNotFoundException("Failed to load class: " + name, e); + } + } +} diff --git a/src/main/tjava/crypto/r35157/nenjim/DockerSwarmSecrets.tjava b/src/main/tjava/crypto/r35157/nenjim/DockerSwarmSecrets.tjava new file mode 100644 index 0000000..fd691d4 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/DockerSwarmSecrets.tjava @@ -0,0 +1,56 @@ +package crypto.r35157.nenjim; + +import java.io.IOException; +import java.nio.file.*; + +public class DockerSwarmSecrets implements ImmutableProperties { + public DockerSwarmSecrets() { + refresh(); + } + + @Override + public String getProperty(String key) throws IOException { + Path dir = Paths.get(BASE_DIR); + + if(!Files.exists(dir)) { + throw new IOException(ERROR_DIR_PREFIX + "do not exist!"); + } + + if(!Files.isDirectory(dir)) { + throw new IOException(ERROR_DIR_PREFIX + "is not a directory!"); + } + + if(!Files.isReadable(dir)) { + throw new IOException(ERROR_DIR_PREFIX + "is not readable!"); + } + + Path file = Paths.get(BASE_DIR, key); + + if(!Files.exists(file)) { + throw new IOException(ERROR_FILE_PREFIX + file + "' do not exist!"); + } + + if(Files.isDirectory(file)) { + throw new IOException(ERROR_FILE_PREFIX + file + "' is not a file!"); + } + + if(!Files.isReadable(file)) { + throw new IOException(ERROR_FILE_PREFIX + file + "' is not readable!"); + } + + String value = Files.readString(file).trim(); + return value; + } + + @Override + public void refresh() {} + + @Override + public int getSize() { + return -1; + } + + private static final String BASE_DIR = "/run/secrets/"; + private static final String ERROR_DIR_PREFIX = "Docker Swarm Secrets directory '" + BASE_DIR + "' "; + private static final String ERROR_FILE_PREFIX = "Docker Swarm Secrets file '"; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/EncryptedFileProperties.tjava b/src/main/tjava/crypto/r35157/nenjim/EncryptedFileProperties.tjava new file mode 100644 index 0000000..752fd4e --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/EncryptedFileProperties.tjava @@ -0,0 +1,60 @@ +package crypto.r35157.nenjim; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.util.*; + +public class EncryptedFileProperties implements ImmutableProperties { + public EncryptedFileProperties(String encFilePath, String password) throws IOException { + this.encFilePath = encFilePath; + this.password = password; + refresh(); + } + + @Override + public String getProperty(String key) { + return secretsMap.get(key); + } + + @Override + public void refresh() throws IOException { + log.info("Refreshing encrypted secrets file (" + encFilePath + ")..."); + // TODO: This is not too pretty as the execution results in some warnings which will also get included in the map! + ProcessBuilder pb = new ProcessBuilder( + "openssl", "enc", "-d", "-aes-256-cbc", "-in", encFilePath, "-pass", "pass:" + password + ); + pb.redirectErrorStream(true); + + Process process = pb.start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + String[] parts = line.split(":", 2); + if (parts.length == 2) { + secretsMap.put(parts[0].trim(), parts[1].trim()); + } + } + } + + try { + process.waitFor(); + } catch (Exception e) { + log.error("ERROR refreshing secrets file: " + e.getMessage()); + throw new IOException(e); + } + + log.info("Finished refreshing secrets file! Found " + secretsMap.size() + " entries."); + } + + @Override + public int getSize() { + return secretsMap.size(); + } + + private static final Logger log = LoggerFactory.getLogger(EncryptedFileProperties.class); + private Map secretsMap = new HashMap<>(); + private String encFilePath; + private String password; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/ImmutableProperties.tjava b/src/main/tjava/crypto/r35157/nenjim/ImmutableProperties.tjava new file mode 100644 index 0000000..ccf6a05 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/ImmutableProperties.tjava @@ -0,0 +1,9 @@ +package crypto.r35157.nenjim; + +import java.io.IOException; + +public interface ImmutableProperties { + String getProperty(String key) throws IOException; + void refresh() throws IOException; + int getSize(); +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimCLIAdminClient.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimCLIAdminClient.tjava new file mode 100644 index 0000000..20eb726 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimCLIAdminClient.tjava @@ -0,0 +1,5 @@ +package crypto.r35157.nenjim; + +public class NenjimCLIAdminClient { + private NenjimHubClient hubClient; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimContext.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimContext.tjava new file mode 100644 index 0000000..a5ec5cf --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimContext.tjava @@ -0,0 +1,4 @@ +package crypto.r35157.nenjim; + +public interface NenjimContext { +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHub.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHub.tjava new file mode 100644 index 0000000..57059b4 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHub.tjava @@ -0,0 +1,25 @@ +package crypto.r35157.nenjim; + +import java.util.HashMap; + +public interface NenjimHub { + /** + * Starts a process based on the provided fully qualified interface name. + * The actual implementation that is run is binded according to the Context. + * + * @param fqInterfaceName the fully qualified interface name of the process to start. The interface must extend the {@code NenjimProcess} interface. + */ + void startProcess(String fqInterfaceName); + + /** + * Provides a map of all running processes managed by the NenjimHub, keyed by the processId. + * + * @return a map of {@code NenjimProcess} running processes + */ + HashMap getRunningProcesses(); + + /** + * A no-operation (noop). This method does nothing. + */ + void noop(); +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubClient.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubClient.tjava new file mode 100644 index 0000000..7d1a019 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubClient.tjava @@ -0,0 +1,4 @@ +package crypto.r35157.nenjim; + +public interface NenjimHubClient { +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubImpl.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubImpl.tjava new file mode 100644 index 0000000..b28d93a --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubImpl.tjava @@ -0,0 +1,93 @@ +package crypto.r35157.nenjim; + +import java.util.HashMap; +//import java.util.concurrent.StructuredTaskScope; + +public class NenjimHubImpl implements NenjimHub { + public static void main() throws Exception { + new NenjimHubImpl(); + } + + public NenjimHubImpl() throws Exception { + System.out.println("Initializing NenjimHub..."); + + nextProcessId = 1; + //processesScope = new StructuredTaskScope.ShutdownOnFailure(); + processes = new HashMap<>(); + + System.out.println("Starting autorun processes:"); + startAutoRunProcesses(); + waitForAndShutdown(); + } + + private void waitForAndShutdown() { + System.out.println("Done - Now online!"); + + /* + try { + processesScope.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + processesScope.close(); + */ + System.out.println("Nenjim is now shutdown (stopped all processes)!"); + } + + private void startAutoRunProcesses() throws Exception { + String[] processesToAutoStart = { + "crypto.r35157.nenjim.NenjimHubSocketAdminAdapter", + "crypto.r35157.nenjim.NenjimHubRestAdminAdapter", + "crypto.r35157.nenjim.NenjimHubRPCAdminAdapter", + "crypto.r35157.nenjim.SuwimoHub", + "crypto.r35157.nenjim.SodaTaskManager", + "crypto.r35157.assetaz.hub.AssetAZHub" + }; + + for (String processInterfaceName : processesToAutoStart) { + startProcess(processInterfaceName); + } + } + + @Override + public void startProcess(String className) { + ClassLoader loader = ClassLoader.getSystemClassLoader(); + + /*processesScope.fork(() -> { + Class clazz = loader.loadClass(className); + if (!NenjimProcess.class.isAssignableFrom(clazz)) { + throw new IllegalArgumentException("'" + className + "' does NOT implement NenjimProcess"); + } + + NenjimProcess proc = (NenjimProcess) clazz.getDeclaredConstructor().newInstance(); + + processes.put(nextProcessId, proc); + nextProcessId++; + + NenjimContext context = new NenjimContext() { }; + proc.setNenjimProcessContext(context); + proc.setNenjimHub(this); + + Thread.currentThread().setName(proc.getProcessName()); + System.out.println(" Starter Nenjim process '" + proc.getProcessName() + "'"); + proc.run(); + + return null; + });*/ + } + + @Override + public HashMap getRunningProcesses() { + return processes; + } + + @Override + public void noop() { + System.out.println("NenjimHub command: 'noop'"); + } + + private HashMap processes; + //private StructuredTaskScope.ShutdownOnFailure processesScope; + private int nextProcessId; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminAdapter.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminAdapter.tjava new file mode 100644 index 0000000..4a190ca --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminAdapter.tjava @@ -0,0 +1,24 @@ +package crypto.r35157.nenjim; + +public class NenjimHubRPCAdminAdapter implements NenjimProcess { + @Override + public void run() throws Exception { + } + + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "NenjimHub RPC Admin Adapter"; + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminBridge.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminBridge.tjava new file mode 100644 index 0000000..fc86423 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRPCAdminBridge.tjava @@ -0,0 +1,4 @@ +package crypto.r35157.nenjim; + +public class NenjimHubRPCAdminBridge { +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminAdapter.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminAdapter.tjava new file mode 100644 index 0000000..fc1df3b --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminAdapter.tjava @@ -0,0 +1,23 @@ +package crypto.r35157.nenjim; + +public class NenjimHubRestAdminAdapter implements NenjimProcess { + @Override + public void run() throws Exception { + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "NenjimHub Rest Admin Adapter"; + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminBridge.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminBridge.tjava new file mode 100644 index 0000000..c6f2287 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubRestAdminBridge.tjava @@ -0,0 +1,4 @@ +package crypto.r35157.nenjim; + +public class NenjimHubRestAdminBridge { +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminAdapter.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminAdapter.tjava new file mode 100644 index 0000000..e1fbc6c --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminAdapter.tjava @@ -0,0 +1,116 @@ +package crypto.r35157.nenjim; + +import crypto.r35157.RingBuffer; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.HashMap; +import java.util.List; + +public class NenjimHubSocketAdminAdapter implements NenjimProcess { + public NenjimHubSocketAdminAdapter() { + this.portNumber = SOCKET_ADMIN_PORT; + } + + @Override + public void run() throws Exception { + try (ServerSocket server = new ServerSocket(portNumber)) { + System.out.println("NenjimHub Socket Admin Client ready and listening on port: " + portNumber); + while (true) { + try ( + Socket client = server.accept(); + BufferedReader in = new BufferedReader( + new InputStreamReader(client.getInputStream())); + PrintWriter out = new PrintWriter( + client.getOutputStream(), true) // autoFlush = true + ) { + boolean doContinue = true; + System.out.println("Connection received from: " + + client.getRemoteSocketAddress()); + + out.println("NenjimHub 0.1 r35157_reference_implementation 0.0.0.1"); + out.println("OK"); + out.flush(); + + while (doContinue) { + String line = in.readLine(); + if (line.trim().isEmpty()) { + out.println("OK"); + } else { + long start = System.currentTimeMillis(); + String command = line.split(" ")[0].toUpperCase(); + + switch (command) { + case "START": + String fqInterfaceName = line.split(" ")[1]; + nenjimHub.startProcess(fqInterfaceName); + break; + + case "PS": + HashMap processesMap = nenjimHub.getRunningProcesses(); + for(int processId : processesMap.keySet()) { + NenjimProcess process = processesMap.get(processId); + out.println( + processId + + " '" + process.getProcessName() + "'" + + " " + process.getClass().getName() + ); + } + break; + + case "NOOP": + nenjimHub.noop(); + break; + + case "QUIT": + doContinue = false; + System.out.println("Connection closed by client"); + break; + + case "HELP": + out.println("START - Start a new process using the given fully qualified interface name"); + out.println("PS - Show running processes"); + out.println("NOOP - No Operation"); + out.println("QUIT - Quit the session"); + out.println("HELP - This list of commands"); + break; + + default: + out.println("Error: 400 - Unknown command"); + break; + } + + out.println("OK (" + (System.currentTimeMillis() - start) + "ms)"); + } + } + } + System.out.println("Session ended!"); + } + } + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub nenjimHub) { + this.nenjimHub = nenjimHub; + } + + @Override + public String getProcessName() { + return "NenjimHub Socket Admin Adapter"; + } + + private static final int SOCKET_ADMIN_PORT = 38010; + private static final int RING_BUFFER_CAPACITY = 25; + + private NenjimContext nenjimContext; + private NenjimHub nenjimHub; + private final int portNumber; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminBridge.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminBridge.tjava new file mode 100644 index 0000000..9b4dd4e --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminBridge.tjava @@ -0,0 +1,4 @@ +package crypto.r35157.nenjim; + +public class NenjimHubSocketAdminBridge { +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminClient.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminClient.tjava new file mode 100644 index 0000000..14ce751 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimHubSocketAdminClient.tjava @@ -0,0 +1,74 @@ +package crypto.r35157.nenjim; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; + +public class NenjimHubSocketAdminClient implements NenjimProcess { + public NenjimHubSocketAdminClient() { + this.portNumber = SOCKET_ADMIN_PORT; + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "NenjimHub Socket Admin Client"; + } + + @Override + public void run() throws Exception { + try (ServerSocket server = new ServerSocket(portNumber)) { + System.out.println("NenjimHub Socket Admin Client ready and listening on port: " + portNumber); + while (true) { + try ( + Socket client = server.accept(); + BufferedReader in = new BufferedReader( + new InputStreamReader(client.getInputStream())); + PrintWriter out = new PrintWriter( + client.getOutputStream(), true) // autoFlush = true + ) { + System.out.println("Connection received from: " + + client.getRemoteSocketAddress()); + + out.println("Protocol: NenjimAdmin-0.1"); + out.println("OK"); + out.flush(); + + while(true) { + String line = in.readLine(); + if(line.trim().isEmpty()) { + out.println("OK"); + } else if (line.toUpperCase().startsWith("NOOP")) { + long start = System.currentTimeMillis(); + //nenjimContext.noOp(); + out.println("OK (" + (System.currentTimeMillis() - start) + "ms)"); + } else if ("QUIT".equalsIgnoreCase(line)) { + out.println("OK"); + System.out.println("Connection closed by client"); + break; + } else { + out.println("Error: 400 - Unknown command"); + } + } + System.out.println("Session ended!"); + } + } + } + } + + private static final int SOCKET_ADMIN_PORT = 38010; + + private final int portNumber; + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimProcess.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimProcess.tjava new file mode 100644 index 0000000..364f637 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimProcess.tjava @@ -0,0 +1,10 @@ +package crypto.r35157.nenjim; + +public interface NenjimProcess { + void run() throws Exception; + + void setNenjimProcessContext(NenjimContext context); + void setNenjimHub(NenjimHub hub); + + String getProcessName(); +} diff --git a/src/main/tjava/crypto/r35157/nenjim/NenjimTestTool.tjava b/src/main/tjava/crypto/r35157/nenjim/NenjimTestTool.tjava new file mode 100644 index 0000000..d3a4820 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/NenjimTestTool.tjava @@ -0,0 +1,245 @@ +package crypto.r35157.nenjim; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.event.ActionEvent; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +//import org.junit.jupiter.api.Disabled; +//import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +public class NenjimTestTool extends Application implements NenjimProcess { + @Override + public void run() throws Exception { + launch(); + } + + public static class FileNode { + private final SimpleStringProperty name; + private final SimpleLongProperty size; + private final SimpleStringProperty type; + private final BooleanProperty selected = new SimpleBooleanProperty(false); + + public FileNode(String name, long size, String type) { + this.name = new SimpleStringProperty(name); + this.size = new SimpleLongProperty(size); + this.type = new SimpleStringProperty(type); + } + public SimpleStringProperty nameProperty() { return name; } + public SimpleLongProperty sizeProperty() { return size; } + public SimpleStringProperty typeProperty() { return type; } + public BooleanProperty selectedProperty() { return selected; } + } + + @Override + public void stop() throws Exception { + super.stop(); + } + + @Override + public void start(Stage primaryStage) { + // When the user clicks the window “X”, tear down JavaFX only: + primaryStage.setOnCloseRequest(event -> { + // triggers Application.stop() and shuts down FX machinery + Platform.exit(); + // → no System.exit(): the rest of your Nenjin VM keeps running + }); + + // Load icons + Image folderIcon = new Image(getClass().getResourceAsStream("/icons/folder.png")); + Image fileIcon = new Image(getClass().getResourceAsStream("/icons/file.png")); + + // Build tree items + TreeItem root = new TreeItem<>(new FileNode("Projekt", 0, "Folder")); + root.setExpanded(true); + + TreeItem src = new TreeItem<>(new FileNode("src", 0, "Folder")); + src.getChildren().add(new TreeItem<>(new FileNode("Main.java", 15000, "File"))); + src.getChildren().add(new TreeItem<>(new FileNode("Utils.java", 8200, "File"))); + root.getChildren().add(src); + + TreeItem res = new TreeItem<>(new FileNode("resources", 0, "Folder")); + res.getChildren().add(new TreeItem<>(new FileNode("icon.png", 2500, "File"))); + root.getChildren().add(res); + + // Create TreeTableView + TreeTableView treeTable = new TreeTableView<>(root); + treeTable.setShowRoot(true); + + // Name column with tri-state checkbox and icon + TreeTableColumn nameCol = new TreeTableColumn<>("Name"); + nameCol.setCellValueFactory(param -> param.getValue().getValue().nameProperty()); + nameCol.setPrefWidth(300); + nameCol.setCellFactory(col -> new TreeTableCell() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { + setText(null); + setGraphic(null); + } else { + TreeItem treeItem = getTreeTableRow().getTreeItem(); + FileNode node = treeItem.getValue(); + // Create checkbox + CheckBox checkBox = new CheckBox(); + checkBox.setAllowIndeterminate(true); + // Bind selected + checkBox.selectedProperty().bindBidirectional(node.selectedProperty()); + // Bind indeterminate to partial selection of children + Observable[] childProps = treeItem.getChildren().stream() + .map(child -> (Observable) child.getValue().selectedProperty()) + .toArray(Observable[]::new); + checkBox.indeterminateProperty().bind(Bindings.createBooleanBinding(() -> { + boolean any = treeItem.getChildren().stream() + .anyMatch(child -> child.getValue().selectedProperty().get()); + boolean all = treeItem.getChildren().stream() + .allMatch(child -> child.getValue().selectedProperty().get()); + return any && !all; + }, childProps)); + // Propagate clicks to children + checkBox.addEventHandler(ActionEvent.ACTION, e -> { + if (!checkBox.isIndeterminate()) { + treeItem.getChildren().forEach(child -> + child.getValue().selectedProperty().set(checkBox.isSelected()) + ); + } + }); + // Create icon + ImageView iv = new ImageView( + node.typeProperty().get().equals("Folder") ? folderIcon : fileIcon + ); + iv.setFitHeight(16); + iv.setFitWidth(16); + // Layout + HBox container = new HBox(5, checkBox, iv); + setText(item); + setGraphic(container); + } + } + }); + + // Size column + TreeTableColumn sizeCol = new TreeTableColumn<>("Size (bytes)"); + sizeCol.setCellValueFactory(param -> param.getValue().getValue().sizeProperty()); + sizeCol.setPrefWidth(120); + + // Type column + TreeTableColumn typeCol = new TreeTableColumn<>("Type"); + typeCol.setCellValueFactory(param -> param.getValue().getValue().typeProperty()); + typeCol.setPrefWidth(100); + + treeTable.getColumns().setAll(nameCol, sizeCol, typeCol); + + Button runButton = new Button("Execute Tests"); + runButton.setOnAction(event -> runTests()); + + // Show scene + StackPane stackPane = new StackPane(treeTable); + BorderPane rootPane = new BorderPane(); + rootPane.setTop(stackPane); + rootPane.setBottom(runButton); + + Scene scene = new Scene(rootPane, 700, 450); + + primaryStage.setTitle("Nenjim Test Tool"); + primaryStage.setScene(scene); + primaryStage.show(); + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "Nenjim Test Tool"; + } + + private void runTests() { + // Map over projekter og deres konfiguration + Map projects = Map.of( + "NenjimHub", new ProjectConfig( + Path.of("/home/minimons/projects/nenjim_testtool-tests/build/classes/java/test"), + List.of( + "crypto.r35157.nenjim.testtool.TestsTOC" + ) + ) + ); + + for (Map.Entry entry : projects.entrySet()) { + String projectName = entry.getKey(); + ProjectConfig config = entry.getValue(); + + System.out.println("===> Projekt: " + projectName); + DirectoryClassLoader loader = new DirectoryClassLoader(config.classDir); + + for (String className : config.testClasses) { + System.out.println("--> Tester klasse: " + className); + try { + Class clazz = loader.loadClass(className); + + // TODO: Renable when import is fixed + /*Disabled disabledClass = clazz.getAnnotation(Disabled.class); + if (disabledClass != null) { + String reason = disabledClass.value(); + System.out.println("\uD83D\uDEAB --> Klasse '" + className + "' disabled! (Reason: " + reason + ")"); + continue; + }*/ + + Object instance = clazz.getDeclaredConstructor().newInstance(); + + // TODO: Renable when import is fixed + /* + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(Test.class)) { + Disabled disabledTest = method.getAnnotation(Disabled.class); + String methodName = method.getName(); + + if(disabledTest != null) { + String reason = disabledTest.value(); + System.out.println("\uD83D\uDEAB --> Test '" + methodName + "' disabled! (Reason: " + reason + ")"); + continue; + } + + System.out.print(" Kører test: '" + methodName + "' ... "); + try { + method.setAccessible(true); + method.invoke(instance); + System.out.println("✅"); + } catch (Throwable t) { + System.out.println("❌ Fejl: " + t.getCause()); + } + } + }*/ + } catch (Exception e) { + System.out.println("🚫 Kunne ikke loade klasse: " + e.getMessage()); + e.printStackTrace(); + } + } + } + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/ProjectConfig.tjava b/src/main/tjava/crypto/r35157/nenjim/ProjectConfig.tjava new file mode 100644 index 0000000..03cc81d --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/ProjectConfig.tjava @@ -0,0 +1,14 @@ +package crypto.r35157.nenjim; + +import java.nio.file.Path; +import java.util.List; + +public class ProjectConfig { + public final Path classDir; + public final List testClasses; + + public ProjectConfig(Path classDir, List testClasses) { + this.classDir = classDir; + this.testClasses = testClasses; + } +} \ No newline at end of file diff --git a/src/main/tjava/crypto/r35157/nenjim/SodaTaskManager.tjava b/src/main/tjava/crypto/r35157/nenjim/SodaTaskManager.tjava new file mode 100644 index 0000000..1cbee4c --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/SodaTaskManager.tjava @@ -0,0 +1,25 @@ +package crypto.r35157.nenjim; + +import java.util.concurrent.CountDownLatch; + +public class SodaTaskManager implements NenjimProcess { + @Override + public void run() throws Exception { + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "Soda Task Manager"; + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/SuwimoHub.tjava b/src/main/tjava/crypto/r35157/nenjim/SuwimoHub.tjava new file mode 100644 index 0000000..23d4163 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/SuwimoHub.tjava @@ -0,0 +1,23 @@ +package crypto.r35157.nenjim; + +public class SuwimoHub implements NenjimProcess { + @Override + public void run() throws Exception { + } + + @Override + public void setNenjimProcessContext(NenjimContext context) { + this.nenjimContext = context; + } + + @Override + public void setNenjimHub(NenjimHub hub) { + } + + @Override + public String getProcessName() { + return "Suwimo Hub"; + } + + private NenjimContext nenjimContext; +} diff --git a/src/main/tjava/crypto/r35157/nenjim/SystemEnvironmentProperties.tjava b/src/main/tjava/crypto/r35157/nenjim/SystemEnvironmentProperties.tjava new file mode 100644 index 0000000..bced6f3 --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/SystemEnvironmentProperties.tjava @@ -0,0 +1,27 @@ +package crypto.r35157.nenjim; + +public class SystemEnvironmentProperties implements ImmutableProperties { + public SystemEnvironmentProperties() { + refresh(); + } + + @Override + public String getProperty(String key) { + String value = System.getenv(key); + if(value == null) { + return null; + } + + value = value.trim(); + + return value; + } + + @Override + public int getSize() { + return -1; + } + + @Override + public void refresh() {} +} diff --git a/src/main/tjava/crypto/r35157/nenjim/scorevisualizer/ScoreVisualizerApp.tjava b/src/main/tjava/crypto/r35157/nenjim/scorevisualizer/ScoreVisualizerApp.tjava new file mode 100644 index 0000000..0dc967a --- /dev/null +++ b/src/main/tjava/crypto/r35157/nenjim/scorevisualizer/ScoreVisualizerApp.tjava @@ -0,0 +1,209 @@ +package crypto.r35157.nenjim.scorevisualizer; + +import crypto.r35157.cauldron.afets.PositionType; +import crypto.r35157.cauldron.afets.ScoreCalculator; +import crypto.r35157.cauldron.afets.ScoreCalculatorImpl; +import crypto.r35157.cauldron.afets.ScoreCalculatorInput; +import javafx.application.Application; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.scene.control.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class ScoreVisualizerApp extends Application { + public ScoreCalculator scoreCalculator = new ScoreCalculatorImpl(); + + @Override + public void start(Stage stage) { + // CHART (top) + NumberAxis xAxis = new NumberAxis(); + xAxis.setLabel("Point"); + NumberAxis yAxis = new NumberAxis(); + // xAxis name set dynamically later + + LineChart chart = new LineChart<>(xAxis, yAxis); + chart.setTitle("Evelyn Score Visualization"); + chart.setLegendVisible(false); + + XYChart.Series scoreSeries = new XYChart.Series<>(); + chart.getData().add(scoreSeries); + + // CONTROLS (bottom) + GridPane controls = new GridPane(); + controls.setHgap(10); + controls.setVgap(8); + controls.setPadding(new Insets(10, 10, 10, 10)); + + Label pointsLabel = new Label("Points"); + TextField pointsField = new TextField("30"); + + Label currentTokenBaseNotationPriceLabel = new Label("CurrentTokenBaseNotationPrice"); + TextField currentTokenBaseNotationPriceFrom = new TextField("0.01"); + TextField currentTokenBaseNotationPriceTo = new TextField("0.1"); + + Label futureTokenBaseNotationPriceLabel = new Label("FutureTokenBaseNotationPrice"); + TextField futureTokenBaseNotationPriceFrom = new TextField("0.1"); + TextField futureTokenBaseNotationPriceTo = new TextField("2.0"); + + Label currentTokenNotationPriceLabel = new Label("CurrentTokenNotationPrice"); + TextField currentTokenNotationPriceFrom = new TextField("0.01"); + TextField currentTokenNotationPriceTo = new TextField("0.1"); + + Label amountNotationalToSpendLabel = new Label("AmountNotationalToSpend"); + TextField amountNotationalToSpendFrom = new TextField("0"); + TextField amountNotationalToSpendTo = new TextField("100"); + + Label totalNumberOfPositionsLabel = new Label("TotalNumberOfPositions"); + TextField totalNumberOfPositionsFrom = new TextField("0.01"); + TextField totalNumberOfPositionsTo = new TextField("2.0"); + + Label contractSizeLabel = new Label("ContractSize"); + TextField contractSizeFrom = new TextField("1.0"); + TextField contractSizeTo = new TextField("2.0"); + + Label leverageLabel = new Label("Leverage"); + TextField leverageFrom = new TextField("1.0"); + TextField leverageTo = new TextField("2.0"); + + Label averagePositionValueLabel = new Label("AveragePositionValue"); + TextField averagePositionValueFrom = new TextField("1.0"); + TextField averagePositionValueTo = new TextField("2.0"); + + Label tradeTypeLabel = new Label("TradeType"); + ComboBox tradeTypeCombo = new ComboBox<>(); + tradeTypeCombo.getItems().addAll(PositionType.marginLong, PositionType.marginShort); + tradeTypeCombo.setValue(PositionType.marginLong); + + Label seriesLabel = new Label("DataSeries"); + ComboBox seriesCombo = new ComboBox<>(); + seriesCombo.getItems().addAll( + "Score", + currentTokenBaseNotationPriceLabel.getText(), + futureTokenBaseNotationPriceLabel.getText(), + currentTokenNotationPriceLabel.getText(), + amountNotationalToSpendLabel.getText(), + totalNumberOfPositionsLabel.getText(), + contractSizeLabel.getText(), + leverageLabel.getText(), + averagePositionValueLabel.getText() + ); + + Button updateBtn = new Button("Update Graph"); + + int row = 0; + controls.add(seriesLabel, 0, row); controls.add(seriesCombo, 1, row); controls.add(updateBtn, 2, row++); + controls.add(pointsLabel, 0, row); controls.add(pointsField, 1, row++); + controls.add(currentTokenBaseNotationPriceLabel, 0, row); controls.add(currentTokenBaseNotationPriceFrom, 1, row); controls.add(currentTokenBaseNotationPriceTo, 2, row++); + controls.add(futureTokenBaseNotationPriceLabel, 0, row); controls.add(futureTokenBaseNotationPriceFrom, 1, row); controls.add(futureTokenBaseNotationPriceTo, 2, row++); + controls.add(currentTokenNotationPriceLabel, 0, row); controls.add(currentTokenNotationPriceFrom, 1, row); controls.add(currentTokenNotationPriceTo, 2, row++); + controls.add(amountNotationalToSpendLabel, 0, row); controls.add(amountNotationalToSpendFrom, 1, row); controls.add(amountNotationalToSpendTo, 2, row++); + controls.add(totalNumberOfPositionsLabel, 0, row); controls.add(totalNumberOfPositionsFrom, 1, row); controls.add(totalNumberOfPositionsTo, 2, row++); + controls.add(contractSizeLabel, 0, row); controls.add(contractSizeFrom, 1, row); controls.add(contractSizeTo, 2, row++); + controls.add(leverageLabel, 0, row); controls.add(leverageFrom, 1, row); controls.add(leverageTo, 2, row++); + controls.add(averagePositionValueLabel, 0, row); controls.add(averagePositionValueFrom, 1, row); controls.add(averagePositionValueTo, 2, row++); + controls.add(tradeTypeLabel, 0, row); controls.add(tradeTypeCombo, 1, row++); + + // Layout: VBox (graph on top, controls below) + VBox root = new VBox(chart, controls); + + // Action: Update Graph + updateBtn.setOnAction(e -> { + yAxis.setLabel(seriesCombo.getValue()); + scoreSeries.getData().clear(); + + try { + int points = Integer.parseInt(pointsField.getText().trim()); + if (points < 2) { points = 2; } // At least two points + + BigDecimal currentTokenBaseNotationPriceFromVal = new BigDecimal(currentTokenBaseNotationPriceFrom.getText().trim()); + BigDecimal currentTokenBaseNotationPriceToVal = new BigDecimal(currentTokenBaseNotationPriceTo.getText().trim()); + BigDecimal futureTokenBaseNotationPriceFromVal = new BigDecimal(futureTokenBaseNotationPriceFrom.getText().trim()); + BigDecimal futureTokenBaseNotationPriceToVal = new BigDecimal(futureTokenBaseNotationPriceTo.getText().trim()); + BigDecimal currentTokenNotationPriceFromVal = new BigDecimal(currentTokenNotationPriceFrom.getText().trim()); + BigDecimal currentTokenNotationPriceToVal = new BigDecimal(currentTokenNotationPriceTo.getText().trim()); + BigDecimal amountNotationalToSpendFromVal = new BigDecimal(amountNotationalToSpendFrom.getText().trim()); + BigDecimal amountNotationalToSpendToVal = new BigDecimal(amountNotationalToSpendTo.getText().trim()); + BigDecimal totalNumberOfPositionsFromVal = new BigDecimal(totalNumberOfPositionsFrom.getText().trim()); + BigDecimal totalNumberOfPositionsToVal = new BigDecimal(totalNumberOfPositionsTo.getText().trim()); + BigDecimal contractSizeFromVal = new BigDecimal(contractSizeFrom.getText().trim()); + BigDecimal contractSizeToVal = new BigDecimal(contractSizeTo.getText().trim()); + BigDecimal leverageFromVal = new BigDecimal(leverageFrom.getText().trim()); + BigDecimal leverageToVal = new BigDecimal(leverageTo.getText().trim()); + BigDecimal averagePositionValueFromVal = new BigDecimal(averagePositionValueFrom.getText().trim()); + BigDecimal averagePositionValueToVal = new BigDecimal(averagePositionValueTo.getText().trim()); + + PositionType type = tradeTypeCombo.getValue(); + + for (int i = 0; i < points; i++) { + BigDecimal ratio = (points == 1) + ? BigDecimal.ZERO + : new BigDecimal(i).divide(new BigDecimal(points - 1), 10, RoundingMode.HALF_UP); + + BigDecimal currentTokenBaseNotationPriceVal = interpolate(currentTokenBaseNotationPriceFromVal, currentTokenBaseNotationPriceToVal, ratio); + BigDecimal futureTokenBaseNotationPriceVal = interpolate(futureTokenBaseNotationPriceFromVal, futureTokenBaseNotationPriceToVal, ratio); + BigDecimal currentTokenNotationPriceVal = interpolate(currentTokenNotationPriceFromVal, currentTokenNotationPriceToVal, ratio); + BigDecimal amountNotationalToSpendVal = interpolate(amountNotationalToSpendFromVal, amountNotationalToSpendToVal, ratio); + BigDecimal totalNumberOfPositionsVal = interpolate(totalNumberOfPositionsFromVal, totalNumberOfPositionsToVal, ratio); + BigDecimal contractSizeVal = interpolate(contractSizeFromVal, contractSizeToVal, ratio); + BigDecimal leverageVal = interpolate(leverageFromVal, leverageToVal, ratio); + BigDecimal averagePositionValueVal = interpolate(averagePositionValueFromVal, averagePositionValueToVal, ratio); + + try { + double point = switch(seriesCombo.getValue()) { + case "CurrentTokenBaseNotationPrice" -> currentTokenBaseNotationPriceVal.doubleValue(); + case "FutureTokenBaseNotationPrice" -> futureTokenBaseNotationPriceVal.doubleValue(); + case "CurrentTokenNotationPrice" -> currentTokenNotationPriceVal.doubleValue(); + case "AmountNotationalToSpend" -> amountNotationalToSpendVal.doubleValue(); + case "TotalNumberOfPositions" -> totalNumberOfPositionsVal.doubleValue(); + case "ContractSize" -> contractSizeVal.doubleValue(); + case "Leverage" -> leverageVal.doubleValue(); + case "AveragePositionValue" -> averagePositionValueVal.doubleValue(); + case "Score" -> { + ScoreCalculatorInput input = new ScoreCalculatorInput( + currentTokenBaseNotationPriceVal, + futureTokenBaseNotationPriceVal, + currentTokenNotationPriceVal, + amountNotationalToSpendVal, + totalNumberOfPositionsVal.intValue(), + contractSizeVal, + leverageVal, + type, + averagePositionValueVal); + yield scoreCalculator.calculateScore(input).doubleValue(); + } + default -> throw new IllegalStateException("Unexpected value: " + seriesCombo.getValue()); + }; + scoreSeries.getData().add(new XYChart.Data<>(i + 1, point)); + } catch (Exception ex) { + // Skip points with invalid input (e.g., divide by zero) + } + } + } catch (Exception ex) { + // You could show a dialog here if desired + ex.printStackTrace(); + } + }); + + // Window setup + stage.setScene(new Scene(root, 900, 800)); + stage.setTitle("Evelyn Score Visualizer"); + stage.show(); + } + + // Helper for linear interpolation between from and to + private static BigDecimal interpolate(BigDecimal from, BigDecimal to, BigDecimal ratio) { + return from.add(to.subtract(from).multiply(ratio)); + } + + public static void main(String[] args) { + launch(args); + } +}