diff --git a/.env.example b/.env.example index d562ea9..7c8a935 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # TradeStreamEE Environment Variables +# Payara Micro Version (used by all Dockerfiles) +PAYARA_VERSION=7.2026.5 + # JVM Options for Azul Platform Prime # Adjust these for your performance requirements JAVA_OPTS=-XX:+UseZGC -Xms512m -Xmx2g -Xlog:gc*:file=/opt/payara/gc.log:time,uptime:filecount=5,filesize=10M -Djava.net.preferIPv4Stack=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..040b68a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Build with Maven + run: ./mvnw clean compile + + - name: Generate SBE sources + run: ./mvnw generate-sources + + - name: Run tests with JaCoCo + run: ./mvnw clean test jacoco:report -Pcoverage + + - name: Check coverage thresholds + run: | + COVERAGE=$(grep -oP '(?<=Total.*?">).*?(?=%)' target/site/jacoco/index.html | head -1) + echo "Coverage: $COVERAGE%" + + if (( $(echo "$COVERAGE < 15" | bc -l) )); then + echo "❌ Coverage ($COVERAGE%) is below minimum threshold (15%)" + exit 1 + else + echo "✅ Coverage ($COVERAGE%) meets minimum threshold (15%)" + fi diff --git a/.gitignore b/.gitignore index a8b3edc..7ae3e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .settings .project .classpath +deployment/ *.iml .DS_Store @@ -27,4 +28,34 @@ container/ docs/* *.md !README.md -*.pdf \ No newline at end of file +*.pdf +*.jar +monitoring/jmx-exporter/*.jar +monitoring/logs/ +monitoring/gc-logs/ +monitoring/prometheus/data/ +monitoring/grafana/data/ +monitoring/loki/data/ + +# Runtime artefacts produced by start-comparison.sh and the running clusters. +# Bind-mounted JFR dumps land here; the curated workshop set lives in +# workshop/recordings/ instead. +monitoring/recordings/ +monitoring/metrics.log +monitoring/.scraper.pid + +# Crash dumps from any local JVM (Zing safepoint aborts in particular). +hs_err_pid*.log +hs_err_pid* + +# Workshop tree: everything is intentional content. Re-include it explicitly +# so the broad *.md / *.jar ignores above don't swallow it. +!workshop/ +!workshop/** +!workshop/**/*.md +!workshop/**/*.jfc +!workshop/**/*.jfr +!workshop/**/*.java +!workshop/**/*.xml +!workshop/**/*.sh +!workshop/**/*.txt diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..cb28b0e Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..346d645 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/.profileconfig.json b/.profileconfig.json new file mode 100644 index 0000000..b2f42d9 --- /dev/null +++ b/.profileconfig.json @@ -0,0 +1,64 @@ +{ + "jfrConfig": { + "settings": "profile" + }, + "asyncProfilerConfig": { + "jfrsync": true, + "alloc": true, + "event": "wall", + "misc": "" + }, + "file": "$PROJECT_DIR/profile.jfr", + "conversionConfig": { + "nonProjectPackagePrefixes": [ + "java.", + "javax.", + "kotlin.", + "jdk.", + "com.google.", + "org.apache.", + "org.spring.", + "sun.", + "scala." + ], + "enableMarkers": true, + "initialVisibleThreads": 10, + "initialSelectedThreads": 10, + "includeGCThreads": false, + "includeInitialSystemProperty": false, + "includeInitialEnvironmentVariables": false, + "includeSystemProcesses": false, + "ignoredEvents": [ + "jdk.ActiveSetting", + "jdk.ActiveRecording", + "jdk.BooleanFlag", + "jdk.IntFlag", + "jdk.DoubleFlag", + "jdk.LongFlag", + "jdk.NativeLibrary", + "jdk.StringFlag", + "jdk.UnsignedIntFlag", + "jdk.UnsignedLongFlag", + "jdk.InitialSystemProperty", + "jdk.InitialEnvironmentVariable", + "jdk.SystemProcess", + "jdk.ModuleExport", + "jdk.ModuleRequire" + ], + "minRequiredItemsPerThread": 3 + }, + "additionalGradleTargets": [ + { + "targetPrefix": "quarkus", + "optionForVmArgs": "-Djvm.args", + "description": "Example quarkus config, adding profiling arguments via -Djvm.args option to the Gradle task run" + } + ], + "additionalMavenTargets": [ + { + "targetPrefix": "quarkus:", + "optionForVmArgs": "-Djvm.args", + "description": "Example quarkus config, adding profiling arguments via -Djvm.args option to the Maven goal run" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d41c266..69d3b03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ # Multi-stage Dockerfile for TradeStreamEE -# Uses Azul Platform Prime (Zing) for Pauseless Garbage Collection demonstration +# Uses Azul Zulu 25 with ZGC for low-latency concurrent garbage collection -FROM azul/zulu-openjdk:21 AS build +FROM azul/zulu-openjdk:25-latest AS build WORKDIR /app # Copy Maven wrapper and pom.xml first for better layer caching COPY mvnw . COPY mvnw.cmd . +COPY spotless ./spotless COPY .mvn .mvn COPY pom.xml . @@ -14,45 +15,69 @@ RUN ./mvnw dependency:go-offline -B COPY src ./src +RUN ./mvnw spotless:apply RUN ./mvnw clean package -DskipTests -# Use Azul Platform Prime for C4 GC -FROM azul/prime:21 +# Azul Zulu 25 with ZGC for concurrent garbage collection +FROM azul/zulu-openjdk:25-latest LABEL maintainer="TradeStreamEE" -LABEL description="High-frequency trading dashboard with Aeron + SBE + Payara Micro + Azul C4" +LABEL description="High-frequency trading dashboard with Aeron + SBE + Payara Micro + Zulu 25 ZGC" WORKDIR /opt/payara -# Download Payara Micro -ARG PAYARA_VERSION=7.2025.2 -RUN apt-get update && \ - apt-get install -y wget curl && \ - wget -q -O payara-micro.jar \ - "https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar" && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +# Add Payara Micro from URL +ARG PAYARA_VERSION=7.2026.5 +ADD https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar /opt/payara/payara-micro.jar # Copy WAR file from build stage COPY --from=build /app/target/*.war ROOT.war +# Copy entrypoint script for JFR configuration +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Create recordings directory for JFR output and gc-logs for GC logs +RUN mkdir -p /opt/payara/recordings /opt/payara/gc-logs && chmod 777 /opt/payara/recordings /opt/payara/gc-logs + EXPOSE 8080 +EXPOSE 9009 -# Default JVM Options for Azul Platform Prime -# Note: These are defaults - can be overridden via docker-compose or docker run -e -# Azul Platform Prime uses C4 GC by default - no need to specify -XX:+UseZGC -ENV JAVA_OPTS="-Xms2g \ - -Xmx4g \ - -Xlog:gc*:file=/opt/payara/gc.log:time,uptime:filecount=5,filesize=10M \ - -XX:+UnlockDiagnosticVMOptions \ - -XX:+UnlockExperimentalVMOptions \ +# Default JVM Options for Azul Zulu 25 + ZGC +# +# NOTE: These JAVA_OPTS are used for single-instance deployments (start.sh script). +# For cluster deployments (start-comparison.sh), these values are overridden by +# docker-compose-{c4,g1}.yml environment variables. See those files for actual +# runtime flags in cluster mode. +# +# ZGC is a concurrent collector with sub-millisecond pauses on Java 25. +# +# JFR is OFF by default. Ad-hoc recordings are produced via the /api/jfr REST +# endpoints. To enable an always-on circular recording, set JFR_ALWAYS_ON=true +# in the environment; see docker-entrypoint.sh. +ENV JAVA_OPTS="-Xms8g \ + -Xmx8g \ + -XX:+UseZGC \ + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \ + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ + --add-opens java.base/sun.nio.ch=ALL-UNNAMED \ + --add-opens java.base/java.nio=ALL-UNNAMED \ -XX:+AlwaysPreTouch \ - -XX:-UseBiasedLocking \ - -Djava.net.preferIPv4Stack=true" + -XX:+UseTransparentHugePages \ + -XX:+UseStringDeduplication \ + -XX:+OptimizeStringConcat \ + -Djava.net.preferIPv4Stack=true \ + -Xlog:jfr*=info" + +# Set recording name for JFR +ENV RECORDING_NAME="production" # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/trader-stream-ee/api/status || exit 1 +# Use entrypoint to handle JFR configuration +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + # Run Payara Micro with the WAR CMD java ${JAVA_OPTS} -jar payara-micro.jar --deploy ROOT.war --contextroot trader-stream-ee --nohazelcast diff --git a/Dockerfile.scale b/Dockerfile.scale new file mode 100644 index 0000000..2263a6f --- /dev/null +++ b/Dockerfile.scale @@ -0,0 +1,84 @@ +# Multi-stage Dockerfile for TradeStreamEE with Hazelcast Clustering +# Uses Azul Zulu 25 with ZGC for low-latency concurrent garbage collection + +FROM azul/zulu-openjdk:25-latest AS build +WORKDIR /app + +# Copy Maven wrapper and pom.xml first for better layer caching +COPY mvnw . +COPY mvnw.cmd . +COPY .mvn .mvn +COPY pom.xml . +COPY spotless ./spotless + +RUN ./mvnw dependency:go-offline -B + +COPY src ./src + +RUN ./mvnw spotless:apply +RUN ./mvnw clean package -DskipTests + +# Azul Zulu 25 with ZGC for concurrent garbage collection +FROM azul/zulu-openjdk:25-latest + +LABEL maintainer="TradeStreamEE" +LABEL description="High-frequency trading dashboard with Aeron + SBE + Payara Micro + Zulu 25 ZGC + Hazelcast Clustering" + +WORKDIR /opt/payara + +# Add Payara Micro from URL +ARG PAYARA_VERSION=7.2026.5 +ADD https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar /opt/payara/payara-micro.jar + +# Copy WAR file from build stage +COPY --from=build /app/target/*.war trader-stream-ee.war + +# Create recordings directory for JFR output and gc-logs for GC logs +RUN mkdir -p /opt/payara/recordings /opt/payara/gc-logs && chmod 777 /opt/payara/recordings /opt/payara/gc-logs + +EXPOSE 8080 +EXPOSE 5701 + +# Default JVM Options for Azul Zulu 25 + ZGC +# +# NOTE: These JAVA_OPTS are defaults for the cluster image. However, when launched +# via docker-compose-c4.yml, these values are overridden by environment variables +# in that file. See docker-compose-c4.yml for actual runtime flags in cluster mode. +# +# Reduced heap size (4GB vs 8GB) for multi-instance deployments. +# ZGC is a concurrent collector with sub-millisecond pauses on Java 25. +# +# JFR is OFF by default. Workshop scenarios use ad-hoc recordings via the REST API +# (POST /api/jfr/recording/start). To enable a long-running always-on circular +# recording, set JFR_ALWAYS_ON=true in the environment. +ENV JAVA_OPTS="-Xms4g \ + -Xmx4g \ + -XX:+UseZGC \ + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \ + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ + --add-opens java.base/sun.nio.ch=ALL-UNNAMED \ + --add-opens java.base/java.nio=ALL-UNNAMED \ + -XX:+AlwaysPreTouch \ + -XX:+UseTransparentHugePages \ + -XX:+UseStringDeduplication \ + -XX:+OptimizeStringConcat \ + -Djava.net.preferIPv4Stack=true \ + -Xlog:jfr*=info" + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --spider -q http://localhost:8080/trader-stream-ee/api/status || exit 1 + +# Copy Hazelcast configuration +COPY src/main/resources/hazelcast-config.xml /opt/payara/hazelcast-config.xml + +# Set unique cluster name for ZGC environment and update discovery members +RUN sed -i 's/payara-trader-cluster/payara-trader-zgc/' /opt/payara/hazelcast-config.xml && \ + sed -i 's/trader-stream-/trader-stream-zgc-/' /opt/payara/hazelcast-config.xml + +# Run Payara Micro with the WAR. +# NOTE: --nohazelcast is omitted so clustering stays enabled. +# When JFR_ALWAYS_ON=true is set, the shell substitution below appends a +# circular always-on recording. Otherwise only ad-hoc recordings via the +# /api/jfr REST endpoints will produce .jfr files. +CMD ["sh", "-c", "exec java $JAVA_OPTS ${JFR_ALWAYS_ON:+-XX:StartFlightRecording=name=${JFR_RECORDING_NAME:-zgc-cluster},filename=/opt/payara/recordings/recording.jfr,dumponexit=true,maxage=1h,maxsize=1g,method-profiling=normal -XX:FlightRecorderOptions=stackdepth=256} -jar payara-micro.jar --deploy /opt/payara/trader-stream-ee.war --contextroot trader-stream-ee --hzconfigfile /opt/payara/hazelcast-config.xml"] diff --git a/Dockerfile.scale.standard b/Dockerfile.scale.standard new file mode 100644 index 0000000..eb37521 --- /dev/null +++ b/Dockerfile.scale.standard @@ -0,0 +1,89 @@ +# Multi-stage Dockerfile for TradeStreamEE with Hazelcast Clustering +# Uses Eclipse Temurin 25 (Standard OpenJDK) for comparison against Azul Zulu ZGC + +FROM eclipse-temurin:25 AS build +WORKDIR /app + +# Copy Maven wrapper and pom.xml first for better layer caching +COPY mvnw . +COPY mvnw.cmd . +COPY .mvn .mvn +COPY pom.xml . +COPY spotless ./spotless + +RUN ./mvnw dependency:go-offline -B + +COPY src ./src + +RUN ./mvnw spotless:apply +RUN ./mvnw clean package -DskipTests + +# Eclipse Temurin 25 with G1GC for comparison against ZGC +FROM eclipse-temurin:25 + +LABEL maintainer="TradeStreamEE" +LABEL description="High-frequency trading dashboard with Hazelcast Clustering (Standard JVM Comparison)" + +WORKDIR /opt/payara + +# Add Payara Micro from URL +ARG PAYARA_VERSION=7.2026.5 +ADD https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar /opt/payara/payara-micro.jar + +# Copy WAR file from build stage +COPY --from=build /app/target/*.war trader-stream-ee.war + +# Create recordings directory for JFR output and gc-logs for GC logs +RUN mkdir -p /opt/payara/recordings /opt/payara/gc-logs && chmod 777 /opt/payara/recordings /opt/payara/gc-logs + +EXPOSE 8080 +EXPOSE 5701 + +# Standard JVM Options (G1GC) +# +# NOTE: These JAVA_OPTS are defaults for the cluster image. However, when launched +# via docker-compose-g1.yml, these values are overridden by environment variables +# in that file. See docker-compose-g1.yml for actual runtime flags in cluster mode. +# +# Reduced heap size (4GB vs 8GB) for multi-instance deployments. +# +# JFR is OFF by default. Workshop scenarios use ad-hoc recordings via the REST API +# (POST /api/jfr/recording/start). To enable a long-running always-on circular +# recording, set JFR_ALWAYS_ON=true in the environment. +ENV JAVA_OPTS="-Xms4g \ + -Xmx4g \ + -XX:+UseG1GC \ + -XX:G1HeapRegionSize=16m \ + -XX:MaxGCPauseMillis=10 \ + -XX:InitiatingHeapOccupancyPercent=40 \ + -XX:ParallelGCThreads=12 \ + -XX:ConcGCThreads=4 \ + -XX:-ExplicitGCInvokesConcurrent \ + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \ + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ + --add-opens java.base/sun.nio.ch=ALL-UNNAMED \ + --add-opens java.base/java.nio=ALL-UNNAMED \ + -XX:+AlwaysPreTouch \ + -XX:+UseTransparentHugePages \ + -XX:+UseStringDeduplication \ + -XX:+OptimizeStringConcat \ + -Djava.net.preferIPv4Stack=true \ + -Xlog:jfr*=info" + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --spider -q http://localhost:8080/trader-stream-ee/api/status || exit 1 + +# Copy Hazelcast configuration +COPY src/main/resources/hazelcast-config.xml /opt/payara/hazelcast-config.xml + +# Set unique cluster name for G1 environment and update discovery members +RUN sed -i 's/payara-trader-cluster/payara-trader-g1/' /opt/payara/hazelcast-config.xml && \ + sed -i 's/trader-stream-/trader-stream-g1-/' /opt/payara/hazelcast-config.xml + +# Run Payara Micro with the WAR. +# NOTE: --nohazelcast is omitted so clustering stays enabled. +# When JFR_ALWAYS_ON=true is set, the shell substitution below appends a +# circular always-on recording. Otherwise only ad-hoc recordings via the +# /api/jfr REST endpoints will produce .jfr files. +CMD ["sh", "-c", "exec java $JAVA_OPTS ${JFR_ALWAYS_ON:+-XX:StartFlightRecording=name=${JFR_RECORDING_NAME:-g1-cluster},filename=/opt/payara/recordings/recording.jfr,dumponexit=true,maxage=1h,maxsize=1g,method-profiling=normal -XX:FlightRecorderOptions=stackdepth=256} -jar payara-micro.jar --deploy /opt/payara/trader-stream-ee.war --contextroot trader-stream-ee --hzconfigfile /opt/payara/hazelcast-config.xml"] diff --git a/Dockerfile.standard b/Dockerfile.standard index 0496ab8..090fea2 100644 --- a/Dockerfile.standard +++ b/Dockerfile.standard @@ -1,7 +1,7 @@ # Multi-stage Dockerfile for TradeStreamEE -# Uses Eclipse Temurin (Standard OpenJDK) for comparison against Azul Platform Prime +# Uses Eclipse Temurin 25 (Standard OpenJDK) for comparison against Azul Zulu ZGC -FROM azul/zulu-openjdk:21 AS build +FROM eclipse-temurin:25 AS build WORKDIR /app # Copy Maven wrapper and pom.xml first for better layer caching @@ -9,44 +9,78 @@ COPY mvnw . COPY mvnw.cmd . COPY .mvn .mvn COPY pom.xml . +COPY spotless ./spotless RUN ./mvnw dependency:go-offline -B COPY src ./src +RUN ./mvnw spotless:apply RUN ./mvnw clean package -DskipTests -# Use Eclipse Temurin (Standard OpenJDK) -FROM eclipse-temurin:21 +# Eclipse Temurin 25 with G1GC for comparison against ZGC +FROM eclipse-temurin:25 LABEL maintainer="TradeStreamEE" LABEL description="High-frequency trading dashboard (Standard JVM Comparison)" WORKDIR /opt/payara -# Download Payara Micro -ARG PAYARA_VERSION=7.2025.2 -RUN apt-get update && \ - apt-get install -y wget curl && \ - wget -q -O payara-micro.jar \ - "https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar" && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +# Add Payara Micro from URL +ARG PAYARA_VERSION=7.2026.5 +ADD https://nexus.payara.fish/repository/payara-community/fish/payara/extras/payara-micro/${PAYARA_VERSION}/payara-micro-${PAYARA_VERSION}.jar /opt/payara/payara-micro.jar # Copy WAR file from build stage COPY --from=build /app/target/*.war ROOT.war +# Copy entrypoint script for JFR configuration +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Create recordings directory for JFR output and gc-logs for GC logs +RUN mkdir -p /opt/payara/recordings /opt/payara/gc-logs && chmod 777 /opt/payara/recordings /opt/payara/gc-logs + EXPOSE 8080 # Standard JVM Options (G1GC) -ENV JAVA_OPTS="-Xms2g \ - -Xmx4g \ +# +# NOTE: These JAVA_OPTS are used for single-instance deployments (start.sh script). +# For cluster deployments (start-comparison.sh), these values are overridden by +# docker-compose-g1.yml environment variables. See that file for actual runtime +# flags in cluster mode. +# +# JFR is OFF by default. Ad-hoc recordings are produced via the /api/jfr REST +# endpoints. To enable an always-on circular recording, set JFR_ALWAYS_ON=true +# in the environment; see docker-entrypoint.sh. +ENV JAVA_OPTS="-Xms8g \ + -Xmx8g \ -XX:+UseG1GC \ - -Djava.net.preferIPv4Stack=true" + -XX:G1HeapRegionSize=16m \ + -XX:MaxGCPauseMillis=10 \ + -XX:InitiatingHeapOccupancyPercent=40 \ + -XX:ParallelGCThreads=12 \ + -XX:ConcGCThreads=4 \ + -XX:-ExplicitGCInvokesConcurrent \ + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \ + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ + --add-opens java.base/sun.nio.ch=ALL-UNNAMED \ + --add-opens java.base/java.nio=ALL-UNNAMED \ + -XX:+AlwaysPreTouch \ + -XX:+UseTransparentHugePages \ + -XX:+UseStringDeduplication \ + -XX:+OptimizeStringConcat \ + -Djava.net.preferIPv4Stack=true \ + -Xlog:jfr*=info" + +# Set recording name for JFR +ENV RECORDING_NAME="g1-baseline" # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:8080/trader-stream-ee/api/status || exit 1 + CMD wget --spider -q http://localhost:8080/trader-stream-ee/api/status || exit 1 + +# Use entrypoint to handle JFR configuration +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] # Run Payara Micro with the WAR CMD java ${JAVA_OPTS} -jar payara-micro.jar --deploy ROOT.war --contextroot trader-stream-ee --nohazelcast diff --git a/README.md b/README.md index 06d9ffe..d2607b9 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,461 @@ -# TradeStreamEE: High-Frequency Trading Reference Architecture +# TradeStreamEE: Low-Latency Trading on Jakarta EE -**TradeStreamEE** is a technical demonstration application designed to showcase the "Pauseless Performance" symbiosis between **Payara Server Enterprise** (Jakarta EE) and **Azul Platform Prime** (High-Performance JVM). +A Payara Platform (Jakarta EE 11) application that serves two purposes: -It simulates a high-frequency trading (HFT) dashboard that ingests tens of thousands of market data messages per second, processes them in real-time, and broadcasts updates to a web frontend—all without the latency spikes ("jitter") associated with standard Java Garbage Collection. +1. **GC Performance Comparison** - Side-by-side benchmark of Azul Zulu 25 (ZGC) vs Eclipse Temurin 25 (G1GC) under realistic allocation pressure from market data ingestion +2. **Trading System Reference** - A matching engine, risk engine, portfolio analytics, and technical analysis built with idiomatic Java 25 +The application simulates a high-frequency trading desk that ingests market data via Aeron IPC + SBE (zero-copy binary), processes it through a price-time priority matching engine, computes risk metrics, and streams results to a browser dashboard via WebSocket. +## Prerequisites -## ⚡ The Core Technologies +- Docker and Docker Compose (for containerized deployment) +- Java 25+ JDK (for building from source; Maven wrapper included) -TradeStreamEE gets its speed by removing the middleman. We swapped out heavy, traditional methods (REST/JSON) for 'Mechanical Sympathy', an approach that respects the underlying hardware to squeeze out maximum efficiency. +## Quick Start -### 1\. What is Binary Encoding? +### Side-by-Side JVM Comparison (Primary Use Case) -Computers do not natively understand text; they understand bits. - -* **Text Encoding (JSON/XML):** Easy for humans (`{"price": 100}`), but expensive for computers. The CPU must parse every character, handle whitespace, and convert strings to numbers. This burns CPU cycles and creates massive amounts of temporary memory "garbage." -* **Binary Encoding:** Stores data exactly as the machine represents it in memory (e.g., `100` is stored as 4 raw bytes). No parsing is required. This results in **deterministic latency** and significantly reduced CPU usage. - -### 2\. Aeron (The Transport) - -[Aeron](https://aeron.io/) is a peer-to-peer, broker-less transport protocol designed for **ultra-low latency** applications. +```bash +./start-comparison.sh all +``` -* **Broker-Less:** There is no central server "middleman." The Publisher sends data directly to the Subscriber's memory address. -* **IPC (Inter-Process Communication):** When components run on the same machine (like in this demo), Aeron bypasses the network stack entirely, writing data directly to shared memory (RAM). +Deploys both ZGC and G1GC clusters (3 instances each) plus a full monitoring stack (Prometheus, Grafana, Loki). -### 3\. SBE (Simple Binary Encoding) +| Endpoint | URL | +|:------------|:----------------------------------------| +| ZGC Cluster | http://localhost:8080/trader-stream-ee/ | +| G1 Cluster | http://localhost:9080/trader-stream-ee/ | +| Grafana | http://localhost:3000 (admin/admin) | +| Prometheus | http://localhost:9090 | -[SBE](https://github.com/real-logic/simple-binary-encoding) is the standard for high-frequency financial trading (FIX SBE). It serves as the **Presentation Layer**, defining how business data (Trades, Quotes) is structured inside the Aeron buffers. +Other options: -#### **How SBE Works** +```bash +./start-comparison.sh # Clusters only, no monitoring +./start-comparison.sh direct # DIRECT ingestion mode (GC stress test) +./start-comparison.sh all direct # Full deployment, GC stress mode +./stop-comparison.sh # Tear everything down +``` -Unlike JSON, where you just write data, SBE is **Schema-Driven**. This ensures strict structure and maximum speed. +### Single-JVM Testing -1. **Define the Schema (`market-data.xml`):** You define your messages in XML. This acts as the contract between Publisher and Subscriber. - ```xml - - - - - ``` -2. **Generate Code:** During the build process (`mvn generate-sources`), the **SbeTool** reads the XML and generates Java classes (Encoders and Decoders). -3. **Zero-Copy Encoding/Decoding:** - * **The Flyweight Pattern:** The generated Java classes are "Flyweights." They do not hold data themselves. Instead, they act as a "window" over the raw byte buffer. - * **No Allocation:** When we read a Trade message, **we do not create a `Trade` object**. We simply move the "window" to the correct position in memory and read the `long` value for price. This generates **zero garbage** for the Garbage Collector to clean up. +```bash +./start.sh azul-aeron # Azul Zulu 25 (ZGC) + Aeron (peak performance) +./start.sh standard-direct # Eclipse Temurin 25 (G1GC) + Direct (baseline) +./start.sh azul-direct # ZGC with high allocation +./start.sh standard-aeron # G1GC with Aeron +``` +Cluster modes (3 instances + Traefik LB + Hazelcast): +```bash +./start.sh cluster # ZGC + Aeron cluster +./start.sh cluster-standard # G1GC + Aeron cluster +./start.sh cluster-direct # ZGC + Direct cluster +./start.sh cluster-dynamic 5 # Scale to N instances on the fly +``` -## 🚀 The Rationale: Why This Project Exists +Utilities: `logs`, `status`, `stop`, `clean` -Enterprise Java applications often struggle with two competing requirements: +## Architecture Overview -1. **High Throughput:** Ingesting massive data streams (IoT, Financial Data). -2. **Low Latency:** Processing that data without "Stop-the-World" pauses. +``` +MarketDataPublisher (synthetic) + | SBE binary encode (off-heap) + v +Aeron IPC (shared memory, stream 1001) + | + v +MarketDataFragmentHandler (SBE flyweight decode, zero-copy) + | + +---> MatchingEngine (order matching, execution) + +---> BarAggregator (OHLCV bars, ta4j indicators) + +---> MarketDataBroadcaster (JSON, 1:50 sampling) + | + +---> Hazelcast ITopic (cluster fan-out) + | + v + WebSocket (/market-data, /executions, /indicators, /sla) + | + v + Browser Dashboard +``` -Standard JVMs (using G1GC or ParallelGC) often "hiccup" under high load, causing UI freezes or missed SLAs. **TradeStreamEE** proves that by combining a modern, broker-less transport (**Aeron**) with a pauseless runtime (**Azul C4**), standard Jakarta EE applications can achieve microsecond-level latency and massive throughput. +**GC monitoring runs in parallel:** `GCPauseMonitor` registers as a JMX `NotificationListener` on all `GarbageCollectorMXBean` instances, records every pause duration, computes percentiles (P50/P95/P99/P999), and pushes SLA violation events to WebSocket clients. -### The "A/B" Comparison +### Ingestion Modes -This project includes built-in tools to benchmark "The Old Way" vs. "The New Way": +Configurable via `TRADER_INGESTION_MODE` (`AERON` or `DIRECT`). -* **Scenario A (Baseline):** Standard OpenJDK + Naive String Processing. -* **Scenario B (Optimized):** Azul Platform Prime + Aeron IPC + Zero-Copy SBE. +| Mode | Path | Allocation | Purpose | +|:----------------|:-----------------------------------------------------------------------|:---------------|:--------------------------------------------------| +| AERON (default) | Publisher -> SBE binary -> Aeron IPC -> FragmentHandler -> Broadcaster | Low (off-heap) | Production-grade zero-copy pipeline | +| DIRECT | Publisher -> JSON + 1KB padding -> Broadcaster | High (on-heap) | Stress-test GC behavior under allocation pressure | +### Domain Model +``` +MatchingEngine + |-- PriceTimePriorityMatcher (price-time priority algorithm) + |-- StopOrderTracker (stop, stop-limit, trailing-stop promotion) + |-- IcebergOrderHandler (display quantity management) + |-- OrderBook (NavigableMap>) + | + +--> Execution --> PositionTracker --> RiskEngine + | | |-- Historical VaR + | | |-- Parametric VaR + | | |-- Stress scenarios (sealed interface) + | | +-- Exposure summary + | | + | +---> PortfolioService + | |-- NAV tracking + | |-- Sharpe ratio, max drawdown, win rate + | +-- Rebalance plan generation + | + +--> BarAggregator --> IndicatorService + |-- SMA, EMA, RSI, MACD, Bollinger Bands, ATR + +-- ta4j integration +``` -## 🏗️ Technical Architecture +`Order` and `Price` are Java records. `Price` uses fixed-point arithmetic (`long` ticks at 1/10000 scale) to avoid floating-point issues in the matching hot path. `StressScenario` is a sealed interface with five record implementations (`FlashCrash`, `VolatilitySpike`, `LiquidityFreeze`, `CorrelationBreakdown`, `InterestRateShock`). -The application implements a **Hybrid Architecture**: +## Tech Stack -1. **Ingestion Layer (Broker-less):** - * Uses **Aeron IPC** (Inter-Process Communication) via an Embedded Media Driver. - * Bypasses the network stack for ultra-low latency between components. -2. **Serialization Layer (Zero-Copy):** - * Uses **Simple Binary Encoding (SBE)**. - * Decodes messages directly from memory buffers (Flyweight pattern) without allocating Java Objects, reducing GC pressure. -3. **Application Layer (Jakarta EE 11):** - * **Payara Micro 7** serves as the container. - * **CDI** manages the lifecycle of the Aeron Publisher and Subscriber. - * **WebSockets** push updates to the browser. -4. **Runtime Layer:** - * **Azul Platform Prime** uses the **C4 Collector** to clean up the "garbage" created by the WebSocket layer concurrently, ensuring a flat latency profile. +| Component | Technology | Version | +|:-------------------|:------------------------------------------------|:--------------------------------------------------------------| +| Language | Java 25 | Records, pattern matching, virtual threads, sealed interfaces | +| Runtime | Jakarta EE 11 / Payara Micro 7 | CDI, WebSocket, REST, Concurrency 3.1 | +| Transport | Aeron | 1.50.0 (IPC shared memory, kernel bypass) | +| Serialization | SBE (Simple Binary Encoding) | 1.34.0 (FIX standard, flyweight decoders) | +| Technical Analysis | ta4j | 0.22.4 | +| Clustering | Hazelcast | 5.5.0 (distributed topics, atomic counters) | +| GC Comparison | Azul Zulu 25 (ZGC) vs Eclipse Temurin 25 (G1GC) | | +| Frontend | HTML5 + Chart.js + vanilla JS | No build tools, no frameworks | +| Observability | Prometheus + Grafana + Loki + JFR | | +| Build | Maven (wrapper) + Docker | Multi-stage builds | +## Project Structure +```text +src/main/java/fish/payara/trader/ + aeron/ Aeron MediaDriver, Publisher, Subscriber, FragmentHandler + analysis/ BarAggregator, IndicatorService (ta4j bridge) + concurrency/ VirtualThreadExecutor qualifier, ManagedExecutorDefinition + demo/ DemoPresetService, scenario orchestration + dto/ REST response objects + gc/ GCStatsService, GCStats (JMX MXBean collection) + impact/ BusinessImpactCalculator (GC pauses -> dollar impact) + jfr/ Custom JFR events, recording management + matching/ + book/ PriceTimeOrderBook, OrderBook (NavigableMap-based) + config/ MatchingConfig + engine/ MatchingEngine (central orchestrator) + exception/ Matching exceptions + history/ ExecutionHistory (circular buffer, query API) + model/ Order, Execution, Price, Side, OrderType, OrderStatus (records/enums) + position/ PositionService, PositionTracker (ConcurrentHashMap) + jfr/ Matching-specific JFR events + websocket/ ExecutionBroadcaster + monitoring/ GCPauseMonitor, SLAMonitorService, MemoryPressure + portfolio/ PortfolioService (NAV, Sharpe, drawdown, rebalance) + pressure/ MemoryPressureService, AllocationMode, Workload implementations + workload/ 8 CPU/GC stress workloads (TradingMatching, Collection, Compression, etc.) + risk/ + model/ Position, RiskSnapshot, StressScenario (sealed), VarResult + RiskEngine VaR computation, stress testing + RiskConfig Position limits + rest/ 16 JAX-RS resource classes (ApplicationConfig, CorsFilter) + util/ Instance utilities + websocket/ MarketDataBroadcaster (Hazelcast topic + WebSocket fan-out) + +src/main/resources/ + sbe/market-data.xml SBE schema (23 message types, FIX-inspired) + demo-presets.yml 5 demo scenario definitions + microprofile-config.properties + hazelcast-config.xml Hazelcast 5.5.0 (TCP/IP discovery for Docker) + +src/main/webapp/ + index.html Main dashboard (GC charts, market data, demo presets) + trading.html Trading desk (order entry, order book, positions) + comparison.html ZGC vs G1 side-by-side view + presentation.html Conference presentation mode + blog.html Documentation page + health.html Health check + help.html Usage guide + swagger.html API documentation + trading.js Trading desk client logic + pressure-modes.js Shared GC stress scenario selector + +monitoring/ + grafana/ Provisioning configs, dashboards + jmx-exporter/ Prometheus JMX exporter + prometheus/ Prometheus config + loki/ Log aggregation config + promtail/ Log shipping config +``` -## 🛠️ Tech Stack +## REST API + +All endpoints under `/api/`. + +### Trading + +| Method | Path | Description | +|:-------|:---------------------------------|:------------------------| +| POST | `/matching/orders` | Submit an order | +| DELETE | `/matching/orders/{orderId}` | Cancel an order | +| GET | `/matching/orders` | List orders | +| GET | `/matching/orders/book/{symbol}` | Order book snapshot | +| GET | `/matching/order-book/{symbol}` | Order book for a symbol | +| GET | `/matching/positions` | All positions | +| GET | `/matching/positions/{symbol}` | Position for a symbol | +| GET | `/matching/executions` | Execution history | + +### Risk & Portfolio + +| Method | Path | Description | +|:-------|:------------------------|:---------------------------| +| GET | `/risk` | Risk overview | +| GET | `/risk/{symbol}` | Risk snapshot for a symbol | +| GET | `/risk/metrics` | Risk metrics | +| GET | `/risk/var` | Value at Risk | +| GET | `/risk/stress` | Stress test results | +| GET | `/risk/exposure` | Portfolio exposure | +| POST | `/risk/limits/{symbol}` | Set position limits | +| GET | `/portfolio` | Portfolio overview | +| GET | `/portfolio/snapshot` | NAV snapshot | +| GET | `/portfolio/metrics` | Sharpe, drawdown, win rate | +| POST | `/portfolio/rebalance` | Generate rebalance plan | +| POST | `/portfolio/reset` | Reset portfolio state | +| GET | `/analysis/{symbol}` | Technical indicators | + +### Monitoring & GC + +| Method | Path | Description | +|:-------|:-------------------|:-------------------------------| +| GET | `/gc/stats` | GC pause percentiles | +| GET | `/gc/comparison` | Cluster comparison data | +| GET | `/gc/pauses` | Recent pause history | +| GET | `/gc/sla` | SLA violation counts | +| POST | `/gc/reset` | Reset GC statistics | +| POST | `/gc/sla/reset` | Reset SLA counters | +| POST | `/gc/pauses/reset` | Reset pause history | +| GET | `/status` | System status | +| GET | `/status/cluster` | Hazelcast cluster info | +| GET | `/health/check` | Health check | +| GET | `/health/ready` | Readiness probe | +| GET | `/health/live` | Liveness probe | +| GET | `/business/impact` | Business impact of GC pauses | +| GET | `/business/config` | Business impact configuration | +| POST | `/business/reset` | Reset business impact counters | + +### Memory Pressure + +| Method | Path | Description | +|:-------|:------------------------|:------------------------------| +| POST | `/pressure/mode/{mode}` | Set GC stress scenario | +| GET | `/pressure/status` | Current pressure status | +| GET | `/pressure/modes` | Available scenarios and types | + +Memory scenarios: `STEADY_LOAD`, `INTRADAY_POSITION_GROWTH`, `EARNINGS_SPIKE`, `MULTI_VENUE_QUOTE_CHURN`, `LONG_HORIZON_POSITION_BOOK`, `OFF` + +CPU workloads: `TRADING_MATCHING`, `TECHNICAL_ANALYSIS`, `COMPRESSION`, `CRYPTO`, `COLLECTION`, `SERIALIZATION`, `STRING` + +### Demo Presets & JFR + +| Method | Path | Description | +|:-------|:---------------------------------------|:-------------------------------| +| GET | `/demo/presets` | List all demo presets | +| GET | `/demo/preset/{id}` | Get a demo preset | +| POST | `/demo/preset/{id}/start` | Start a demo preset | +| GET | `/demo/execution/{executionId}` | Get execution status | +| POST | `/demo/execution/{id}/step/{n}` | Step through a preset | +| POST | `/demo/execution/{executionId}/cancel` | Cancel a preset execution | +| GET | `/jfr/status` | JFR availability | +| GET | `/jfr/recordings` | List recordings | +| GET | `/jfr/files` | Available .jfr downloads | +| GET | `/jfr/stats` | JFR statistics | +| POST | `/jfr/recording/start` | Start a time-bounded recording | +| POST | `/jfr/recording/stop` | Stop a recording | +| GET | `/jfr/download/{file}` | Download a recording | + +## GC Monitoring & Stress Testing + +### Memory Pressure Scenarios + +`MemoryPressureService` generates controlled allocation pressure with 4 parallel virtual threads. Each scenario targets specific GC algorithm weaknesses. + +| Scenario | Rate | Live Set | What It Tests | +|:---------------------------|:---------|:------------------------|:-------------------------------------| +| STEADY_LOAD | 200 MB/s | 512 MB stable | Baseline young generation collection | +| INTRADAY_POSITION_GROWTH | 150 MB/s | 100 MB to 2 GB over 60s | Mixed collection pause scaling | +| EARNINGS_SPIKE | 300 MB/s | 1 GB (50% survival) | Old gen under high promotion | +| MULTI_VENUE_QUOTE_CHURN | 200 MB/s | 1 GB fragmented | Compaction behavior | +| LONG_HORIZON_POSITION_BOOK | 150 MB/s | 800 MB in old gen | Remembered set scanning overhead | + +Expected behavior: G1GC shows stop-the-world pauses that scale with live set size. ZGC maintains sub-millisecond pauses across all scenarios. + +### JFR Integration + +Java Flight Recorder is OFF by default. Set `JFR_ALWAYS_ON=true` to enable a circular recording (1h/1GB, dumps on exit). Ad-hoc recordings via the REST API always work regardless. 14 custom JFR events correlate domain metrics with JVM behavior: + +Market data: `TradePublished`, `QuotePublished`, `MarketDepthPublished`, `BatchProcessed`, `WebSocketBroadcast` +Pipeline: `SbeEncode`, `SbeDecode`, `BackpressureEvent`, `BurstModeActivated`, `SlaViolation` +Matching: `OrderSubmitted`, `OrderMatched`, `StopTriggered`, `OrderCanceled` + +Capture ad-hoc snapshots from the web UI or via REST: -| Component | Technology | Role | -|:---------------|:----------------------------------------|:----------------------------------------| -| **Runtime** | **Azul Platform Prime** (Zulu Prime 21) | The Pauseless JVM engine. | -| **App Server** | **Payara Micro 7** (Jakarta EE 11) | Cloud-native Jakarta EE runtime. | -| **Transport** | **Aeron** | Low-latency, high-throughput messaging. | -| **Encoding** | **SBE (Simple Binary Encoding)** | Binary serialization (FIX standard). | -| **Frontend** | **HTML5 / Chart.js** | Real-time visualization via WebSockets. | -| **Build** | **Docker / Maven** | Containerized deployment. | +```bash +curl -X POST "http://localhost:8080/api/jfr/recording/start?name=gc-stress&durationSeconds=120" +curl http://localhost:8080/api/jfr/download/gc-stress.jfr -o analysis.jfr +jmc analysis.jfr +``` +## Configuration & Tuning +### Environment Variables -## 🔍 Understanding the Modes +| Variable | Values | Default | Description | +|:------------------------|:---------------------------------|:--------|:------------------------------| +| `TRADER_INGESTION_MODE` | `AERON`, `DIRECT` | `AERON` | Data ingestion architecture | +| `ENABLE_PUBLISHER` | `true`, `false` | - | Enable market data publishing | +| `JFR_ENABLED` | `true`, `false` | `true` | Enable default JFR recording | +| `JVM_TYPE` | `zulu-zgc`, `eclipse-temurin-g1` | - | Label for monitoring | -This demo allows you to switch between two distinct ingestion pipelines to visualize the impact of architectural choices on JVM performance. +### JVM Heap Sizes -### 1\. DIRECT Mode (The "Heavy" Path) +| Deployment | Dockerfiles | Heap | Reason | +|:------------------------|:------------------------------------------------|:-----|:------------------------| +| Single instance | `Dockerfile`, `Dockerfile.standard` | 8 GB | Maximum throughput | +| Clustered (3 instances) | `Dockerfile.scale`, `Dockerfile.scale.standard` | 4 GB | ~12 GB per cluster | +| Workshop (1+1) | `docker-compose-workshop.yml` | 2 GB | 4 GB total, laptop host | -**Goal:** Simulate a standard, naive enterprise application with high object allocation rates. -**Runtime:** Standard OpenJDK (Eclipse Temurin 21) with G1GC. +### Notable JVM Flags (Both) -**Data Flow:** +`AlwaysPreTouch`, `UseTransparentHugePages`, `UseStringDeduplication`, `OptimizeStringConcat`, `UseContainerSupport`, `MaxRAMPercentage=75.0`, `--add-opens` for Aeron's `Unsafe` usage (`jdk.internal.misc`, `sun.nio.ch`, `java.nio`) -```mermaid -graph TD - classDef purple fill:#667eea,stroke:#4a5be7,stroke-width:2px,color:#ffffff,font-weight:bold; - classDef red fill:#dc3545,stroke:#c82333,stroke-width:2px,color:#ffffff,font-weight:bold; - classDef lightgray fill:#f8f9fa,stroke:#ced4da,stroke-width:1px,color:#333333; - classDef darkgray fill:#6c757d,stroke:#5a6268,stroke-width:1px,color:#ffffff; +G1GC instances add: `G1HeapRegionSize=16m`, `MaxGCPauseMillis=10`, `ParallelGCThreads=12`, `ConcGCThreads=4` - A[Publisher]:::purple -->|Generates POJOs| B(JSON Builder):::lightgray - B -->|Large String Allocation| C(Heavy JSON):::red - C -->|Direct Method Call| D[Broadcaster]:::purple - D -->|WebSocket Payload > 1KB| E[Browser]:::darkgray +## Testing - linkStyle 0 stroke:#667eea,stroke-width:2px; - linkStyle 1 stroke:#dc3545,stroke-width:2px; - linkStyle 2 stroke:#667eea,stroke-width:2px; - linkStyle 3 stroke:#764ba2,stroke-width:2px; +```bash +./test.sh quick # Unit tests only (~30 seconds) +./test.sh full # Unit + integration tests +./test.sh unit # Unit tests +./test.sh integration # Integration tests +./test.sh coverage # Generate JaCoCo report +./test.sh benchmarks # JMH benchmarks ``` -1. **Publisher:** Generates synthetic market data as standard Java Objects. -2. **Allocation:** Immediately converts data to a JSON `String` using `StringBuilder` (high allocation). -3. **Artificial Load:** Wraps the JSON in a large "envelope" with 1KB of padding to stress the Garbage Collector. -4. **Transport:** Direct method call to `MarketDataBroadcaster`. -5. **WebSocket:** Pushes the heavy JSON string to the browser. -6. **Browser:** Unwraps the payload and renders the chart. - -**Performance Characteristics:** - -* **High Allocation Rate:** Gigabytes of temporary String objects created per second. -* **GC Pressure:** Frequent "Stop-the-World" pauses from G1GC lead to "jitter" in the UI charts. - -### 2\. AERON Mode (The "Optimized" Path) - -**Goal:** Simulate a low-latency financial pipeline using off-heap memory and zero-copy semantics. -**Runtime:** Azul Platform Prime (Zulu Prime 21) with C4 Pauseless GC. +Maven commands: -**Data Flow:** - -```mermaid -graph TD - classDef green fill:#28a745,stroke:#218838,stroke-width:2px,color:#ffffff,font-weight:bold; - classDef blue fill:#007bff,stroke:#0069d9,stroke-width:2px,color:#ffffff,font-weight:bold; - classDef purple fill:#667eea,stroke:#4a5be7,stroke-width:2px,color:#ffffff,font-weight:bold; - classDef darkgray fill:#6c757d,stroke:#5a6268,stroke-width:1px,color:#ffffff; - - A[Publisher]:::purple -->|Generates POJOs| B(SBE Encoder):::green - B -->|Binary IPC| C{Aeron Ring Buffer}:::blue - C -->|Shared Memory| D(Fragment Handler):::green - D -->|Decode & JSON| E[Broadcaster]:::purple - E -->|WebSocket Payload| F[Browser]:::darkgray - - linkStyle 0 stroke:#667eea,stroke-width:2px; - linkStyle 1 stroke:#28a745,stroke-width:2px; - linkStyle 2 stroke:#007bff,stroke-width:2px; - linkStyle 3 stroke:#28a745,stroke-width:2px; - linkStyle 4 stroke:#667eea,stroke-width:2px; +```bash +./mvnw test # Unit tests (JaCoCo enforced) +./mvnw verify # Unit + integration (Failsafe) +./mvnw clean package -Pquick-test # Exclude integration/load/benchmark +./mvnw clean verify -Pcoverage # CI coverage report (relaxed thresholds) ``` -1. **Publisher:** Generates synthetic market data. -2. **Encoding:** Encodes data into a compact binary format using **SBE**. - * *Zero-Copy:* Writes directly to an off-heap direct buffer. -3. **Transport (Aeron):** Publishes the binary message to the **Aeron IPC** ring buffer. - * *Kernel Bypass:* Data moves via shared memory, avoiding the OS network stack. -4. **Subscriber (Fragment Handler):** Reads the binary message using SBE "Flyweights" (reusable view objects). - * *Zero-Allocation:* No new Java objects are created during decoding. -5. **Transformation:** Converts the binary data to a compact, flat JSON string (minimal allocation). -6. **WebSocket:** Pushes the lightweight JSON to the browser. +Coverage gates (default): 70% instruction, 60% branch, 80% class. -**Performance Characteristics:** +Test stack: JUnit 5.13.4 + Mockito 5.20.0 + AssertJ 3.27.6. Tests instantiate objects directly with field-injected mocks. No CDI containers in unit tests. -* **Low Allocation:** Almost no garbage generated in the ingestion hot-path. -* **Pauseless:** Azul C4 collector handles the WebSocket strings concurrently, maintaining a flat latency profile. -* **High Throughput:** Aeron IPC handles millions of messages/sec with sub-microsecond latency. +## Building +```bash +./mvnw clean package # Full build with tests +./mvnw clean package -DskipTests # Build without tests +``` +Build pipeline: SBE code generation (`exec-maven-plugin` against `market-data.xml`) -> compile (Java 25) -> WAR packaging -> test -> JaCoCo report -> Spotless format check. -## 🚦 Quick Start: The Comparison Matrix +Maven wrapper included. -The `start.sh` script provides commands to run the TradeStreamEE application in various configurations, allowing for a comprehensive comparison of JVM and architectural performance. +## Docker -| Scenario | Command | JVM | Ingestion Architecture | Goal | -|:--------------------------------|:-----------------------------|:--------------------|:-----------------------|:----------------------------------------------------------------------| -| **1. Modern Stack** | `./start.sh azul-aeron` | Azul Prime (C4) | Aeron (Optimized) | Demonstrate peak performance: Pauseless GC + Zero-Copy Transport. | -| **2. Legacy Baseline** | `./start.sh standard-direct` | Standard JDK (G1GC) | Direct (Heavy) | Establish the baseline: High allocation on G1GC. Expect jitter. | -| **3. Fixing Legacy Code** | `./start.sh azul-direct` | Azul Prime (C4) | Direct (Heavy) | Show how C4 can stabilize a high-allocation app without code changes. | -| **4. Optimizing Standard Java** | `./start.sh standard-aeron` | Standard JDK (G1GC) | Aeron (Optimized) | See if architectural optimization helps G1GC performance. | +Four Dockerfiles for the JVM comparison matrix: -### Utilities -* `./start.sh logs` - View live logs -* `./start.sh stop` - Stop containers -* `./start.sh clean` - Deep clean (remove volumes/images) +| Dockerfile | Runtime | GC | Cluster | +|:----------------------------|:-------------------|:-----|:--------------------------| +| `Dockerfile` | Azul Zulu 25 | ZGC | No | +| `Dockerfile.standard` | Eclipse Temurin 25 | G1GC | No | +| `Dockerfile.scale` | Azul Zulu 25 | ZGC | Yes (Hazelcast + Traefik) | +| `Dockerfile.scale.standard` | Eclipse Temurin 25 | G1GC | Yes (Hazelcast + Traefik) | +All use multi-stage builds: compile and runtime on JDK 25, copy WAR to runtime image. Payara Micro 7.2026.5 downloaded at build time. +Seven docker-compose files: `docker-compose.yml` (single ZGC), `docker-compose-standard.yml` (single G1), `docker-compose-scale.yml` (dynamic cluster), `docker-compose-c4.yml` (3-instance ZGC + Traefik + JMX exporter), `docker-compose-g1.yml` (3-instance G1 + Traefik + JMX exporter), `docker-compose-workshop.yml` (laptop: 1 ZGC + 1 G1, no monitoring stack), `docker-compose-monitoring.yml` (Prometheus + Grafana + Loki + Promtail). -## ⚙️ Configuration & Tuning +## Design Patterns -You can tweak the performance characteristics via `docker-compose.yml` or the `.env` file (if created). +- **Flyweight** - SBE decoders wrap DirectBuffer at different offsets. No allocation per message. +- **Sealed Interface** - `StressScenario` with five record implementations. Pattern matching dispatches stress tests. +- **Strategy** - `MatchingStrategy` interface with `PriceTimePriorityMatcher` implementation. +- **Observer** - JMX `NotificationListener` on GC beans. JFR events as telemetry observation layer. +- **CDI Qualifier** - `@VirtualThreadExecutor` injects a virtual-thread-based `ManagedExecutorService` via `@ManagedExecutorDefinition(virtual = true)`. -### Ingestion Modes (`TRADER_INGESTION_MODE`) +## SBE Wire Protocol -Controls how data moves from the Publisher to the Processor. +`market-data.xml` defines 23 FIX-inspired message types. Code generated at build time into `fish.payara.trader.sbe`. All prices use `int64` fixed-point (multiply by 10000 for decimal). Currently 5 message types are decoded in the ingestion path: Trade, Quote, MarketDepth, OrderAck, Heartbeat. The remaining 18 exist as pre-built infrastructure for the full trading lifecycle. -* `AERON` (Default): Uses the high-speed binary ring buffer. -* `DIRECT`: Bypasses Aeron; generates Strings directly in the Publisher loop. Useful for isolating Transport vs. GC overhead. +## Key Design Decisions -### JVM Tuning (Azul Prime) +1. **Intentional garbage generation** - JSON construction via `StringBuilder` in the broadcast path is deliberately unoptimized. This creates measurable allocation pressure to demonstrate GC behavior differences. -The `Dockerfile` is pre-configured with best practices for the C4 collector: +2. **1:50 sampling** - Only every 50th message reaches the browser. All messages still flow through the SBE decoder and matching engine. The browser gets 600-2000 updates/sec, not 30,000-100,000. -```dockerfile -ENV JAVA_OPTS="-Xms2g -Xmx2g -XX:+AlwaysPreTouch -Djava.net.preferIPv4Stack=true" -``` +3. **In-memory state** - Order books, positions, execution history, and bar data live in ConcurrentHashMaps and NavigableMaps. No database. Restarting loses all state. `persistence.xml` exists but is unused. -* **Note:** We purposefully **do not** use `-XX:+UseZGC` in the optimized image, as C4 is the native collector for Azul Prime. +4. **Hazelcast for cluster fan-out** - In multi-instance mode, market data published on one instance is distributed to all instances via a Hazelcast `ITopic`. WebSocket clients on any instance receive all messages. +5. **Burst pattern** - The publisher runs 500 base iterations (3 messages each) per cycle with 5us parks. Burst multiplier spikes to 5x during "news events" (seconds 20-25 of each minute) and 3x during "market close" (seconds 45-50). Circuit breaker stops after 50 consecutive failures. +## Trading Terms Glossary -## 📊 Monitoring & Metrics +### Market Data -The application exposes a lightweight REST endpoint for health checks and internal metrics. +| Term | Definition | +|:------------------------------|:--------------------------------------------------------------------------------------------------------------------| +| High-Frequency Trading (HFT) | Automated strategies executing thousands of orders per second. Sub-millisecond latency is a hard requirement. | +| Order Book / Limit Order Book | Data structure containing all buy and sell orders, organized by price level. Updated thousands of times per second. | +| Bid / Ask | Highest price a buyer will pay / lowest price a seller will accept. | +| Bid-Ask Spread | Difference between best bid and best ask. Cost of immediate execution. | +| L1 / L2 / L3 Data | Level 1: best bid/ask. Level 2: multiple price levels with depth. Level 3: individual orders. | +| Crossed Market | Best bid exceeds best ask. Indicates data error or system malfunction. | -**Check Status:** +### Memory & GC -```bash -./start.sh status -``` - -**Sample Output:** - -```json -{ - "application": "TradeStreamEE", - "subscriber": "Channel: aeron:ipc, Stream: 1001, Running: true", - "publisher": { "messagesPublished": 1543021 }, - "runtime": { - "gcType": "GPGC", - "freeMemory": "1450 MB" - } -} -``` +| Term | Definition | +|:----------------------|:----------------------------------------------------------------------------------------------| +| Allocation Rate | Speed of object creation (bytes/sec). Higher rates increase GC pressure. | +| Live Set | Total size of reachable objects. G1's mixed collection pause time scales with live set size. | +| Concurrent Collection | GC running alongside application threads. ZGC is concurrent; G1GC uses stop-the-world pauses. | +| Promotion | Moving objects from young to old generation after surviving multiple collections. | +| Remembered Set | G1 data structure tracking old-to-young references. Scanning adds overhead. ZGC has none. | +| Heap Fragmentation | Free space scattered in small chunks. G1 pauses to compact; ZGC compacts concurrently. | +### Serialization & Performance +| Term | Definition | +|:----------------|:------------------------------------------------------------------------------------------| +| SBE | Simple Binary Encoding. FIX-standard binary format for ultra-low-latency trading systems. | +| Aeron IPC | Shared-memory messaging transport. Bypasses kernel networking. Sub-microsecond latency. | +| Flyweight | Reusable objects over byte buffers. SBE decoders are flyweights, not allocated objects. | +| Zero-Copy | Processing data without copying between buffers. Both Aeron IPC and SBE use zero-copy. | +| P50 / P95 / P99 | Percentile latencies. 50% / 95% / 99% of requests complete faster than this value. | +| Jitter | Latency variability. High jitter (unpredictable spikes) is unacceptable for HFT. | -## 📂 Project Structure +## References -```text -src/main/ -├── java/fish/payara/trader/ -│ ├── aeron/ # Aeron Publisher, Subscriber, FragmentHandler -│ ├── sbe/ # Generated SBE Codecs (Flyweights) -│ ├── websocket/ # Jakarta WebSocket Endpoint -│ └── rest/ # Status Resource -├── resources/sbe/ -│ └── market-data.xml # SBE Schema Definition -└── webapp/ - └── index.html # Dashboard UI (Chart.js + WebSocket) -``` +- [ZGC Garbage Collector](https://openjdk.org/jeps/333) +- [Azul Zulu JDK](https://www.azul.com/downloads/?package=jdk) +- [Eclipse Temurin JDK](https://adoptium.net/) +- [Aeron Messaging](https://aeron.io/) +- [Simple Binary Encoding](https://github.com/Real-Logic-FIX/Simple-Binary-Encoding) +- [Payara Platform](https://www.payara.fish/) +- [ta4j Technical Analysis](https://ta4j.github.io/) -## 📜 License +## License -This project is a reference implementation provided for demonstration purposes. \ No newline at end of file +This project is a reference implementation showing low-latency Java techniques for educational purposes. diff --git a/demo-quick-start.sh b/demo-quick-start.sh new file mode 100755 index 0000000..dfe4214 --- /dev/null +++ b/demo-quick-start.sh @@ -0,0 +1,286 @@ +#!/bin/bash + +set -e + +USAGE=" +Usage: ./demo-quick-start.sh [OPTIONS] + +Quick demo launcher for TradeStreamEE. Uses pre-built images for fast startup. + +OPTIONS: + all Start both clusters with monitoring (default) + apps Start clusters only (no monitoring) + c4 Start C4 cluster only + g1 Start G1 cluster only + stop Stop all demo services + help Show this help message + +EXAMPLES: + ./demo-quick-start.sh # Start everything (recommended for demos) + ./demo-quick-start.sh apps # Start clusters only, no monitoring + ./demo-quick-start.sh stop # Stop all services +" + +# Default mode +MODE="${1:-all}" + +# Color output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_header() { + echo "" + echo "================================================" + echo " TradeStreamEE - Quick Demo Launcher" + echo "================================================" + echo "" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +print_info() { + echo -e "${BLUE}ℹ${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +# Check if Docker is running +check_docker() { + if ! docker info &> /dev/null; then + print_error "Docker is not running. Please start Docker and try again." + exit 1 + fi + print_success "Docker is running" +} + +# Check required images +check_images() { + print_info "Checking for required Docker images..." + + C4_IMAGE=$(docker images -q trader-stream-ee:c4 2>/dev/null) + G1_IMAGE=$(docker images -q trader-stream-ee:g1 2>/dev/null) + + if [ -z "$C4_IMAGE" ] || [ -z "$G1_IMAGE" ]; then + print_warning "Pre-built images not found. Building now (this takes 2-3 minutes)..." + build_images + else + print_success "Pre-built images found" + fi +} + +# Build images if needed +build_images() { + print_info "Building Docker images..." + docker build -t trader-stream-ee:c4 -f Dockerfile.scale . + docker build -t trader-stream-ee:g1 -f Dockerfile.scale.standard . + print_success "Images built successfully" +} + +# Create networks +create_networks() { + print_info "Creating Docker networks..." + docker network create trader-network 2>/dev/null || echo "Network trader-network exists" + docker network create monitoring 2>/dev/null || echo "Network monitoring exists" + print_success "Networks ready" +} + +# Download JMX Exporter +download_jmx_exporter() { + if [ ! -f "monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar" ]; then + print_info "Downloading JMX Exporter..." + mkdir -p monitoring/jmx-exporter + wget -q -O monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar \ + https://repo1.maven.org/maven2/io/prometheus/jmx/jmx_prometheus_javaagent/1.0.1/jmx_prometheus_javaagent-1.0.1.jar + print_success "JMX Exporter downloaded" + fi +} + +# Start monitoring stack +start_monitoring() { + print_info "Starting monitoring stack..." + docker compose -f docker-compose-monitoring.yml up -d + print_success "Monitoring stack started" + sleep 5 +} + +# Start C4 cluster +start_c4() { + print_info "Starting C4 cluster (ports 8080-8083)..." + docker compose -f docker-compose-c4.yml up -d + print_success "C4 cluster started" +} + +# Start G1 cluster +start_g1() { + print_info "Starting G1 cluster (ports 9080-9083)..." + docker compose -f docker-compose-g1.yml up -d + print_success "G1 cluster started" +} + +# Stop all services +stop_all() { + print_info "Stopping all demo services..." + + # Stop clusters + docker compose -f docker-compose-c4.yml down 2>/dev/null || true + docker compose -f docker-compose-g1.yml down 2>/dev/null || true + + # Stop monitoring if requested + if [ "$MODE" = "stop" ]; then + docker compose -f docker-compose-monitoring.yml down 2>/dev/null || true + fi + + print_success "All services stopped" +} + +# Wait for clusters to be ready +wait_for_ready() { + print_info "Waiting for clusters to initialize (this may take 30-60 seconds)..." + + local c4_ready=false + local g1_ready=false + local attempts=0 + local max_attempts=30 + + while [ $attempts -lt $max_attempts ]; do + if [ "$c4_ready" = false ]; then + if curl -s http://localhost:8080/trader-stream-ee/api/status &> /dev/null; then + c4_ready=true + print_success "C4 cluster is ready" + fi + fi + + if [ "$g1_ready" = false ]; then + if curl -s http://localhost:9080/trader-stream-ee/api/status &> /dev/null; then + g1_ready=true + print_success "G1 cluster is ready" + fi + fi + + if [ "$c4_ready" = true ] && [ "$g1_ready" = true ]; then + break + fi + + sleep 2 + attempts=$((attempts + 1)) + echo -n "." + done + echo "" + + if [ $attempts -eq $max_attempts ]; then + print_warning "Clusters are taking longer than expected. Check logs with: docker compose logs" + fi +} + +# Show access information +show_access_info() { + echo "" + echo "================================================" + echo " Demo Ready!" + echo "================================================" + echo "" + echo "Application Endpoints:" + echo " C4 Cluster: http://localhost:8080/trader-stream-ee/" + echo " G1 Cluster: http://localhost:9080/trader-stream-ee/" + echo " Comparison: http://localhost:8080/trader-stream-ee/comparison.html" + echo " Health Check: http://localhost:8080/trader-stream-ee/health.html" + echo "" + + # Check if monitoring is running + if docker ps | grep -q "trader-prometheus"; then + echo "Monitoring:" + echo " Grafana: http://localhost:3000 (admin/admin)" + echo " Prometheus: http://localhost:9090" + echo "" + fi + + echo "Quick Demo Commands:" + echo " # Apply stress test to both clusters" + echo " curl -X POST http://localhost:8080/trader-stream-ee/api/pressure/mode/EARNINGS_SPIKE" + echo " curl -X POST http://localhost:9080/trader-stream-ee/api/pressure/mode/EARNINGS_SPIKE" + echo "" + echo " # Check GC stats" + echo " curl http://localhost:8080/trader-stream-ee/api/gc/stats" + echo " curl http://localhost:9080/trader-stream-ee/api/gc/stats" + echo "" + echo "To stop: ./demo-quick-start.sh stop" + echo "" +} + +# Main execution +main() { + case $MODE in + help|--help|-h) + echo "$USAGE" + exit 0 + ;; + stop) + print_header + check_docker + stop_all + exit 0 + ;; + apps) + START_MONITORING=false + ;; + c4) + print_header + check_docker + check_images + create_networks + download_jmx_exporter + start_c4 + echo "" + print_success "C4 Cluster running on http://localhost:8080/trader-stream-ee/" + exit 0 + ;; + g1) + print_header + check_docker + check_images + create_networks + download_jmx_exporter + start_g1 + echo "" + print_success "G1 Cluster running on http://localhost:9080/trader-stream-ee/" + exit 0 + ;; + all|"") + START_MONITORING=true + ;; + *) + echo "Unknown mode: $MODE" + echo "$USAGE" + exit 1 + ;; + esac + + print_header + check_docker + check_images + create_networks + download_jmx_exporter + + if [ "$START_MONITORING" = true ]; then + start_monitoring + fi + + start_c4 + start_g1 + + wait_for_ready + show_access_info +} + +main "$@" diff --git a/docker-compose-c4.yml b/docker-compose-c4.yml new file mode 100644 index 0000000..1e79822 --- /dev/null +++ b/docker-compose-c4.yml @@ -0,0 +1,180 @@ +# JVM Configuration Notes: +# ========================== +# The JAVA_OPTS environment variables below OVERRIDE the ENV JAVA_OPTS defined in +# Dockerfile.scale. These are the authoritative flags used for cluster deployments. +# If you update flags here, ensure consistency across all instances (c4-1, c4-2, c4-3). + +services: + traefik-c4: + image: traefik:v3.6.12 + container_name: trader-traefik-c4 + command: + - "--api.dashboard=true" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.constraints=Label(`cluster`, `c4`)" + - "--entrypoints.web.address=:80" + - "--metrics.prometheus=true" + - "--metrics.prometheus.addServicesLabels=true" + ports: + - "8080:80" + - "8084:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - trader-network + - monitoring + labels: + - "jvm_type=zulu-zgc" + restart: unless-stopped + + trader-stream-c4-1: + image: trader-stream-ee:c4 + build: + context: . + dockerfile: Dockerfile.scale + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-c4-1 + hostname: trader-stream-c4-1 + ports: + - "8081:8080" + - "9010:9010" # JMX Exporter + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=c4-instance-1 + - HAZELCAST_MEMBER_NAME=trader-stream-c4-1 + - ENABLE_PUBLISHER=true + - JVM_TYPE=zulu-zgc + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseZGC + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/c4-1:/opt/payara/logs + - ./monitoring/gc-logs/c4-1:/opt/payara/gc-logs + - ./monitoring/recordings/c4-1:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=c4" + - "traefik.http.routers.trader-stream-c4.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-c4.loadbalancer.server.port=8080" + - "jvm_type=zulu-zgc" + - "instance=c4-1" + networks: + - trader-network + - monitoring + restart: unless-stopped + + trader-stream-c4-2: + image: trader-stream-ee:c4 + container_name: trader-stream-c4-2 + hostname: trader-stream-c4-2 + ports: + - "8082:8080" + - "9011:9010" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=c4-instance-2 + - HAZELCAST_MEMBER_NAME=trader-stream-c4-2 + - ENABLE_PUBLISHER=true + - JVM_TYPE=zulu-zgc + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseZGC + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/c4-2:/opt/payara/logs + - ./monitoring/gc-logs/c4-2:/opt/payara/gc-logs + - ./monitoring/recordings/c4-2:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=c4" + - "traefik.http.routers.trader-stream-c4.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-c4.loadbalancer.server.port=8080" + - "jvm_type=zulu-zgc" + - "instance=c4-2" + networks: + - trader-network + - monitoring + restart: unless-stopped + + trader-stream-c4-3: + image: trader-stream-ee:c4 + container_name: trader-stream-c4-3 + hostname: trader-stream-c4-3 + ports: + - "8083:8080" + - "9012:9010" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=c4-instance-3 + - HAZELCAST_MEMBER_NAME=trader-stream-c4-3 + - ENABLE_PUBLISHER=true + - JVM_TYPE=zulu-zgc + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseZGC + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/c4-3:/opt/payara/logs + - ./monitoring/gc-logs/c4-3:/opt/payara/gc-logs + - ./monitoring/recordings/c4-3:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=c4" + - "traefik.http.routers.trader-stream-c4.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-c4.loadbalancer.server.port=8080" + - "jvm_type=zulu-zgc" + - "instance=c4-3" + networks: + - trader-network + - monitoring + restart: unless-stopped + +networks: + trader-network: + external: true + monitoring: + external: true diff --git a/docker-compose-g1.yml b/docker-compose-g1.yml new file mode 100644 index 0000000..e4f5862 --- /dev/null +++ b/docker-compose-g1.yml @@ -0,0 +1,206 @@ +# JVM Configuration Notes: +# ========================== +# The JAVA_OPTS environment variables below OVERRIDE the ENV JAVA_OPTS defined in +# Dockerfile.scale.standard. These are the authoritative flags used for cluster deployments. +# If you update flags here, ensure consistency across all instances (g1-1, g1-2, g1-3). + +services: + traefik-g1: + image: traefik:v3.6.12 + container_name: trader-traefik-g1 + command: + - "--api.dashboard=true" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.constraints=Label(`cluster`, `g1`)" + - "--entrypoints.web.address=:80" + - "--metrics.prometheus=true" + - "--metrics.prometheus.addServicesLabels=true" + ports: + - "9080:80" + - "9084:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - trader-network + - monitoring + labels: + - "jvm_type=eclipse-temurin-g1" + restart: unless-stopped + + trader-stream-g1-1: + image: trader-stream-ee:g1 + build: + context: . + dockerfile: Dockerfile.scale.standard + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-g1-1 + hostname: trader-stream-g1-1 + ports: + - "9081:8080" + - "9020:9010" # JMX Exporter + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=g1-instance-1 + - HAZELCAST_MEMBER_NAME=trader-stream-g1-1 + - ENABLE_PUBLISHER=true + - JVM_TYPE=eclipse-temurin-g1 + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseG1GC + -XX:G1HeapRegionSize=16m + -XX:MaxGCPauseMillis=10 + -XX:InitiatingHeapOccupancyPercent=40 + -XX:ParallelGCThreads=12 + -XX:ConcGCThreads=4 + -XX:-ExplicitGCInvokesConcurrent + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseStringDeduplication + -XX:+OptimizeStringConcat + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/g1-1:/opt/payara/logs + - ./monitoring/gc-logs/g1-1:/opt/payara/gc-logs + - ./monitoring/recordings/g1-1:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=g1" + - "traefik.http.routers.trader-stream-g1.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-g1.loadbalancer.server.port=8080" + - "jvm_type=eclipse-temurin-g1" + - "instance=g1-1" + networks: + - trader-network + - monitoring + restart: unless-stopped + + trader-stream-g1-2: + image: trader-stream-ee:g1 + container_name: trader-stream-g1-2 + hostname: trader-stream-g1-2 + ports: + - "9082:8080" + - "9021:9010" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=g1-instance-2 + - HAZELCAST_MEMBER_NAME=trader-stream-g1-2 + - ENABLE_PUBLISHER=true + - JVM_TYPE=eclipse-temurin-g1 + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseG1GC + -XX:G1HeapRegionSize=16m + -XX:MaxGCPauseMillis=10 + -XX:InitiatingHeapOccupancyPercent=40 + -XX:ParallelGCThreads=12 + -XX:ConcGCThreads=4 + -XX:-ExplicitGCInvokesConcurrent + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseStringDeduplication + -XX:+OptimizeStringConcat + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/g1-2:/opt/payara/logs + - ./monitoring/gc-logs/g1-2:/opt/payara/gc-logs + - ./monitoring/recordings/g1-2:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=g1" + - "traefik.http.routers.trader-stream-g1.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-g1.loadbalancer.server.port=8080" + - "jvm_type=eclipse-temurin-g1" + - "instance=g1-2" + networks: + - trader-network + - monitoring + restart: unless-stopped + + trader-stream-g1-3: + image: trader-stream-ee:g1 + container_name: trader-stream-g1-3 + hostname: trader-stream-g1-3 + ports: + - "9083:8080" + - "9022:9010" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=g1-instance-3 + - HAZELCAST_MEMBER_NAME=trader-stream-g1-3 + - ENABLE_PUBLISHER=true + - JVM_TYPE=eclipse-temurin-g1 + - JAVA_OPTS=-Xms4g -Xmx4g + -XX:+UseG1GC + -XX:G1HeapRegionSize=16m + -XX:MaxGCPauseMillis=10 + -XX:InitiatingHeapOccupancyPercent=40 + -XX:ParallelGCThreads=12 + -XX:ConcGCThreads=4 + -XX:-ExplicitGCInvokesConcurrent + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=20,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseStringDeduplication + -XX:+OptimizeStringConcat + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -javaagent:/opt/payara/jmx_prometheus_javaagent.jar=9010:/opt/payara/jmx-exporter-config.yml + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/jmx-exporter/jmx_prometheus_javaagent-1.0.1.jar:/opt/payara/jmx_prometheus_javaagent.jar:ro + - ./monitoring/jmx-exporter/jmx-exporter-config.yml:/opt/payara/jmx-exporter-config.yml:ro + - ./monitoring/logs/g1-3:/opt/payara/logs + - ./monitoring/gc-logs/g1-3:/opt/payara/gc-logs + - ./monitoring/recordings/g1-3:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + labels: + - "traefik.enable=true" + - "cluster=g1" + - "traefik.http.routers.trader-stream-g1.rule=PathPrefix(`/`)" + - "traefik.http.services.trader-stream-g1.loadbalancer.server.port=8080" + - "jvm_type=eclipse-temurin-g1" + - "instance=g1-3" + networks: + - trader-network + - monitoring + restart: unless-stopped + +networks: + trader-network: + external: true + monitoring: + external: true diff --git a/docker-compose-monitoring.yml b/docker-compose-monitoring.yml new file mode 100644 index 0000000..bce2738 --- /dev/null +++ b/docker-compose-monitoring.yml @@ -0,0 +1,82 @@ +services: + # Prometheus - Metrics Collection + prometheus: + image: prom/prometheus:latest + container_name: trader-prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - trader-network + - monitoring + restart: unless-stopped + + # Grafana - Visualization + grafana: + image: grafana/grafana:latest + container_name: trader-grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS=grafana-piechart-panel + volumes: + - grafana-data:/var/lib/grafana + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + networks: + - monitoring + restart: unless-stopped + depends_on: + - prometheus + - loki + + # Loki - Log Aggregation + loki: + image: grafana/loki:latest + container_name: trader-loki + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + volumes: + - ./monitoring/loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki-data:/loki + networks: + - monitoring + restart: unless-stopped + + # Promtail - Log Shipper (for Docker logs) + promtail: + image: grafana/promtail:latest + container_name: trader-promtail + command: -config.file=/etc/promtail/config.yml + volumes: + - ./monitoring/promtail/promtail-config.yml:/etc/promtail/config.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock + networks: + - monitoring + restart: unless-stopped + depends_on: + - loki + +volumes: + prometheus-data: + grafana-data: + loki-data: + +networks: + trader-network: + external: true + monitoring: + driver: bridge diff --git a/docker-compose-scale.yml b/docker-compose-scale.yml new file mode 100644 index 0000000..8d559f9 --- /dev/null +++ b/docker-compose-scale.yml @@ -0,0 +1,219 @@ +services: + # Traefik Load Balancer + traefik: + image: traefik:v3.6.5 + container_name: trader-traefik + command: + # API and dashboard + - "--api.dashboard=true" + - "--api.insecure=true" + # Providers + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=trader-stream-ee_trader-network" + # Entrypoints + - "--entrypoints.web.address=:80" + # Access logs + - "--accesslog=true" + # Metrics + - "--metrics.prometheus=true" + # Health check + - "--ping=true" + # Debug logging + - "--log.level=DEBUG" + ports: + - "8080:80" # Application traffic + - "8084:8080" # Traefik dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - trader-network + restart: unless-stopped + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 10s + timeout: 5s + retries: 3 + + # Payara Micro Instance 1 (Publisher enabled for GC stress testing) + trader-stream-1: + image: trader-stream-ee:scale + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile.scale} + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-1 + hostname: trader-stream-1 + ports: + - "8081:8080" # Direct access to instance-1 + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${MODE:-AERON} + - PAYARA_INSTANCE_NAME=instance-1 + - HAZELCAST_MEMBER_NAME=trader-stream-1 + - ENABLE_PUBLISHER=true + labels: + - "traefik.enable=true" + - "traefik.http.routers.trader-stream.rule=PathPrefix(`/`)" + - "traefik.http.routers.trader-stream.entrypoints=web" + - "traefik.http.services.trader-stream.loadbalancer.server.port=8080" + # Health check + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.path=/trader-stream-ee/api/status" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.interval=30s" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.timeout=10s" + # Sticky sessions for WebSocket (optional) + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie=true" + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie.name=trader-session" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + + # Payara Micro Instance 2 (Publisher enabled for GC stress testing) + trader-stream-2: + image: trader-stream-ee:scale + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile.scale} + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-2 + hostname: trader-stream-2 + ports: + - "8082:8080" # Direct access to instance-2 + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${MODE:-AERON} + - PAYARA_INSTANCE_NAME=instance-2 + - HAZELCAST_MEMBER_NAME=trader-stream-2 + - ENABLE_PUBLISHER=true + labels: + - "traefik.enable=true" + - "traefik.http.routers.trader-stream.rule=PathPrefix(`/`)" + - "traefik.http.routers.trader-stream.entrypoints=web" + - "traefik.http.services.trader-stream.loadbalancer.server.port=8080" + # Health check + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.path=/trader-stream-ee/api/status" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.interval=30s" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.timeout=10s" + # Sticky sessions for WebSocket (optional) + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie=true" + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie.name=trader-session" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + + # Payara Micro Instance 3 (Publisher enabled for GC stress testing) + trader-stream-3: + image: trader-stream-ee:scale + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile.scale} + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-3 + hostname: trader-stream-3 + ports: + - "8083:8080" # Direct access to instance-3 + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${MODE:-AERON} + - PAYARA_INSTANCE_NAME=instance-3 + - HAZELCAST_MEMBER_NAME=trader-stream-3 + - ENABLE_PUBLISHER=true + labels: + - "traefik.enable=true" + - "traefik.http.routers.trader-stream.rule=PathPrefix(`/`)" + - "traefik.http.routers.trader-stream.entrypoints=web" + - "traefik.http.services.trader-stream.loadbalancer.server.port=8080" + # Health check + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.path=/trader-stream-ee/api/status" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.interval=30s" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.timeout=10s" + # Sticky sessions for WebSocket (optional) + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie=true" + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie.name=trader-session" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + + # Scalable Payara Micro Instances + # Use this service for dynamic scaling: docker compose -f docker-compose-scale.yml up -d --scale trader-stream=5 + trader-stream: + image: trader-stream-ee:scale + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile.scale} + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + # Note: No container_name or hostname - allows multiple instances + expose: + - "8080" + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${MODE:-AERON} + # Instance name will be auto-generated by Docker (e.g., trader-stream-1, trader-stream-2) + labels: + - "traefik.enable=true" + - "traefik.http.routers.trader-stream.rule=PathPrefix(`/`)" + - "traefik.http.routers.trader-stream.entrypoints=web" + - "traefik.http.services.trader-stream.loadbalancer.server.port=8080" + # Health check + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.path=/trader-stream-ee/api/status" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.interval=30s" + - "traefik.http.services.trader-stream.loadbalancer.healthcheck.timeout=10s" + # Sticky sessions for WebSocket + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie=true" + - "traefik.http.services.trader-stream.loadbalancer.sticky.cookie.name=trader-session" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + deploy: + replicas: 0 # Start with 0, use --scale to set desired count + + # Optional: Hazelcast Management Center for cluster monitoring + # Uncomment to enable + # hazelcast-mc: + # image: hazelcast/management-center:latest + # container_name: hazelcast-mc + # ports: + # - "8082:8080" + # networks: + # - trader-network + # environment: + # - MC_INIT_CMD=./bin/mc-conf.sh cluster add --cluster-name=payara-cluster --member-addresses=trader-stream-1:5701,trader-stream-2:5701,trader-stream-3:5701 + +networks: + trader-network: + driver: bridge diff --git a/docker-compose-standard.yml b/docker-compose-standard.yml index d4967a3..2cf0ceb 100644 --- a/docker-compose-standard.yml +++ b/docker-compose-standard.yml @@ -4,6 +4,8 @@ services: build: context: . dockerfile: Dockerfile.standard + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} container_name: trader-stream-ee ports: - "8080:8080" @@ -11,12 +13,15 @@ services: shm_size: 512m environment: - TRADER_INGESTION_MODE=${MODE:-DIRECT} + volumes: + - ./monitoring/gc-logs/standard:/opt/payara/gc-logs healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/trader-stream-ee/api/status"] interval: 30s timeout: 10s retries: 3 start_period: 60s + restart: unless-stopped networks: - trader-network diff --git a/docker-compose-workshop.yml b/docker-compose-workshop.yml new file mode 100644 index 0000000..79fcc79 --- /dev/null +++ b/docker-compose-workshop.yml @@ -0,0 +1,117 @@ +# Laptop-friendly workshop compose: one ZGC instance + one G1 instance. +# +# Two single-node Hazelcast clusters for live side-by-side collector comparison +# on a laptop. Each JDK runs exactly one Payara Micro instance with its own +# Hazelcast cluster name (zgc vs g1), so there is no cross-discovery. +# +# Ports: +# 8080 Zulu 25 + ZGC (trader-stream-workshop-zgc) +# 9080 Temurin 25 + G1 (trader-stream-workshop-g1) +# +# Heap is 2 GB per instance (4 GB total). Large enough for every scenario in +# workshop/jfr-settings/tradestream-workshop.jfc to fire characteristic GC +# behaviour without swapping on a 16 GB laptop. + +services: + trader-stream-workshop-zgc: + image: trader-stream-ee:workshop-zgc + build: + context: . + dockerfile: Dockerfile.scale + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-workshop-zgc + hostname: trader-stream-zgc-1 + ports: + - "8080:8080" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=workshop-zgc + - HAZELCAST_MEMBER_NAME=trader-stream-zgc-1 + - ENABLE_PUBLISHER=true + - JVM_TYPE=zulu-zgc + - JFR_ALWAYS_ON=true + - JFR_RECORDING_NAME=workshop-zgc + - JAVA_OPTS=-Xms2g -Xmx2g + -XX:+UseZGC + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseContainerSupport + -XX:ErrorFile=/opt/payara/logs/hs_err_pid%p.log + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/logs/workshop-zgc:/opt/payara/logs + - ./monitoring/gc-logs/workshop-zgc:/opt/payara/gc-logs + - ./monitoring/recordings/workshop-zgc:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + + trader-stream-workshop-g1: + image: trader-stream-ee:workshop-g1 + build: + context: . + dockerfile: Dockerfile.scale.standard + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} + container_name: trader-stream-workshop-g1 + hostname: trader-stream-g1-1 + ports: + - "9080:8080" + expose: + - "5701" + shm_size: 512m + environment: + - TRADER_INGESTION_MODE=${TRADER_INGESTION_MODE:-AERON} + - PAYARA_INSTANCE_NAME=workshop-g1 + - HAZELCAST_MEMBER_NAME=trader-stream-g1-1 + - ENABLE_PUBLISHER=true + - JVM_TYPE=eclipse-temurin-g1 + - JFR_ALWAYS_ON=true + - JFR_RECORDING_NAME=workshop-g1 + - JAVA_OPTS=-Xms2g -Xmx2g + -XX:+UseG1GC + -XX:G1HeapRegionSize=8m + -XX:MaxGCPauseMillis=10 + -XX:InitiatingHeapOccupancyPercent=40 + -Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED + --add-opens java.base/sun.nio.ch=ALL-UNNAMED + --add-opens java.base/java.nio=ALL-UNNAMED + -XX:+AlwaysPreTouch + -XX:+UseTransparentHugePages + -XX:+UseContainerSupport + -XX:ErrorFile=/opt/payara/logs/hs_err_pid%p.log + -Djava.net.preferIPv4Stack=true + volumes: + - ./monitoring/logs/workshop-g1:/opt/payara/logs + - ./monitoring/gc-logs/workshop-g1:/opt/payara/gc-logs + - ./monitoring/recordings/workshop-g1:/opt/payara/recordings + - ./workshop/jfr-settings:/opt/payara/jfr-settings:ro + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/trader-stream-ee/api/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - trader-network + restart: unless-stopped + +networks: + trader-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index f908d50..4547faa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,23 +4,28 @@ services: build: context: . dockerfile: Dockerfile + args: + PAYARA_VERSION: ${PAYARA_VERSION:-7.2026.5} container_name: trader-stream-ee ports: - "8080:8080" # Increase shared memory for Aeron IPC transport shm_size: 512m - # JVM options are set in Dockerfile by default (C4 GC with 2g heap) + # JVM options are set in Dockerfile by default (ZGC with 8g heap) # Uncomment below to override with custom settings: # environment: # - JAVA_OPTS=-XX:+UseZGC -Xms4g -Xmx4g -Xlog:gc*:stdout:time,uptime environment: - TRADER_INGESTION_MODE=${MODE:-AERON} + volumes: + - ./monitoring/gc-logs/azul:/opt/payara/gc-logs healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/trader-stream-ee/api/status"] interval: 30s timeout: 10s retries: 3 start_period: 60s + restart: unless-stopped networks: - trader-network diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..40092e2 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# docker-entrypoint.sh +# +# Builds JAVA_OPTS for single-instance Payara Micro deployments. +# Always-on JFR is OFF by default; ad-hoc recordings are produced on demand +# via the /api/jfr REST endpoints. Set JFR_ALWAYS_ON=true to opt in to a +# circular always-on recording. + +set -e + +DEFAULT_OPTS="-Xms8g -Xmx8g -XX:+UseZGC \ +-Xlog:gc*:file=/opt/payara/gc-logs/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \ +-XX:+AlwaysPreTouch -XX:+UseTransparentHugePages \ +-XX:+UseStringDeduplication \ +-XX:+OptimizeStringConcat \ +-Djava.net.preferIPv4Stack=true \ +-Xlog:jfr*=info" + +JFR_RECORDING_NAME="${JFR_RECORDING_NAME:-${RECORDING_NAME:-production}}" + +if [ "${JFR_ALWAYS_ON}" = "true" ]; then + export JAVA_OPTS="${DEFAULT_OPTS} \ +--add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ +--add-opens java.base/sun.nio.ch=ALL-UNNAMED \ +--add-opens java.base/java.nio=ALL-UNNAMED \ +-XX:StartFlightRecording=name=${JFR_RECORDING_NAME},filename=/opt/payara/recordings/recording.jfr,dumponexit=true,maxage=1h,maxsize=1g,method-profiling=normal \ +-XX:FlightRecorderOptions=stackdepth=256" +else + export JAVA_OPTS="${DEFAULT_OPTS} \ +--add-opens java.base/jdk.internal.misc=ALL-UNNAMED \ +--add-opens java.base/sun.nio.ch=ALL-UNNAMED \ +--add-opens java.base/java.nio=ALL-UNNAMED" +fi + +exec "$@" diff --git a/keycloak/imports/jdd-poland-realm.json b/keycloak/imports/jdd-poland-realm.json deleted file mode 100644 index 1bb21f8..0000000 --- a/keycloak/imports/jdd-poland-realm.json +++ /dev/null @@ -1,312 +0,0 @@ -{ - "realm": "jdd-poland", - "enabled": true, - "sslRequired": "none", - "registrationAllowed": false, - "loginWithEmailAllowed": true, - "duplicateEmailsAllowed": false, - "resetPasswordAllowed": true, - "editUsernameAllowed": false, - "bruteForceProtected": true, - "permanentLockout": false, - "maxFailureWaitSeconds": 900, - "minimumQuickLoginWaitSeconds": 60, - "waitIncrementSeconds": 60, - "quickLoginCheckMilliSeconds": 1000, - "maxDeltaTimeSeconds": 43200, - "failureFactor": 5, - "accessTokenLifespan": 300, - "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, - "offlineSessionIdleTimeout": 2592000, - "accessCodeLifespan": 60, - "accessCodeLifespanUserAction": 300, - "accessCodeLifespanLogin": 1800, - "roles": { - "realm": [ - { - "name": "DOCTOR", - "description": "Doctor role with full patient access" - }, - { - "name": "NURSE", - "description": "Nurse role with limited patient access" - }, - { - "name": "ADMIN", - "description": "System administrator" - }, - { - "name": "PATIENT", - "description": "Patient role with self-access only" - } - ] - }, - "clients": [ - { - "clientId": "jdd-healthcare-app", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "bearerOnly": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": true, - "authorizationServicesEnabled": true, - "clientAuthenticatorType": "client-secret", - "secret": "jdd-healthcare-secret-2024", - "redirectUris": [ - "http://localhost:8080/*", - "http://localhost:8080/callback" - ], - "webOrigins": [ - "http://localhost:8080" - ], - "attributes": { - "access.token.lifespan": "300", - "pkce.code.challenge.method": "S256" - }, - "protocolMappers": [ - { - "name": "roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-realm-role-mapper", - "consentRequired": false, - "config": { - "multivalued": "true", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "groups", - "jsonType.label": "String" - } - }, - { - "name": "email", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "email", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "email", - "jsonType.label": "String" - } - }, - { - "name": "department", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "department", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "department", - "jsonType.label": "String" - } - }, - { - "name": "audience-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "included.client.audience": "jdd-healthcare-app", - "id.token.claim": "false", - "access.token.claim": "true", - "userinfo.token.claim": "false" - } - }, - { - "name": "subject-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "sub", - "jsonType.label": "String" - } - }, - { - "name": "upn-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-property-mapper", - "consentRequired": false, - "config": { - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "access.token.claim": "true", - "claim.name": "upn", - "jsonType.label": "String" - } - } - ], - "defaultClientScopes": [ - "web-origins", - "acr", - "profile", - "roles", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ] - }, - { - "clientId": "service-client", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "bearerOnly": false, - "standardFlowEnabled": false, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": true, - "clientAuthenticatorType": "client-secret", - "secret": "service-client-secret-2024", - "attributes": { - "access.token.lifespan": "600" - } - } - ], - "users": [ - { - "username": "dr.smith", - "enabled": true, - "email": "dr.smith@hospital.com", - "firstName": "John", - "lastName": "Smith", - "credentials": [ - { - "type": "password", - "value": "doctor123" - } - ], - "realmRoles": [ - "DOCTOR" - ], - "attributes": { - "department": [ - "Cardiology" - ], - "license": [ - "MD-12345" - ] - } - }, - { - "username": "nurse.jones", - "enabled": true, - "email": "nurse.jones@hospital.com", - "firstName": "Sarah", - "lastName": "Jones", - "credentials": [ - { - "type": "password", - "value": "nurse123" - } - ], - "realmRoles": [ - "NURSE" - ], - "attributes": { - "department": [ - "Emergency" - ], - "license": [ - "RN-67890" - ] - } - }, - { - "username": "admin", - "enabled": true, - "email": "admin@hospital.com", - "firstName": "Admin", - "lastName": "User", - "credentials": [ - { - "type": "password", - "value": "admin123" - } - ], - "realmRoles": [ - "ADMIN" - ] - }, - { - "username": "patient.doe", - "enabled": true, - "email": "patient.doe@email.com", - "firstName": "Jane", - "lastName": "Doe", - "credentials": [ - { - "type": "password", - "value": "patient123" - } - ], - "realmRoles": [ - "PATIENT" - ], - "attributes": { - "patientId": [ - "P-001" - ], - "dateOfBirth": [ - "1985-05-15" - ] - } - } - ], - "requiredActions": [ - { - "alias": "CONFIGURE_TOTP", - "name": "Configure OTP", - "providerId": "CONFIGURE_TOTP", - "enabled": true, - "defaultAction": false, - "priority": 10 - }, - { - "alias": "UPDATE_PASSWORD", - "name": "Update Password", - "providerId": "UPDATE_PASSWORD", - "enabled": true, - "defaultAction": true, - "priority": 30 - } - ], - "authenticationFlows": [ - { - "alias": "browser", - "description": "browser based authentication", - "providerId": "basic-flow", - "topLevel": true, - "builtIn": true, - "authenticationExecutions": [] - } - ], - "browserSecurityHeaders": { - "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", - "xContentTypeOptions": "nosniff", - "xRobotsTag": "none", - "xFrameOptions": "SAMEORIGIN", - "xXSSProtection": "1; mode=block", - "strictTransportSecurity": "max-age=31536000; includeSubDomains" - } -} diff --git a/monitor-oom.sh b/monitor-oom.sh new file mode 100755 index 0000000..9d18dff --- /dev/null +++ b/monitor-oom.sh @@ -0,0 +1,323 @@ +#!/bin/bash + +############################################################################# +# OOM Monitor for Payara Micro Containers +# +# This script monitors Payara Micro containers for Out of Memory errors +# and automatically restarts containers when OOM is detected. +# +# Usage: +# ./monitor-oom.sh [interval_minutes] [container_prefix] +# +# Arguments: +# interval_minutes - How often to check (default: 5 minutes) +# container_prefix - Container name prefix to monitor (default: trader-stream-) +# +# Examples: +# ./monitor-oom.sh # Check every 5 minutes +# ./monitor-oom.sh 2 # Check every 2 minutes +# ./monitor-oom.sh 10 trader-stream- # Check every 10 minutes for trader-stream-* containers +# +# To run in background: +# ./monitor-oom.sh & +# echo $! > monitor-oom.pid +# +# To stop: +# kill $(cat monitor-oom.pid) +# +############################################################################# + +set -euo pipefail + +# Show help if requested +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "help" ]; then + echo "================================================" + echo " Payara Micro OOM Monitor - Help" + echo "================================================" + echo "" + echo "Usage:" + echo " ./monitor-oom.sh [interval_minutes] [container_prefix]" + echo "" + echo "Arguments:" + echo " interval_minutes - How often to check for OOM errors (default: 5 minutes)" + echo " container_prefix - Container name prefix to monitor (default: trader-stream-)" + echo "" + echo "Examples:" + echo " ./monitor-oom.sh # Check every 5 minutes for trader-stream-* containers" + echo " ./monitor-oom.sh 2 # Check every 2 minutes" + echo " ./monitor-oom.sh 10 trader-stream- # Check every 10 minutes for trader-stream-* containers" + echo "" + echo "Background Execution:" + echo " ./monitor-oom.sh & # Run in background" + echo " echo \$! > monitor-oom.pid # Save process ID for later" + echo " kill \$(cat monitor-oom.pid) # Stop the monitor" + echo "" + echo "Features:" + echo " - Monitors Docker container logs for OOM errors and WebSocket exceptions" + echo " - Automatically restarts containers when errors are detected" + echo " - Logs all activity to oom-monitor.log" + echo " - Automatic log rotation when file exceeds 10MB" + echo " - Waits for container health checks after restart" + echo "" + echo "Monitored Error Patterns:" + echo " - OutOfMemoryError (all variants)" + echo " - GC overhead limit exceeded" + echo " - WebSocket/Tyrus connection errors" + echo " - Broken pipe / Connection reset" + echo "" + echo "Signals:" + echo " Ctrl+C or SIGTERM - Gracefully stop monitoring" + echo "" + exit 0 +fi + +# Configuration +CHECK_INTERVAL_MINUTES=${1:-5} +CONTAINER_PREFIX=${2:-trader-stream-} +LOG_FILE="oom-monitor.log" +MAX_LOG_SIZE=10485760 # 10MB + +# OOM patterns to search for in logs +OOM_PATTERNS=( + "java.lang.OutOfMemoryError" + "OutOfMemoryError" + "Out of memory" + "GC overhead limit exceeded" + "Requested array size exceeds VM limit" + "unable to create new native thread" + "Metaspace" + # Eclipse Tyrus WebSocket exceptions that indicate container issues + "org.glassfish.tyrus" + "TyrusWebSocketEngine" + "Connection reset by peer" + "Broken pipe" + "UpgradeException" + "WebSocket connection closed" + "DeploymentException" + "HandshakeException" + "SessionException" + "Unexpected error during WebSocket" + "Failed to process WebSocket frame" + "WebSocket frame buffer overflow" +) + +# ANSI color codes +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Logging functions +log() { + echo -e "[$(date +'%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +log_info() { + log "${BLUE}[INFO]${NC} $1" +} + +log_warn() { + log "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + log "${RED}[ERROR]${NC} $1" +} + +log_success() { + log "${GREEN}[SUCCESS]${NC} $1" +} + +# Rotate log file if too large +rotate_log_if_needed() { + if [ -f "$LOG_FILE" ]; then + local size=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE" 2>/dev/null || echo 0) + if [ "$size" -gt "$MAX_LOG_SIZE" ]; then + cp "$LOG_FILE" "${LOG_FILE}.old" + > "$LOG_FILE" # Truncate in place + log_info "Rotated log file (size: $size bytes)" + fi + fi +} + +# Check if Docker is available +check_docker() { + if ! command -v docker &> /dev/null; then + log_error "Docker is not installed or not in PATH" + exit 1 + fi + + if ! docker ps &> /dev/null; then + log_error "Cannot connect to Docker daemon. Is Docker running?" + exit 1 + fi +} + +# Get list of containers matching prefix +get_containers() { + docker ps --filter "name=${CONTAINER_PREFIX}" --format "{{.Names}}" 2>/dev/null || true +} + +# Check if container has OOM errors in logs +check_container_oom() { + local container=$1 + local found_oom=false + local oom_pattern="" + + # Get logs from last check interval (plus buffer) + local since_time="${CHECK_INTERVAL_MINUTES}m" + local logs=$(docker logs --since "$since_time" "$container" 2>&1 || true) + + if [ -z "$logs" ]; then + return 1 + fi + + # Combine patterns for a single grep search (more efficient) + local combined_pattern=$(printf "|%s" "${OOM_PATTERNS[@]}") + combined_pattern="${combined_pattern:1}" # Remove leading pipe + + if echo "$logs" | grep -Eq "$combined_pattern"; then + found_oom=true + # Find the specific matching line for reporting + local matching_line=$(echo "$logs" | grep -E "$combined_pattern" | head -1) + log_error "OOM detected in container '$container' - Match: '$matching_line'" + + # Extract and log context around the error + local oom_lines=$(echo "$logs" | grep -B 1 -A 5 -E "$combined_pattern" | head -20) + echo "$oom_lines" | while IFS= read -r line; do + log " | $line" + done + + return 0 + fi + + return 1 +} + +# Restart container +restart_container() { + local container=$1 + + log_warn "Restarting container '$container' due to OOM..." + + if docker restart "$container" &> /dev/null; then + log_success "Container '$container' restarted successfully" + + # Wait for container to be healthy + local max_wait=60 + local waited=0 + while [ $waited -lt $max_wait ]; do + local health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "none") + + if [ "$health" = "healthy" ]; then + log_success "Container '$container' is healthy after restart" + return 0 + elif [ "$health" = "none" ]; then + # No healthcheck defined, just check if running + if docker ps --filter "name=$container" --filter "status=running" | grep -q "$container"; then + log_info "Container '$container' is running (no healthcheck defined)" + return 0 + fi + fi + + sleep 2 + waited=$((waited + 2)) + done + + log_warn "Container '$container' restarted but health check timed out after ${max_wait}s" + return 0 + else + log_error "Failed to restart container '$container'" + return 1 + fi +} + +# Main monitoring loop +monitor() { + log_info "Starting OOM monitor for containers matching '$CONTAINER_PREFIX*'" + log_info "Check interval: ${CHECK_INTERVAL_MINUTES} minutes" + log_info "Log file: $LOG_FILE" + log_info "Press Ctrl+C to stop" + echo "" + + local iteration=0 + + while true; do + iteration=$((iteration + 1)) + rotate_log_if_needed + + log_info "Check #${iteration} - Scanning for OOM errors..." + + local containers=$(get_containers) + + if [ -z "$containers" ]; then + log_warn "No containers found matching prefix '$CONTAINER_PREFIX'" + else + local container_count=$(echo "$containers" | wc -l | tr -d ' ') + log_info "Found $container_count container(s) to monitor" + + local oom_detected=false + local restart_count=0 + + # Check each container + while IFS= read -r container; do + if [ -n "$container" ]; then + if check_container_oom "$container"; then + oom_detected=true + if restart_container "$container"; then + restart_count=$((restart_count + 1)) + fi + fi + fi + done <<< "$containers" + + if [ "$oom_detected" = false ]; then + log_success "No OOM errors detected in any container" + else + log_warn "Total containers restarted: $restart_count" + fi + fi + + log_info "Next check in ${CHECK_INTERVAL_MINUTES} minutes..." + echo "" + + # Sleep for specified interval + sleep $((CHECK_INTERVAL_MINUTES * 60)) + done +} + +# Signal handler for graceful shutdown +cleanup() { + echo "" + log_info "Received shutdown signal. Exiting..." + exit 0 +} + +trap cleanup SIGINT SIGTERM + +# Main entry point +main() { + echo "================================================" + echo " Payara Micro OOM Monitor" + echo "================================================" + echo "" + + # Validate interval - show help if invalid + if ! [[ "$CHECK_INTERVAL_MINUTES" =~ ^[0-9]+$ ]] || [ "$CHECK_INTERVAL_MINUTES" -lt 1 ]; then + echo "Error: Invalid parameter '$CHECK_INTERVAL_MINUTES'" + echo "Check interval must be a positive integer (minutes)" + echo "" + echo "For help, run: $0 --help" + echo "" + echo "Usage: $0 [interval_minutes] [container_prefix]" + echo "Example: $0 5 trader-stream-" + exit 1 + fi + + check_docker + monitor +} + +# Run main function +main diff --git a/monitoring/grafana/dashboards/jvm-comparison.json b/monitoring/grafana/dashboards/jvm-comparison.json new file mode 100644 index 0000000..7f3890c --- /dev/null +++ b/monitoring/grafana/dashboards/jvm-comparison.json @@ -0,0 +1,337 @@ +{ + "dashboard": { + "title": "JVM Performance Comparison - C4 vs G1GC", + "tags": [ "jvm", "gc", "performance", "comparison" ], + "timezone": "browser", + "editable": true, + "panels": [ { + "id": 10, + "title": "Response Latency - JIT Warmup (P50 / P99)", + "type": "timeseries", + "description": "Request latency from Traefik edge. Watch for the latency drop during the first few minutes as JIT compiles hot methods.", + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "targets": [ { + "expr": "histogram_quantile(0.50, rate(traefik_service_request_duration_seconds_bucket{job=~\"traefik-c4|traefik-g1\"}[30s]))", + "legendFormat": "P50 - {{job}}", + "refId": "A" + }, { + "expr": "histogram_quantile(0.99, rate(traefik_service_request_duration_seconds_bucket{job=~\"traefik-c4|traefik-g1\"}[30s]))", + "legendFormat": "P99 - {{job}}", + "refId": "B" + } ], + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "axisLabel": "Response Time (s)", + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 10, + "pointSize": 3, + "showPoints": "never", + "spanNulls": false, + "lineWidth": 2, + "gradientMode": "scheme" + }, + "thresholds": { + "mode": "absolute", + "steps": [ { + "value": 0, + "color": "green" + }, { + "value": 0.5, + "color": "yellow" + }, { + "value": 1, + "color": "red" + } ] + } + }, + "overrides": [ { + "matcher": { + "id": "byRegexp", + "options": ".*P99.*" + }, + "properties": [ { + "id": "lineWidth", + "value": 1 + }, { + "id": "dashes", + "value": true + }, { + "id": "dashLength", + "value": 5 + } ] + }, { + "matcher": { + "id": "byRegexp", + "options": ".*traefik-c4.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } ] + }, { + "matcher": { + "id": "byRegexp", + "options": ".*traefik-g1.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } ] + } ] + }, + "options": { + "tooltip": { + "mode": "multi", + "sort": "desc" + }, + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ "mean", "max", "lastNotNull" ] + } + } + }, { + "id": 11, + "title": "Request Throughput", + "type": "timeseries", + "description": "Requests per second through each cluster. Use this to correlate throughput changes with the warmup latency drop.", + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "targets": [ { + "expr": "sum(rate(traefik_service_requests_total{code=~\"2..\", job=~\"traefik-c4|traefik-g1\"}[30s])) by (job)", + "legendFormat": "{{job}} req/s", + "refId": "A" + } ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "axisLabel": "Requests/sec", + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 15, + "lineWidth": 2 + } + }, + "overrides": [ { + "matcher": { + "id": "byRegexp", + "options": ".*traefik-c4.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } ] + }, { + "matcher": { + "id": "byRegexp", + "options": ".*traefik-g1.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } ] + } ] + } + }, { + "id": 1, + "title": "GC Pause Time Comparison (P99)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "targets": [ { + "expr": "histogram_quantile(0.99, rate(jvm_gc_collection_time_ms[5m])) by (jvm_type, gc)", + "legendFormat": "{{jvm_type}} - {{gc}}", + "refId": "A" + } ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "axisLabel": "Pause Time (ms)" + } + }, + "overrides": [ { + "matcher": { + "id": "byRegexp", + "options": ".*azul-c4.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } ] + }, { + "matcher": { + "id": "byRegexp", + "options": ".*g1.*" + }, + "properties": [ { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } ] + } ] + } + }, { + "id": 2, + "title": "GC Collection Count Rate", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "targets": [ { + "expr": "rate(jvm_gc_collection_count[5m]) by (jvm_type, gc)", + "legendFormat": "{{jvm_type}} - {{gc}}", + "refId": "A" + } ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "axisLabel": "Collections/sec" + } + } + } + }, { + "id": 3, + "title": "Heap Memory Usage", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "targets": [ { + "expr": "jvm_memory_heap_used by (jvm_type, instance) / jvm_memory_heap_max * 100", + "legendFormat": "{{jvm_type}} - {{instance}}", + "refId": "A" + } ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "axisLabel": "Heap Usage %" + } + } + } + }, { + "id": 4, + "title": "Thread Count", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "targets": [ { + "expr": "jvm_threads_current by (jvm_type, instance)", + "legendFormat": "{{jvm_type}} - {{instance}}", + "refId": "A" + } ], + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "axisLabel": "Thread Count" + } + } + } + }, { + "id": 5, + "title": "GC Pause Time Distribution (Heatmap)", + "type": "heatmap", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 32 + }, + "targets": [ { + "expr": "rate(jvm_gc_collection_time_ms[1m]) by (jvm_type, le)", + "legendFormat": "{{jvm_type}}", + "refId": "A", + "format": "heatmap" + } ] + }, { + "id": 6, + "title": "Performance Summary", + "type": "stat", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 40 + }, + "targets": [ { + "expr": "histogram_quantile(0.99, rate(jvm_gc_collection_time_ms[5m])) by (jvm_type)", + "legendFormat": "{{jvm_type}} P99", + "refId": "A" + }, { + "expr": "max(jvm_gc_collection_time_ms) by (jvm_type)", + "legendFormat": "{{jvm_type}} Max", + "refId": "B" + } ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ { + "value": 0, + "color": "green" + }, { + "value": 10, + "color": "yellow" + }, { + "value": 50, + "color": "red" + } ] + } + } + } + } ], + "refresh": "5s", + "time": { + "from": "now-30m", + "to": "now" + } + } +} \ No newline at end of file diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..8984841 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'JVM Comparison' + orgId: 1 + folder: 'Trader Stream' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..3958205 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,19 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://trader-prometheus:9090 + isDefault: true + editable: true + jsonData: + timeInterval: 15s + + - name: Loki + type: loki + access: proxy + url: http://trader-loki:3100 + editable: true + jsonData: + maxLines: 1000 diff --git a/monitoring/jmx-exporter/jmx-exporter-config.yml b/monitoring/jmx-exporter/jmx-exporter-config.yml new file mode 100644 index 0000000..a4b8208 --- /dev/null +++ b/monitoring/jmx-exporter/jmx-exporter-config.yml @@ -0,0 +1,57 @@ +# JMX Exporter Configuration for GC Metrics +--- +startDelaySeconds: 0 +lowercaseOutputName: false +lowercaseOutputLabelNames: false + +rules: + # GC Metrics + - pattern: 'java.lang<>CollectionCount' + name: jvm_gc_collection_count + labels: + gc: "$1" + jvm_type: "$2" + type: COUNTER + + - pattern: 'java.lang<>CollectionTime' + name: jvm_gc_collection_time_ms + labels: + gc: "$1" + type: COUNTER + + - pattern: 'java.lang<>LastGcInfo' + name: jvm_gc_last_info + labels: + gc: "$1" + type: GAUGE + + # Memory Metrics + - pattern: 'java.lang(.+)' + name: jvm_memory_heap_$1 + type: GAUGE + + - pattern: 'java.lang(.+)' + name: jvm_memory_nonheap_$1 + type: GAUGE + + - pattern: 'java.lang(.+)' + name: jvm_memory_pool_$2 + labels: + pool: "$1" + type: GAUGE + + # Thread Metrics + - pattern: 'java.lang<>ThreadCount' + name: jvm_threads_current + type: GAUGE + + - pattern: 'java.lang<>PeakThreadCount' + name: jvm_threads_peak + type: GAUGE + + # Application-specific metrics (if using MicroProfile Metrics) + - pattern: 'fish.payara.trader<>(.+)' + name: trader_$1_$3 + labels: + metric: "$2" + type: GAUGE diff --git a/monitoring/loki/loki-config.yml b/monitoring/loki/loki-config.yml new file mode 100644 index 0000000..0e4527e --- /dev/null +++ b/monitoring/loki/loki-config.yml @@ -0,0 +1,37 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://localhost:9093 + +limits_config: + allow_structured_metadata: false + reject_old_samples: true + reject_old_samples_max_age: 168h + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..842589a --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,57 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'trader-stream-comparison' + +scrape_configs: + # Azul C4 Instances - JMX Metrics + - job_name: 'trader-c4-jmx' + static_configs: + - targets: + - 'trader-stream-c4-1:9010' + - 'trader-stream-c4-2:9010' + - 'trader-stream-c4-3:9010' + labels: + jvm_type: 'azul-c4' + cluster: 'c4-cluster' + metric_relabel_configs: + - source_labels: [__name__] + regex: 'jvm_.*' + action: keep + + # Eclipse Temurin G1GC Instances - JMX Metrics + - job_name: 'trader-g1-jmx' + static_configs: + - targets: + - 'trader-stream-g1-1:9010' + - 'trader-stream-g1-2:9010' + - 'trader-stream-g1-3:9010' + labels: + jvm_type: 'eclipse-temurin-g1' + cluster: 'g1-cluster' + metric_relabel_configs: + - source_labels: [__name__] + regex: 'jvm_.*' + action: keep + + # Traefik Metrics (Load Balancer) + - job_name: 'traefik-c4' + static_configs: + - targets: + - 'trader-traefik-c4:8080' + labels: + jvm_type: 'azul-c4' + + - job_name: 'traefik-g1' + static_configs: + - targets: + - 'trader-traefik-g1:8080' + labels: + jvm_type: 'eclipse-temurin-g1' + + # Prometheus Self-Monitoring + - job_name: 'prometheus' + static_configs: + - targets: + - 'localhost:9090' diff --git a/monitoring/promtail/promtail-config.yml b/monitoring/promtail/promtail-config.yml new file mode 100644 index 0000000..0596e0a --- /dev/null +++ b/monitoring/promtail/promtail-config.yml @@ -0,0 +1,67 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://trader-loki:3100/loki/api/v1/push + +scrape_configs: + # Docker container logs for C4 instances + - job_name: docker-c4 + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: ["jvm_type=azul-c4"] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'stream' + - source_labels: ['__meta_docker_container_label_jvm_type'] + target_label: 'jvm_type' + - source_labels: ['__meta_docker_container_label_instance'] + target_label: 'instance' + + # Docker container logs for G1 instances + - job_name: docker-g1 + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + filters: + - name: label + values: ["jvm_type=eclipse-temurin-g1"] + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_log_stream'] + target_label: 'stream' + - source_labels: ['__meta_docker_container_label_jvm_type'] + target_label: 'jvm_type' + - source_labels: ['__meta_docker_container_label_instance'] + target_label: 'instance' + + # GC log files (if writing to files) + - job_name: gc-logs-c4 + static_configs: + - targets: + - localhost + labels: + job: gc-logs + jvm_type: azul-c4 + __path__: /var/log/trader/c4-*/gc.log + + - job_name: gc-logs-g1 + static_configs: + - targets: + - localhost + labels: + job: gc-logs + jvm_type: eclipse-temurin-g1 + __path__: /var/log/trader/g1-*/gc.log diff --git a/monitoring/scrape-traefik.sh b/monitoring/scrape-traefik.sh new file mode 100755 index 0000000..a1034f8 --- /dev/null +++ b/monitoring/scrape-traefik.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +OUT="/data/home/seeraj/development/repos/payara/payara/trader-stream-ee/monitoring/metrics.log" +echo "=== Scraping traefik-c4 at http://localhost:8084/metrics/prometheus every 2s ===" > "$OUT" +while true; do + echo "--- $(date -Iseconds) ---" >> "$OUT" + curl -sf http://localhost:8084/metrics/prometheus >> "$OUT" 2>&1 + echo "" >> "$OUT" + sleep 2 +done diff --git a/pom.xml b/pom.xml index f9cd6cb..4cb8ba9 100644 --- a/pom.xml +++ b/pom.xml @@ -15,7 +15,8 @@ 11.0.0 7.2025.2 payara6 - 1.46.7 + 5.5.0 + 1.50.0 1.34.0 @@ -48,6 +49,12 @@ payara-api provided + + + com.hazelcast + hazelcast + provided + io.aeron @@ -60,6 +67,55 @@ sbe-all ${sbe.version} + + + org.snakeyaml + snakeyaml-engine + 2.7 + + + org.ta4j + ta4j-core + 0.22.4 + + + + + org.junit.jupiter + junit-jupiter + 5.13.4 + test + + + org.mockito + mockito-core + 5.20.0 + test + + + org.mockito + mockito-junit-jupiter + 5.20.0 + test + + + org.assertj + assertj-core + 3.27.6 + test + + + + + org.glassfish.jersey.core + jersey-common + test + + + org.glassfish.jersey.inject + jersey-hk2 + test + @@ -138,48 +194,187 @@ false + - com.googlecode.maven-download-plugin - download-maven-plugin - 1.13.0 + maven-resources-plugin + 3.3.1 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.3 + + + **/*Test.java + + + ${surefire.argLine} + + + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + prepare-agent + + prepare-agent + + + surefire.argLine + + - swagger-ui - generate-resources + prepare-agent-integration - wget + prepare-agent-integration - true - https://github.com/swagger-api/swagger-ui/archive/master.tar.gz - true - ${project.build.directory} + failsafe.argLine + + + + report + test + + report + + + + report-integration + verify + + report-integration + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.70 + + + BRANCH + COVEREDRATIO + 0.60 + + + CLASS + COVEREDRATIO + 0.80 + + + + + + + + merge-results + verify + + merge + + + + + ${project.build.directory} + + *.exec + + + + ${project.build.directory}/jacoco/merged.exec + + - maven-resources-plugin - 3.3.1 + org.apache.maven.plugins + maven-failsafe-plugin + 3.2.3 - copy-swagger-resources - process-resources - copy-resources + integration-test + verify - - ${project.build.directory}/${project.artifactId}-${project.version} - - - ${project.build.directory}/swagger-ui-master/dist - true - - index.html - - - - + + + + + **/integration/*Test.java + + ${failsafe.argLine} -Xmx4g -XX:+UseG1GC + + ${project.basedir}/src/test/resources/logback-test.xml + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.0.0 + + + + ${project.basedir}/spotless/java-formatter.xml + + + + + + + + src/**/*.json + monitoring/**/*.json + + + 2.15.2 + + true + + + + + + + **/*.md + + + workshop/slides/** + workshop/WORKSHOP.md + workshop/analysis-checklist.md + workshop/exercises/**/*.md + workshop/recordings/**/*.md + + + + + + + + check + + compile @@ -201,6 +396,88 @@ + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + XML + HTML + + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.15 + + + CLASS + MISSEDCOUNT + 40 + + + + + + + + + + + + + + + quick-test + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + + + **/integration/** + **/*LoadTest.java + **/*BenchmarkTest.java + + + + + + diff --git a/postgres/init.sql b/postgres/init.sql deleted file mode 100644 index d770781..0000000 --- a/postgres/init.sql +++ /dev/null @@ -1,62 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Audit log table for security events -CREATE TABLE audit_log -( - id VARCHAR(36) PRIMARY KEY, - timestamp TIMESTAMP NOT NULL, - username VARCHAR(100), - ip_address VARCHAR(45), - action VARCHAR(100) NOT NULL, - resource VARCHAR(255), - method_name VARCHAR(255), - class_name VARCHAR(255), - sensitivity_level VARCHAR(20), - success BOOLEAN NOT NULL, - duration_ms BIGINT, - error_message VARCHAR(1000), - user_agent VARCHAR(500), - details TEXT -); - --- Indexes for audit log queries -CREATE INDEX idx_audit_username ON audit_log (username); -CREATE INDEX idx_audit_timestamp ON audit_log (timestamp); -CREATE INDEX idx_audit_action ON audit_log (action); -CREATE INDEX idx_audit_level ON audit_log (sensitivity_level); - --- Patient table -CREATE TABLE patient -( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - first_name VARCHAR(50) NOT NULL, - last_name VARCHAR(50) NOT NULL, - date_of_birth DATE NOT NULL, - ssn VARCHAR(255), - email VARCHAR(255), - phone VARCHAR(255), - address VARCHAR(255), - blood_type VARCHAR(10), - allergies VARCHAR(500), - medical_conditions VARCHAR(1000), - assigned_doctor VARCHAR(100), - department VARCHAR(50), - created_at TIMESTAMP, - updated_at TIMESTAMP -); - -INSERT INTO patient (id, first_name, last_name, date_of_birth, ssn, email, phone, address, blood_type, allergies, - medical_conditions, assigned_doctor, department, created_at, updated_at) -VALUES ('a8b8b4f0-3e5b-48b0-8b4a-0e1b6d4b0b1b', 'Jane', 'Doe', '1985-05-15', '123-45-6789', 'jane.doe@email.com', - '555-0101', '123 Main St, Springfield', 'O+', 'Penicillin', 'Hypertension', 'Dr. Smith', 'Cardiology', NOW(), - NOW()), - ('f8b8b4f0-3e5b-48b0-8b4a-0e1b6d4b0b1c', 'John', 'Smith', '1978-03-22', '987-65-4321', 'john.smith@email.com', - '555-0102', '456 Oak Ave, Springfield', 'A+', 'None', 'Diabetes Type 2', 'Dr. Smith', 'Cardiology', NOW(), - NOW()), - ('e8b8b4f0-3e5b-48b0-8b4a-0e1b6d4b0b1d', 'Alice', 'Johnson', '1992-11-08', '456-78-9012', 'alice.j@email.com', - '555-0103', '789 Pine Rd, Springfield', 'B-', 'Latex', 'Asthma', 'Dr. Williams', 'Emergency', NOW(), NOW()), - ('d8b8b4f0-3e5b-48b0-8b4a-0e1b6d4b0b1e', 'Robert', 'Brown', '1965-07-30', '321-54-9876', 'rbrown@email.com', - '555-0104', '321 Elm St, Springfield', 'AB+', 'Shellfish', 'Coronary artery disease', 'Dr. Smith', 'Cardiology', - NOW(), NOW()), - ('c8b8b4f0-3e5b-48b0-8b4a-0e1b6d4b0b1f', 'Emma', 'Davis', '1988-09-14', '654-32-1098', 'emma.d@email.com', - '555-0105', '555 Maple Ln, Springfield', 'O-', 'None', 'None', 'Dr. Williams', 'Emergency', NOW(), NOW()); diff --git a/spotless/java-formatter.xml b/spotless/java-formatter.xml new file mode 100644 index 0000000..71f63ff --- /dev/null +++ b/spotless/java-formatter.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/fish/payara/resource/HelloWorldResource.java b/src/main/java/fish/payara/resource/HelloWorldResource.java index b82e4f5..0a7c26b 100644 --- a/src/main/java/fish/payara/resource/HelloWorldResource.java +++ b/src/main/java/fish/payara/resource/HelloWorldResource.java @@ -12,9 +12,9 @@ import org.eclipse.microprofile.metrics.annotation.Counted; import org.eclipse.microprofile.metrics.annotation.Timed; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; @Path("hello") public class HelloWorldResource { @@ -25,29 +25,22 @@ public class HelloWorldResource { @GET @Operation(summary = "Get a personalized greeting") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "Successful operation"), - @APIResponse(responseCode = "400", description = "Invalid input") - }) + @APIResponses(value = {@APIResponse(responseCode = "200", description = "Successful operation"), + @APIResponse(responseCode = "400", description = "Invalid input")}) @Counted(name = "helloEndpointCount", description = "Count of calls to the hello endpoint") @Timed(name = "helloEndpointTime", description = "Time taken to execute the hello endpoint") - @Timeout(3000) // Timeout after 3 seconds - @Retry(maxRetries = 3) // Retry the request up to 3 times on failure + @Timeout(3000) + @Retry(maxRetries = 3) @Fallback(fallbackMethod = "fallbackMethod") - public Response hello(@QueryParam("name") @Parameter(name = "name", description = "Name to include in the greeting", required = false, example = "John") String name) { + public Response hello( + @QueryParam("name") @Parameter(name = "name", description = "Name to include in the greeting", required = false, example = "John") String name) { if ((name == null) || name.trim().isEmpty()) { name = defaultName; } - return Response - .ok(name) - .build(); + return Response.ok(name).build(); } public Response fallbackMethod(@QueryParam("name") String name) { - // Fallback logic when the hello method fails or exceeds retries - return Response - .ok("Fallback data") - .build(); + return Response.ok("Fallback data").build(); } - -} \ No newline at end of file +} diff --git a/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java b/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java index 192229d..8b38782 100644 --- a/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java +++ b/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java @@ -5,30 +5,24 @@ import io.aeron.Subscription; import io.aeron.driver.MediaDriver; import io.aeron.driver.ThreadingMode; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import jakarta.ejb.Singleton; -import jakarta.ejb.Startup; import jakarta.enterprise.concurrent.ManagedExecutorService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Initialized; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; -import org.agrona.CloseHelper; -import org.agrona.concurrent.BackoffIdleStrategy; -import org.agrona.concurrent.IdleStrategy; - import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import org.agrona.CloseHelper; +import org.agrona.concurrent.BackoffIdleStrategy; +import org.agrona.concurrent.IdleStrategy; +import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * Aeron Ingress Singleton Bean - * Launches an embedded MediaDriver and subscribes to market data stream. - * Uses SBE decoders for zero-copy message processing. - * Runs in a dedicated thread to continuously poll for messages. - * IMPORTANT: This must initialize BEFORE MarketDataPublisher + * Aeron Ingress Singleton Bean Launches an embedded MediaDriver and subscribes to market data stream. Uses SBE decoders for zero-copy message processing. Runs + * in a dedicated thread to continuously poll for messages. IMPORTANT: This must initialize BEFORE MarketDataPublisher */ @ApplicationScoped public class AeronSubscriberBean { @@ -47,39 +41,46 @@ public class AeronSubscriberBean { @Inject private MarketDataFragmentHandler fragmentHandler; - + @Inject @VirtualThreadExecutor private ManagedExecutorService managedExecutorService; + @Inject + @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") + private String ingestionMode; + void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { - init(); + managedExecutorService.submit(this::init); } - public void init() { + if ("DIRECT".equalsIgnoreCase(ingestionMode)) { + LOGGER.info("Running in DIRECT mode - Skipping Aeron/MediaDriver initialization."); + return; + } + LOGGER.info("Initializing Aeron Subscriber Bean..."); try { LOGGER.info("Launching embedded MediaDriver..."); - mediaDriver = MediaDriver.launchEmbedded( - new MediaDriver.Context() - .threadingMode(ThreadingMode.SHARED) - .dirDeleteOnStart(true) - .dirDeleteOnShutdown(true) - ); + // BackoffIdleStrategy on the shared MediaDriver thread ensures the + // driver eventually parks when idle, crossing a JVM safepoint poll. + // Without this, Zing/GPGC aborts under sustained load with + // "Checkpoint sync time longer than 200000 ms detected" because the + // Aeron driver thread never reaches a safepoint on its own. + final IdleStrategy driverIdleStrategy = new BackoffIdleStrategy(100, 10, TimeUnit.MICROSECONDS.toNanos(1), TimeUnit.MICROSECONDS.toNanos(100)); + mediaDriver = MediaDriver.launchEmbedded(new MediaDriver.Context().threadingMode(ThreadingMode.SHARED) + .sharedIdleStrategy(driverIdleStrategy) + .dirDeleteOnStart(true) + .dirDeleteOnShutdown(true)); LOGGER.info("MediaDriver launched at: " + mediaDriver.aeronDirectoryName()); LOGGER.info("Connecting Aeron client..."); - aeron = Aeron.connect( - new Aeron.Context() - .aeronDirectoryName(mediaDriver.aeronDirectoryName()) - .errorHandler(this::onError) - .availableImageHandler(image -> - LOGGER.info("Available image: " + image.sourceIdentity())) - .unavailableImageHandler(image -> - LOGGER.info("Unavailable image: " + image.sourceIdentity())) - ); + aeron = Aeron.connect(new Aeron.Context().aeronDirectoryName(mediaDriver.aeronDirectoryName()) + .errorHandler(this::onError) + .availableImageHandler(image -> LOGGER.info("Available image: " + image.sourceIdentity())) + .unavailableImageHandler(image -> LOGGER.info("Unavailable image: " + image.sourceIdentity()))); LOGGER.info("Adding subscription on channel: " + CHANNEL + ", stream: " + STREAM_ID); subscription = aeron.addSubscription(CHANNEL, STREAM_ID); startPolling(); @@ -92,19 +93,13 @@ public void init() { } } - /** - * Start background task to continuously poll for messages - */ + /** Start background task to continuously poll for messages */ private void startPolling() { running = true; pollingFuture = managedExecutorService.submit(() -> { LOGGER.info("Aeron polling task started"); - final IdleStrategy idleStrategy = new BackoffIdleStrategy( - 100, 10, - TimeUnit.MICROSECONDS.toNanos(1), - TimeUnit.MICROSECONDS.toNanos(100) - ); + final IdleStrategy idleStrategy = new BackoffIdleStrategy(100, 10, TimeUnit.MICROSECONDS.toNanos(1), TimeUnit.MICROSECONDS.toNanos(100)); while (running && !Thread.currentThread().isInterrupted()) { try { @@ -121,9 +116,7 @@ private void startPolling() { }); } - /** - * Error handler for Aeron - */ + /** Error handler for Aeron */ private void onError(Throwable throwable) { LOGGER.log(Level.SEVERE, "Aeron error occurred", throwable); } @@ -132,43 +125,32 @@ private void onError(Throwable throwable) { public void shutdown() { LOGGER.info("Shutting down Aeron Subscriber Bean..."); running = false; - + if (pollingFuture != null) { pollingFuture.cancel(true); } - + cleanup(); LOGGER.info("Aeron Subscriber Bean shut down"); } - /** - * Clean up all Aeron resources - */ + /** Clean up all Aeron resources */ private void cleanup() { CloseHelper.quietClose(subscription); CloseHelper.quietClose(aeron); CloseHelper.quietClose(mediaDriver); } - /** - * Get subscription statistics - */ + /** Get subscription statistics */ public String getStatus() { if (subscription != null) { - return String.format( - "Channel: %s, Stream: %d, Images: %d, Running: %b", - subscription.channel(), - subscription.streamId(), - subscription.imageCount(), - running - ); + return String.format("Channel: %s, Stream: %d, Images: %d, Running: %b", subscription.channel(), subscription.streamId(), subscription.imageCount(), + running); } return "Not initialized"; } - /** - * Get the Aeron directory name for connecting publishers - */ + /** Get the Aeron directory name for connecting publishers */ public String getAeronDirectoryName() { if (mediaDriver != null) { return mediaDriver.aeronDirectoryName(); @@ -176,10 +158,8 @@ public String getAeronDirectoryName() { return null; } - /** - * Check if the MediaDriver is ready - */ + /** Check if the MediaDriver is ready */ public boolean isReady() { return mediaDriver != null && aeron != null && subscription != null && running; } -} \ No newline at end of file +} diff --git a/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java b/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java index c3de352..577670b 100644 --- a/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java +++ b/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java @@ -1,53 +1,51 @@ package fish.payara.trader.aeron; +import fish.payara.trader.jfr.MarketDataEvents; import fish.payara.trader.sbe.*; import fish.payara.trader.websocket.MarketDataBroadcaster; import io.aeron.logbuffer.FragmentHandler; import io.aeron.logbuffer.Header; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import org.agrona.DirectBuffer; - import java.util.logging.Level; import java.util.logging.Logger; +import org.agrona.DirectBuffer; /** - * FragmentHandler that uses SBE Flyweights for zero-copy message decoding. - * This handler processes Aeron fragments by: - * 1. Decoding the SBE message header to identify message type - * 2. Using the appropriate SBE decoder (flyweight pattern) - * 3. Extracting data without object allocation - * 4. Broadcasting as JSON (intentionally creating garbage for GC stress testing) + * FragmentHandler that uses SBE Flyweights for zero-copy message decoding. This handler processes Aeron fragments by: 1. Decoding the SBE message header to + * identify message type 2. Using the appropriate SBE decoder (flyweight pattern) 3. Extracting data without object allocation 4. Broadcasting as JSON + * (intentionally creating garbage for GC stress testing) */ @ApplicationScoped public class MarketDataFragmentHandler implements FragmentHandler { private static final Logger LOGGER = Logger.getLogger(MarketDataFragmentHandler.class.getName()); - // SBE Message Header Decoder (reusable flyweight) private final MessageHeaderDecoder headerDecoder = new MessageHeaderDecoder(); - // SBE Message Decoders (reusable flyweights) private final TradeDecoder tradeDecoder = new TradeDecoder(); private final QuoteDecoder quoteDecoder = new QuoteDecoder(); private final MarketDepthDecoder marketDepthDecoder = new MarketDepthDecoder(); private final OrderAckDecoder orderAckDecoder = new OrderAckDecoder(); private final HeartbeatDecoder heartbeatDecoder = new HeartbeatDecoder(); - // Statistics private long messagesProcessed = 0; private long messagesBroadcast = 0; private long lastLogTime = System.currentTimeMillis(); - // Sampling: Only broadcast 1 in N messages to avoid overwhelming browser private static final int SAMPLE_RATE = 50; private long sampleCounter = 0; @Inject - private MarketDataBroadcaster broadcaster; + MarketDataBroadcaster broadcaster; + + private final byte[] symbolBuffer = new byte[128]; + private final StringBuilder sb = new StringBuilder(1024); @Override public void onFragment(DirectBuffer buffer, int offset, int length, Header header) { + long startTime = System.nanoTime(); + try { headerDecoder.wrap(buffer, offset); @@ -61,31 +59,42 @@ public void onFragment(DirectBuffer buffer, int offset, int length, Header heade final boolean shouldBroadcast = (sampleCounter % SAMPLE_RATE == 0); switch (templateId) { - case TradeDecoder.TEMPLATE_ID: - processTrade(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case TradeDecoder.TEMPLATE_ID : + processTrade(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case QuoteDecoder.TEMPLATE_ID: - processQuote(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case QuoteDecoder.TEMPLATE_ID : + processQuote(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case MarketDepthDecoder.TEMPLATE_ID: - processMarketDepth(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case MarketDepthDecoder.TEMPLATE_ID : + processMarketDepth(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case OrderAckDecoder.TEMPLATE_ID: - processOrderAck(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case OrderAckDecoder.TEMPLATE_ID : + processOrderAck(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case HeartbeatDecoder.TEMPLATE_ID: - processHeartbeat(buffer, offset, actingBlockLength, actingVersion); - break; + case HeartbeatDecoder.TEMPLATE_ID : + processHeartbeat(buffer, offset, actingBlockLength, actingVersion); + break; - default: - LOGGER.warning("Unknown message template ID: " + templateId); + default : + LOGGER.warning("Unknown message template ID: " + templateId); } messagesProcessed++; + + // JFR event for batch processing + long processingTime = System.nanoTime() - startTime; + MarketDataEvents.BatchProcessed batchEvent = new MarketDataEvents.BatchProcessed(); + if (batchEvent.isEnabled()) { + batchEvent.messageCount = 1; + batchEvent.processingTimeNanos = processingTime; + batchEvent.source = "AERON"; + batchEvent.commit(); + } + if (shouldBroadcast) { messagesBroadcast++; } @@ -96,43 +105,61 @@ public void onFragment(DirectBuffer buffer, int offset, int length, Header heade } } - /** - * Process Trade message using SBE decoder (zero-copy) - */ + /** Process Trade message using SBE decoder (zero-copy) */ private void processTrade(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + long decodeStart = System.nanoTime(); + tradeDecoder.wrap(buffer, offset, blockLength, version); - // Extract fields from SBE decoder final long timestamp = tradeDecoder.timestamp(); final long tradeId = tradeDecoder.tradeId(); final long price = tradeDecoder.price(); final long quantity = tradeDecoder.quantity(); final Side side = tradeDecoder.side(); - // Extract variable-length symbol string final int symbolLength = tradeDecoder.symbolLength(); - final byte[] symbolBytes = new byte[symbolLength]; - tradeDecoder.getSymbol(symbolBytes, 0, symbolLength); - final String symbol = new String(symbolBytes); - - // Only broadcast sampled messages to avoid overwhelming browser - if (shouldBroadcast) { - // Convert to JSON (intentionally creating garbage for GC testing) - String json = String.format( - "{\"type\":\"trade\",\"timestamp\":%d,\"tradeId\":%d,\"symbol\":\"%s\"," + - "\"price\":%.4f,\"quantity\":%d,\"side\":\"%s\"}", - timestamp, tradeId, symbol, - price / 10000.0, quantity, side - ); - - broadcaster.broadcast(json); + tradeDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + // JFR event for SBE decoding performance + long decodeTime = System.nanoTime() - decodeStart; + MarketDataEvents.SbeDecode decodeEvent = new MarketDataEvents.SbeDecode(); + if (decodeEvent.isEnabled()) { + decodeEvent.messageType = "Trade"; + decodeEvent.decodedBytes = blockLength; + decodeEvent.decodeTimeNanos = decodeTime; + decodeEvent.commit(); + } + + if (!shouldBroadcast) { + return; } + + sb.setLength(0); + sb.append("{\"type\":\"trade\",\"timestamp\":") + .append(timestamp) + .append(",\"tradeId\":") + .append(tradeId) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"price\":") + .append(price / 10000.0) + .append(",\"quantity\":") + .append(quantity) + .append(",\"side\":\"") + .append(side) + .append("\"}"); + + broadcaster.broadcast(sb.toString()); } - /** - * Process Quote message using SBE decoder (zero-copy) - */ + /** Process Quote message using SBE decoder (zero-copy) */ private void processQuote(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + quoteDecoder.wrap(buffer, offset, blockLength, version); final long timestamp = quoteDecoder.timestamp(); @@ -142,84 +169,79 @@ private void processQuote(DirectBuffer buffer, int offset, int blockLength, int final long askSize = quoteDecoder.askSize(); final int symbolLength = quoteDecoder.symbolLength(); - final byte[] symbolBytes = new byte[symbolLength]; - quoteDecoder.getSymbol(symbolBytes, 0, symbolLength); - final String symbol = new String(symbolBytes); - - // Only broadcast sampled messages - if (shouldBroadcast) { - String json = String.format( - "{\"type\":\"quote\",\"timestamp\":%d,\"symbol\":\"%s\"," + - "\"bid\":{\"price\":%.4f,\"size\":%d},\"ask\":{\"price\":%.4f,\"size\":%d}}", - timestamp, symbol, - bidPrice / 10000.0, bidSize, - askPrice / 10000.0, askSize - ); - - broadcaster.broadcast(json); - } + quoteDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.setLength(0); + sb.append("{\"type\":\"quote\",\"timestamp\":") + .append(timestamp) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"bid\":{\"price\":") + .append(bidPrice / 10000.0) + .append(",\"size\":") + .append(bidSize) + .append("}") + .append(",\"ask\":{\"price\":") + .append(askPrice / 10000.0) + .append(",\"size\":") + .append(askSize) + .append("}}"); + + broadcaster.broadcast(sb.toString()); } - /** - * Process MarketDepth message with repeating groups (zero-copy) - */ + /** Process MarketDepth message with repeating groups (zero-copy) */ private void processMarketDepth(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + marketDepthDecoder.wrap(buffer, offset, blockLength, version); final long timestamp = marketDepthDecoder.timestamp(); final long sequenceNumber = marketDepthDecoder.sequenceNumber(); - // Only broadcast sampled messages (but always decode for GC stress) - if (shouldBroadcast) { - // Build JSON for bids - StringBuilder bidsJson = new StringBuilder("["); - MarketDepthDecoder.BidsDecoder bids = marketDepthDecoder.bids(); - int bidCount = 0; - while (bids.hasNext()) { - bids.next(); - if (bidCount > 0) bidsJson.append(","); - bidsJson.append(String.format( - "{\"price\":%.4f,\"quantity\":%d}", - bids.price() / 10000.0, bids.quantity() - )); - bidCount++; - } - bidsJson.append("]"); - - // Build JSON for asks - StringBuilder asksJson = new StringBuilder("["); - MarketDepthDecoder.AsksDecoder asks = marketDepthDecoder.asks(); - int askCount = 0; - while (asks.hasNext()) { - asks.next(); - if (askCount > 0) asksJson.append(","); - asksJson.append(String.format( - "{\"price\":%.4f,\"quantity\":%d}", - asks.price() / 10000.0, asks.quantity() - )); - askCount++; - } - asksJson.append("]"); + sb.setLength(0); + sb.append("{\"type\":\"depth\",\"timestamp\":").append(timestamp).append(",\"sequence\":").append(sequenceNumber).append(",\"bids\":["); + + MarketDepthDecoder.BidsDecoder bids = marketDepthDecoder.bids(); + int bidCount = 0; + while (bids.hasNext()) { + bids.next(); + if (bidCount > 0) + sb.append(","); + sb.append("{\"price\":").append(bids.price() / 10000.0).append(",\"quantity\":").append(bids.quantity()).append("}"); + bidCount++; + } + sb.append("],\"asks\":["); + + MarketDepthDecoder.AsksDecoder asks = marketDepthDecoder.asks(); + int askCount = 0; + while (asks.hasNext()) { + asks.next(); + if (askCount > 0) + sb.append(","); + sb.append("{\"price\":").append(asks.price() / 10000.0).append(",\"quantity\":").append(asks.quantity()).append("}"); + askCount++; + } + sb.append("]"); - final int symbolLength = marketDepthDecoder.symbolLength(); - final byte[] symbolBytes = new byte[symbolLength]; - marketDepthDecoder.getSymbol(symbolBytes, 0, symbolLength); - final String symbol = new String(symbolBytes); + final int symbolLength = marketDepthDecoder.symbolLength(); + marketDepthDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); - String json = String.format( - "{\"type\":\"depth\",\"timestamp\":%d,\"symbol\":\"%s\",\"sequence\":%d," + - "\"bids\":%s,\"asks\":%s}", - timestamp, symbol, sequenceNumber, bidsJson, asksJson - ); + sb.append(",\"symbol\":\"").append(symbol).append("\"}"); - broadcaster.broadcast(json); - } + broadcaster.broadcast(sb.toString()); } - /** - * Process OrderAck message - */ private void processOrderAck(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + orderAckDecoder.wrap(buffer, offset, blockLength, version); final long timestamp = orderAckDecoder.timestamp(); @@ -234,54 +256,58 @@ private void processOrderAck(DirectBuffer buffer, int offset, int blockLength, i final long cumQty = orderAckDecoder.cumQty(); final int symbolLength = orderAckDecoder.symbolLength(); - final byte[] symbolBytes = new byte[symbolLength]; - orderAckDecoder.getSymbol(symbolBytes, 0, symbolLength); - final String symbol = new String(symbolBytes); - - // Only broadcast sampled messages - if (shouldBroadcast) { - String json = String.format( - "{\"type\":\"orderAck\",\"timestamp\":%d,\"orderId\":%d,\"clientOrderId\":%d," + - "\"symbol\":\"%s\",\"side\":\"%s\",\"orderType\":\"%s\",\"price\":%.4f," + - "\"quantity\":%d,\"execType\":\"%s\",\"leavesQty\":%d,\"cumQty\":%d}", - timestamp, orderId, clientOrderId, symbol, side, orderType, - price / 10000.0, quantity, execType, leavesQty, cumQty - ); - - broadcaster.broadcast(json); - } + orderAckDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.setLength(0); + sb.append("{\"type\":\"orderAck\",\"timestamp\":") + .append(timestamp) + .append(",\"orderId\":") + .append(orderId) + .append(",\"clientOrderId\":") + .append(clientOrderId) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"side\":\"") + .append(side) + .append("\"") + .append(",\"orderType\":\"") + .append(orderType) + .append("\"") + .append(",\"price\":") + .append(price / 10000.0) + .append(",\"quantity\":") + .append(quantity) + .append(",\"execType\":\"") + .append(execType) + .append("\"") + .append(",\"leavesQty\":") + .append(leavesQty) + .append(",\"cumQty\":") + .append(cumQty) + .append("}"); + + broadcaster.broadcast(sb.toString()); } - /** - * Process Heartbeat message - */ private void processHeartbeat(DirectBuffer buffer, int offset, int blockLength, int version) { heartbeatDecoder.wrap(buffer, offset, blockLength, version); final long timestamp = heartbeatDecoder.timestamp(); final long sequenceNumber = heartbeatDecoder.sequenceNumber(); - // Log heartbeats but don't broadcast them if (LOGGER.isLoggable(Level.FINE)) { LOGGER.fine("Heartbeat: timestamp=" + timestamp + ", seq=" + sequenceNumber); } } - /** - * Log statistics periodically - */ private void logStatistics() { long now = System.currentTimeMillis(); - if (now - lastLogTime > 5000) { // Log every 5 seconds + if (now - lastLogTime > 5000) { double elapsedSeconds = (now - lastLogTime) / 1000.0; - LOGGER.info(String.format( - "FragmentHandler Stats - Processed: %,d (%.0f msg/sec) | Broadcast to UI: %,d (%.0f msg/sec) | Sample rate: 1 in %d", - messagesProcessed, - messagesProcessed / elapsedSeconds, - messagesBroadcast, - messagesBroadcast / elapsedSeconds, - SAMPLE_RATE - )); + LOGGER.info(String.format("FragmentHandler Stats - Processed: %,d (%.0f msg/sec) | Broadcast to UI: %,d (%.0f msg/sec) | Sample rate: 1 in %d", + messagesProcessed, messagesProcessed / elapsedSeconds, messagesBroadcast, messagesBroadcast / elapsedSeconds, SAMPLE_RATE)); lastLogTime = now; messagesProcessed = 0; messagesBroadcast = 0; diff --git a/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java b/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java index 1cf1345..6047ce4 100644 --- a/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java +++ b/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java @@ -1,38 +1,35 @@ package fish.payara.trader.aeron; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.core.HazelcastInstanceNotActiveException; +import com.hazelcast.cp.IAtomicLong; import fish.payara.trader.concurrency.VirtualThreadExecutor; +import fish.payara.trader.jfr.MarketDataEvents; import fish.payara.trader.sbe.*; import fish.payara.trader.websocket.MarketDataBroadcaster; import io.aeron.Aeron; import io.aeron.Publication; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; -import jakarta.ejb.DependsOn; -import jakarta.ejb.Singleton; -import jakarta.ejb.Startup; -import jakarta.enterprise.concurrent.ManagedExecutorDefinition; import jakarta.enterprise.concurrent.ManagedExecutorService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Initialized; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; -import org.agrona.concurrent.UnsafeBuffer; -import org.eclipse.microprofile.config.inject.ConfigProperty; - import java.nio.ByteBuffer; -import java.util.Random; import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; import java.util.logging.Level; import java.util.logging.Logger; +import org.agrona.concurrent.UnsafeBuffer; +import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * Market Data Publisher Simulator - * Generates synthetic market data and publishes via Aeron using SBE encoding. - * This simulates a high-frequency data feed for testing the ingestion pipeline. - * IMPORTANT: Depends on AeronSubscriberBean to initialize first (provides MediaDriver) + * Market Data Publisher Simulator Generates synthetic market data and publishes via Aeron using SBE encoding. This simulates a high-frequency data feed for + * testing the ingestion pipeline. IMPORTANT: Depends on AeronSubscriberBean to initialize first (provides MediaDriver) */ @ApplicationScoped public class MarketDataPublisher { @@ -42,32 +39,33 @@ public class MarketDataPublisher { private static final String CHANNEL = "aeron:ipc"; private static final int STREAM_ID = 1001; private static final int BUFFER_SIZE = 4096; - private static final int SAMPLE_RATE = 50; // Broadcast 1 in 50 messages to prevent flooding + private static final int SAMPLE_RATE = 50; private static final String[] SYMBOLS = {"AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX"}; private Aeron aeron; private Publication publication; - // SBE encoders (reusable flyweights) private final MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); private final TradeEncoder tradeEncoder = new TradeEncoder(); private final QuoteEncoder quoteEncoder = new QuoteEncoder(); private final MarketDepthEncoder marketDepthEncoder = new MarketDepthEncoder(); private final HeartbeatEncoder heartbeatEncoder = new HeartbeatEncoder(); - // Buffer for encoding messages - private final UnsafeBuffer buffer = new UnsafeBuffer(ByteBuffer.allocateDirect(BUFFER_SIZE)); + private UnsafeBuffer buffer; - private final Random random = new Random(); private final AtomicLong sequenceNumber = new AtomicLong(0); private final AtomicLong tradeIdGenerator = new AtomicLong(1000); private final AtomicLong messagesPublished = new AtomicLong(0); + private final AtomicInteger consecutiveFailures = new AtomicInteger(0); + private static final int MAX_CONSECUTIVE_FAILURES = 50; private long sampleCounter = 0; - private volatile boolean initialized = false; private volatile boolean running = false; - + private boolean isDirectMode; + private long lastWarningLogTime = 0; + private static final long WARNING_LOG_INTERVAL_MS = 5000; + private Future publisherFuture; private Future statsFuture; @@ -78,49 +76,73 @@ public class MarketDataPublisher { @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") private String ingestionMode; + @Inject + @ConfigProperty(name = "ENABLE_PUBLISHER", defaultValue = "true") + String enablePublisherEnv; + @Inject private MarketDataBroadcaster broadcaster; - + + @Inject + private HazelcastInstance hazelcastInstance; + @Inject @VirtualThreadExecutor private ManagedExecutorService managedExecutorService; + private IAtomicLong clusterMessageCounter; + void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { init(); } public void init() { - LOGGER.info("Initializing Market Data Publisher. Mode: " + ingestionMode); + if (hazelcastInstance != null) { + try { + clusterMessageCounter = hazelcastInstance.getCPSubsystem().getAtomicLong("cluster-message-count"); + LOGGER.info("Initialized cluster-wide message counter"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to initialize cluster message counter, using local counter only", e); + } + } + + if (enablePublisherEnv != null && !"true".equalsIgnoreCase(enablePublisherEnv)) { + LOGGER.info("Market Data Publisher DISABLED on this instance (ENABLE_PUBLISHER=" + enablePublisherEnv + ")"); + LOGGER.info("This instance will only consume messages from the cluster topic via Hazelcast"); + return; + } + + LOGGER.info("Initializing Market Data Publisher (ENABLE_PUBLISHER=" + enablePublisherEnv + "). Mode: " + ingestionMode); if ("DIRECT".equalsIgnoreCase(ingestionMode)) { LOGGER.info("Running in DIRECT mode - Bypassing Aeron/SBE setup."); - initialized = true; + isDirectMode = true; + startPublishing(); return; } + this.buffer = new UnsafeBuffer(ByteBuffer.allocateDirect(BUFFER_SIZE)); + try { - // Wait for AeronSubscriberBean to be ready (both observers fire at roughly same time) LOGGER.info("Waiting for AeronSubscriberBean to be ready..."); int waitAttempts = 0; while (!subscriberBean.isReady() && waitAttempts < 60) { Thread.sleep(500); waitAttempts++; + LOGGER.info("Waiting for AeronSubscriberBean to be ready... " + waitAttempts + " wait count"); } if (!subscriberBean.isReady()) { - LOGGER.severe("AeronSubscriberBean did not become ready in time"); + LOGGER.severe("AeronSubscriberBean did not become ready in time after " + waitAttempts + " wait count"); return; } String aeronDir = subscriberBean.getAeronDirectoryName(); LOGGER.info("Connecting to embedded MediaDriver at: " + aeronDir); - aeron = Aeron.connect(new Aeron.Context() - .aeronDirectoryName(aeronDir) - .errorHandler(throwable -> - LOGGER.log(Level.SEVERE, "Aeron publisher error", throwable)) - ); + aeron = Aeron.connect(new Aeron.Context().aeronDirectoryName(aeronDir) + .errorHandler(throwable -> LOGGER.log(Level.SEVERE, "Aeron publisher error", throwable))); LOGGER.info("Connected to Aeron. Adding publication..."); @@ -135,7 +157,7 @@ public void init() { if (publication.isConnected()) { LOGGER.info("Market Data Publisher initialized successfully"); - initialized = true; + startPublishing(); } else { LOGGER.warning("Publisher not connected after waiting"); @@ -147,19 +169,42 @@ public void init() { } /** - * Start background thread to continuously publish market data at high throughput + * Start background thread to continuously publish market data at high throughput. + * + *

+ * Burst Pattern: Each burst publishes 1,500 messages (500 iterations of Trade + Quote + MarketDepth), followed by a 5μs park. This yields a + * theoretical upper limit of ~300M messages/sec, but actual throughput is limited by Aeron backpressure and SBE encoding overhead. + * + *

+ * Message Counting: In AERON mode, messages are counted AFTER successful publication via {@code offer()} (i.e., "delivered" count). In DIRECT mode, + * messages are counted immediately upon generation (i.e., "attempted" count). + * + *

+ * Burst Multiplier: Time-based multiplier simulates market events: 1x normal, 5x during news events (seconds 20-25 of each minute), 3x during market + * close (seconds 45-50). */ private void startPublishing() { running = true; publisherFuture = managedExecutorService.submit(() -> { - LOGGER.info("Market data publisher task started - targeting 50k-100k messages/sec"); + Thread.currentThread().setName("market-data-publisher"); + LOGGER.info("Market data publisher task started - burst pattern: 1,500 messages per 5μs (rate limited by Aeron backpressure)"); - final int BURST_SIZE = 500; - final long PARK_NANOS = 5_000; // 5 microseconds = ~100k messages/sec + final int BASE_BURST_SIZE = 500; + final long PARK_NANOS = 5_000; while (running && !Thread.currentThread().isInterrupted()) { try { - for (int i = 0; i < BURST_SIZE && running; i++) { + int burstMultiplier = getBurstMultiplier(); + int adjustedBurstSize = BASE_BURST_SIZE * burstMultiplier; + + if (burstMultiplier > 1) { + long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; + if (secondOfMinute == 20 || secondOfMinute == 45) { + LOGGER.info("BURST MODE: " + burstMultiplier + "x allocation spike started"); + } + } + + for (int i = 0; i < adjustedBurstSize && running; i++) { publishTrade(); publishQuote(); publishMarketDepth(); @@ -171,8 +216,8 @@ private void startPublishing() { LockSupport.parkNanos(PARK_NANOS); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Error in publisher loop", e); + } catch (Throwable e) { + LOGGER.log(Level.SEVERE, "Critical error in publisher loop", e); LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); } } @@ -181,6 +226,7 @@ private void startPublishing() { }); statsFuture = managedExecutorService.submit(() -> { + Thread.currentThread().setName("market-data-stats"); long lastCount = 0; long lastTime = System.currentTimeMillis(); @@ -194,12 +240,15 @@ private void startPublishing() { double elapsedSeconds = (currentTime - lastTime) / 1000.0; double messagesPerSecond = messagesSinceLastLog / elapsedSeconds; - LOGGER.info(String.format( - "Publisher Stats - Total: %,d | Last 5s: %,d (%.0f msg/sec)", - currentCount, - messagesSinceLastLog, - messagesPerSecond - )); + int currentMultiplier = getBurstMultiplier(); + String burstStatus = currentMultiplier > 1 ? " [BURST: " + currentMultiplier + "x]" : ""; + + LOGGER.info(String.format("Publisher Stats - Total: %,d | Last 5s: %,d (%.0f msg/sec)%s", currentCount, messagesSinceLastLog, + messagesPerSecond, burstStatus)); + + String statsJson = String.format("{\"type\":\"stats\",\"total\":%d,\"rate\":%.0f,\"burstMultiplier\":%d}", currentCount, messagesPerSecond, + currentMultiplier); + broadcaster.broadcast(statsJson); lastCount = currentCount; lastTime = currentTime; @@ -212,168 +261,265 @@ private void startPublishing() { } /** - * Publish a Trade message + * Calculate burst multiplier based on time pattern simulating market events + * + *

+ * Pattern (60-second cycle): - 00-20s: Normal trading (1x) - 20-25s: News event burst (5x allocation spike) - 25-45s: Normal trading (1x) - 45-50s: Market + * close spike (3x allocation) - 50-60s: Normal trading (1x) */ + private int getBurstMultiplier() { + long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; + + if (secondOfMinute >= 20 && secondOfMinute < 25) { + MarketDataEvents.BurstModeActivated event = new MarketDataEvents.BurstModeActivated(); + if (event.isEnabled()) { + event.multiplier = 5; + event.reason = "News Event"; + event.secondOfMinute = secondOfMinute; + event.commit(); + } + return 5; + } else if (secondOfMinute >= 45 && secondOfMinute < 50) { + MarketDataEvents.BurstModeActivated event = new MarketDataEvents.BurstModeActivated(); + if (event.isEnabled()) { + event.multiplier = 3; + event.reason = "Market Close"; + event.secondOfMinute = secondOfMinute; + event.commit(); + } + return 3; + } + + return 1; + } + + /** Publish a Trade message */ private void publishTrade() { - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; - final double price = 100.0 + random.nextDouble() * 400.0; - final int quantity = random.nextInt(1000) + 100; - final String side = random.nextBoolean() ? "BUY" : "SELL"; - - String json = String.format( - "{\"type\":\"trade\",\"timestamp\":%d,\"tradeId\":%d,\"symbol\":\"%s\",\"price\":%.4f,\"quantity\":%d,\"side\":\"%s\"}", - System.currentTimeMillis(), - tradeIdGenerator.incrementAndGet(), - symbol, - price, - quantity, - side - ); + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + if (isDirectMode) { + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double price = 100.0 + currentRandom.nextDouble() * 400.0; + final int quantity = currentRandom.nextInt(1000) + 100; + final String side = currentRandom.nextBoolean() ? "BUY" : "SELL"; + + String json = String.format("{\"type\":\"trade\",\"timestamp\":%d,\"tradeId\":%d,\"symbol\":\"%s\",\"price\":%.4f,\"quantity\":%d,\"side\":\"%s\"}", + System.currentTimeMillis(), tradeIdGenerator.incrementAndGet(), symbol, price, quantity, side); + + MarketDataEvents.TradePublished tradeEvent = new MarketDataEvents.TradePublished(); + if (tradeEvent.isEnabled()) { + tradeEvent.symbol = symbol; + tradeEvent.price = (long) (price * 10000); + tradeEvent.quantity = quantity; + tradeEvent.side = side; + tradeEvent.commit(); + } + if (++sampleCounter % SAMPLE_RATE == 0) { broadcaster.broadcastWithArtificialLoad(json); } messagesPublished.incrementAndGet(); + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + } + } return; } + long encodeStart = System.nanoTime(); int bufferOffset = 0; - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; headerEncoder.wrap(buffer, bufferOffset) - .blockLength(tradeEncoder.sbeBlockLength()) - .templateId(tradeEncoder.sbeTemplateId()) - .schemaId(tradeEncoder.sbeSchemaId()) - .version(tradeEncoder.sbeSchemaVersion()); + .blockLength(tradeEncoder.sbeBlockLength()) + .templateId(tradeEncoder.sbeTemplateId()) + .schemaId(tradeEncoder.sbeSchemaId()) + .version(tradeEncoder.sbeSchemaVersion()); bufferOffset += headerEncoder.encodedLength(); + final long encodedPrice = (long) ((100.0 + currentRandom.nextDouble() * 400.0) * 10000); + final int encodedQuantity = currentRandom.nextInt(1000) + 100; + final Side encodedSide = currentRandom.nextBoolean() ? Side.BUY : Side.SELL; + tradeEncoder.wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .tradeId(tradeIdGenerator.incrementAndGet()) - .price((long) ((100.0 + random.nextDouble() * 400.0) * 10000)) // $100-$500 - .quantity(random.nextInt(1000) + 100) - .side(random.nextBoolean() ? Side.BUY : Side.SELL) - .symbol(symbol); + .timestamp(System.currentTimeMillis()) + .tradeId(tradeIdGenerator.incrementAndGet()) + .price(encodedPrice) + .quantity(encodedQuantity) + .side(encodedSide) + .symbol(symbol); final int length = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); - offer(buffer, 0, length, "Trade"); + + MarketDataEvents.SbeEncode encodeEvent = new MarketDataEvents.SbeEncode(); + if (encodeEvent.isEnabled()) { + encodeEvent.messageType = "Trade"; + encodeEvent.encodedBytes = length; + encodeEvent.encodeTimeNanos = System.nanoTime() - encodeStart; + encodeEvent.commit(); + } + + offer(buffer, 0, length, "Trade", symbol, encodedPrice, encodedQuantity, encodedSide.name()); } - /** - * Publish a Quote message - */ + /** Publish a Quote message */ private void publishQuote() { - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + random.nextDouble() * 400.0; + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + if (isDirectMode) { + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; final double bidPrice = basePrice - 0.01; final double askPrice = basePrice + 0.01; - final int bidSize = random.nextInt(10000) + 100; - final int askSize = random.nextInt(10000) + 100; + final int bidSize = currentRandom.nextInt(10000) + 100; + final int askSize = currentRandom.nextInt(10000) + 100; + + MarketDataEvents.QuotePublished quoteEvent = new MarketDataEvents.QuotePublished(); + if (quoteEvent.isEnabled()) { + quoteEvent.symbol = symbol; + quoteEvent.bidPrice = (long) (bidPrice * 10000); + quoteEvent.askPrice = (long) (askPrice * 10000); + quoteEvent.bidSize = bidSize; + quoteEvent.askSize = askSize; + quoteEvent.commit(); + } String json = String.format( - "{\"type\":\"quote\",\"timestamp\":%d,\"symbol\":\"%s\",\"bid\":{\"price\":%.4f,\"size\":%d},\"ask\":{\"price\":%.4f,\"size\":%d}}", - System.currentTimeMillis(), symbol, - bidPrice, bidSize, - askPrice, askSize - ); + "{\"type\":\"quote\",\"timestamp\":%d,\"symbol\":\"%s\",\"bid\":{\"price\":%.4f,\"size\":%d},\"ask\":{\"price\":%.4f,\"size\":%d}}", + System.currentTimeMillis(), symbol, bidPrice, bidSize, askPrice, askSize); if (++sampleCounter % SAMPLE_RATE == 0) { broadcaster.broadcastWithArtificialLoad(json); } messagesPublished.incrementAndGet(); + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + } + } return; } + long encodeStart = System.nanoTime(); int bufferOffset = 0; - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + random.nextDouble() * 400.0; + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; headerEncoder.wrap(buffer, bufferOffset) - .blockLength(quoteEncoder.sbeBlockLength()) - .templateId(quoteEncoder.sbeTemplateId()) - .schemaId(quoteEncoder.sbeSchemaId()) - .version(quoteEncoder.sbeSchemaVersion()); + .blockLength(quoteEncoder.sbeBlockLength()) + .templateId(quoteEncoder.sbeTemplateId()) + .schemaId(quoteEncoder.sbeSchemaId()) + .version(quoteEncoder.sbeSchemaVersion()); bufferOffset += headerEncoder.encodedLength(); + final long bidPrice = (long) ((basePrice - 0.01) * 10000); + final long askPrice = (long) ((basePrice + 0.01) * 10000); + final int bidSize = currentRandom.nextInt(10000) + 100; + final int askSize = currentRandom.nextInt(10000) + 100; + quoteEncoder.wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .bidPrice((long) ((basePrice - 0.01) * 10000)) - .bidSize(random.nextInt(10000) + 100) - .askPrice((long) ((basePrice + 0.01) * 10000)) - .askSize(random.nextInt(10000) + 100) - .symbol(symbol); + .timestamp(System.currentTimeMillis()) + .bidPrice(bidPrice) + .bidSize(bidSize) + .askPrice(askPrice) + .askSize(askSize) + .symbol(symbol); final int length = headerEncoder.encodedLength() + quoteEncoder.encodedLength(); + + MarketDataEvents.SbeEncode encodeEvent = new MarketDataEvents.SbeEncode(); + if (encodeEvent.isEnabled()) { + encodeEvent.messageType = "Quote"; + encodeEvent.encodedBytes = length; + encodeEvent.encodeTimeNanos = System.nanoTime() - encodeStart; + encodeEvent.commit(); + } + offer(buffer, 0, length, "Quote"); + + MarketDataEvents.QuotePublished quoteEvent = new MarketDataEvents.QuotePublished(); + if (quoteEvent.isEnabled()) { + quoteEvent.symbol = symbol; + quoteEvent.bidPrice = bidPrice; + quoteEvent.askPrice = askPrice; + quoteEvent.bidSize = bidSize; + quoteEvent.askSize = askSize; + quoteEvent.commit(); + } } - /** - * Publish a MarketDepth message - */ + /** Publish a MarketDepth message */ private void publishMarketDepth() { - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + random.nextDouble() * 400.0; + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + if (isDirectMode) { + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; final long timestamp = System.currentTimeMillis(); final long seq = sequenceNumber.incrementAndGet(); + MarketDataEvents.MarketDepthPublished depthEvent = new MarketDataEvents.MarketDepthPublished(); + if (depthEvent.isEnabled()) { + depthEvent.symbol = symbol; + depthEvent.depthLevels = 5; + depthEvent.sequenceNumber = seq; + depthEvent.commit(); + } + StringBuilder bidsJson = new StringBuilder("["); for (int i = 0; i < 5; i++) { - if (i > 0) bidsJson.append(","); - bidsJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", - basePrice - (i + 1) * 0.01, random.nextInt(5000) + 100)); + if (i > 0) + bidsJson.append(","); + bidsJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", basePrice - (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); } bidsJson.append("]"); StringBuilder asksJson = new StringBuilder("["); for (int i = 0; i < 5; i++) { - if (i > 0) asksJson.append(","); - asksJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", - basePrice + (i + 1) * 0.01, random.nextInt(5000) + 100)); + if (i > 0) + asksJson.append(","); + asksJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", basePrice + (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); } asksJson.append("]"); - String json = String.format( - "{\"type\":\"depth\",\"timestamp\":%d,\"symbol\":\"%s\",\"sequence\":%d,\"bids\":%s,\"asks\":%s}", - timestamp, symbol, seq, bidsJson, asksJson - ); + String json = String.format("{\"type\":\"depth\",\"timestamp\":%d,\"symbol\":\"%s\",\"sequence\":%d,\"bids\":%s,\"asks\":%s}", timestamp, symbol, + seq, bidsJson, asksJson); if (++sampleCounter % SAMPLE_RATE == 0) { broadcaster.broadcastWithArtificialLoad(json); } messagesPublished.incrementAndGet(); + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + } + } return; } int bufferOffset = 0; - final String symbol = SYMBOLS[random.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + random.nextDouble() * 400.0; + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; headerEncoder.wrap(buffer, bufferOffset) - .blockLength(marketDepthEncoder.sbeBlockLength()) - .templateId(marketDepthEncoder.sbeTemplateId()) - .schemaId(marketDepthEncoder.sbeSchemaId()) - .version(marketDepthEncoder.sbeSchemaVersion()); + .blockLength(marketDepthEncoder.sbeBlockLength()) + .templateId(marketDepthEncoder.sbeTemplateId()) + .schemaId(marketDepthEncoder.sbeSchemaId()) + .version(marketDepthEncoder.sbeSchemaVersion()); bufferOffset += headerEncoder.encodedLength(); - marketDepthEncoder.wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .sequenceNumber(sequenceNumber.incrementAndGet()); + marketDepthEncoder.wrap(buffer, bufferOffset).timestamp(System.currentTimeMillis()).sequenceNumber(sequenceNumber.incrementAndGet()); MarketDepthEncoder.BidsEncoder bidsEncoder = marketDepthEncoder.bidsCount(5); for (int i = 0; i < 5; i++) { - bidsEncoder.next() - .price((long) ((basePrice - (i + 1) * 0.01) * 10000)) - .quantity(random.nextInt(5000) + 100); + bidsEncoder.next().price((long) ((basePrice - (i + 1) * 0.01) * 10000)).quantity(currentRandom.nextInt(5000) + 100); } MarketDepthEncoder.AsksEncoder asksEncoder = marketDepthEncoder.asksCount(5); for (int i = 0; i < 5; i++) { - asksEncoder.next() - .price((long) ((basePrice + (i + 1) * 0.01) * 10000)) - .quantity(random.nextInt(5000) + 100); + asksEncoder.next().price((long) ((basePrice + (i + 1) * 0.01) * 10000)).quantity(currentRandom.nextInt(5000) + 100); } marketDepthEncoder.symbol(symbol); @@ -382,13 +528,9 @@ private void publishMarketDepth() { offer(buffer, 0, length, "MarketDepth"); } - /** - * Publish a Heartbeat message - */ + /** Publish a Heartbeat message */ private void publishHeartbeat() { - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - // In DIRECT mode, we just increment counter but don't broadcast heartbeats to UI - // as they are mainly for system health checks in the Aeron log + if (isDirectMode) { messagesPublished.incrementAndGet(); return; } @@ -396,53 +538,138 @@ private void publishHeartbeat() { int bufferOffset = 0; headerEncoder.wrap(buffer, bufferOffset) - .blockLength(heartbeatEncoder.sbeBlockLength()) - .templateId(heartbeatEncoder.sbeTemplateId()) - .schemaId(heartbeatEncoder.sbeSchemaId()) - .version(heartbeatEncoder.sbeSchemaVersion()); + .blockLength(heartbeatEncoder.sbeBlockLength()) + .templateId(heartbeatEncoder.sbeTemplateId()) + .schemaId(heartbeatEncoder.sbeSchemaId()) + .version(heartbeatEncoder.sbeSchemaVersion()); bufferOffset += headerEncoder.encodedLength(); - heartbeatEncoder.wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .sequenceNumber(sequenceNumber.get()); + heartbeatEncoder.wrap(buffer, bufferOffset).timestamp(System.currentTimeMillis()).sequenceNumber(sequenceNumber.get()); final int length = headerEncoder.encodedLength() + heartbeatEncoder.encodedLength(); offer(buffer, 0, length, "Heartbeat"); } - /** - * Offer buffer to Aeron publication with retry logic - */ + /** Offer buffer to Aeron publication with retry logic */ private void offer(UnsafeBuffer buffer, int offset, int length, String messageType) { long result; - int retries = 3; - while (retries > 0) { + for (int retries = 3; retries > 0; retries--) { result = publication.offer(buffer, offset, length); if (result > 0) { messagesPublished.incrementAndGet(); + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + } + } + consecutiveFailures.set(0); return; } else if (result == Publication.BACK_PRESSURED) { - LOGGER.fine("Back pressured on " + messageType); - retries--; - try { - Thread.sleep(1); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + MarketDataEvents.BackpressureEvent bpEvent = new MarketDataEvents.BackpressureEvent(); + if (bpEvent.isEnabled()) { + bpEvent.messageType = messageType; + bpEvent.consecutiveFailures = consecutiveFailures.get() + 1; + bpEvent.result = "BACK_PRESSURED"; + bpEvent.commit(); + } + if (retries > 1) { + continue; } } else if (result == Publication.NOT_CONNECTED) { - LOGGER.warning("Publication not connected"); + logWarningRateLimited("Publication not connected"); + emitBackpressureEvent(messageType, result); + handlePublishFailure(messageType); return; } else { - LOGGER.warning("Offer failed for " + messageType + ": " + result); + logWarningRateLimited("Offer failed for " + messageType + ": " + result); + emitBackpressureEvent(messageType, result); + handlePublishFailure(messageType); return; } } - LOGGER.warning("Failed to publish " + messageType + " after retries"); + logWarningRateLimited("Failed to publish " + messageType + " after retries"); + handlePublishFailure(messageType); + } + + /** Offer Trade message with JFR event emission */ + private void offer(UnsafeBuffer buffer, int offset, int length, String messageType, String symbol, long price, int quantity, String side) { + long result; + + for (int retries = 3; retries > 0; retries--) { + result = publication.offer(buffer, offset, length); + + if (result > 0) { + messagesPublished.incrementAndGet(); + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + } + } + consecutiveFailures.set(0); + + MarketDataEvents.TradePublished event = new MarketDataEvents.TradePublished(); + if (event.isEnabled()) { + event.symbol = symbol; + event.price = price; + event.quantity = quantity; + event.side = side; + event.commit(); + } + return; + } else if (result == Publication.BACK_PRESSURED) { + emitBackpressureEvent("Trade", result); + if (retries > 1) { + continue; + } + } else if (result == Publication.NOT_CONNECTED) { + logWarningRateLimited("Publication not connected"); + emitBackpressureEvent("Trade", result); + handlePublishFailure("Trade"); + return; + } else { + logWarningRateLimited("Offer failed for Trade: " + result); + emitBackpressureEvent("Trade", result); + handlePublishFailure("Trade"); + return; + } + } + + logWarningRateLimited("Failed to publish Trade after retries"); + handlePublishFailure("Trade"); + } + + private void emitBackpressureEvent(String messageType, long resultCode) { + MarketDataEvents.BackpressureEvent event = new MarketDataEvents.BackpressureEvent(); + if (event.isEnabled()) { + event.messageType = messageType; + event.consecutiveFailures = consecutiveFailures.get() + 1; + event.result = resultCode == Publication.BACK_PRESSURED ? "BACK_PRESSURED" + : resultCode == Publication.NOT_CONNECTED ? "NOT_CONNECTED" : String.valueOf(resultCode); + event.commit(); + } + } + + private void logWarningRateLimited(String message) { + long now = System.currentTimeMillis(); + if (now - lastWarningLogTime >= WARNING_LOG_INTERVAL_MS) { + LOGGER.warning(message); + lastWarningLogTime = now; + } + } + + private void handlePublishFailure(String messageType) { + int failures = consecutiveFailures.incrementAndGet(); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + LOGGER.severe(String.format("Circuit breaker triggered: %d consecutive publish failures (last attempted: %s). Stopping message generation.", + failures, messageType)); + running = false; + } } @PreDestroy @@ -450,15 +677,21 @@ public void shutdown() { LOGGER.info("Shutting down Market Data Publisher..."); running = false; - + if (publisherFuture != null) { publisherFuture.cancel(true); } - + if (statsFuture != null) { statsFuture.cancel(true); } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (publication != null) { publication.close(); } @@ -472,4 +705,42 @@ public void shutdown() { public long getMessagesPublished() { return messagesPublished.get(); } + + public long getClusterMessagesPublished() { + if (clusterMessageCounter != null) { + try { + return clusterMessageCounter.get(); + } catch (HazelcastInstanceNotActiveException e) { + LOGGER.log(Level.FINE, "Cluster message counter unavailable (Hazelcast shutting down)"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to read cluster message counter", e); + } + } + return 0; + } + + private volatile long lastMessagesPublishedAtRateCheck = 0; + private volatile long lastRateCheckTime = 0; + + public long getMessageRatePerSecond() { + long now = System.currentTimeMillis(); + long currentCount = messagesPublished.get(); + long elapsedMs = now - lastRateCheckTime; + + if (elapsedMs < 1000) { + return lastMessagesPublishedAtRateCheck; + } + + long rate = (currentCount - lastMessagesPublishedAtRateCheck) * 1000 / elapsedMs; + lastMessagesPublishedAtRateCheck = currentCount; + lastRateCheckTime = now; + return Math.max(rate, 0); + } + + /** + * Check if the publisher is actively running and publishing messages. Used by health check endpoints to verify system readiness. + */ + public boolean isRunning() { + return running; + } } diff --git a/src/main/java/fish/payara/trader/analysis/BarAggregator.java b/src/main/java/fish/payara/trader/analysis/BarAggregator.java new file mode 100644 index 0000000..cfcb014 --- /dev/null +++ b/src/main/java/fish/payara/trader/analysis/BarAggregator.java @@ -0,0 +1,206 @@ +package fish.payara.trader.analysis; + +import fish.payara.trader.concurrency.VirtualThreadExecutor; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.ta4j.core.Bar; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilder; + +/** + * Aggregates raw ticks (trades and quotes) into OHLCV bars for ta4j. One series per symbol, capped at maxBars. A background task closes completed bars and + * opens new ones on the configured interval. + */ +@ApplicationScoped +public class BarAggregator { + + private static final Logger LOGGER = Logger.getLogger(BarAggregator.class.getName()); + + private final ConcurrentHashMap series = new ConcurrentHashMap<>(); + + private record MutableBar(double open, double high, double low, double close, double volume, Instant endTime) { + } + + private final ConcurrentHashMap mutableBars = new ConcurrentHashMap<>(); + + @Inject + private IndicatorConfig config; + + @Inject + @VirtualThreadExecutor + private ManagedExecutorService executorService; + + private ScheduledExecutorService barScheduler; + private Future barTask; + + @PostConstruct + public void init() { + barScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "bar-aggregator"); + t.setDaemon(true); + return t; + }); + + long intervalMs = config.barDurationSeconds() * 1000L; + barTask = barScheduler.scheduleAtFixedRate(this::closeAllBars, intervalMs, intervalMs, TimeUnit.MILLISECONDS); + LOGGER.info("BarAggregator initialized with %d-second bars, max %d bars".formatted(config.barDurationSeconds(), config.maxBars())); + } + + @PreDestroy + public void shutdown() { + if (barTask != null) { + barTask.cancel(false); + } + if (barScheduler != null) { + barScheduler.shutdown(); + } + } + + /** + * Feed a trade into the current bar for the given symbol. + */ + public void onTrade(String symbol, double price, long quantity, long timestamp) { + executorService.submit(() -> updateBar(symbol, price, quantity, timestamp)); + } + + /** + * Feed a quote (bid/ask) into the current bar using the mid-price. + */ + public void onQuote(String symbol, double bidPrice, double askPrice, long timestamp) { + executorService.submit(() -> { + double midPrice = (bidPrice + askPrice) / 2.0; + updateBar(symbol, midPrice, 0, timestamp); + }); + } + + private void updateBar(String symbol, double price, long quantity, long timestamp) { + Instant endTime = Instant.ofEpochMilli(timestamp); + + MutableBar current = mutableBars.compute(symbol, (key, existing) -> { + if (existing == null) { + getOrCreateSeries(symbol); + Instant barEnd = alignToEndOfBar(endTime, config.barDurationSeconds()); + return new MutableBar(price, price, price, price, quantity, barEnd); + } + + Instant barEnd = existing.endTime(); + Instant barStart = barEnd.minus(Duration.ofSeconds(config.barDurationSeconds())); + + if (!timestampIsInBar(endTime, barStart, barEnd)) { + closeBar(symbol); + Instant newBarEnd = alignToEndOfBar(endTime, config.barDurationSeconds()); + return new MutableBar(price, price, price, price, quantity, newBarEnd); + } + + double newHigh = Math.max(existing.high(), price); + double newLow = Math.min(existing.low(), price); + double newVolume = existing.volume() + quantity; + return new MutableBar(existing.open(), newHigh, newLow, price, newVolume, barEnd); + }); + } + + private Instant alignToEndOfBar(Instant timestamp, int barDurationSeconds) { + long epochSeconds = timestamp.getEpochSecond(); + long alignedEnd = ((epochSeconds / barDurationSeconds) + 1) * barDurationSeconds; + return Instant.ofEpochSecond(alignedEnd); + } + + private boolean timestampIsInBar(Instant timestamp, Instant barStart, Instant barEnd) { + return !timestamp.isBefore(barStart) && timestamp.isBefore(barEnd); + } + + private BarSeries getOrCreateSeries(String symbol) { + return series.computeIfAbsent(symbol, k -> { + var srs = new BaseBarSeriesBuilder().withName(k).build(); + srs.setMaximumBarCount(config.maxBars()); + return srs; + }); + } + + private void closeBar(String symbol) { + MutableBar mutable = mutableBars.get(symbol); + if (mutable == null) { + return; + } + + BarSeries srs = getOrCreateSeries(symbol); + try { + var builder = new TimeBarBuilder().timePeriod(Duration.ofSeconds(config.barDurationSeconds())) + .endTime(mutable.endTime()) + .openPrice(mutable.open()) + .highPrice(mutable.high()) + .lowPrice(mutable.low()) + .closePrice(mutable.close()) + .volume(mutable.volume()); + + srs.addBar(builder.build()); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to close bar for " + symbol, e); + } + } + + /** + * Called by the scheduled task to close all open bars and start fresh ones. + */ + private void closeAllBars() { + for (String symbol : mutableBars.keySet()) { + try { + closeBar(symbol); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error closing bar for " + symbol, e); + } + } + mutableBars.clear(); + } + + /** + * Returns the BarSeries for the given symbol, or an empty series if none exists. + */ + public BarSeries getSeries(String symbol) { + return series.getOrDefault(symbol, new BaseBarSeriesBuilder().withName(symbol).build()); + } + + /** + * Returns the current (in-progress) bar if one exists. + */ + public Optional getCurrentBar(String symbol) { + MutableBar mutable = mutableBars.get(symbol); + if (mutable == null) { + return Optional.empty(); + } + try { + var builder = new TimeBarBuilder().timePeriod(Duration.ofSeconds(config.barDurationSeconds())) + .endTime(mutable.endTime()) + .openPrice(mutable.open()) + .highPrice(mutable.high()) + .lowPrice(mutable.low()) + .closePrice(mutable.close()) + .volume(mutable.volume()); + return Optional.of(builder.build()); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * Returns the number of completed bars in the series. + */ + public int getBarCount(String symbol) { + BarSeries srs = series.get(symbol); + return srs != null ? srs.getBarCount() : 0; + } +} diff --git a/src/main/java/fish/payara/trader/analysis/IndicatorConfig.java b/src/main/java/fish/payara/trader/analysis/IndicatorConfig.java new file mode 100644 index 0000000..8930eb1 --- /dev/null +++ b/src/main/java/fish/payara/trader/analysis/IndicatorConfig.java @@ -0,0 +1,108 @@ +package fish.payara.trader.analysis; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * MicroProfile configuration for technical analysis indicator parameters. All values configurable via environment variables or microprofile-config.properties. + */ +@ApplicationScoped +public class IndicatorConfig { + + @Inject + @ConfigProperty(name = "analysis.sma.period", defaultValue = "20") + private int smaPeriod; + + @Inject + @ConfigProperty(name = "analysis.ema.period", defaultValue = "12") + private int emaPeriod; + + @Inject + @ConfigProperty(name = "analysis.rsi.period", defaultValue = "14") + private int rsiPeriod; + + @Inject + @ConfigProperty(name = "analysis.macd.fast", defaultValue = "12") + private int macdFast; + + @Inject + @ConfigProperty(name = "analysis.macd.slow", defaultValue = "26") + private int macdSlow; + + @Inject + @ConfigProperty(name = "analysis.macd.signal", defaultValue = "9") + private int macdSignal; + + @Inject + @ConfigProperty(name = "analysis.bb.period", defaultValue = "20") + private int bbPeriod; + + @Inject + @ConfigProperty(name = "analysis.bb.stddev", defaultValue = "2.0") + private double bbStdDevMultiplier; + + @Inject + @ConfigProperty(name = "analysis.atr.period", defaultValue = "14") + private int atrPeriod; + + @Inject + @ConfigProperty(name = "analysis.bar.duration.seconds", defaultValue = "60") + private int barDurationSeconds; + + @Inject + @ConfigProperty(name = "analysis.max.bars", defaultValue = "500") + private int maxBars; + + @Inject + @ConfigProperty(name = "analysis.broadcast.interval.ms", defaultValue = "1000") + private long broadcastIntervalMs; + + public int smaPeriod() { + return smaPeriod; + } + + public int emaPeriod() { + return emaPeriod; + } + + public int rsiPeriod() { + return rsiPeriod; + } + + public int macdFast() { + return macdFast; + } + + public int macdSlow() { + return macdSlow; + } + + public int macdSignal() { + return macdSignal; + } + + public int bbPeriod() { + return bbPeriod; + } + + public double bbStdDevMultiplier() { + return bbStdDevMultiplier; + } + + public int atrPeriod() { + return atrPeriod; + } + + public int barDurationSeconds() { + return barDurationSeconds; + } + + public int maxBars() { + return maxBars; + } + + public long broadcastIntervalMs() { + return broadcastIntervalMs; + } +} diff --git a/src/main/java/fish/payara/trader/analysis/IndicatorService.java b/src/main/java/fish/payara/trader/analysis/IndicatorService.java new file mode 100644 index 0000000..c866266 --- /dev/null +++ b/src/main/java/fish/payara/trader/analysis/IndicatorService.java @@ -0,0 +1,149 @@ +package fish.payara.trader.analysis; + +import fish.payara.trader.analysis.model.IndicatorSnapshot; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.ta4j.core.BarSeries; +import org.ta4j.core.indicators.ATRIndicator; +import org.ta4j.core.indicators.MACDIndicator; +import org.ta4j.core.indicators.RSIIndicator; +import org.ta4j.core.indicators.averages.EMAIndicator; +import org.ta4j.core.indicators.averages.SMAIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsLowerIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsMiddleIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator; +import org.ta4j.core.indicators.helpers.ClosePriceIndicator; +import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; +import org.ta4j.core.num.Num; + +/** + * Computes technical analysis indicators over aggregated bar data. All indicator families evaluated: SMA, EMA, RSI, MACD, Bollinger Bands (lower/middle/upper), + * and ATR. + */ +@ApplicationScoped +public class IndicatorService { + + private static final Logger LOGGER = Logger.getLogger(IndicatorService.class.getName()); + + @Inject + private BarAggregator barAggregator; + + @Inject + private IndicatorConfig config; + + /** + * Returns a snapshot of all indicators for the given symbol. Returns null if the series has insufficient bars. + */ + public IndicatorSnapshot getSnapshot(String symbol) { + Map values = calculateIndicators(symbol); + if (values.isEmpty()) { + return null; + } + + BarSeries series = barAggregator.getSeries(symbol); + double lastPrice = 0.0; + if (!series.isEmpty()) { + lastPrice = series.getLastBar().getClosePrice().doubleValue(); + } + + return new IndicatorSnapshot(symbol, System.currentTimeMillis(), lastPrice, values); + } + + /** + * Walks the series backward computing indicators at each bar. + */ + public List getHistoricalSnapshots(String symbol, int barCount) { + BarSeries series = barAggregator.getSeries(symbol); + if (series.isEmpty()) { + return List.of(); + } + + int requiredBars = requiredBarCount(); + if (series.getEndIndex() < requiredBars) { + return List.of(); + } + + List snapshots = new ArrayList<>(); + int endIndex = series.getEndIndex(); + int startIndex = Math.max(requiredBars, endIndex - barCount + 1); + + for (int i = startIndex; i <= endIndex; i++) { + BarSeries subSeries = series.getSubSeries(0, i); + Map values = calculateIndicatorsForSeries(subSeries); + if (values.isEmpty()) { + continue; + } + + double lastPrice = subSeries.getLastBar().getClosePrice().doubleValue(); + long timestamp = subSeries.getLastBar().getEndTime().toEpochMilli(); + + snapshots.add(new IndicatorSnapshot(symbol, timestamp, lastPrice, values)); + } + + return snapshots; + } + + /** + * Calculates all indicator families for the given symbol. + */ + public Map calculateIndicators(String symbol) { + BarSeries series = barAggregator.getSeries(symbol); + if (series.isEmpty() || series.getEndIndex() < requiredBarCount()) { + return Map.of(); + } + return calculateIndicatorsForSeries(series); + } + + private Map calculateIndicatorsForSeries(BarSeries series) { + ClosePriceIndicator closePrice = new ClosePriceIndicator(series); + Map values = new LinkedHashMap<>(); + int last = series.getEndIndex(); + + int maxRequired = requiredBarCount(); + if (last < maxRequired) { + return Map.of(); + } + + try { + SMAIndicator sma = new SMAIndicator(closePrice, config.smaPeriod()); + values.put("SMA(%d)".formatted(config.smaPeriod()), sma.getValue(last).doubleValue()); + + EMAIndicator ema = new EMAIndicator(closePrice, config.emaPeriod()); + values.put("EMA(%d)".formatted(config.emaPeriod()), ema.getValue(last).doubleValue()); + + RSIIndicator rsi = new RSIIndicator(closePrice, config.rsiPeriod()); + values.put("RSI(%d)".formatted(config.rsiPeriod()), rsi.getValue(last).doubleValue()); + + MACDIndicator macd = new MACDIndicator(closePrice, config.macdFast(), config.macdSlow()); + values.put("MACD(%d,%d)".formatted(config.macdFast(), config.macdSlow()), macd.getValue(last).doubleValue()); + + BollingerBandsMiddleIndicator bbMiddle = new BollingerBandsMiddleIndicator(sma); + StandardDeviationIndicator sd = new StandardDeviationIndicator(closePrice, config.bbPeriod()); + Num k = series.numFactory().numOf(config.bbStdDevMultiplier()); + BollingerBandsLowerIndicator bbLower = new BollingerBandsLowerIndicator(bbMiddle, sd, k); + BollingerBandsUpperIndicator bbUpper = new BollingerBandsUpperIndicator(bbMiddle, sd, k); + + values.put("BB_Lower(%d)".formatted(config.bbPeriod()), bbLower.getValue(last).doubleValue()); + values.put("BB_Middle(%d)".formatted(config.bbPeriod()), bbMiddle.getValue(last).doubleValue()); + values.put("BB_Upper(%d)".formatted(config.bbPeriod()), bbUpper.getValue(last).doubleValue()); + + ATRIndicator atr = new ATRIndicator(series, config.atrPeriod()); + values.put("ATR(%d)".formatted(config.atrPeriod()), atr.getValue(last).doubleValue()); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to compute indicators: " + e.getMessage(), e); + return Map.of(); + } + + return values; + } + + private int requiredBarCount() { + return Math.max(config.macdSlow(), Math.max(config.bbPeriod(), config.atrPeriod())) + 1; + } +} diff --git a/src/main/java/fish/payara/trader/analysis/model/IndicatorSnapshot.java b/src/main/java/fish/payara/trader/analysis/model/IndicatorSnapshot.java new file mode 100644 index 0000000..3294c0c --- /dev/null +++ b/src/main/java/fish/payara/trader/analysis/model/IndicatorSnapshot.java @@ -0,0 +1,16 @@ +package fish.payara.trader.analysis.model; + +import java.util.Map; + +/** + * Snapshot of computed indicator values for a symbol at a point in time. Values are keyed by indicator label (e.g. "SMA(20)", "RSI(14)"). + */ +public record IndicatorSnapshot(String symbol, long timestamp, double lastPrice, Map values) { + + /** + * Returns the indicator value for the given name, or Double.NaN if absent. + */ + public double getValue(String name) { + return values.getOrDefault(name, Double.NaN); + } +} diff --git a/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java b/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java index a9079aa..caab85b 100644 --- a/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java +++ b/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java @@ -4,14 +4,9 @@ import jakarta.enterprise.context.ApplicationScoped; /** - * Configuration for Jakarta Concurrency resources. - * Defines a ManagedExecutorService that uses Virtual Threads (Project Loom). + * Configuration for Jakarta Concurrency resources. Defines a ManagedExecutorService that uses Virtual Threads (Project Loom). */ @ApplicationScoped -@ManagedExecutorDefinition( - name = "java:module/concurrent/VirtualThreadExecutor", - virtual = true, - qualifiers = {VirtualThreadExecutor.class} -) +@ManagedExecutorDefinition(name = "java:module/concurrent/VirtualThreadExecutor", virtual = true, qualifiers = {VirtualThreadExecutor.class}) public class ConcurrencyConfig { } diff --git a/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java b/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java index 10c85c6..6cdf766 100644 --- a/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java +++ b/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java @@ -6,14 +6,11 @@ import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; +import jakarta.inject.Qualifier; import java.lang.annotation.Retention; import java.lang.annotation.Target; -import jakarta.inject.Qualifier; - -/** - * Qualifier for Virtual Thread ManagedExecutorService - */ +/** Qualifier for Virtual Thread ManagedExecutorService */ @Qualifier @Retention(RUNTIME) @Target({METHOD, FIELD, PARAMETER, TYPE}) diff --git a/src/main/java/fish/payara/trader/demo/DemoPresetConfig.java b/src/main/java/fish/payara/trader/demo/DemoPresetConfig.java new file mode 100644 index 0000000..5fb4037 --- /dev/null +++ b/src/main/java/fish/payara/trader/demo/DemoPresetConfig.java @@ -0,0 +1,114 @@ +package fish.payara.trader.demo; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; +import org.snakeyaml.engine.v2.exceptions.YamlEngineException; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Loads and provides access to demo preset configurations from demo-presets.yml. + */ +@ApplicationScoped +public class DemoPresetConfig { + + private static final Logger LOGGER = Logger.getLogger(DemoPresetConfig.class.getName()); + private static final String CONFIG_PATH = "/demo-presets.yml"; + + private List presets; + + @PostConstruct + public void init() { + this.presets = loadPresets(); + LOGGER.info("Loaded " + presets.size() + " demo presets from configuration"); + } + + /** + * Loads demo presets from YAML configuration file. + */ + private List loadPresets() { + try (InputStream input = getClass().getResourceAsStream(CONFIG_PATH)) { + if (input == null) { + LOGGER.warning("Demo presets configuration file not found: " + CONFIG_PATH); + return List.of(); + } + + LoadSettings settings = LoadSettings.builder().build(); + Load yaml = new Load(settings); + + @SuppressWarnings("unchecked") + Map root = (Map) yaml.loadFromInputStream(input); + List> presetList = (List>) root.get("presets"); + + List result = new ArrayList<>(); + for (Map presetData : presetList) { + result.add(parsePreset(presetData)); + } + + return result; + + } catch (YamlEngineException e) { + LOGGER.log(Level.SEVERE, "Failed to parse demo presets YAML configuration", e); + return List.of(); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to load demo presets configuration", e); + return List.of(); + } + } + + /** + * Parses a single preset from YAML map. + */ + @SuppressWarnings("unchecked") + private DemoPresetDefinition parsePreset(Map data) { + String id = (String) data.get("id"); + String name = (String) data.get("name"); + String description = (String) data.get("description"); + int durationSeconds = ((Number) data.get("durationSeconds")).intValue(); + String expectedImpact = (String) data.get("expectedImpact"); + + List> stepsData = (List>) data.get("steps"); + List steps = stepsData.stream().map(this::parseStep).collect(Collectors.toList()); + + return new DemoPresetDefinition(id, name, description, durationSeconds, expectedImpact, steps); + } + + /** + * Parses a single step from YAML map. + */ + private PresetStep parseStep(Map data) { + String mode = (String) data.get("mode"); + int durationSeconds = ((Number) data.get("durationSeconds")).intValue(); + String description = (String) data.get("description"); + return PresetStep.of(mode, durationSeconds, description); + } + + /** + * Returns all available demo presets. + */ + public List getPresets() { + return List.copyOf(presets); + } + + /** + * Finds a preset by ID. + */ + public DemoPresetDefinition getPresetById(String id) { + return presets.stream().filter(p -> p.id().equals(id)).findFirst().orElse(null); + } + + /** + * Checks if a preset ID exists. + */ + public boolean hasPreset(String id) { + return presets.stream().anyMatch(p -> p.id().equals(id)); + } +} diff --git a/src/main/java/fish/payara/trader/demo/DemoPresetDefinition.java b/src/main/java/fish/payara/trader/demo/DemoPresetDefinition.java new file mode 100644 index 0000000..697630e --- /dev/null +++ b/src/main/java/fish/payara/trader/demo/DemoPresetDefinition.java @@ -0,0 +1,31 @@ +package fish.payara.trader.demo; + +import java.io.Serializable; +import java.util.List; +import java.util.stream.IntStream; + +/** + * Complete definition of a demo preset. Contains metadata and execution steps for client-side orchestration. + */ +public record DemoPresetDefinition(String id, String name, String description, int durationSeconds, String expectedImpact, + List steps) implements Serializable { + + private static final long serialVersionUID = 1L; + /** + * Calculates total duration across all steps. + */ + public int totalDuration() { + return steps.stream().mapToInt(PresetStep::durationSeconds).sum(); + } + + /** + * Creates a new definition with indexed step descriptions. + */ + public DemoPresetDefinition withIndexedSteps() { + List indexed = IntStream.range(0, steps.size()).mapToObj(i -> { + PresetStep original = steps.get(i); + return new PresetStep(original.mode(), original.durationSeconds(), (i + 1) + ". " + original.description()); + }).toList(); + return new DemoPresetDefinition(id, name, description, durationSeconds, expectedImpact, indexed); + } +} diff --git a/src/main/java/fish/payara/trader/demo/DemoPresetService.java b/src/main/java/fish/payara/trader/demo/DemoPresetService.java new file mode 100644 index 0000000..e018cca --- /dev/null +++ b/src/main/java/fish/payara/trader/demo/DemoPresetService.java @@ -0,0 +1,207 @@ +package fish.payara.trader.demo; + +import com.hazelcast.core.HazelcastInstance; +import fish.payara.trader.dto.DemoPresetResponse; +import fish.payara.trader.dto.DemoPresetResponse.PresetStepResponse; +import fish.payara.trader.pressure.AllocationMode; +import fish.payara.trader.pressure.MemoryPressureService; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import java.util.stream.IntStream; + +/** + * Service for managing demo preset execution. Preset steps are executed client-side via REST calls (hybrid approach). + */ +@ApplicationScoped +public class DemoPresetService { + + private static final Logger LOGGER = Logger.getLogger(DemoPresetService.class.getName()); + private static final String EXECUTIONS_MAP_NAME = "demo-preset-executions"; + + @Inject + private DemoPresetConfig config; + + @Inject + private MemoryPressureService pressureService; + + @Inject + private HazelcastInstance hazelcastInstance; + + private Map getActiveExecutions() { + if (hazelcastInstance != null && hazelcastInstance.getLifecycleService().isRunning()) { + return hazelcastInstance.getMap(EXECUTIONS_MAP_NAME); + } + return new HashMap<>(); + } + + /** + * Returns all available demo presets. + */ + public List getAllPresets() { + return config.getPresets().stream().map(this::toResponse).toList(); + } + + /** + * Returns a specific preset by ID. + */ + public DemoPresetResponse getPreset(String id) { + DemoPresetDefinition definition = config.getPresetById(id); + return definition != null ? toResponse(definition) : null; + } + + /** + * Initializes a preset execution session. Returns execution context with step details for client-side orchestration. + */ + public PresetExecutionContext initializeExecution(String presetId) { + DemoPresetDefinition preset = config.getPresetById(presetId); + if (preset == null) { + return null; + } + + String executionId = UUID.randomUUID().toString(); + PresetExecutionContext context = new PresetExecutionContext(executionId, presetId, preset, System.currentTimeMillis()); + + getActiveExecutions().put(executionId, context); + LOGGER.info("Initialized preset execution: " + presetId + " (executionId: " + executionId + ")"); + + return context; + } + + /** + * Executes a single preset step. Called by client for each step in the sequence. + */ + public boolean executeStep(String executionId, int stepIndex) { + Map activeExecutions = getActiveExecutions(); + PresetExecutionContext context = activeExecutions.get(executionId); + if (context == null) { + LOGGER.warning("Execution context not found: " + executionId); + return false; + } + + if (stepIndex < 0 || stepIndex >= context.preset().steps().size()) { + LOGGER.warning("Invalid step index: " + stepIndex); + return false; + } + + PresetStep step = context.preset().steps().get(stepIndex); + try { + AllocationMode mode = AllocationMode.valueOf(step.mode()); + pressureService.setAllocationMode(mode); + context.markStepCompleted(stepIndex); + activeExecutions.put(executionId, context); + LOGGER.info("Executed step " + stepIndex + " of preset " + context.presetId() + ": " + step.mode()); + return true; + } catch (IllegalArgumentException e) { + LOGGER.warning("Invalid allocation mode: " + step.mode()); + return false; + } + } + + /** + * Cancels an active preset execution. + */ + public void cancelExecution(String executionId) { + Map activeExecutions = getActiveExecutions(); + PresetExecutionContext context = activeExecutions.get(executionId); + if (context == null) { + LOGGER.warning("Execution context not found for cancellation: " + executionId); + return; + } + + context.cancel(); + pressureService.setAllocationMode(AllocationMode.OFF); + activeExecutions.remove(executionId); + LOGGER.info("Cancelled preset execution: " + executionId); + } + + /** + * Returns active execution context. + */ + public PresetExecutionContext getExecution(String executionId) { + return getActiveExecutions().get(executionId); + } + + @PreDestroy + public void cleanup() { + getActiveExecutions().clear(); + } + + private DemoPresetResponse toResponse(DemoPresetDefinition definition) { + List steps = IntStream.range(0, definition.steps().size()).mapToObj(i -> { + PresetStep step = definition.steps().get(i); + return PresetStepResponse.of(i, step.mode(), step.durationSeconds(), step.description()); + }).toList(); + + return new DemoPresetResponse(definition.id(), definition.name(), definition.description(), definition.durationSeconds(), definition.expectedImpact(), + steps); + } + + /** + * Execution context for a running demo preset. Must be Serializable for Hazelcast distributed map storage. + */ + public static class PresetExecutionContext implements Serializable { + private static final long serialVersionUID = 1L; + + private final String executionId; + private final String presetId; + private final DemoPresetDefinition preset; + private final long startTime; + private boolean cancelled = false; + private final Map completedSteps = new HashMap<>(); + + public PresetExecutionContext(String executionId, String presetId, DemoPresetDefinition preset, long startTime) { + this.executionId = executionId; + this.presetId = presetId; + this.preset = preset; + this.startTime = startTime; + } + + public synchronized void markStepCompleted(int stepIndex) { + completedSteps.put(stepIndex, System.currentTimeMillis()); + } + + public synchronized void cancel() { + this.cancelled = true; + } + + public String executionId() { + return executionId; + } + + public String presetId() { + return presetId; + } + + public DemoPresetDefinition preset() { + return preset; + } + + public long startTime() { + return startTime; + } + + public synchronized boolean isCancelled() { + return cancelled; + } + + public Map completedSteps() { + return Map.copyOf(completedSteps); + } + + public int currentStepIndex() { + return completedSteps.size(); + } + + public boolean isComplete() { + return completedSteps.size() >= preset.steps().size(); + } + } +} diff --git a/src/main/java/fish/payara/trader/demo/PresetStep.java b/src/main/java/fish/payara/trader/demo/PresetStep.java new file mode 100644 index 0000000..bf91b2c --- /dev/null +++ b/src/main/java/fish/payara/trader/demo/PresetStep.java @@ -0,0 +1,17 @@ +package fish.payara.trader.demo; + +import java.io.Serializable; + +/** + * Single step in a demo preset. Defines a memory pressure mode to apply and its duration. + */ +public record PresetStep(String mode, int durationSeconds, String description) implements Serializable { + /** + * Creates a preset step. + */ + public static PresetStep of(String mode, int durationSeconds, String description) { + return new PresetStep(mode, durationSeconds, description); + } + + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/fish/payara/trader/dto/BusinessImpactResponse.java b/src/main/java/fish/payara/trader/dto/BusinessImpactResponse.java new file mode 100644 index 0000000..924d33b --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/BusinessImpactResponse.java @@ -0,0 +1,16 @@ +package fish.payara.trader.dto; + +/** + * Response DTO for business impact calculations. Shows the business cost of SLA violations in terms of missed trades and revenue. + */ +public record BusinessImpactResponse(int tradeValue, String currency, long messageRate, long slaViolations10ms, long missedTrades, long revenueAtRisk, + double slaCompliancePercent, int instancesNeededC4, int instancesNeededG1, int infrastructureSavingsPercent, long windowSeconds) { + /** + * Factory method for creating impact response with all computed values. Business logic is handled by BusinessImpactCalculator. + */ + public static BusinessImpactResponse of(int tradeValue, String currency, long messageRate, long slaViolations10ms, long missedTrades, long revenueAtRisk, + double slaCompliancePercent, int instancesNeededC4, int instancesNeededG1, int infrastructureSavingsPercent, long windowSeconds) { + return new BusinessImpactResponse(tradeValue, currency, messageRate, slaViolations10ms, missedTrades, revenueAtRisk, slaCompliancePercent, + instancesNeededC4, instancesNeededG1, infrastructureSavingsPercent, windowSeconds); + } +} diff --git a/src/main/java/fish/payara/trader/dto/DemoPresetResponse.java b/src/main/java/fish/payara/trader/dto/DemoPresetResponse.java new file mode 100644 index 0000000..0a26143 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/DemoPresetResponse.java @@ -0,0 +1,17 @@ +package fish.payara.trader.dto; + +import java.util.List; + +/** + * Response DTO for demo preset information. Contains preset metadata and execution steps for client-side orchestration. + */ +public record DemoPresetResponse(String id, String name, String description, int durationSeconds, String expectedImpact, List steps) { + /** + * Individual step in a demo preset. + */ + public record PresetStepResponse(int stepIndex, String mode, int durationSeconds, String description) { + public static PresetStepResponse of(int index, String mode, int duration, String description) { + return new PresetStepResponse(index, mode, duration, description); + } + } +} diff --git a/src/main/java/fish/payara/trader/dto/GCComparisonResponse.java b/src/main/java/fish/payara/trader/dto/GCComparisonResponse.java new file mode 100644 index 0000000..c617ed4 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/GCComparisonResponse.java @@ -0,0 +1,25 @@ +package fish.payara.trader.dto; + +import fish.payara.trader.gc.GCStats; +import fish.payara.trader.monitoring.GCPauseMonitor.GCPauseStats; + +import java.util.List; + +/** + * Response DTO for GC comparison data. Provides comprehensive GC metrics for C4 vs G1 comparison. + */ +public record GCComparisonResponse(String instanceName, String jvmVendor, String jvmName, String gcCollectors, boolean isAzulC4, long heapSizeMB, + String allocationMode, int allocationRateMBps, long messageRate, List gcStats, double pauseP50Ms, double pauseP95Ms, double pauseP99Ms, + double pauseP999Ms, double pauseMaxMs, double pauseAvgMs, long totalPauseCount, long totalPauseTimeMs, long slaViolations10ms, + long slaViolations50ms, long slaViolations100ms, int pauseSampleSize) { + /** + * Creates response from collected GC data. + */ + public static GCComparisonResponse from(String instanceName, String jvmVendor, String jvmName, String gcCollectors, boolean isAzulC4, long heapSizeMB, + String allocationMode, int allocationRateMBps, long messageRate, List gcStats, GCPauseStats pauseStats) { + return new GCComparisonResponse(instanceName, jvmVendor, jvmName, gcCollectors, isAzulC4, heapSizeMB, allocationMode, allocationRateMBps, messageRate, + gcStats, pauseStats.p50Ms, pauseStats.p95Ms, pauseStats.p99Ms, pauseStats.p999Ms, pauseStats.maxMs, pauseStats.avgPauseMs, + pauseStats.totalPauseCount, pauseStats.totalPauseTimeMs, pauseStats.violationsOver10ms, pauseStats.violationsOver50ms, + pauseStats.violationsOver100ms, pauseStats.sampleSize); + } +} diff --git a/src/main/java/fish/payara/trader/dto/IndicatorResponse.java b/src/main/java/fish/payara/trader/dto/IndicatorResponse.java new file mode 100644 index 0000000..271747c --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/IndicatorResponse.java @@ -0,0 +1,29 @@ +package fish.payara.trader.dto; + +import java.util.Map; + +/** + * DTO for indicator snapshot responses. Includes manual JSON serialization. + */ +public record IndicatorResponse(String symbol, long timestamp, Map indicators) { + + public String toJson() { + StringBuilder sb = new StringBuilder(); + sb.append("{\"symbol\":\"").append(symbol).append('"'); + sb.append(",\"timestamp\":").append(timestamp); + sb.append(",\"indicators\":{"); + + boolean first = true; + for (Map.Entry entry : indicators.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + sb.append('"').append(entry.getKey()).append('"'); + sb.append(':').append(entry.getValue()); + } + + sb.append("}}"); + return sb.toString(); + } +} diff --git a/src/main/java/fish/payara/trader/dto/PortfolioResponse.java b/src/main/java/fish/payara/trader/dto/PortfolioResponse.java new file mode 100644 index 0000000..bbb6515 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/PortfolioResponse.java @@ -0,0 +1,11 @@ +package fish.payara.trader.dto; + +import fish.payara.trader.portfolio.model.PerformanceMetrics; +import fish.payara.trader.portfolio.model.PortfolioSnapshot; +import fish.payara.trader.portfolio.model.RebalancePlan; + +/** + * Aggregated portfolio response DTO. + */ +public record PortfolioResponse(PortfolioSnapshot snapshot, PerformanceMetrics metrics, RebalancePlan rebalancePlan) { +} diff --git a/src/main/java/fish/payara/trader/dto/PressureStatusResponse.java b/src/main/java/fish/payara/trader/dto/PressureStatusResponse.java new file mode 100644 index 0000000..fdfeca7 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/PressureStatusResponse.java @@ -0,0 +1,17 @@ +package fish.payara.trader.dto; + +import fish.payara.trader.pressure.AllocationMode; + +/** + * Response DTO for memory pressure status. Provides type-safe API contract replacing raw Map usage. + */ +public record PressureStatusResponse(String currentMode, String description, boolean running, int allocationRateMBPerSec, int liveSetSizeMB, + String scenarioType, String workloadType) { + /** + * Creates response from current AllocationMode state. + */ + public static PressureStatusResponse from(AllocationMode mode, boolean running) { + return new PressureStatusResponse(mode.name(), mode.getDescription(), running, mode.getAllocationRateMBPerSec(), mode.getLiveSetSizeMB(), + mode.getScenarioType().name(), mode.getWorkloadType().name()); + } +} diff --git a/src/main/java/fish/payara/trader/dto/RiskResponse.java b/src/main/java/fish/payara/trader/dto/RiskResponse.java new file mode 100644 index 0000000..14877d7 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/RiskResponse.java @@ -0,0 +1,14 @@ +package fish.payara.trader.dto; + +import fish.payara.trader.risk.model.ExposureSummary; +import fish.payara.trader.risk.model.RiskSnapshot; +import fish.payara.trader.risk.model.StressResult; +import fish.payara.trader.risk.model.VarResult; +import java.util.List; + +/** + * Aggregated risk response DTO combining per-symbol risk, exposure, VaR, and stress test results. + */ +public record RiskResponse(List positions, ExposureSummary exposure, VarResult historicalVar, VarResult parametricVar, + List stressResults) { +} diff --git a/src/main/java/fish/payara/trader/dto/SLAViolationEvent.java b/src/main/java/fish/payara/trader/dto/SLAViolationEvent.java new file mode 100644 index 0000000..3f5e472 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/SLAViolationEvent.java @@ -0,0 +1,19 @@ +package fish.payara.trader.dto; + +/** + * WebSocket event for SLA violation alerts. Pushed to frontend for real-time visual feedback (flashing). + */ +public record SLAViolationEvent(String type, long pauseTimeMs, String threshold, long timestamp, String instanceName) { + public static SLAViolationEvent create(long pauseTimeMs, String threshold, String instanceName) { + return new SLAViolationEvent("sla-violation", pauseTimeMs, threshold, System.currentTimeMillis(), instanceName); + } + + /** + * Converts to JSON for WebSocket transmission. NOTE: Manual JSON construction is intentional - it generates garbage to stress-test the garbage collector + * for demo purposes. + */ + public String toJson() { + return "{\"type\":\"" + type + "\",\"pauseTimeMs\":" + pauseTimeMs + ",\"threshold\":\"" + threshold + "\",\"timestamp\":" + timestamp + + ",\"instanceName\":\"" + instanceName + "\"}"; + } +} diff --git a/src/main/java/fish/payara/trader/dto/TradingRiskMetrics.java b/src/main/java/fish/payara/trader/dto/TradingRiskMetrics.java new file mode 100644 index 0000000..79997f2 --- /dev/null +++ b/src/main/java/fish/payara/trader/dto/TradingRiskMetrics.java @@ -0,0 +1,14 @@ +package fish.payara.trader.dto; + +/** + * DTO for the trading desk frontend risk metrics display. + */ +public record TradingRiskMetrics(double valueAtRisk95, double totalExposure, double netDelta, double maxDrawdown, StressTestMetrics stressTests) { + + public record StressTestMetrics(double flashCrash, double volatilitySpike) { + } + + public static TradingRiskMetrics empty() { + return new TradingRiskMetrics(0, 0, 0, 0, new StressTestMetrics(0, 0)); + } +} diff --git a/src/main/java/fish/payara/trader/gc/GCStats.java b/src/main/java/fish/payara/trader/gc/GCStats.java new file mode 100644 index 0000000..b0f07ff --- /dev/null +++ b/src/main/java/fish/payara/trader/gc/GCStats.java @@ -0,0 +1,159 @@ +package fish.payara.trader.gc; + +import java.util.List; +import java.util.Map; + +public class GCStats { + private String gcName; + private long collectionCount; + private long collectionTime; + private long lastPauseDuration; + private List recentPauses; + private PausePercentiles percentiles; + private long totalMemory; + private long usedMemory; + private long freeMemory; + private Map phaseBreakdown; + + public static class PausePercentiles { + private long p50; + private long p95; + private long p99; + private long p999; + private long max; + + public PausePercentiles() { + } + + public PausePercentiles(long p50, long p95, long p99, long p999, long max) { + this.p50 = p50; + this.p95 = p95; + this.p99 = p99; + this.p999 = p999; + this.max = max; + } + + public long getP50() { + return p50; + } + + public void setP50(long p50) { + this.p50 = p50; + } + + public long getP95() { + return p95; + } + + public void setP95(long p95) { + this.p95 = p95; + } + + public long getP99() { + return p99; + } + + public void setP99(long p99) { + this.p99 = p99; + } + + public long getP999() { + return p999; + } + + public void setP999(long p999) { + this.p999 = p999; + } + + public long getMax() { + return max; + } + + public void setMax(long max) { + this.max = max; + } + } + + public GCStats() { + } + + public String getGcName() { + return gcName; + } + + public void setGcName(String gcName) { + this.gcName = gcName; + } + + public long getCollectionCount() { + return collectionCount; + } + + public void setCollectionCount(long collectionCount) { + this.collectionCount = collectionCount; + } + + public long getCollectionTime() { + return collectionTime; + } + + public void setCollectionTime(long collectionTime) { + this.collectionTime = collectionTime; + } + + public long getLastPauseDuration() { + return lastPauseDuration; + } + + public void setLastPauseDuration(long lastPauseDuration) { + this.lastPauseDuration = lastPauseDuration; + } + + public List getRecentPauses() { + return recentPauses; + } + + public void setRecentPauses(List recentPauses) { + this.recentPauses = recentPauses; + } + + public PausePercentiles getPercentiles() { + return percentiles; + } + + public void setPercentiles(PausePercentiles percentiles) { + this.percentiles = percentiles; + } + + public long getTotalMemory() { + return totalMemory; + } + + public void setTotalMemory(long totalMemory) { + this.totalMemory = totalMemory; + } + + public long getUsedMemory() { + return usedMemory; + } + + public void setUsedMemory(long usedMemory) { + this.usedMemory = usedMemory; + } + + public long getFreeMemory() { + return freeMemory; + } + + public void setFreeMemory(long freeMemory) { + this.freeMemory = freeMemory; + } + + public Map getPhaseBreakdown() { + return phaseBreakdown; + } + + public void setPhaseBreakdown(Map phaseBreakdown) { + this.phaseBreakdown = phaseBreakdown; + } +} diff --git a/src/main/java/fish/payara/trader/gc/GCStatsService.java b/src/main/java/fish/payara/trader/gc/GCStatsService.java new file mode 100644 index 0000000..4eaa8ab --- /dev/null +++ b/src/main/java/fish/payara/trader/gc/GCStatsService.java @@ -0,0 +1,287 @@ +package fish.payara.trader.gc; + +import com.sun.management.GarbageCollectionNotificationInfo; +import com.sun.management.GcInfo; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.util.*; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.management.Notification; +import javax.management.NotificationEmitter; +import javax.management.NotificationListener; +import javax.management.openmbean.CompositeData; + +@ApplicationScoped +public class GCStatsService implements NotificationListener { + + private static final Logger LOGGER = Logger.getLogger(GCStatsService.class.getName()); + private static final int MAX_PAUSE_HISTORY = 1000; + + private final Map> pauseHistory = new HashMap<>(); + private final Map> phaseHistory = new HashMap<>(); + + /** + * Breakdown of GC pause into constituent phases (e.g., Mark, Relocate, Evacuate). + * + *

+ * Not all JVMs expose detailed phase information via GcInfo. When unavailable, the breakdown will contain only a "Total" phase with the full pause + * duration. This is a best-effort extraction that varies by GC implementation (G1, C4, ZGC, etc.). + */ + public static class GCPhaseBreakdown { + public final long totalDurationMs; + public final Map phaseDurationsMs; + public final long timestamp; + + public GCPhaseBreakdown(long totalDurationMs, Map phaseDurationsMs, long timestamp) { + this.totalDurationMs = totalDurationMs; + this.phaseDurationsMs = Map.copyOf(phaseDurationsMs); // Immutable copy + this.timestamp = timestamp; + } + } + + /** + * Statistics for a single GC phase across multiple collections. + * + *

+ * Provides percentile distribution of phase durations to identify which phases dominate pause times. + */ + public static class PhaseStats { + public final String phaseName; + public final long count; + public final long p50Ms; + public final long p95Ms; + public final long p99Ms; + public final long maxMs; + + public PhaseStats(String phaseName, long count, long p50Ms, long p95Ms, long p99Ms, long maxMs) { + this.phaseName = phaseName; + this.count = count; + this.p50Ms = p50Ms; + this.p95Ms = p95Ms; + this.p99Ms = p99Ms; + this.maxMs = maxMs; + } + } + + @PostConstruct + public void init() { + LOGGER.info("Initializing GC Notification Listener..."); + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + for (GarbageCollectorMXBean gcBean : gcBeans) { + LOGGER.info("Registering listener for GC Bean: " + gcBean.getName()); + if (gcBean instanceof NotificationEmitter) { + ((NotificationEmitter) gcBean).addNotificationListener(this, null, null); + } + } + } + + @Override + public void handleNotification(Notification notification, Object handback) { + if (notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { + GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from((CompositeData) notification.getUserData()); + + String gcName = info.getGcName(); + String gcAction = info.getGcAction(); + String gcCause = info.getGcCause(); + GcInfo gcInfo = info.getGcInfo(); + long duration = gcInfo.getDuration(); + + // FILTERING LOGIC: + // Azul C4 exposes "GPGC" (Concurrent Cycle) and "GPGC Pauses" (STW Pauses). + // We MUST ignore "GPGC" because it reports cycle time (hundreds of ms) which is NOT a pause. + if ("GPGC".equals(gcName)) { + return; + } + + // Also ignore other known concurrent cycle beans if they appear + if (gcName.contains("Cycles") && !gcName.contains("Pauses")) { + return; + } + + // Pause duration history (existing logic) + ConcurrentLinkedDeque history = pauseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); + history.addLast(duration); + while (history.size() > MAX_PAUSE_HISTORY) { + history.removeFirst(); + } + + // Phase breakdown extraction (new logic) + Map phaseTimes = extractPhaseTimes(gcInfo, gcName, gcAction); + GCPhaseBreakdown breakdown = new GCPhaseBreakdown(duration, phaseTimes, System.currentTimeMillis()); + + ConcurrentLinkedDeque phaseHist = phaseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); + phaseHist.addLast(breakdown); + while (phaseHist.size() > MAX_PAUSE_HISTORY) { + phaseHist.removeFirst(); + } + + if (duration > 10) { + LOGGER.info(String.format("GC Pause detected: %s | Action: %s | Cause: %s | Duration: %d ms", gcName, gcAction, gcCause, duration)); + } + } + } + + /** + * Extracts GC phase timings from GcInfo metadata. + * + *

+ * This is JVM and collector-specific - detailed phase data may not be available on all platforms. The standard GcInfo API does not expose phase-level + * timing; vendors may provide this via custom MBeans or extended attributes. + * + *

+ * For production-grade phase analysis, consider parsing GC logs with -Xlog:gc*=debug or using vendor-specific monitoring tools. + * + * @param gcInfo + * GC information from notification + * @param gcName + * Name of garbage collector (e.g., "G1 Young Generation", "GPGC Pauses") + * @param gcAction + * GC action string (e.g., "end of minor GC", "end of major GC") + * @return Map of phase names to durations in milliseconds. Contains at minimum a "Total" entry with the full pause duration. + */ + private Map extractPhaseTimes(GcInfo gcInfo, String gcName, String gcAction) { + Map phases = new HashMap<>(); + + try { + // Fallback: Always include total duration as baseline + phases.put("Total", gcInfo.getDuration()); + + // Vendor-specific extraction would go here + // GcInfo doesn't expose phases in the standard API + // + // For G1GC: Would need to parse GC logs or use JFR events + // For C4: Would need Azul-specific MBeans + // For ZGC/Shenandoah: Would need to parse logs or use vendor tools + // + // Current implementation provides "Total" phase as a foundation. + // Future enhancement: Add vendor-specific extraction logic. + + } catch (Exception e) { + LOGGER.fine("Could not extract GC phase breakdown: " + e.getMessage()); + phases.clear(); + phases.put("Total", gcInfo.getDuration()); + } + + return phases; + } + + public List collectGCStats() { + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage(); + + List statsList = new ArrayList<>(); + + for (GarbageCollectorMXBean gcBean : gcBeans) { + String gcName = gcBean.getName(); + + // Exclude concurrent cycle collectors from the public stats to avoid confusion + if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { + continue; + } + + GCStats stats = new GCStats(); + stats.setGcName(gcName); + stats.setCollectionCount(gcBean.getCollectionCount()); + stats.setCollectionTime(gcBean.getCollectionTime()); + + ConcurrentLinkedDeque history = pauseHistory.get(gcName); + if (history != null && !history.isEmpty()) { + List pauses = new ArrayList<>(history); + stats.setLastPauseDuration(pauses.get(pauses.size() - 1)); + + stats.setRecentPauses(pauses.subList(Math.max(0, pauses.size() - 100), pauses.size())); + + List sortedPauses = pauses.stream().sorted().collect(Collectors.toList()); + + stats.setPercentiles(calculatePercentiles(sortedPauses)); + } else { + stats.setLastPauseDuration(0); + stats.setRecentPauses(Collections.emptyList()); + stats.setPercentiles(new GCStats.PausePercentiles(0, 0, 0, 0, 0)); + } + + stats.setTotalMemory(heapUsage.getMax()); + stats.setUsedMemory(heapUsage.getUsed()); + stats.setFreeMemory(heapUsage.getMax() - heapUsage.getUsed()); + + ConcurrentLinkedDeque phaseHist = phaseHistory.get(gcName); + if (phaseHist != null && !phaseHist.isEmpty()) { + stats.setPhaseBreakdown(calculatePhaseStats(new ArrayList<>(phaseHist))); + } + + statsList.add(stats); + } + + return statsList; + } + + private GCStats.PausePercentiles calculatePercentiles(List sortedPauses) { + if (sortedPauses.isEmpty()) { + return new GCStats.PausePercentiles(0, 0, 0, 0, 0); + } + + int size = sortedPauses.size(); + return new GCStats.PausePercentiles(percentile(sortedPauses, 0.50), percentile(sortedPauses, 0.95), percentile(sortedPauses, 0.99), + percentile(sortedPauses, 0.999), sortedPauses.get(size - 1)); + } + + private long percentile(List sortedValues, double percentile) { + int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; + index = Math.max(0, Math.min(index, sortedValues.size() - 1)); + return sortedValues.get(index); + } + + /** + * Calculates per-phase statistics from breakdown history. + * + *

+ * Groups phase durations by phase name and computes percentiles for each phase independently. This allows identifying which phases contribute most to pause + * times (e.g., "Mark" vs "Evacuate" in G1GC). + * + * @param breakdowns + * List of GC phase breakdowns from notification history + * @return Map of phase names to statistics (count, percentiles) + */ + private Map calculatePhaseStats(List breakdowns) { + Map phaseStatsMap = new HashMap<>(); + + if (breakdowns.isEmpty()) { + return phaseStatsMap; + } + + // Group durations by phase name + Map> phaseGroups = new HashMap<>(); + for (GCPhaseBreakdown breakdown : breakdowns) { + for (Map.Entry entry : breakdown.phaseDurationsMs.entrySet()) { + phaseGroups.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue()); + } + } + + for (Map.Entry> entry : phaseGroups.entrySet()) { + String phaseName = entry.getKey(); + List durations = entry.getValue(); + Collections.sort(durations); + + PhaseStats stats = new PhaseStats(phaseName, durations.size(), percentile(durations, 0.50), percentile(durations, 0.95), + percentile(durations, 0.99), durations.get(durations.size() - 1) // max + ); + + phaseStatsMap.put(phaseName, stats); + } + + return phaseStatsMap; + } + + public void resetStats() { + pauseHistory.clear(); + phaseHistory.clear(); + LOGGER.info("GC statistics reset"); + } +} diff --git a/src/main/java/fish/payara/trader/impact/BusinessImpactCalculator.java b/src/main/java/fish/payara/trader/impact/BusinessImpactCalculator.java new file mode 100644 index 0000000..dde97d6 --- /dev/null +++ b/src/main/java/fish/payara/trader/impact/BusinessImpactCalculator.java @@ -0,0 +1,76 @@ +package fish.payara.trader.impact; + +import fish.payara.trader.aeron.MarketDataPublisher; +import fish.payara.trader.dto.BusinessImpactResponse; +import fish.payara.trader.monitoring.GCPauseMonitor; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Calculates business impact of SLA violations. Shows missed trades and revenue at risk based on GC pause behavior. + */ +@ApplicationScoped +public class BusinessImpactCalculator { + + @Inject + private BusinessImpactConfig config; + + @Inject + private GCPauseMonitor gcPauseMonitor; + + @Inject + private MarketDataPublisher marketDataPublisher; + + /** + * Calculates current business impact based on SLA violations and message rate. + */ + public BusinessImpactResponse calculateImpact() { + GCPauseMonitor.GCPauseStats stats = gcPauseMonitor.getStats(); + long messageRate = marketDataPublisher.getMessageRatePerSecond(); + long slaViolations10ms = stats.violationsOver10ms; + + double slaCompliancePercent = calculateSLACompliance(stats.totalPauseCount, slaViolations10ms); + long missedTrades = calculateMissedTrades(messageRate, slaViolations10ms); + long revenueAtRisk = missedTrades * config.tradeValue(); + int savingsPercent = calculateSavingsPercent(config.instancesNeededC4(), config.instancesNeededG1()); + + return BusinessImpactResponse.of(config.tradeValue(), config.currency(), messageRate, slaViolations10ms, missedTrades, revenueAtRisk, + slaCompliancePercent, config.instancesNeededC4(), config.instancesNeededG1(), savingsPercent, config.windowSeconds()); + } + + /** + * Resets business impact calculations by resetting underlying SLA statistics. + */ + public void reset() { + gcPauseMonitor.reset(); + } + + /** + * Calculates SLA compliance percentage. SLA is met if no pauses exceed 10ms. + */ + private double calculateSLACompliance(long totalPauses, long violations) { + if (totalPauses == 0) { + return 100.0; + } + double complianceRate = (totalPauses - violations) / (double) totalPauses; + return complianceRate * 100.0; + } + + /** + * Estimate missed trades based on SLA violations and message rate. Assumes each 10ms violation blocks approximately 1ms of message processing. + */ + private long calculateMissedTrades(long messageRate, long violations) { + double messagesPerMs = messageRate / 1000.0; + return (long) (violations * messagesPerMs); + } + + /** + * Calculate infrastructure savings percentage between C4 and G1 instances. + */ + private int calculateSavingsPercent(int instancesNeededC4, int instancesNeededG1) { + if (instancesNeededG1 <= 0) { + return 0; + } + return (instancesNeededG1 - instancesNeededC4) * 100 / instancesNeededG1; + } +} diff --git a/src/main/java/fish/payara/trader/impact/BusinessImpactConfig.java b/src/main/java/fish/payara/trader/impact/BusinessImpactConfig.java new file mode 100644 index 0000000..dd5997e --- /dev/null +++ b/src/main/java/fish/payara/trader/impact/BusinessImpactConfig.java @@ -0,0 +1,52 @@ +package fish.payara.trader.impact; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * Configuration for business impact calculations. Values are configurable via environment variables or microprofile-config.properties. + */ +@ApplicationScoped +public class BusinessImpactConfig { + + @Inject + @ConfigProperty(name = "business.impact.per.trade", defaultValue = "25000") + private int tradeValue; + + @Inject + @ConfigProperty(name = "business.impact.currency", defaultValue = "USD") + private String currency; + + @Inject + @ConfigProperty(name = "business.impact.window.seconds", defaultValue = "60") + private long windowSeconds; + + @Inject + @ConfigProperty(name = "business.impact.instances.c4", defaultValue = "3") + private int instancesNeededC4; + + @Inject + @ConfigProperty(name = "business.impact.instances.g1", defaultValue = "5") + private int instancesNeededG1; + + public int tradeValue() { + return tradeValue; + } + + public String currency() { + return currency; + } + + public long windowSeconds() { + return windowSeconds; + } + + public int instancesNeededC4() { + return instancesNeededC4; + } + + public int instancesNeededG1() { + return instancesNeededG1; + } +} diff --git a/src/main/java/fish/payara/trader/jfr/MarketDataEvents.java b/src/main/java/fish/payara/trader/jfr/MarketDataEvents.java new file mode 100644 index 0000000..204932f --- /dev/null +++ b/src/main/java/fish/payara/trader/jfr/MarketDataEvents.java @@ -0,0 +1,147 @@ +package fish.payara.trader.jfr; + +import jdk.jfr.Event; +import jdk.jfr.Label; +import jdk.jfr.Name; +import jdk.jfr.Category; +import jdk.jfr.StackTrace; + +/** + * Custom JFR events for Market Data Pipeline monitoring. + * + *

+ * These events provide domain-specific insights into the HFT trading simulation: + *

    + *
  • Message publishing rates and timing
  • + *
  • SBE encoding/decoding performance
  • + *
  • WebSocket broadcast latency
  • + *
  • GC SLA violations
  • + *
  • Aeron backpressure events
  • + *
+ * + *

+ * Usage: Events are emitted only when enabled. Check {@link Event#isEnabled()} before committing to avoid unnecessary overhead. + */ +@Category("Market Data") +@Label("Market Data Processing") +public class MarketDataEvents { + + /** + * Emitted when a trade message is published to Aeron or WebSocket. Stack trace disabled for minimal overhead on hot path. + */ + @Name("trade.published") + @Label("Trade Published") + @StackTrace(false) + public static class TradePublished extends Event { + public String symbol; + public long price; + public int quantity; + public String side; + } + + /** + * Emitted when a quote message is published. + */ + @Name("quote.published") + @Label("Quote Published") + @StackTrace(false) + public static class QuotePublished extends Event { + public String symbol; + public long bidPrice; + public long askPrice; + public int bidSize; + public int askSize; + } + + /** + * Emitted when a market depth message is published. + */ + @Name("marketdepth.published") + @Label("Market Depth Published") + @StackTrace(false) + public static class MarketDepthPublished extends Event { + public String symbol; + public int depthLevels; + public long sequenceNumber; + } + + /** + * Emitted after processing a batch of messages. Tracks throughput and latency for the ingestion pipeline. + */ + @Name("message.batch.processed") + @Label("Message Batch Processed") + public static class BatchProcessed extends Event { + public int messageCount; + public long processingTimeNanos; + public String source; + } + + /** + * Emitted when broadcasting to WebSocket clients. Tracks client load and message size distribution. + */ + @Name("websocket.broadcast") + @Label("WebSocket Broadcast") + @StackTrace(false) + public static class WebSocketBroadcast extends Event { + public int clientCount; + public int messageSizeBytes; + public String messageType; + } + + /** + * Emitted after SBE encode operation completes. Measures binary encoding performance. + */ + @Name("sbe.encode") + @Label("SBE Encode Operation") + @StackTrace(false) + public static class SbeEncode extends Event { + public String messageType; + public int encodedBytes; + public long encodeTimeNanos; + } + + /** + * Emitted after SBE decode operation completes. Measures binary decoding performance in the fragment handler. + */ + @Name("sbe.decode") + @Label("SBE Decode Operation") + @StackTrace(false) + public static class SbeDecode extends Event { + public String messageType; + public int decodedBytes; + public long decodeTimeNanos; + } + + /** + * Emitted when a GC pause exceeds SLA threshold. Correlates GC behavior with application performance degradation. + */ + @Name("gc.sla.violation") + @Label("GC SLA Violation") + public static class SlaViolation extends Event { + public long pauseTimeMillis; + public String threshold; + public long violationsInWindow; + } + + /** + * Emitted when Aeron publication experiences backpressure. Indicates consumer cannot keep up with producer rate. + */ + @Name("aeron.backpressure") + @Label("Aeron Backpressure Event") + public static class BackpressureEvent extends Event { + public String messageType; + public int consecutiveFailures; + public String result; + } + + /** + * Emitted during burst mode activation. Tracks when the system enters high-allocation phases for GC stress testing. + */ + @Name("burst.mode.activated") + @Label("Burst Mode Activated") + public static class BurstModeActivated extends Event { + public int multiplier; + public String reason; + public long secondOfMinute; + } +} diff --git a/src/main/java/fish/payara/trader/matching/book/CancelResult.java b/src/main/java/fish/payara/trader/matching/book/CancelResult.java new file mode 100644 index 0000000..b59fb77 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/book/CancelResult.java @@ -0,0 +1,22 @@ +package fish.payara.trader.matching.book; + +import fish.payara.trader.matching.model.Order; + +public record CancelResult(boolean canceled, String reason, Order canceledOrder) { + + public static CancelResult success(Order order) { + return new CancelResult(true, "OK", order); + } + + public static CancelResult notFound(long orderId) { + return new CancelResult(false, "Order not found: " + orderId, null); + } + + public static CancelResult alreadyFilled(long orderId) { + return new CancelResult(false, "Order already filled: " + orderId, null); + } + + public static CancelResult alreadyCanceled(long orderId) { + return new CancelResult(false, "Order already canceled: " + orderId, null); + } +} diff --git a/src/main/java/fish/payara/trader/matching/book/OrderBook.java b/src/main/java/fish/payara/trader/matching/book/OrderBook.java new file mode 100644 index 0000000..cb11adc --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/book/OrderBook.java @@ -0,0 +1,36 @@ +package fish.payara.trader.matching.book; + +import fish.payara.trader.matching.model.Execution; +import fish.payara.trader.matching.model.Order; +import fish.payara.trader.matching.model.OrderBookSnapshot; +import fish.payara.trader.matching.model.Price; + +import java.util.List; +import java.util.Optional; + +public interface OrderBook { + + String getSymbol(); + + void addOrder(Order order); + + CancelResult cancelOrder(long orderId); + + List matchOrder(Order incomingOrder); + + List matchAgainstMarket(Price bidPrice, Price askPrice, long bidSize, long askSize); + + OrderBookSnapshot getSnapshot(int maxDepth); + + int bidDepth(); + + int askDepth(); + + Optional bestBid(); + + Optional bestAsk(); + + boolean hasOrders(); + + void clear(); +} diff --git a/src/main/java/fish/payara/trader/matching/book/OrderBookFactory.java b/src/main/java/fish/payara/trader/matching/book/OrderBookFactory.java new file mode 100644 index 0000000..42fb085 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/book/OrderBookFactory.java @@ -0,0 +1,24 @@ +package fish.payara.trader.matching.book; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.concurrent.ConcurrentHashMap; + +@ApplicationScoped +public class OrderBookFactory { + + private final ConcurrentHashMap books = new ConcurrentHashMap<>(); + + public OrderBook getOrCreate(String symbol) { + return books.computeIfAbsent(symbol, k -> new PriceTimeOrderBook(k)); + } + + public OrderBook get(String symbol) { + return books.get(symbol); + } + + public void clearAll() { + books.values().forEach(OrderBook::clear); + books.clear(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/book/PriceTimeOrderBook.java b/src/main/java/fish/payara/trader/matching/book/PriceTimeOrderBook.java new file mode 100644 index 0000000..071186a --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/book/PriceTimeOrderBook.java @@ -0,0 +1,308 @@ +package fish.payara.trader.matching.book; + +import fish.payara.trader.matching.model.*; + +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Price-time priority order book. Bids sorted descending (best = highest), asks sorted ascending (best = lowest). + */ +public class PriceTimeOrderBook implements OrderBook { + + private static final Logger LOGGER = Logger.getLogger(PriceTimeOrderBook.class.getName()); + + private final String symbol; + private final NavigableMap> bids = new TreeMap<>(Comparator.reverseOrder()); + private final NavigableMap> asks = new TreeMap<>(); + private final Map orderIndex = new HashMap<>(); + + private final AtomicLong executionIdGenerator = new AtomicLong(System.currentTimeMillis() * 10000); + + public PriceTimeOrderBook(String symbol) { + this.symbol = symbol; + } + + @Override + public String getSymbol() { + return symbol; + } + + @Override + public void addOrder(Order order) { + synchronized (this) { + OrderBookEntry entry = new OrderBookEntry(order, System.currentTimeMillis()); + orderIndex.put(order.orderId(), entry); + + var book = order.side() == Side.BUY ? bids : asks; + book.computeIfAbsent(order.price(), k -> new LinkedList<>()).addLast(entry); + } + } + + @Override + public CancelResult cancelOrder(long orderId) { + synchronized (this) { + OrderBookEntry entry = orderIndex.remove(orderId); + if (entry == null) { + return CancelResult.notFound(orderId); + } + Order order = entry.order(); + if (order.isTerminal()) { + return order.status() == OrderStatus.FILLED ? CancelResult.alreadyFilled(orderId) : CancelResult.alreadyCanceled(orderId); + } + + var book = order.side() == Side.BUY ? bids : asks; + var level = book.get(order.price()); + if (level != null) { + level.removeIf(e -> e.order().orderId() == orderId); + if (level.isEmpty()) { + book.remove(order.price()); + } + } + return CancelResult.success(order.withStatus(OrderStatus.CANCELED)); + } + } + + @Override + public List matchOrder(Order incomingOrder) { + List executions = new ArrayList<>(); + + if (incomingOrder.leavesQty() <= 0) { + return executions; + } + + long remaining = incomingOrder.leavesQty(); + + synchronized (this) { + if (incomingOrder.side() == Side.BUY) { + Iterator>> levelIter = asks.entrySet().iterator(); + + while (levelIter.hasNext() && remaining > 0) { + Map.Entry> level = levelIter.next(); + Price askPrice = level.getKey(); + + if (incomingOrder.type() == OrderType.LIMIT && askPrice.compareTo(incomingOrder.price()) > 0) { + break; + } + + Iterator entryIter = level.getValue().iterator(); + while (entryIter.hasNext() && remaining > 0) { + OrderBookEntry entry = entryIter.next(); + Order resting = entry.order(); + long fillQty = Math.min(remaining, resting.leavesQty()); + + Execution exec = new Execution(executionIdGenerator.incrementAndGet(), resting.orderId(), resting.clientOrderId(), resting.symbol(), + Side.BUY, askPrice, fillQty, System.currentTimeMillis()); + executions.add(exec); + remaining -= fillQty; + entryIter.remove(); + } + + if (level.getValue().isEmpty()) { + levelIter.remove(); + } + } + } else { + Iterator>> levelIter = bids.entrySet().iterator(); + + while (levelIter.hasNext() && remaining > 0) { + Map.Entry> level = levelIter.next(); + Price bidPrice = level.getKey(); + + if (incomingOrder.type() == OrderType.LIMIT && bidPrice.compareTo(incomingOrder.price()) < 0) { + break; + } + + Iterator entryIter = level.getValue().iterator(); + while (entryIter.hasNext() && remaining > 0) { + OrderBookEntry entry = entryIter.next(); + Order resting = entry.order(); + long fillQty = Math.min(remaining, resting.leavesQty()); + + Execution exec = new Execution(executionIdGenerator.incrementAndGet(), resting.orderId(), resting.clientOrderId(), resting.symbol(), + Side.SELL, bidPrice, fillQty, System.currentTimeMillis()); + executions.add(exec); + remaining -= fillQty; + entryIter.remove(); + } + + if (level.getValue().isEmpty()) { + levelIter.remove(); + } + } + } + } + + if (!executions.isEmpty()) { + long totalFilled = executions.stream().mapToLong(Execution::quantity).sum(); + LOGGER.fine(() -> "Matched " + totalFilled + " qty for order " + incomingOrder.orderId() + " across " + executions.size() + " executions"); + } + + return executions; + } + + @Override + public List matchAgainstMarket(Price bidPrice, Price askPrice, long bidSize, long askSize) { + List executions = new ArrayList<>(); + synchronized (this) { + sweepAsks(bidPrice, bidSize, Side.BUY, executions); + sweepBids(askPrice, askSize, Side.SELL, executions); + } + return executions; + } + + private void sweepAsks(Price bidPrice, long bidSize, Side aggressiveSide, List executions) { + Iterator>> levelIter = asks.entrySet().iterator(); + long remainingBid = bidSize; + + while (levelIter.hasNext() && remainingBid > 0) { + Map.Entry> level = levelIter.next(); + Price askPrice = level.getKey(); + + if (askPrice.compareTo(bidPrice) > 0) { + break; + } + + Iterator entryIter = level.getValue().iterator(); + while (entryIter.hasNext() && remainingBid > 0) { + OrderBookEntry entry = entryIter.next(); + Order resting = entry.order(); + long fillQty = Math.min(remainingBid, resting.leavesQty()); + + Execution exec = new Execution(executionIdGenerator.incrementAndGet(), resting.orderId(), resting.clientOrderId(), resting.symbol(), + aggressiveSide, askPrice, fillQty, System.currentTimeMillis()); + executions.add(exec); + + remainingBid -= fillQty; + Order updatedResting = resting.filled(fillQty); + entry = entry.filledBy(fillQty); + + if (entry.remainingQty() <= 0) { + orderIndex.remove(resting.orderId()); + entryIter.remove(); + } else { + entryIter.remove(); + level.getValue().addFirst(new OrderBookEntry(updatedResting, entry.entryTimestamp())); + } + } + + if (level.getValue().isEmpty()) { + levelIter.remove(); + } + } + } + + private void sweepBids(Price askPrice, long askSize, Side aggressiveSide, List executions) { + Iterator>> levelIter = bids.entrySet().iterator(); + long remainingAsk = askSize; + + while (levelIter.hasNext() && remainingAsk > 0) { + Map.Entry> level = levelIter.next(); + Price bidPrice = level.getKey(); + + if (bidPrice.compareTo(askPrice) < 0) { + break; + } + + Iterator entryIter = level.getValue().iterator(); + while (entryIter.hasNext() && remainingAsk > 0) { + OrderBookEntry entry = entryIter.next(); + Order resting = entry.order(); + long fillQty = Math.min(remainingAsk, resting.leavesQty()); + + Execution exec = new Execution(executionIdGenerator.incrementAndGet(), resting.orderId(), resting.clientOrderId(), resting.symbol(), + aggressiveSide, bidPrice, fillQty, System.currentTimeMillis()); + executions.add(exec); + + remainingAsk -= fillQty; + Order updatedResting = resting.filled(fillQty); + entry = entry.filledBy(fillQty); + + if (entry.remainingQty() <= 0) { + orderIndex.remove(resting.orderId()); + entryIter.remove(); + } else { + entryIter.remove(); + level.getValue().addFirst(new OrderBookEntry(updatedResting, entry.entryTimestamp())); + } + } + + if (level.getValue().isEmpty()) { + levelIter.remove(); + } + } + } + + @Override + public OrderBookSnapshot getSnapshot(int maxDepth) { + synchronized (this) { + List bidLevels = buildLevels(bids, maxDepth); + List askLevels = buildLevels(asks, maxDepth); + int totalBidDepth = bids.values().stream().mapToInt(List::size).sum(); + int totalAskDepth = asks.values().stream().mapToInt(List::size).sum(); + return new OrderBookSnapshot(symbol, System.currentTimeMillis(), bidLevels, askLevels, totalBidDepth, totalAskDepth); + } + } + + private List buildLevels(NavigableMap> book, int maxDepth) { + List levels = new ArrayList<>(); + int count = 0; + for (Map.Entry> entry : book.entrySet()) { + if (count >= maxDepth) + break; + long totalQty = entry.getValue().stream().mapToLong(e -> e.order().leavesQty()).sum(); + levels.add(new OrderBookLevel(entry.getKey(), totalQty, entry.getValue().size())); + count++; + } + return levels; + } + + @Override + public int bidDepth() { + synchronized (this) { + return bids.values().stream().mapToInt(List::size).sum(); + } + } + + @Override + public int askDepth() { + synchronized (this) { + return asks.values().stream().mapToInt(List::size).sum(); + } + } + + @Override + public Optional bestBid() { + synchronized (this) { + var first = bids.firstEntry(); + return first == null ? Optional.empty() : Optional.of(first.getKey()); + } + } + + @Override + public Optional bestAsk() { + synchronized (this) { + var first = asks.firstEntry(); + return first == null ? Optional.empty() : Optional.of(first.getKey()); + } + } + + @Override + public boolean hasOrders() { + synchronized (this) { + return !bids.isEmpty() || !asks.isEmpty(); + } + } + + @Override + public void clear() { + synchronized (this) { + bids.clear(); + asks.clear(); + orderIndex.clear(); + LOGGER.info("Order book cleared"); + } + } + +} diff --git a/src/main/java/fish/payara/trader/matching/config/MatchingConfig.java b/src/main/java/fish/payara/trader/matching/config/MatchingConfig.java new file mode 100644 index 0000000..532128a --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/config/MatchingConfig.java @@ -0,0 +1,41 @@ +package fish.payara.trader.matching.config; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class MatchingConfig { + + @Inject + @ConfigProperty(name = "matching.max.history.size", defaultValue = "10000") + private int maxHistorySize; + + @Inject + @ConfigProperty(name = "matching.max.book.depth", defaultValue = "50") + private int maxBookDepth; + + @Inject + @ConfigProperty(name = "matching.default.tif", defaultValue = "GTC") + private String defaultTimeInForce; + + @Inject + @ConfigProperty(name = "matching.price.scale", defaultValue = "10000") + private int priceScale; + + public int maxHistorySize() { + return maxHistorySize; + } + + public int maxBookDepth() { + return maxBookDepth; + } + + public String defaultTimeInForce() { + return defaultTimeInForce; + } + + public int priceScale() { + return priceScale; + } +} diff --git a/src/main/java/fish/payara/trader/matching/engine/IcebergOrderHandler.java b/src/main/java/fish/payara/trader/matching/engine/IcebergOrderHandler.java new file mode 100644 index 0000000..0b9fb92 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/engine/IcebergOrderHandler.java @@ -0,0 +1,54 @@ +package fish.payara.trader.matching.engine; + +import fish.payara.trader.matching.model.Order; +import fish.payara.trader.matching.model.OrderType; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +@ApplicationScoped +public class IcebergOrderHandler { + + private static final Logger LOGGER = Logger.getLogger(IcebergOrderHandler.class.getName()); + + private final ConcurrentHashMap hiddenQuantities = new ConcurrentHashMap<>(); + + public void track(Order order) { + if (order.type() == OrderType.ICEBERG && order.displayQuantity() > 0) { + long totalQty = order.quantity(); + long displayed = Math.min(order.displayQuantity(), totalQty); + long hidden = totalQty - displayed; + hiddenQuantities.put(order.orderId(), hidden); + LOGGER.fine(() -> "Tracking iceberg order " + order.orderId() + ": displayed=" + displayed + " hidden=" + hidden); + } + } + + public long revealNextSlice(Order order) { + if (order.type() != OrderType.ICEBERG) { + return order.leavesQty(); + } + + long hidden = hiddenQuantities.getOrDefault(order.orderId(), 0L); + if (hidden <= 0) { + return order.leavesQty(); + } + + long slice = Math.min(order.displayQuantity(), hidden); + hiddenQuantities.put(order.orderId(), hidden - slice); + LOGGER.fine(() -> "Revealing iceberg slice " + slice + " for order " + order.orderId()); + return slice; + } + + public long remainingHidden(long orderId) { + return hiddenQuantities.getOrDefault(orderId, 0L); + } + + public void removeTracking(long orderId) { + hiddenQuantities.remove(orderId); + } + + public void clear() { + hiddenQuantities.clear(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/engine/MatchingEngine.java b/src/main/java/fish/payara/trader/matching/engine/MatchingEngine.java new file mode 100644 index 0000000..08f8f94 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/engine/MatchingEngine.java @@ -0,0 +1,288 @@ +package fish.payara.trader.matching.engine; + +import fish.payara.trader.matching.book.CancelResult; +import fish.payara.trader.matching.book.OrderBook; +import fish.payara.trader.matching.book.OrderBookFactory; +import fish.payara.trader.matching.config.MatchingConfig; +import fish.payara.trader.matching.exception.OrderValidationException; +import fish.payara.trader.matching.history.ExecutionHistory; +import fish.payara.trader.matching.history.ExecutionHistoryQuery; +import fish.payara.trader.matching.jfr.MatchingEvents; +import fish.payara.trader.matching.model.*; +import fish.payara.trader.matching.position.PositionService; +import fish.payara.trader.matching.websocket.ExecutionBroadcaster; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; + +@ApplicationScoped +public class MatchingEngine { + + private static final Logger LOGGER = Logger.getLogger(MatchingEngine.class.getName()); + private static final Set VALID_SYMBOLS = Set.of("AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX"); + + @Inject + private OrderBookFactory orderBookFactory; + + @Inject + private PriceTimePriorityMatcher matcher; + + @Inject + private StopOrderTracker stopOrderTracker; + + @Inject + private IcebergOrderHandler icebergOrderHandler; + + @Inject + private PositionService positionService; + + @Inject + private ExecutionBroadcaster executionBroadcaster; + + @Inject + private ExecutionHistory executionHistory; + + @Inject + private MatchingConfig matchingConfig; + + private final AtomicLong orderIdGenerator = new AtomicLong(System.currentTimeMillis() * 10000); + + public Order submitOrder(OrderRequest request) { + validate(request); + + long orderId = orderIdGenerator.incrementAndGet(); + long now = System.currentTimeMillis(); + + Price limitPrice = request.price() != null ? Price.fromDouble(request.price()) : Price.ZERO; + Price stopPrice = request.stopPrice() != null ? Price.fromDouble(request.stopPrice()) : Price.ZERO; + Price trailingOffset = request.trailingStopOffset() != null ? Price.fromDouble(request.trailingStopOffset()) : Price.ZERO; + + TimeInForce tif = request.timeInForce() != null ? request.timeInForce() : TimeInForce.GTC; + long displayQty = request.displayQuantity() != null ? request.displayQuantity() : 0; + + boolean isStopOrder = request.type() == OrderType.STOP || request.type() == OrderType.STOP_LIMIT || request.type() == OrderType.TRAILING_STOP; + + OrderStatus initialStatus = isStopOrder ? OrderStatus.PENDING_TRIGGER : OrderStatus.NEW; + + Order order = new Order(orderId, orderId, request.symbol(), request.side(), request.type(), initialStatus, limitPrice, request.quantity(), + request.quantity(), 0, stopPrice, tif, displayQty, trailingOffset, Price.ZERO, now, now); + + emitOrderSubmitted(order); + + if (isStopOrder) { + stopOrderTracker.register(order); + LOGGER.info("Stop order registered: " + orderId + " " + request.symbol() + " " + request.side() + " type=" + request.type()); + return order; + } + + if (request.type() == OrderType.ICEBERG && displayQty > 0) { + icebergOrderHandler.track(order); + } + + OrderBook book = orderBookFactory.getOrCreate(request.symbol()); + + List executions = matcher.match(order, book); + long totalFilled = executions.stream().mapToLong(Execution::quantity).sum(); + + if (totalFilled > 0) { + Order filledOrder = order; + for (Execution exec : executions) { + executionHistory.append(exec); + positionService.updatePosition(exec); + filledOrder = filledOrder.filled(exec.quantity()); + executionBroadcaster.broadcast(exec.toJson()); + } + emitOrderMatched(filledOrder, totalFilled); + + if (filledOrder.leavesQty() > 0 && !isImmediateOrFillAndKill(tif)) { + Order resting = new Order(filledOrder.orderId(), filledOrder.clientOrderId(), filledOrder.symbol(), filledOrder.side(), filledOrder.type(), + OrderStatus.NEW, filledOrder.price(), filledOrder.quantity(), filledOrder.leavesQty(), filledOrder.cumQty(), + filledOrder.stopPrice(), filledOrder.timeInForce(), filledOrder.displayQuantity(), filledOrder.trailingStopOffset(), + filledOrder.trailingStopReference(), filledOrder.createdTimestamp(), System.currentTimeMillis()); + book.addOrder(resting); + return resting; + } + return filledOrder; + } + + book.addOrder(order); + return order; + } + + private boolean isImmediateOrFillAndKill(TimeInForce tif) { + return tif == TimeInForce.IOC || tif == TimeInForce.FOK; + } + + public CancelResult cancelOrder(long orderId) { + boolean removedFromStops = stopOrderTracker.cancel(orderId); + + if (removedFromStops) { + emitOrderCanceled(orderId, "Stop order canceled"); + return CancelResult.success(null); + } + + CancelResult result = null; + for (String symbol : VALID_SYMBOLS) { + OrderBook book = orderBookFactory.get(symbol); + if (book != null && book.hasOrders()) { + CancelResult attempt = book.cancelOrder(orderId); + if (attempt.canceled()) { + result = attempt; + break; + } + if (result == null && !attempt.canceled() && attempt.canceledOrder() == null) { + result = attempt; + } + } + } + + if (result != null && result.canceled()) { + emitOrderCanceled(orderId, result.reason()); + } + + if (result == null) { + result = CancelResult.notFound(orderId); + } + return result; + } + + public List onMarketData(String symbol, Price bid, Price ask, long bidSize, long askSize) { + List allExecutions = new ArrayList<>(); + + List triggeredStops = stopOrderTracker.onMarketData(symbol, bid, ask); + for (Order triggered : triggeredStops) { + emitStopTriggered(triggered); + OrderRequest promotedRequest = new OrderRequest(triggered.symbol(), triggered.side(), triggered.type(), triggered.price().toDouble(), + triggered.leavesQty(), null, triggered.timeInForce(), triggered.displayQuantity(), triggered.trailingStopOffset().toDouble()); + try { + Order promotedOrder = submitOrder(promotedRequest); + LOGGER.info("Promoted stop order " + triggered.orderId() + " -> " + promotedOrder.orderId()); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to promote triggered stop order " + triggered.orderId(), e); + } + } + + OrderBook book = orderBookFactory.get(symbol); + if (book == null) { + return allExecutions; + } + + List sweepExecs = book.matchAgainstMarket(bid, ask, bidSize, askSize); + for (Execution exec : sweepExecs) { + executionHistory.append(exec); + positionService.updatePosition(exec); + executionBroadcaster.broadcast(exec.toJson()); + allExecutions.add(exec); + } + + positionService.updateMarkPrice(symbol, bid); + + return allExecutions; + } + + public Optional getBook(String symbol) { + OrderBook book = orderBookFactory.get(symbol); + if (book == null) { + return Optional.empty(); + } + return Optional.of(book.getSnapshot(matchingConfig.maxBookDepth())); + } + + public Map getPositions() { + return positionService.getAllPositions(); + } + + public Optional getPosition(String symbol) { + return Optional.of(positionService.getPosition(symbol)); + } + + public List getExecutions(ExecutionHistoryQuery query) { + return executionHistory.query(query); + } + + private void validate(OrderRequest request) { + if (request.symbol() == null || !VALID_SYMBOLS.contains(request.symbol())) { + throw new OrderValidationException("Invalid symbol: " + request.symbol()); + } + if (request.side() == null) { + throw new OrderValidationException("Side is required"); + } + if (request.type() == null) { + throw new OrderValidationException("Order type is required"); + } + if (request.quantity() == null || request.quantity() <= 0) { + throw new OrderValidationException("Quantity must be positive"); + } + + switch (request.type()) { + case LIMIT, STOP_LIMIT -> { + if (request.price() == null || request.price() <= 0) { + throw new OrderValidationException("Price is required for " + request.type() + " orders"); + } + } + case STOP, TRAILING_STOP -> { + if (request.stopPrice() == null || request.stopPrice() <= 0) { + throw new OrderValidationException("Stop price is required for " + request.type() + " orders"); + } + } + case ICEBERG -> { + if (request.displayQuantity() == null || request.displayQuantity() <= 0) { + throw new OrderValidationException("Display quantity is required for ICEBERG orders"); + } + if (request.price() == null || request.price() <= 0) { + throw new OrderValidationException("Price is required for ICEBERG orders"); + } + } + } + } + + private void emitOrderSubmitted(Order order) { + var event = new MatchingEvents.OrderSubmitted(); + if (event.isEnabled()) { + event.orderId = order.orderId(); + event.symbol = order.symbol(); + event.side = order.side().name(); + event.type = order.type().name(); + event.quantity = order.quantity(); + event.price = order.price().ticks(); + event.commit(); + } + } + + private void emitOrderMatched(Order order, long totalFilled) { + var event = new MatchingEvents.OrderMatched(); + if (event.isEnabled()) { + event.orderId = order.orderId(); + event.symbol = order.symbol(); + event.side = order.side().name(); + event.filledQuantity = totalFilled; + event.remainingQuantity = order.leavesQty(); + event.status = order.status().name(); + event.commit(); + } + } + + private void emitStopTriggered(Order order) { + var event = new MatchingEvents.StopTriggered(); + if (event.isEnabled()) { + event.orderId = order.orderId(); + event.symbol = order.symbol(); + event.side = order.side().name(); + event.stopPrice = order.stopPrice().ticks(); + event.commit(); + } + } + + private void emitOrderCanceled(long orderId, String reason) { + var event = new MatchingEvents.OrderCanceled(); + if (event.isEnabled()) { + event.orderId = orderId; + event.reason = reason; + event.commit(); + } + } +} diff --git a/src/main/java/fish/payara/trader/matching/engine/MatchingStrategy.java b/src/main/java/fish/payara/trader/matching/engine/MatchingStrategy.java new file mode 100644 index 0000000..50c6f07 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/engine/MatchingStrategy.java @@ -0,0 +1,12 @@ +package fish.payara.trader.matching.engine; + +import fish.payara.trader.matching.model.Execution; +import fish.payara.trader.matching.model.Order; +import fish.payara.trader.matching.book.OrderBook; + +import java.util.List; + +public interface MatchingStrategy { + + List match(Order incomingOrder, OrderBook book); +} diff --git a/src/main/java/fish/payara/trader/matching/engine/PriceTimePriorityMatcher.java b/src/main/java/fish/payara/trader/matching/engine/PriceTimePriorityMatcher.java new file mode 100644 index 0000000..612b1eb --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/engine/PriceTimePriorityMatcher.java @@ -0,0 +1,20 @@ +package fish.payara.trader.matching.engine; + +import fish.payara.trader.matching.book.OrderBook; +import fish.payara.trader.matching.model.Execution; +import fish.payara.trader.matching.model.Order; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.logging.Logger; + +@ApplicationScoped +public class PriceTimePriorityMatcher implements MatchingStrategy { + + private static final Logger LOGGER = Logger.getLogger(PriceTimePriorityMatcher.class.getName()); + + @Override + public List match(Order incomingOrder, OrderBook book) { + return book.matchOrder(incomingOrder); + } +} diff --git a/src/main/java/fish/payara/trader/matching/engine/StopOrderTracker.java b/src/main/java/fish/payara/trader/matching/engine/StopOrderTracker.java new file mode 100644 index 0000000..8b2f670 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/engine/StopOrderTracker.java @@ -0,0 +1,118 @@ +package fish.payara.trader.matching.engine; + +import fish.payara.trader.matching.model.Order; +import fish.payara.trader.matching.model.OrderStatus; +import fish.payara.trader.matching.model.OrderType; +import fish.payara.trader.matching.model.Price; +import fish.payara.trader.matching.model.Side; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +@ApplicationScoped +public class StopOrderTracker { + + private static final Logger LOGGER = Logger.getLogger(StopOrderTracker.class.getName()); + + private final ConcurrentHashMap> buyStops = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> sellStops = new ConcurrentHashMap<>(); + private final ConcurrentHashMap allStops = new ConcurrentHashMap<>(); + + public void register(Order order) { + if (order.status() != OrderStatus.PENDING_TRIGGER) { + return; + } + + allStops.put(order.orderId(), order); + + String symbol = order.symbol(); + switch (order.side()) { + case BUY -> buyStops.computeIfAbsent(symbol, k -> new ArrayList<>()).add(order); + case SELL -> sellStops.computeIfAbsent(symbol, k -> new ArrayList<>()).add(order); + } + + LOGGER.fine(() -> "Registered stop order: " + order.orderId() + " " + order.symbol() + " " + order.side() + " @ " + order.stopPrice().toDouble()); + } + + public List onMarketData(String symbol, Price bid, Price ask) { + List triggered = new ArrayList<>(); + + List symbolBuyStops = buyStops.get(symbol); + if (symbolBuyStops != null) { + Iterator iter = symbolBuyStops.iterator(); + while (iter.hasNext()) { + Order stop = iter.next(); + if (ask.compareTo(stop.stopPrice()) >= 0) { + triggered.add(promote(stop, bid)); + allStops.remove(stop.orderId()); + iter.remove(); + } + } + } + + List symbolSellStops = sellStops.get(symbol); + if (symbolSellStops != null) { + Iterator iter = symbolSellStops.iterator(); + while (iter.hasNext()) { + Order stop = iter.next(); + if (bid.compareTo(stop.stopPrice()) <= 0) { + triggered.add(promote(stop, ask)); + allStops.remove(stop.orderId()); + iter.remove(); + } + } + } + + if (!triggered.isEmpty()) { + LOGGER.info("Triggered " + triggered.size() + " stop orders for " + symbol); + } + + return triggered; + } + + private Order promote(Order stop, Price triggerPrice) { + OrderStatus newStatus = switch (stop.type()) { + case STOP -> OrderStatus.NEW; + case STOP_LIMIT -> OrderStatus.NEW; + case TRAILING_STOP -> OrderStatus.NEW; + default -> OrderStatus.NEW; + }; + + return new Order(stop.orderId(), stop.clientOrderId(), stop.symbol(), stop.side(), + stop.type() == OrderType.STOP_LIMIT ? OrderType.LIMIT : OrderType.MARKET, newStatus, + stop.type() == OrderType.STOP_LIMIT ? stop.price() : triggerPrice, stop.quantity(), stop.leavesQty(), stop.cumQty(), Price.ZERO, + stop.timeInForce(), stop.displayQuantity(), stop.trailingStopOffset(), triggerPrice, stop.createdTimestamp(), + System.currentTimeMillis()); + } + + public boolean cancel(long orderId) { + Order removed = allStops.remove(orderId); + if (removed == null) { + return false; + } + + String symbol = removed.symbol(); + List stops = removed.side() == Side.BUY ? buyStops.get(symbol) : sellStops.get(symbol); + + if (stops != null) { + stops.removeIf(o -> o.orderId() == orderId); + } + + LOGGER.fine("Canceled stop order: " + orderId); + return true; + } + + public int pendingStopCount(String symbol) { + int buyCount = buyStops.getOrDefault(symbol, List.of()).size(); + int sellCount = sellStops.getOrDefault(symbol, List.of()).size(); + return buyCount + sellCount; + } + + public int totalPendingStops() { + return allStops.size(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/exception/OrderNotFoundException.java b/src/main/java/fish/payara/trader/matching/exception/OrderNotFoundException.java new file mode 100644 index 0000000..3f991b4 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/exception/OrderNotFoundException.java @@ -0,0 +1,16 @@ +package fish.payara.trader.matching.exception; + +public class OrderNotFoundException extends RuntimeException { + + public OrderNotFoundException() { + super(); + } + + public OrderNotFoundException(String message) { + super(message); + } + + public OrderNotFoundException(long orderId) { + super("Order not found: " + orderId); + } +} diff --git a/src/main/java/fish/payara/trader/matching/exception/OrderRejectedException.java b/src/main/java/fish/payara/trader/matching/exception/OrderRejectedException.java new file mode 100644 index 0000000..d7a5e98 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/exception/OrderRejectedException.java @@ -0,0 +1,15 @@ +package fish.payara.trader.matching.exception; + +public class OrderRejectedException extends RuntimeException { + + private final String reason; + + public OrderRejectedException(String reason) { + super(reason); + this.reason = reason; + } + + public String reason() { + return reason; + } +} diff --git a/src/main/java/fish/payara/trader/matching/exception/OrderValidationException.java b/src/main/java/fish/payara/trader/matching/exception/OrderValidationException.java new file mode 100644 index 0000000..afeb770 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/exception/OrderValidationException.java @@ -0,0 +1,15 @@ +package fish.payara.trader.matching.exception; + +public class OrderValidationException extends RuntimeException { + + private final String reason; + + public OrderValidationException(String reason) { + super(reason); + this.reason = reason; + } + + public String reason() { + return reason; + } +} diff --git a/src/main/java/fish/payara/trader/matching/history/ExecutionHistory.java b/src/main/java/fish/payara/trader/matching/history/ExecutionHistory.java new file mode 100644 index 0000000..54e6e2f --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/history/ExecutionHistory.java @@ -0,0 +1,107 @@ +package fish.payara.trader.matching.history; + +import fish.payara.trader.matching.model.Execution; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import fish.payara.trader.matching.config.MatchingConfig; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Fixed-capacity ring buffer for execution history. Pre-allocates storage to avoid GC pressure. + */ +@ApplicationScoped +public class ExecutionHistory { + + private static final Logger LOGGER = Logger.getLogger(ExecutionHistory.class.getName()); + + private Execution[] buffer; + private int maxSize; + private int head = 0; + private int count = 0; + private ReentrantLock lock = new ReentrantLock(); + + protected ExecutionHistory() { + this.maxSize = 10000; + this.buffer = new Execution[maxSize]; + } + + @Inject + public ExecutionHistory(MatchingConfig config) { + this.maxSize = config.maxHistorySize(); + this.buffer = new Execution[maxSize]; + LOGGER.info("Execution history initialized with capacity: " + maxSize); + } + + public void append(Execution execution) { + lock.lock(); + try { + buffer[head] = execution; + head = (head + 1) % maxSize; + if (count < maxSize) { + count++; + } + } finally { + lock.unlock(); + } + } + + public List query(ExecutionHistoryQuery query) { + lock.lock(); + try { + List results = new ArrayList<>(); + + int effectiveLimit = Math.min(query.limit(), count); + + for (int i = 0; i < count && results.size() < effectiveLimit; i++) { + int idx = (head - 1 - i + maxSize) % maxSize; + Execution exec = buffer[idx]; + if (exec == null) { + continue; + } + + if (query.symbol() != null && !query.symbol().equals(exec.symbol())) { + continue; + } + if (query.fromTimestamp() != null && exec.timestamp() < query.fromTimestamp()) { + continue; + } + if (query.toTimestamp() != null && exec.timestamp() > query.toTimestamp()) { + continue; + } + + results.add(exec); + } + + return results; + } finally { + lock.unlock(); + } + } + + public int size() { + lock.lock(); + try { + return count; + } finally { + lock.unlock(); + } + } + + public void clear() { + lock.lock(); + try { + for (int i = 0; i < maxSize; i++) { + buffer[i] = null; + } + head = 0; + count = 0; + LOGGER.info("Execution history cleared"); + } finally { + lock.unlock(); + } + } +} diff --git a/src/main/java/fish/payara/trader/matching/history/ExecutionHistoryQuery.java b/src/main/java/fish/payara/trader/matching/history/ExecutionHistoryQuery.java new file mode 100644 index 0000000..3937c03 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/history/ExecutionHistoryQuery.java @@ -0,0 +1,22 @@ +package fish.payara.trader.matching.history; + +public record ExecutionHistoryQuery(String symbol, Long fromTimestamp, Long toTimestamp, Integer limit) { + + public ExecutionHistoryQuery { + if (limit == null || limit <= 0) { + limit = 100; + } + } + + public static ExecutionHistoryQuery all() { + return new ExecutionHistoryQuery(null, null, null, 100); + } + + public static ExecutionHistoryQuery forSymbol(String symbol) { + return new ExecutionHistoryQuery(symbol, null, null, 100); + } + + public static ExecutionHistoryQuery forSymbol(String symbol, int limit) { + return new ExecutionHistoryQuery(symbol, null, null, limit); + } +} diff --git a/src/main/java/fish/payara/trader/matching/jfr/MatchingEvents.java b/src/main/java/fish/payara/trader/matching/jfr/MatchingEvents.java new file mode 100644 index 0000000..0036329 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/jfr/MatchingEvents.java @@ -0,0 +1,57 @@ +package fish.payara.trader.matching.jfr; + +import jdk.jfr.Category; +import jdk.jfr.Event; +import jdk.jfr.Label; +import jdk.jfr.Name; +import jdk.jfr.StackTrace; + +/** + * JFR events for Order Matching Engine monitoring. + */ +@Category("Matching") +@Label("Order Matching") +public class MatchingEvents { + + @Name("order.submitted") + @Label("Order Submitted") + @StackTrace(false) + public static class OrderSubmitted extends Event { + public long orderId; + public String symbol; + public String side; + public String type; + public long quantity; + public long price; + } + + @Name("order.matched") + @Label("Order Matched") + @StackTrace(false) + public static class OrderMatched extends Event { + public long orderId; + public String symbol; + public String side; + public long filledQuantity; + public long remainingQuantity; + public String status; + } + + @Name("stop.triggered") + @Label("Stop Order Triggered") + @StackTrace(false) + public static class StopTriggered extends Event { + public long orderId; + public String symbol; + public String side; + public long stopPrice; + } + + @Name("order.canceled") + @Label("Order Canceled") + @StackTrace(false) + public static class OrderCanceled extends Event { + public long orderId; + public String reason; + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/Execution.java b/src/main/java/fish/payara/trader/matching/model/Execution.java new file mode 100644 index 0000000..8a89dd8 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/Execution.java @@ -0,0 +1,20 @@ +package fish.payara.trader.matching.model; + +public record Execution(long executionId, long orderId, long clientOrderId, String symbol, Side side, Price price, long quantity, long timestamp) { + + public String toJson() { + StringBuilder sb = new StringBuilder(256); + sb.append('{'); + sb.append("\"executionId\":").append(executionId).append(','); + sb.append("\"orderId\":").append(orderId).append(','); + sb.append("\"clientOrderId\":").append(clientOrderId).append(','); + sb.append("\"symbol\":\"").append(symbol).append("\","); + sb.append("\"side\":\"").append(side.name()).append("\","); + sb.append("\"price\":").append(price.ticks()).append(','); + sb.append("\"priceDisplay\":").append(price.toDouble()).append(','); + sb.append("\"quantity\":").append(quantity).append(','); + sb.append("\"timestamp\":").append(timestamp); + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/Order.java b/src/main/java/fish/payara/trader/matching/model/Order.java new file mode 100644 index 0000000..dd85868 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/Order.java @@ -0,0 +1,23 @@ +package fish.payara.trader.matching.model; + +public record Order(long orderId, long clientOrderId, String symbol, Side side, OrderType type, OrderStatus status, Price price, long quantity, long leavesQty, + long cumQty, Price stopPrice, TimeInForce timeInForce, long displayQuantity, Price trailingStopOffset, Price trailingStopReference, + long createdTimestamp, long updatedTimestamp) { + + public Order withStatus(OrderStatus newStatus) { + return new Order(orderId, clientOrderId, symbol, side, type, newStatus, price, quantity, leavesQty, cumQty, stopPrice, timeInForce, displayQuantity, + trailingStopOffset, trailingStopReference, createdTimestamp, System.currentTimeMillis()); + } + + public Order filled(long fillQty) { + long newCumQty = cumQty + fillQty; + long newLeavesQty = leavesQty - fillQty; + OrderStatus newStatus = newLeavesQty <= 0 ? OrderStatus.FILLED : OrderStatus.PARTIALLY_FILLED; + return new Order(orderId, clientOrderId, symbol, side, type, newStatus, price, quantity, Math.max(0, newLeavesQty), newCumQty, stopPrice, timeInForce, + displayQuantity, trailingStopOffset, trailingStopReference, createdTimestamp, System.currentTimeMillis()); + } + + public boolean isTerminal() { + return status == OrderStatus.FILLED || status == OrderStatus.CANCELED || status == OrderStatus.REJECTED; + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderBookEntry.java b/src/main/java/fish/payara/trader/matching/model/OrderBookEntry.java new file mode 100644 index 0000000..41e4813 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderBookEntry.java @@ -0,0 +1,14 @@ +package fish.payara.trader.matching.model; + +public record OrderBookEntry(Order order, long entryTimestamp) { + + public OrderBookEntry filledBy(long fillQty) { + long remaining = Math.max(0, order.leavesQty() - fillQty); + Order updatedOrder = order.filled(fillQty); + return new OrderBookEntry(updatedOrder, entryTimestamp); + } + + public long remainingQty() { + return order.leavesQty(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderBookLevel.java b/src/main/java/fish/payara/trader/matching/model/OrderBookLevel.java new file mode 100644 index 0000000..0e8c677 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderBookLevel.java @@ -0,0 +1,21 @@ +package fish.payara.trader.matching.model; + +import jakarta.json.bind.annotation.JsonbTransient; + +public record OrderBookLevel(@JsonbTransient Price price, double priceDisplay, long quantity, int orderCount) { + + public OrderBookLevel(Price price, long quantity, int orderCount) { + this(price, price.toDouble(), quantity, orderCount); + } + + public String toJson() { + StringBuilder sb = new StringBuilder(128); + sb.append('{'); + sb.append("\"price\":").append(price.ticks()).append(','); + sb.append("\"priceDisplay\":").append(priceDisplay).append(','); + sb.append("\"quantity\":").append(quantity).append(','); + sb.append("\"orderCount\":").append(orderCount); + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderBookSnapshot.java b/src/main/java/fish/payara/trader/matching/model/OrderBookSnapshot.java new file mode 100644 index 0000000..49f13af --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderBookSnapshot.java @@ -0,0 +1,34 @@ +package fish.payara.trader.matching.model; + +import java.util.List; + +public record OrderBookSnapshot(String symbol, long timestamp, List bids, List asks, int totalBidDepth, int totalAskDepth) { + + public String toJson() { + StringBuilder sb = new StringBuilder(2048); + sb.append('{'); + sb.append("\"symbol\":\"").append(symbol).append("\","); + sb.append("\"timestamp\":").append(timestamp).append(','); + sb.append("\"totalBidDepth\":").append(totalBidDepth).append(','); + sb.append("\"totalAskDepth\":").append(totalAskDepth).append(','); + + sb.append("\"bids\":["); + for (int i = 0; i < bids.size(); i++) { + if (i > 0) + sb.append(','); + sb.append(bids.get(i).toJson()); + } + sb.append("],"); + + sb.append("\"asks\":["); + for (int i = 0; i < asks.size(); i++) { + if (i > 0) + sb.append(','); + sb.append(asks.get(i).toJson()); + } + sb.append("]"); + + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderRequest.java b/src/main/java/fish/payara/trader/matching/model/OrderRequest.java new file mode 100644 index 0000000..17c916e --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderRequest.java @@ -0,0 +1,5 @@ +package fish.payara.trader.matching.model; + +public record OrderRequest(String symbol, Side side, OrderType type, Double price, Long quantity, Double stopPrice, TimeInForce timeInForce, + Long displayQuantity, Double trailingStopOffset) { +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderStatus.java b/src/main/java/fish/payara/trader/matching/model/OrderStatus.java new file mode 100644 index 0000000..38e6c47 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderStatus.java @@ -0,0 +1,5 @@ +package fish.payara.trader.matching.model; + +public enum OrderStatus { + NEW, PARTIALLY_FILLED, FILLED, CANCELED, REJECTED, PENDING_TRIGGER, SUSPENDED +} diff --git a/src/main/java/fish/payara/trader/matching/model/OrderType.java b/src/main/java/fish/payara/trader/matching/model/OrderType.java new file mode 100644 index 0000000..2ad9fef --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/OrderType.java @@ -0,0 +1,5 @@ +package fish.payara.trader.matching.model; + +public enum OrderType { + MARKET, LIMIT, STOP, STOP_LIMIT, ICEBERG, TRAILING_STOP +} diff --git a/src/main/java/fish/payara/trader/matching/model/Position.java b/src/main/java/fish/payara/trader/matching/model/Position.java new file mode 100644 index 0000000..547f923 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/Position.java @@ -0,0 +1,25 @@ +package fish.payara.trader.matching.model; + +import jakarta.json.bind.annotation.JsonbTransient; + +public record Position(String symbol, long netQuantity, @JsonbTransient Price averageEntryPrice, long realizedPnlTicks, @JsonbTransient Price markPrice, + long unrealizedPnlTicks) { + + public double getAverageEntryPrice() { + return averageEntryPrice.toDouble(); + } + + public double getMarkPrice() { + return markPrice.toDouble(); + } + + @JsonbTransient + public double notionalValue(double markPrice) { + return netQuantity * markPrice; + } + + @JsonbTransient + public boolean isFlat() { + return netQuantity == 0; + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/Price.java b/src/main/java/fish/payara/trader/matching/model/Price.java new file mode 100644 index 0000000..4145fe1 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/Price.java @@ -0,0 +1,50 @@ +package fish.payara.trader.matching.model; + +import jakarta.json.bind.annotation.JsonbTransient; + +public record Price(long ticks) implements Comparable { + + public static final int SCALE = 10_000; + + public double toDouble() { + return ticks / (double) SCALE; + } + + public static Price fromDouble(double value) { + return new Price(Math.round(value * SCALE)); + } + + public static final Price ZERO = new Price(0); + + public Price add(Price other) { + return new Price(ticks + other.ticks); + } + + public Price subtract(Price other) { + return new Price(ticks - other.ticks); + } + + public Price multiply(long factor) { + return new Price(ticks * factor); + } + + @Override + public int compareTo(Price other) { + return Long.compare(ticks, other.ticks); + } + + @JsonbTransient + public boolean isZero() { + return ticks == 0; + } + + @JsonbTransient + public boolean isPositive() { + return ticks > 0; + } + + @JsonbTransient + public boolean isNegative() { + return ticks < 0; + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/Side.java b/src/main/java/fish/payara/trader/matching/model/Side.java new file mode 100644 index 0000000..a724006 --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/Side.java @@ -0,0 +1,23 @@ +package fish.payara.trader.matching.model; + +public enum Side { + + BUY(0), SELL(1); + + private final int code; + + Side(int code) { + this.code = code; + } + + public int code() { + return code; + } + + public Side opposite() { + return switch (this) { + case BUY -> SELL; + case SELL -> BUY; + }; + } +} diff --git a/src/main/java/fish/payara/trader/matching/model/TimeInForce.java b/src/main/java/fish/payara/trader/matching/model/TimeInForce.java new file mode 100644 index 0000000..348058f --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/model/TimeInForce.java @@ -0,0 +1,5 @@ +package fish.payara.trader.matching.model; + +public enum TimeInForce { + DAY, GTC, IOC, FOK, GTX +} diff --git a/src/main/java/fish/payara/trader/matching/position/PnlCalculator.java b/src/main/java/fish/payara/trader/matching/position/PnlCalculator.java new file mode 100644 index 0000000..0ca27df --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/position/PnlCalculator.java @@ -0,0 +1,68 @@ +package fish.payara.trader.matching.position; + +import fish.payara.trader.matching.model.Position; +import fish.payara.trader.matching.model.Price; + +/** + * Static utility methods for P&L and average entry price calculations. All prices in fixed-point ticks (multiply by 10000 for decimal). + */ +public final class PnlCalculator { + + private PnlCalculator() { + } + + /** + * Calculates realized P&L when closing a position. Positive return = profit, negative = loss. + */ + public static long calculateRealizedPnl(Position position, Price closePrice, long closeQty) { + long currentNetQty = position.netQuantity(); + Price entryPrice = position.averageEntryPrice(); + + if (entryPrice.isZero() || closeQty == 0) { + return 0; + } + + if (currentNetQty > 0) { + return (closePrice.ticks() - entryPrice.ticks()) * closeQty; + } else { + return (entryPrice.ticks() - closePrice.ticks()) * closeQty; + } + } + + /** + * Calculates unrealized P&L at the given mark price. + */ + public static long calculateUnrealizedPnl(Position position, Price markPrice) { + long netQty = position.netQuantity(); + + if (netQty == 0 || position.averageEntryPrice().isZero()) { + return 0; + } + + if (netQty > 0) { + return (markPrice.ticks() - position.averageEntryPrice().ticks()) * netQty; + } else { + return (position.averageEntryPrice().ticks() - markPrice.ticks()) * Math.abs(netQty); + } + } + + /** + * Recalculates the volume-weighted average entry price after a new fill. + */ + public static Price calculateAverageEntryPrice(Position position, Price newFillPrice, long newFillQty, long currentNetQty) { + if (newFillQty <= 0 || newFillPrice.isZero()) { + return position.averageEntryPrice(); + } + + long absCurrent = Math.abs(currentNetQty); + long totalQty = absCurrent + newFillQty; + + if (absCurrent == 0) { + return newFillPrice; + } + + long weightedSum = position.averageEntryPrice().ticks() * absCurrent + newFillPrice.ticks() * newFillQty; + + return new Price(weightedSum / totalQty); + } +} diff --git a/src/main/java/fish/payara/trader/matching/position/PositionService.java b/src/main/java/fish/payara/trader/matching/position/PositionService.java new file mode 100644 index 0000000..630dc5b --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/position/PositionService.java @@ -0,0 +1,94 @@ +package fish.payara.trader.matching.position; + +import fish.payara.trader.matching.model.Execution; +import fish.payara.trader.matching.model.Position; +import fish.payara.trader.matching.model.Price; +import fish.payara.trader.matching.model.Side; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +@ApplicationScoped +public class PositionService { + + private static final Logger LOGGER = Logger.getLogger(PositionService.class.getName()); + + private final ConcurrentHashMap positions = new ConcurrentHashMap<>(); + + public void updatePosition(Execution execution) { + positions.compute(execution.symbol(), (symbol, current) -> { + Position pos = current != null ? current : emptyPosition(symbol); + + long newNetQty; + Price newAvgPrice; + long newRealizedPnl; + + if (execution.side() == Side.BUY) { + if (pos.netQuantity() >= 0) { + newAvgPrice = PnlCalculator.calculateAverageEntryPrice(pos, execution.price(), execution.quantity(), pos.netQuantity()); + newNetQty = pos.netQuantity() + execution.quantity(); + newRealizedPnl = pos.realizedPnlTicks(); + } else { + long closingQty = Math.min(execution.quantity(), Math.abs(pos.netQuantity())); + newRealizedPnl = PnlCalculator.calculateRealizedPnl(pos, execution.price(), closingQty); + long remainingClose = execution.quantity() - closingQty; + if (remainingClose > 0) { + newAvgPrice = execution.price(); + newNetQty = remainingClose; + } else { + newAvgPrice = closingQty == Math.abs(pos.netQuantity()) ? Price.ZERO : pos.averageEntryPrice(); + newNetQty = pos.netQuantity() + execution.quantity(); + } + newRealizedPnl += pos.realizedPnlTicks(); + } + } else { + if (pos.netQuantity() <= 0) { + newAvgPrice = PnlCalculator.calculateAverageEntryPrice(pos, execution.price(), execution.quantity(), Math.abs(pos.netQuantity())); + newNetQty = pos.netQuantity() - execution.quantity(); + newRealizedPnl = pos.realizedPnlTicks(); + } else { + long closingQty = Math.min(execution.quantity(), pos.netQuantity()); + newRealizedPnl = PnlCalculator.calculateRealizedPnl(pos, execution.price(), closingQty); + long remainingClose = execution.quantity() - closingQty; + if (remainingClose > 0) { + newAvgPrice = execution.price(); + newNetQty = -remainingClose; + } else { + newAvgPrice = closingQty == pos.netQuantity() ? Price.ZERO : pos.averageEntryPrice(); + newNetQty = pos.netQuantity() - execution.quantity(); + } + newRealizedPnl += pos.realizedPnlTicks(); + } + } + + return new Position(symbol, newNetQty, newAvgPrice, newRealizedPnl, pos.markPrice(), 0); + }); + } + + public void updateMarkPrice(String symbol, Price price) { + positions.compute(symbol, (key, current) -> { + Position pos = current != null ? current : emptyPosition(symbol); + long unrealizedPnl = PnlCalculator.calculateUnrealizedPnl(pos, price); + return new Position(symbol, pos.netQuantity(), pos.averageEntryPrice(), pos.realizedPnlTicks(), price, unrealizedPnl); + }); + } + + public Position getPosition(String symbol) { + return positions.getOrDefault(symbol, emptyPosition(symbol)); + } + + public Map getAllPositions() { + return Map.copyOf(positions); + } + + public void reset() { + positions.clear(); + LOGGER.info("All positions reset"); + } + + private Position emptyPosition(String symbol) { + return new Position(symbol, 0, Price.ZERO, 0, Price.ZERO, 0); + } +} diff --git a/src/main/java/fish/payara/trader/matching/websocket/ExecutionBroadcaster.java b/src/main/java/fish/payara/trader/matching/websocket/ExecutionBroadcaster.java new file mode 100644 index 0000000..cced95a --- /dev/null +++ b/src/main/java/fish/payara/trader/matching/websocket/ExecutionBroadcaster.java @@ -0,0 +1,108 @@ +package fish.payara.trader.matching.websocket; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.topic.ITopic; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.websocket.Session; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Broadcaster for execution WebSocket messages. Follows the same Hazelcast topic pattern as MarketDataBroadcaster. + */ +@ApplicationScoped +public class ExecutionBroadcaster { + + private static final Logger LOGGER = Logger.getLogger(ExecutionBroadcaster.class.getName()); + private static final String TOPIC_NAME = "execution-broadcast"; + + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @Inject + private HazelcastInstance hazelcastInstance; + + private ITopic clusterTopic; + + private long messagesSent = 0; + private long lastStatsTime = System.currentTimeMillis(); + + @PostConstruct + public void init() { + try { + if (hazelcastInstance != null) { + clusterTopic = hazelcastInstance.getTopic(TOPIC_NAME); + clusterTopic.addMessageListener(message -> { + broadcastLocal(message.getMessageObject()); + }); + LOGGER.info("ExecutionBroadcaster subscribed to Hazelcast topic: " + TOPIC_NAME); + } else { + LOGGER.info("Hazelcast not available - ExecutionBroadcaster running in standalone mode"); + } + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to initialize ExecutionBroadcaster Hazelcast topic", e); + } + } + + public void addSession(Session session) { + sessions.add(session); + LOGGER.info("Execution WebSocket session added. Total: " + sessions.size()); + } + + public void removeSession(Session session) { + sessions.remove(session); + LOGGER.info("Execution WebSocket session removed. Total: " + sessions.size()); + } + + public void broadcast(String jsonMessage) { + if (clusterTopic != null) { + try { + clusterTopic.publish(jsonMessage); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to publish execution to Hazelcast topic", e); + broadcastLocal(jsonMessage); + } + } else { + broadcastLocal(jsonMessage); + } + } + + private void broadcastLocal(String jsonMessage) { + if (sessions.isEmpty()) { + return; + } + + sessions.removeIf(session -> { + if (!session.isOpen()) { + return true; + } + try { + session.getAsyncRemote().sendText(jsonMessage); + return false; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send execution message", e); + return true; + } + }); + + messagesSent++; + logStatistics(); + } + + public int getSessionCount() { + return sessions.size(); + } + + private void logStatistics() { + long now = System.currentTimeMillis(); + if (now - lastStatsTime > 10000) { + LOGGER.info(String.format("Execution WebSocket Stats - Sessions: %d, Messages: %,d (%.1f msg/sec)", sessions.size(), messagesSent, + messagesSent / ((now - lastStatsTime) / 1000.0))); + lastStatsTime = now; + messagesSent = 0; + } + } +} diff --git a/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java b/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java new file mode 100644 index 0000000..cd76169 --- /dev/null +++ b/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java @@ -0,0 +1,230 @@ +package fish.payara.trader.monitoring; + +import com.sun.management.GarbageCollectionNotificationInfo; +import com.sun.management.GcInfo; +import fish.payara.trader.dto.SLAViolationEvent; +import fish.payara.trader.jfr.MarketDataEvents; +import fish.payara.trader.util.InstanceUtils; +import fish.payara.trader.websocket.MarketDataBroadcaster; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.management.Notification; +import javax.management.NotificationEmitter; +import javax.management.NotificationListener; +import javax.management.openmbean.CompositeData; + +@ApplicationScoped +public class GCPauseMonitor implements NotificationListener { + + private static final Logger LOGGER = Logger.getLogger(GCPauseMonitor.class.getName()); + + private static final int MAX_PAUSE_HISTORY = 500; + + private final ConcurrentLinkedDeque pauseHistory = new ConcurrentLinkedDeque<>(); + + private final AtomicLong totalPauseCount = new AtomicLong(0); + private final AtomicLong totalPauseTimeMs = new AtomicLong(0); + private volatile long maxPauseMs = 0; + + private final AtomicLong violationsOver10ms = new AtomicLong(0); + private final AtomicLong violationsOver50ms = new AtomicLong(0); + private final AtomicLong violationsOver100ms = new AtomicLong(0); + + private final List emitters = new ArrayList<>(); + + @Inject + private MarketDataBroadcaster broadcaster; + + @PostConstruct + public void init() { + LOGGER.info("Initializing GC Pause Monitor with JMX notifications"); + + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + + for (GarbageCollectorMXBean gcBean : gcBeans) { + if (gcBean instanceof NotificationEmitter) { + NotificationEmitter emitter = (NotificationEmitter) gcBean; + emitter.addNotificationListener(this, null, null); + emitters.add(emitter); + LOGGER.info("Registered GC notification listener for: " + gcBean.getName()); + } + } + + if (emitters.isEmpty()) { + LOGGER.warning("No GC notification emitters found - pause monitoring may be limited"); + } + } + + @PreDestroy + public void cleanup() { + for (NotificationEmitter emitter : emitters) { + try { + emitter.removeNotificationListener(this); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to remove GC notification listener", e); + } + } + emitters.clear(); + } + + @Override + public void handleNotification(Notification notification, Object handback) { + if (!notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { + return; + } + + try { + CompositeData cd = (CompositeData) notification.getUserData(); + GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(cd); + String gcName = info.getGcName(); + + if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { + return; + } + + GcInfo gcInfo = info.getGcInfo(); + long pauseMs = gcInfo.getDuration(); + + recordPause(pauseMs, gcName, info.getGcAction()); + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error processing GC notification", e); + } + } + + private void recordPause(long pauseMs, String gcName, String gcAction) { + pauseHistory.addLast(pauseMs); + if (pauseHistory.size() > MAX_PAUSE_HISTORY) { + pauseHistory.removeFirst(); + } + + totalPauseCount.incrementAndGet(); + totalPauseTimeMs.addAndGet(pauseMs); + + if (pauseMs > maxPauseMs) { + synchronized (this) { + if (pauseMs > maxPauseMs) { + maxPauseMs = pauseMs; + } + } + } + + String threshold = null; + if (pauseMs > 100) { + violationsOver100ms.incrementAndGet(); + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + threshold = ">100ms"; + emitSlaViolation(pauseMs, threshold); + } else if (pauseMs > 50) { + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + threshold = ">50ms"; + emitSlaViolation(pauseMs, threshold); + } else if (pauseMs > 10) { + violationsOver10ms.incrementAndGet(); + threshold = ">10ms"; + emitSlaViolation(pauseMs, threshold); + } + + // Broadcast SLA violation to WebSocket clients for real-time flashing + if (threshold != null && broadcaster != null) { + String instanceName = InstanceUtils.getInstanceName(); + SLAViolationEvent event = SLAViolationEvent.create(pauseMs, threshold, instanceName); + broadcaster.broadcast(event.toJson()); + } + + if (pauseMs > 100) { + LOGGER.warning("Large GC pause detected: " + pauseMs + " ms [" + gcName + " - " + gcAction + "]"); + } else if (pauseMs > 50) { + LOGGER.info("Notable GC pause: " + pauseMs + " ms [" + gcName + " - " + gcAction + "]"); + } + } + + private void emitSlaViolation(long pauseMs, String threshold) { + MarketDataEvents.SlaViolation event = new MarketDataEvents.SlaViolation(); + if (event.isEnabled()) { + event.pauseTimeMillis = pauseMs; + event.threshold = threshold; + event.violationsInWindow = violationsOver10ms.get(); + event.commit(); + } + } + + public GCPauseStats getStats() { + List pauses = new ArrayList<>(pauseHistory); + + if (pauses.isEmpty()) { + return new GCPauseStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + Collections.sort(pauses); + + long p50 = InstanceUtils.percentile(pauses, 0.50); + long p95 = InstanceUtils.percentile(pauses, 0.95); + long p99 = InstanceUtils.percentile(pauses, 0.99); + long p999 = InstanceUtils.percentile(pauses, 0.999); + long max = pauses.get(pauses.size() - 1); + + long count = totalPauseCount.get(); + long totalTime = totalPauseTimeMs.get(); + double avgPause = count > 0 ? (double) totalTime / count : 0; + + return new GCPauseStats(count, totalTime, avgPause, p50, p95, p99, p999, maxPauseMs, // All-time max + violationsOver10ms.get(), violationsOver50ms.get(), violationsOver100ms.get(), pauses.size() // Sample size for percentiles + ); + } + + public void reset() { + pauseHistory.clear(); + totalPauseCount.set(0); + totalPauseTimeMs.set(0); + maxPauseMs = 0; + violationsOver10ms.set(0); + violationsOver50ms.set(0); + violationsOver100ms.set(0); + LOGGER.info("GC pause statistics reset"); + } + + public static class GCPauseStats { + public final long totalPauseCount; + public final long totalPauseTimeMs; + public final double avgPauseMs; + public final long p50Ms; + public final long p95Ms; + public final long p99Ms; + public final long p999Ms; + public final long maxMs; + public final long violationsOver10ms; + public final long violationsOver50ms; + public final long violationsOver100ms; + public final int sampleSize; + + public GCPauseStats(long totalPauseCount, long totalPauseTimeMs, double avgPauseMs, long p50Ms, long p95Ms, long p99Ms, long p999Ms, long maxMs, + long violationsOver10ms, long violationsOver50ms, long violationsOver100ms, int sampleSize) { + this.totalPauseCount = totalPauseCount; + this.totalPauseTimeMs = totalPauseTimeMs; + this.avgPauseMs = avgPauseMs; + this.p50Ms = p50Ms; + this.p95Ms = p95Ms; + this.p99Ms = p99Ms; + this.p999Ms = p999Ms; + this.maxMs = maxMs; + this.violationsOver10ms = violationsOver10ms; + this.violationsOver50ms = violationsOver50ms; + this.violationsOver100ms = violationsOver100ms; + this.sampleSize = sampleSize; + } + } +} diff --git a/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java b/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java new file mode 100644 index 0000000..97c7579 --- /dev/null +++ b/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java @@ -0,0 +1,84 @@ +package fish.payara.trader.monitoring; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +@ApplicationScoped +public class SLAMonitorService { + + private static final Logger LOGGER = Logger.getLogger(SLAMonitorService.class.getName()); + + private static final long SLA_10MS = 10; + private static final long SLA_50MS = 50; + private static final long SLA_100MS = 100; + + private final AtomicLong violationsOver10ms = new AtomicLong(0); + private final AtomicLong violationsOver50ms = new AtomicLong(0); + private final AtomicLong violationsOver100ms = new AtomicLong(0); + private final AtomicLong totalOperations = new AtomicLong(0); + + private final ConcurrentHashMap violationsByMinute = new ConcurrentHashMap<>(); + + public void recordOperation(long latencyMs) { + totalOperations.incrementAndGet(); + + if (latencyMs > SLA_100MS) { + violationsOver100ms.incrementAndGet(); + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + recordViolation(); + } else if (latencyMs > SLA_50MS) { + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + recordViolation(); + } else if (latencyMs > SLA_10MS) { + violationsOver10ms.incrementAndGet(); + recordViolation(); + } + } + + private void recordViolation() { + long currentMinute = System.currentTimeMillis() / 60000; + violationsByMinute.merge(currentMinute, 1L, Long::sum); + + long fiveMinutesAgo = currentMinute - 5; + violationsByMinute.keySet().removeIf(minute -> minute < fiveMinutesAgo); + } + + public SLAStats getStats() { + long total = totalOperations.get(); + + return new SLAStats(total, violationsOver10ms.get(), violationsOver50ms.get(), violationsOver100ms.get(), + total > 0 ? (double) violationsOver10ms.get() / total * 100 : 0, violationsByMinute.values().stream().mapToLong(Long::longValue).sum()); + } + + public void reset() { + violationsOver10ms.set(0); + violationsOver50ms.set(0); + violationsOver100ms.set(0); + totalOperations.set(0); + violationsByMinute.clear(); + LOGGER.info("SLA statistics reset"); + } + + public static class SLAStats { + public final long totalOperations; + public final long violationsOver10ms; + public final long violationsOver50ms; + public final long violationsOver100ms; + public final double violationRate; + public final long recentViolations; + + public SLAStats(long totalOperations, long violationsOver10ms, long violationsOver50ms, long violationsOver100ms, double violationRate, + long recentViolations) { + this.totalOperations = totalOperations; + this.violationsOver10ms = violationsOver10ms; + this.violationsOver50ms = violationsOver50ms; + this.violationsOver100ms = violationsOver100ms; + this.violationRate = violationRate; + this.recentViolations = recentViolations; + } + } +} diff --git a/src/main/java/fish/payara/trader/portfolio/PortfolioService.java b/src/main/java/fish/payara/trader/portfolio/PortfolioService.java new file mode 100644 index 0000000..d8ff9ef --- /dev/null +++ b/src/main/java/fish/payara/trader/portfolio/PortfolioService.java @@ -0,0 +1,268 @@ +package fish.payara.trader.portfolio; + +import fish.payara.trader.analysis.BarAggregator; +import fish.payara.trader.portfolio.model.PerformanceMetrics; +import fish.payara.trader.portfolio.model.PortfolioSnapshot; +import fish.payara.trader.portfolio.model.RebalancePlan; +import fish.payara.trader.risk.PositionTracker; +import fish.payara.trader.risk.model.Position; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.logging.Logger; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.ta4j.core.BarSeries; + +/** + * Portfolio management service. Tracks NAV history, computes performance metrics (Sharpe ratio, max drawdown, win rate), and generates rebalance plans. + */ +@ApplicationScoped +public class PortfolioService { + + private static final Logger LOGGER = Logger.getLogger(PortfolioService.class.getName()); + + @Inject + private PositionTracker positionTracker; + + @Inject + private BarAggregator barAggregator; + + @Inject + @ConfigProperty(name = "portfolio.initial.capital", defaultValue = "1000000") + private double initialCapital; + + @Inject + @ConfigProperty(name = "portfolio.risk.free.rate", defaultValue = "0.02") + private double riskFreeRate; + + private final LinkedList navHistory = new LinkedList<>(); + private double cashBalance; + private double lastNav; + + @jakarta.annotation.PostConstruct + public void init() { + cashBalance = initialCapital; + lastNav = initialCapital; + navHistory.add(initialCapital); + LOGGER.info("PortfolioService initialized with capital: %.2f".formatted(initialCapital)); + } + + /** + * Computes performance metrics from position history and NAV series. + */ + public PerformanceMetrics calculateMetrics() { + Map allPositions = positionTracker.getAllPositions(); + + double totalPnl = 0.0; + double realizedPnl = 0.0; + double unrealizedPnl = 0.0; + long totalTrades = 0; + long winningTrades = 0; + long losingTrades = 0; + + for (Map.Entry entry : allPositions.entrySet()) { + Position pos = entry.getValue(); + realizedPnl += pos.realizedPnl(); + double price = getLastPrice(entry.getKey()); + unrealizedPnl += pos.unrealizedPnl(price); + totalTrades += pos.tradeCount(); + + if (pos.realizedPnl() > 0) { + winningTrades += pos.tradeCount(); + } else if (pos.realizedPnl() < 0) { + losingTrades += pos.tradeCount(); + } + } + + totalPnl = realizedPnl + unrealizedPnl; + double winRate = totalTrades > 0 ? (double) winningTrades / totalTrades : 0.0; + + double sharpe = computeSharpeRatio(); + double[] drawdown = computeMaxDrawdown(); + + return new PerformanceMetrics(totalPnl, realizedPnl, unrealizedPnl, sharpe, drawdown[0], drawdown[1], winRate, totalTrades, winningTrades, + losingTrades); + } + + /** + * Returns a snapshot of the current portfolio state. + */ + public PortfolioSnapshot getSnapshot() { + double positionsValue = computePositionsValue(); + double totalValue = cashBalance + positionsValue; + double marginUsed = positionsValue * 0.5; + double marginAvailable = Math.max(0, totalValue - marginUsed); + + int numPositions = positionTracker.getAllPositions().size(); + + // Record NAV for performance metrics (Sharpe ratio, max drawdown) + if (navHistory.isEmpty() || Math.abs(totalValue - navHistory.getLast()) > 0.01) { + navHistory.add(totalValue); + if (navHistory.size() > 5000) { + navHistory.removeFirst(); + } + lastNav = totalValue; + } + + double dailyPnl = 0.0; + if (navHistory.size() >= 2) { + double prevNav = navHistory.get(navHistory.size() - 2); + dailyPnl = totalValue - prevNav; + } + + double totalPnl = totalValue - initialCapital; + + return new PortfolioSnapshot(System.currentTimeMillis(), totalValue, cashBalance, marginAvailable, marginUsed, numPositions, dailyPnl, totalPnl); + } + + /** + * Generates a rebalance plan comparing current position weights to target weights. Actions indicate BUY, SELL, or HOLD for each symbol. + */ + public RebalancePlan generateRebalancePlan(Map targetWeights) { + Map currentWeights = computeCurrentWeights(); + Map actions = new LinkedHashMap<>(); + double totalValue = computePositionsValue() + cashBalance; + + if (totalValue <= 0) { + for (String symbol : targetWeights.keySet()) { + actions.put(symbol, "HOLD"); + } + return new RebalancePlan(System.currentTimeMillis(), targetWeights, actions); + } + + for (Map.Entry entry : targetWeights.entrySet()) { + String symbol = entry.getKey(); + double targetWeight = entry.getValue(); + double currentWeight = currentWeights.getOrDefault(symbol, 0.0); + double diff = targetWeight - currentWeight; + + if (Math.abs(diff) < 0.01) { + actions.put(symbol, "HOLD"); + } else if (diff > 0) { + actions.put(symbol, "BUY %.1f%%".formatted(diff * 100)); + } else { + actions.put(symbol, "SELL %.1f%%".formatted(Math.abs(diff) * 100)); + } + } + + for (String symbol : currentWeights.keySet()) { + if (!targetWeights.containsKey(symbol) && currentWeights.get(symbol) > 0.01) { + actions.put(symbol, "CLOSE"); + } + } + + return new RebalancePlan(System.currentTimeMillis(), targetWeights, actions); + } + + /** + * Clears all portfolio state. + */ + public void reset() { + navHistory.clear(); + cashBalance = initialCapital; + lastNav = initialCapital; + navHistory.add(initialCapital); + positionTracker.reset(); + LOGGER.info("PortfolioService reset"); + } + + private double computePositionsValue() { + double value = 0.0; + Map allPositions = positionTracker.getAllPositions(); + for (Map.Entry entry : allPositions.entrySet()) { + value += entry.getValue().notionalValue(getLastPrice(entry.getKey())); + } + return value; + } + + private Map computeCurrentWeights() { + Map weights = new LinkedHashMap<>(); + Map allPositions = positionTracker.getAllPositions(); + double totalValue = computePositionsValue(); + + if (totalValue <= 0) { + return weights; + } + + for (Map.Entry entry : allPositions.entrySet()) { + double notional = entry.getValue().notionalValue(getLastPrice(entry.getKey())); + weights.put(entry.getKey(), notional / totalValue); + } + + return weights; + } + + private double computeSharpeRatio() { + if (navHistory.size() < 3) { + return 0.0; + } + + LinkedList returns = new LinkedList<>(); + for (int i = 1; i < navHistory.size(); i++) { + double prev = navHistory.get(i - 1); + if (prev > 0) { + returns.add((navHistory.get(i) - prev) / prev); + } + } + + if (returns.isEmpty()) { + return 0.0; + } + + double mean = returns.stream().mapToDouble(Double::doubleValue).average().orElse(0.0); + double variance = returns.stream().mapToDouble(r -> (r - mean) * (r - mean)).average().orElse(0.0); + double stddev = Math.sqrt(variance); + + if (stddev == 0.0) { + return 0.0; + } + + double dailyRiskFree = riskFreeRate / 252.0; + double annualizedReturn = (mean - dailyRiskFree) / stddev * Math.sqrt(252.0); + + return annualizedReturn; + } + + /** + * Returns [maxDrawdown, maxDrawdownDurationSeconds]. + */ + private double[] computeMaxDrawdown() { + if (navHistory.size() < 2) { + return new double[]{0.0, 0.0}; + } + + double peak = navHistory.getFirst(); + double maxDrawdown = 0.0; + int drawdownStart = 0; + int maxDuration = 0; + + for (int i = 1; i < navHistory.size(); i++) { + double nav = navHistory.get(i); + if (nav > peak) { + peak = nav; + drawdownStart = i; + } + double drawdown = (peak - nav) / peak; + if (drawdown > maxDrawdown) { + maxDrawdown = drawdown; + } + int duration = i - drawdownStart; + if (duration > maxDuration) { + maxDuration = duration; + } + } + + double durationSeconds = maxDuration * 60.0; + return new double[]{maxDrawdown, durationSeconds}; + } + + private double getLastPrice(String symbol) { + BarSeries series = barAggregator.getSeries(symbol); + if (series.isEmpty()) { + return 0.0; + } + return series.getLastBar().getClosePrice().doubleValue(); + } +} diff --git a/src/main/java/fish/payara/trader/portfolio/model/PerformanceMetrics.java b/src/main/java/fish/payara/trader/portfolio/model/PerformanceMetrics.java new file mode 100644 index 0000000..bcf0208 --- /dev/null +++ b/src/main/java/fish/payara/trader/portfolio/model/PerformanceMetrics.java @@ -0,0 +1,8 @@ +package fish.payara.trader.portfolio.model; + +/** + * Computed performance metrics for the portfolio. + */ +public record PerformanceMetrics(double totalPnl, double realizedPnl, double unrealizedPnl, double sharpeRatio, double maxDrawdown, + double maxDrawdownDurationSeconds, double winRate, long totalTrades, long winningTrades, long losingTrades) { +} diff --git a/src/main/java/fish/payara/trader/portfolio/model/PortfolioSnapshot.java b/src/main/java/fish/payara/trader/portfolio/model/PortfolioSnapshot.java new file mode 100644 index 0000000..0db547d --- /dev/null +++ b/src/main/java/fish/payara/trader/portfolio/model/PortfolioSnapshot.java @@ -0,0 +1,8 @@ +package fish.payara.trader.portfolio.model; + +/** + * Point-in-time snapshot of portfolio state. + */ +public record PortfolioSnapshot(long timestamp, double totalValue, double cashBalance, double marginAvailable, double marginUsed, int numPositions, + double dailyPnl, double totalPnl) { +} diff --git a/src/main/java/fish/payara/trader/portfolio/model/RebalancePlan.java b/src/main/java/fish/payara/trader/portfolio/model/RebalancePlan.java new file mode 100644 index 0000000..47b2a29 --- /dev/null +++ b/src/main/java/fish/payara/trader/portfolio/model/RebalancePlan.java @@ -0,0 +1,9 @@ +package fish.payara.trader.portfolio.model; + +import java.util.Map; + +/** + * Rebalance plan comparing current weights to target weights with actions. + */ +public record RebalancePlan(long timestamp, Map targetWeights, Map actions) { +} diff --git a/src/main/java/fish/payara/trader/pressure/AllocationMode.java b/src/main/java/fish/payara/trader/pressure/AllocationMode.java new file mode 100644 index 0000000..8626284 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/AllocationMode.java @@ -0,0 +1,96 @@ +package fish.payara.trader.pressure; + +public enum AllocationMode { + OFF(0, 0, 0, ScenarioType.NONE, WorkloadType.NONE, "No allocation"), + + STEADY_LOAD(200, // MB/sec allocation rate + 512, // MB live set size + 0, // No growth + ScenarioType.STEADY, WorkloadType.NONE, "Steady 200 MB/sec allocation, 512 MB live set - Tests baseline runtime behavior"), + + INTRADAY_POSITION_GROWTH(150, // MB/sec allocation rate + 2048, // MB target live set + 60, // seconds to reach target + ScenarioType.INTRADAY_GROWTH, WorkloadType.NONE, + "Position book grows from 100 MB to 2 GB over 60s - intraday accumulation pressure on the runtime"), + + EARNINGS_SPIKE(300, // MB/sec allocation rate + 1024, // MB live set + 0, // No growth + ScenarioType.EARNINGS_SPIKE, WorkloadType.NONE, + "Post-earnings surge: 300 MB/s allocation, 50% order survival - tests how the runtime absorbs sudden directional flow"), + + MULTI_VENUE_QUOTE_CHURN(200, // MB/sec allocation rate + 1024, // MB live set + 0, // No growth + ScenarioType.QUOTE_CHURN, WorkloadType.NONE, + "Quote updates from many venues arrive and expire at random - tests how the runtime handles short-lived object churn"), + + LONG_HORIZON_POSITION_BOOK(150, // MB/sec allocation rate + 800, // MB live set in old gen + 0, // No growth + ScenarioType.POSITION_BOOK, WorkloadType.NONE, + "Long-held positions reference fresh execution objects - tests cross-generation reference overhead on the runtime"), + + COMPRESSION_CPU(300, 1024, 0, ScenarioType.NONE, WorkloadType.COMPRESSION, + "Gzip compress/decompress workload - Tests runtime behaviour under CPU-intensive compression"), + + SERIALIZATION_CPU(250, 1024, 0, ScenarioType.NONE, WorkloadType.SERIALIZATION, + "Jakarta JSON serialization/deserialization - Tests runtime behaviour under object graph materialization"), + + CRYPTO_CPU(200, 1024, 0, ScenarioType.NONE, WorkloadType.CRYPTO, + "SHA-256, HMAC-SHA256, AES-GCM encrypt/decrypt - Tests runtime behaviour under cryptographic operations"), + + COLLECTION_CPU(300, 1024, 0, ScenarioType.NONE, WorkloadType.COLLECTION, + "HashMap/TreeMap insert/lookup/remove - Tests runtime behaviour under collection churn"), + + STRING_CPU(350, 1024, 0, ScenarioType.NONE, WorkloadType.STRING, + "Regex, substring, StringBuilder, String.intern - Tests runtime behaviour under string interning pressure"), + + TRADING_MATCHING(400, 512, 0, ScenarioType.NONE, WorkloadType.TRADING_MATCHING, + "High-frequency order matching - Tests runtime behaviour under Order/Execution object churn with matching engine"), + + TECHNICAL_ANALYSIS(300, 256, 0, ScenarioType.NONE, WorkloadType.TECHNICAL_ANALYSIS, + "Continuous ta4j indicator computation - Tests runtime behaviour under indicator object allocation (SMA, EMA, RSI, MACD, Bollinger, ATR)"); + + private final int allocationRateMBPerSec; + private final int liveSetSizeMB; + private final int growthDurationSeconds; + private final ScenarioType scenarioType; + private final WorkloadType workloadType; + private final String description; + + AllocationMode(int allocationRateMBPerSec, int liveSetSizeMB, int growthDurationSeconds, ScenarioType scenarioType, WorkloadType workloadType, + String description) { + this.allocationRateMBPerSec = allocationRateMBPerSec; + this.liveSetSizeMB = liveSetSizeMB; + this.growthDurationSeconds = growthDurationSeconds; + this.scenarioType = scenarioType; + this.workloadType = workloadType; + this.description = description; + } + + public int getAllocationRateMBPerSec() { + return allocationRateMBPerSec; + } + + public int getLiveSetSizeMB() { + return liveSetSizeMB; + } + + public int getGrowthDurationSeconds() { + return growthDurationSeconds; + } + + public ScenarioType getScenarioType() { + return scenarioType; + } + + public WorkloadType getWorkloadType() { + return workloadType; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java b/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java new file mode 100644 index 0000000..4e31135 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java @@ -0,0 +1,466 @@ +package fish.payara.trader.pressure; + +import fish.payara.trader.concurrency.VirtualThreadExecutor; +import fish.payara.trader.pressure.workload.*; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Deque; +import java.util.LinkedList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Service to generate controlled memory pressure for GC stress testing. Updated to use scenario-based testing targeting G1 vs C4 differences. + */ +@ApplicationScoped +public class MemoryPressureService { + + private static final Logger LOGGER = Logger.getLogger(MemoryPressureService.class.getName()); + + private volatile AllocationMode currentMode = AllocationMode.OFF; + private volatile boolean running = false; + private Future pressureTask; + + private final AtomicLong totalBytesAllocated = new AtomicLong(0); + private long lastStatsTime = System.currentTimeMillis(); + + // Live set management + private final Deque liveSet = new LinkedList<>(); + private final AtomicLong liveSetBytesAllocated = new AtomicLong(0); + + // For Earnings Spike (thread-safe as multiple threads add to it) + private final Deque promotableObjects = new ConcurrentLinkedDeque<>(); + + // For Long-Horizon Position Book scenario - holders in old gen that reference young objects + private final Deque crossRefHolders = new LinkedList<>(); + private final AtomicLong crossRefBytesAllocated = new AtomicLong(0); + + // Scenario state + private final AtomicLong scenarioStartTime = new AtomicLong(0); + + /** + * Holder object that lives in old generation and holds a reference to a young object. Every update to youngRef triggers G1's write barrier and remembered + * set maintenance. + */ + private static class RefHolder { + volatile Object youngRef; // Reference to young gen object - updated frequently + final byte[] padding; // Padding to make holder substantial (~1MB each) + + RefHolder(int paddingSize) { + this.padding = new byte[paddingSize]; + ThreadLocalRandom.current().nextBytes(this.padding); + } + } + + @Inject + @VirtualThreadExecutor + private ManagedExecutorService executorService; + + // CPU workloads + @Inject + private CompressionWorkload compressionWorkload; + + @Inject + private SerializationWorkload serializationWorkload; + + @Inject + private CryptoWorkload cryptoWorkload; + + @Inject + private CollectionWorkload collectionWorkload; + + @Inject + private StringWorkload stringWorkload; + + @Inject + private TradingMatchingWorkload tradingMatchingWorkload; + + @Inject + private TechnicalAnalysisWorkload technicalAnalysisWorkload; + + private WorkloadConfig workloadConfig = new WorkloadConfig(); + + @PostConstruct + public void init() { + LOGGER.info("MemoryPressureService initialized"); + } + + public synchronized void setAllocationMode(AllocationMode mode) { + if (mode == currentMode) { + return; + } + + LOGGER.info("Changing allocation mode from " + currentMode + " to " + mode); + currentMode = mode; + + if (mode == AllocationMode.OFF) { + stopPressure(); + } else { + // clear previous state + liveSet.clear(); + liveSetBytesAllocated.set(0); + promotableObjects.clear(); + crossRefHolders.clear(); + crossRefBytesAllocated.set(0); + scenarioStartTime.set(System.currentTimeMillis()); + + startPressure(); + } + } + + private synchronized void startPressure() { + if (running) { + return; + } + + running = true; + totalBytesAllocated.set(0); + lastStatsTime = System.currentTimeMillis(); + + pressureTask = executorService.submit(() -> { + LOGGER.info("Memory pressure generator started with mode: " + currentMode); + + while (running) { + try { + long loopStartTime = System.currentTimeMillis(); + AllocationMode mode = currentMode; + if (mode == AllocationMode.OFF) { + break; + } + + generateGarbage(mode); + + long executionTime = System.currentTimeMillis() - loopStartTime; + // Target 10 iterations per second (100ms per iteration) + long sleepTime = 100 - executionTime; + + if (sleepTime > 0) { + Thread.sleep(sleepTime); + } + + logStats(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + LOGGER.warning("Error in memory pressure generator: " + e.getMessage()); + } + } + + LOGGER.info("Memory pressure generator stopped"); + liveSet.clear(); + liveSetBytesAllocated.set(0); + promotableObjects.clear(); + crossRefHolders.clear(); + crossRefBytesAllocated.set(0); + }); + } + + private synchronized void stopPressure() { + running = false; + if (pressureTask != null && !pressureTask.isDone()) { + pressureTask.cancel(true); + pressureTask = null; + } + } + + private void generateGarbage(AllocationMode mode) { + // First check if this is a CPU workload mode + if (mode.getWorkloadType() != WorkloadType.NONE) { + executeWorkload(mode); + return; + } + + // Otherwise handle scenario-based modes + switch (mode.getScenarioType()) { + case STEADY : + executeSteadyLoadScenario(mode); + break; + case INTRADAY_GROWTH : + executeIntradayPositionGrowthScenario(mode); + break; + case EARNINGS_SPIKE : + executeEarningsSpikeScenario(mode); + break; + case QUOTE_CHURN : + executeMultiVenueQuoteChurnScenario(mode); + break; + case POSITION_BOOK : + executeLongHorizonPositionBookScenario(mode); + break; + default : + break; + } + } + + private void executeWorkload(AllocationMode mode) { + AbstractCpuWorkload workload = switch (mode.getWorkloadType()) { + case COMPRESSION -> compressionWorkload; + case SERIALIZATION -> serializationWorkload; + case CRYPTO -> cryptoWorkload; + case COLLECTION -> collectionWorkload; + case STRING -> stringWorkload; + case TRADING_MATCHING -> tradingMatchingWorkload; + case TECHNICAL_ANALYSIS -> technicalAnalysisWorkload; + default -> null; + }; + + if (workload != null) { + workload.setConfig(workloadConfig); + workload.execute(workloadConfig.iterationsPerCycle()); + totalBytesAllocated.addAndGet(workload.bytesAllocated()); + } + } + + private void executeSteadyLoadScenario(AllocationMode mode) { + int targetMB = mode.getLiveSetSizeMB(); + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + + // Maintain stable live set (single-threaded) + maintainLiveSet(targetMB); + + // Allocate transient garbage at specified rate across 4 threads + // (100ms iteration = 10 iterations/sec) + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); + } + + private void executeIntradayPositionGrowthScenario(AllocationMode mode) { + long startTime = scenarioStartTime.get(); + long elapsed = (System.currentTimeMillis() - startTime) / 1000; // seconds + + int startMB = 100; + int targetMB = mode.getLiveSetSizeMB(); + int duration = mode.getGrowthDurationSeconds(); + + int currentTargetMB = elapsed >= duration ? targetMB : startMB + (int) ((targetMB - startMB) * elapsed / duration); + + maintainLiveSet(currentTargetMB); + + // Allocate transient garbage + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); + } + + private void executeEarningsSpikeScenario(AllocationMode mode) { + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + // 50% of allocations should survive to old generation + // Allocate half as transient, half as medium-lived + int transientBytes = bytesPerIteration / 2; + int promotableBytes = bytesPerIteration / 2; + + // Multi-threaded allocation for both transient and promotable + allocateTransientGarbageMultiThreaded(transientBytes, 4); + allocatePromotableGarbageMultiThreaded(promotableBytes, 4); + + byte[] promoted; + while ((promoted = promotableObjects.poll()) != null) { + liveSet.add(promoted); + liveSetBytesAllocated.addAndGet(promoted.length); + } + + long targetBytes = mode.getLiveSetSizeMB() * 1024L * 1024L; + while (liveSetBytesAllocated.get() > targetBytes && !liveSet.isEmpty()) { + byte[] removed = liveSet.removeFirst(); + liveSetBytesAllocated.addAndGet(-removed.length); + } + } + + private void executeMultiVenueQuoteChurnScenario(AllocationMode mode) { + int targetMB = mode.getLiveSetSizeMB(); + maintainLiveSet(targetMB); + + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + allocateQuoteChurnGarbageMultiThreaded(bytesPerIteration, 4); + } + + private void executeLongHorizonPositionBookScenario(AllocationMode mode) { + // Maintain old gen holders (these will be promoted after surviving GCs) + maintainCrossRefHolders(mode.getLiveSetSizeMB()); + + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + createCrossGenerationalRefsMultiThreaded(bytesPerIteration, 4); + } + + /** + * Maintain RefHolder objects that will live in old generation. Each holder has ~1MB padding and a reference slot for young objects. + */ + private void maintainCrossRefHolders(int targetMB) { + long targetBytes = targetMB * 1024L * 1024L; + long currentBytes = crossRefBytesAllocated.get(); + + while (currentBytes < targetBytes) { + RefHolder holder = new RefHolder(1024 * 1024); // ~1MB each + crossRefHolders.add(holder); + currentBytes += 1024 * 1024; + crossRefBytesAllocated.set(currentBytes); + } + + while (currentBytes > targetBytes && !crossRefHolders.isEmpty()) { + crossRefHolders.removeFirst(); + currentBytes -= 1024 * 1024; + crossRefBytesAllocated.set(currentBytes); + } + } + + /** + * Create young objects and update old→young references. Each reference update triggers G1's write barrier, which marks the card table and adds to + * remembered sets. This is the overhead we're testing - C4 has no remembered sets. + */ + private void createCrossGenerationalRefsMultiThreaded(int totalBytes, int numThreads) { + if (crossRefHolders.isEmpty()) { + return; + } + + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; + + // Convert to array for random access from multiple threads + RefHolder[] holders = crossRefHolders.toArray(new RefHolder[0]); + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { + int remaining = bytesPerThread; + while (remaining > 0) { + int size = ThreadLocalRandom.current().nextInt(1024, 4097); + if (size > remaining) + size = remaining; + byte[] youngObj = new byte[size]; + ThreadLocalRandom.current().nextBytes(youngObj); + + // Update random old gen holder to point to this young object + // This write triggers G1's post-write barrier: + // 1. Marks card table entry as dirty + // 2. Adds to remembered set for later scanning + int idx = ThreadLocalRandom.current().nextInt(holders.length); + holders[idx].youngRef = youngObj; + + remaining -= size; + } + totalBytesAllocated.addAndGet(bytesPerThread); + }, executorService); + } + + CompletableFuture.allOf(futures).join(); + } + + private void allocateTransientGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { + // Each thread allocates its share + byte[] garbage = new byte[bytesPerThread]; + ThreadLocalRandom.current().nextBytes(garbage); + // Object is now eligible for GC + totalBytesAllocated.addAndGet(bytesPerThread); + }, executorService); + } + + // Wait for all threads to complete + CompletableFuture.allOf(futures).join(); + } + + private void allocatePromotableGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { + byte[] obj = new byte[bytesPerThread]; + ThreadLocalRandom.current().nextBytes(obj); + promotableObjects.add(obj); + totalBytesAllocated.addAndGet(bytesPerThread); + }, executorService); + } + + CompletableFuture.allOf(futures).join(); + } + + private void allocateQuoteChurnGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { + int remaining = bytesPerThread; + while (remaining > 0) { + // Small objects 100-1000 bytes + int size = ThreadLocalRandom.current().nextInt(100, 1001); + if (size > remaining) + size = remaining; + byte[] garbage = new byte[size]; + ThreadLocalRandom.current().nextBytes(garbage); + remaining -= size; + } + totalBytesAllocated.addAndGet(bytesPerThread); + }, executorService); + } + + CompletableFuture.allOf(futures).join(); + } + + private void maintainLiveSet(int targetMB) { + long targetBytes = targetMB * 1024L * 1024L; + long currentBytes = liveSetBytesAllocated.get(); + + while (currentBytes < targetBytes) { + byte[] obj = new byte[1024 * 1024]; // 1 MB object + ThreadLocalRandom.current().nextBytes(obj); + liveSet.add(obj); + currentBytes += 1024 * 1024; + liveSetBytesAllocated.set(currentBytes); + } + + while (currentBytes > targetBytes && !liveSet.isEmpty()) { + liveSet.removeFirst(); + currentBytes -= 1024 * 1024; + liveSetBytesAllocated.set(currentBytes); + } + } + + private void logStats() { + long now = System.currentTimeMillis(); + if (now - lastStatsTime >= 5000) { + double elapsedSeconds = (now - lastStatsTime) / 1000.0; + long allocated = totalBytesAllocated.getAndSet(0); + double mbPerSec = (allocated / (1024.0 * 1024.0)) / elapsedSeconds; + + LOGGER.info(String.format("Memory Pressure Stats - Mode: %s | Allocated: %.2f MB/sec | Live Set: %d MB", currentMode, mbPerSec, + liveSetBytesAllocated.get() / (1024 * 1024))); + + lastStatsTime = now; + } + } + + @PreDestroy + public void shutdown() { + LOGGER.info("Shutting down MemoryPressureService"); + stopPressure(); + } + + public AllocationMode getCurrentMode() { + return currentMode; + } + + public boolean isRunning() { + return running; + } +} diff --git a/src/main/java/fish/payara/trader/pressure/ScenarioType.java b/src/main/java/fish/payara/trader/pressure/ScenarioType.java new file mode 100644 index 0000000..9cc3efe --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/ScenarioType.java @@ -0,0 +1,5 @@ +package fish.payara.trader.pressure; + +public enum ScenarioType { + NONE, STEADY, INTRADAY_GROWTH, EARNINGS_SPIKE, QUOTE_CHURN, POSITION_BOOK +} diff --git a/src/main/java/fish/payara/trader/pressure/Workload.java b/src/main/java/fish/payara/trader/pressure/Workload.java new file mode 100644 index 0000000..296cb61 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/Workload.java @@ -0,0 +1,13 @@ +package fish.payara.trader.pressure; + +public interface Workload { + String name(); + + void execute(int iterations); + + long bytesAllocated(); + + long operationsCompleted(); + + void reset(); +} diff --git a/src/main/java/fish/payara/trader/pressure/WorkloadConfig.java b/src/main/java/fish/payara/trader/pressure/WorkloadConfig.java new file mode 100644 index 0000000..6b5fa45 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/WorkloadConfig.java @@ -0,0 +1,20 @@ +package fish.payara.trader.pressure; + +public record WorkloadConfig(int payloadSizeBytes, int iterationsPerCycle, int threadsPerWorkload) { + + public WorkloadConfig() { + this(65536, 100, 4); + } + + public WorkloadConfig { + if (payloadSizeBytes < 1) { + throw new IllegalArgumentException("payloadSizeBytes must be positive"); + } + if (iterationsPerCycle < 1) { + throw new IllegalArgumentException("iterationsPerCycle must be positive"); + } + if (threadsPerWorkload < 1) { + throw new IllegalArgumentException("threadsPerWorkload must be positive"); + } + } +} diff --git a/src/main/java/fish/payara/trader/pressure/WorkloadResult.java b/src/main/java/fish/payara/trader/pressure/WorkloadResult.java new file mode 100644 index 0000000..3fb462f --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/WorkloadResult.java @@ -0,0 +1,11 @@ +package fish.payara.trader.pressure; + +public record WorkloadResult(String workloadName, long bytesAllocated, long operationsCompleted, long durationNanos, double opsPerSecond, double mbPerSecond) { + + public static WorkloadResult of(String workloadName, long bytesAllocated, long operationsCompleted, long durationNanos) { + double seconds = durationNanos / 1_000_000_000.0; + double opsPerSecond = seconds > 0 ? operationsCompleted / seconds : 0.0; + double mbPerSecond = seconds > 0 ? (bytesAllocated / (1024.0 * 1024.0)) / seconds : 0.0; + return new WorkloadResult(workloadName, bytesAllocated, operationsCompleted, durationNanos, opsPerSecond, mbPerSecond); + } +} diff --git a/src/main/java/fish/payara/trader/pressure/WorkloadType.java b/src/main/java/fish/payara/trader/pressure/WorkloadType.java new file mode 100644 index 0000000..6c307bb --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/WorkloadType.java @@ -0,0 +1,5 @@ +package fish.payara.trader.pressure; + +public enum WorkloadType { + NONE, COMPRESSION, SERIALIZATION, CRYPTO, COLLECTION, STRING, TRADING_MATCHING, TECHNICAL_ANALYSIS +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/AbstractCpuWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/AbstractCpuWorkload.java new file mode 100644 index 0000000..b8722e1 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/AbstractCpuWorkload.java @@ -0,0 +1,58 @@ +package fish.payara.trader.pressure.workload; + +import fish.payara.trader.concurrency.VirtualThreadExecutor; +import fish.payara.trader.pressure.Workload; +import fish.payara.trader.pressure.WorkloadConfig; +import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.inject.Inject; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicLong; + +public abstract class AbstractCpuWorkload implements Workload { + + protected final AtomicLong bytesAllocated = new AtomicLong(0); + protected final AtomicLong operationsCompleted = new AtomicLong(0); + protected WorkloadConfig config = new WorkloadConfig(); + + @Inject + @VirtualThreadExecutor + protected ManagedExecutorService executorService; + + protected void executeMultiThreaded(int totalWork, int numThreads, java.util.function.Consumer work) { + int workPerThread = totalWork / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + int remaining = workPerThread; + while (remaining-- > 0) { + work.accept(rng); + } + }, executorService); + } + + CompletableFuture.allOf(futures).join(); + } + + @Override + public long bytesAllocated() { + return bytesAllocated.get(); + } + + @Override + public long operationsCompleted() { + return operationsCompleted.get(); + } + + @Override + public void reset() { + bytesAllocated.set(0); + operationsCompleted.set(0); + } + + public void setConfig(WorkloadConfig config) { + this.config = config; + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/CollectionWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/CollectionWorkload.java new file mode 100644 index 0000000..f08a0e1 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/CollectionWorkload.java @@ -0,0 +1,92 @@ +package fish.payara.trader.pressure.workload; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ThreadLocalRandom; + +@ApplicationScoped +public class CollectionWorkload extends AbstractCpuWorkload { + + private static final int ENTRY_COUNT = 10_000; + private static final int VALUE_SIZE = 256; + private static final long ESTIMATED_NODE_BYTES = 64L; + + @Override + public String name() { + return "COLLECTION"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + executeHashMapOps(ENTRY_COUNT, rng); + executeTreeMapOps(ENTRY_COUNT, rng); + } + + void executeHashMapOps(int count, ThreadLocalRandom rng) { + Map map = new HashMap<>(count * 2); + + for (Map.Entry entry : generateRandomEntries(count, rng)) { + map.put(entry.getKey(), entry.getValue()); + } + bytesAllocated.addAndGet((long) count * ESTIMATED_NODE_BYTES); + + for (int i = 0; i < count; i++) { + map.get(rng.nextInt(count)); + } + + for (int i = 0; i < count / 2; i++) { + map.remove(rng.nextInt(count)); + } + + long sum = 0; + for (Map.Entry entry : map.entrySet()) { + sum += entry.getKey(); + } + + operationsCompleted.incrementAndGet(); + } + + void executeTreeMapOps(int count, ThreadLocalRandom rng) { + Map map = new TreeMap<>(); + + for (Map.Entry entry : generateRandomEntries(count, rng)) { + map.put(entry.getKey(), entry.getValue()); + } + bytesAllocated.addAndGet((long) count * (ESTIMATED_NODE_BYTES + 16L)); + + for (int i = 0; i < count; i++) { + map.get(rng.nextInt(count)); + } + + for (int i = 0; i < count / 2; i++) { + map.remove(rng.nextInt(count)); + } + + long sum = 0; + for (Map.Entry entry : map.entrySet()) { + sum += entry.getKey(); + } + + operationsCompleted.incrementAndGet(); + } + + Map.Entry[] generateRandomEntries(int count, ThreadLocalRandom rng) { + @SuppressWarnings("unchecked") + Map.Entry[] entries = new Map.Entry[count]; + for (int i = 0; i < count; i++) { + byte[] value = new byte[VALUE_SIZE]; + rng.nextBytes(value); + final int key = rng.nextInt(); + entries[i] = Map.entry(key, value); + bytesAllocated.addAndGet(value.length); + } + return entries; + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/CompressionWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/CompressionWorkload.java new file mode 100644 index 0000000..ff71454 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/CompressionWorkload.java @@ -0,0 +1,84 @@ +package fish.payara.trader.pressure.workload; + +import jakarta.enterprise.context.ApplicationScoped; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.concurrent.ThreadLocalRandom; +import java.util.zip.Deflater; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +@ApplicationScoped +public class CompressionWorkload extends AbstractCpuWorkload { + + @Override + public String name() { + return "COMPRESSION"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + int size = config.payloadSizeBytes(); + byte[] payload = generatePayload(size, rng); + byte[] compressed = compress(payload); + byte[] recovered = decompress(compressed); + + verify(payload, recovered); + + bytesAllocated.addAndGet((long) payload.length + compressed.length + recovered.length); + operationsCompleted.incrementAndGet(); + } + + byte[] generatePayload(int size, ThreadLocalRandom rng) { + byte[] data = new byte[size]; + rng.nextBytes(data); + return data; + } + + byte[] compress(byte[] data) { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length)) { + try (GZIPOutputStream gzip = new GZIPOutputStream(bos) { + { + def.setLevel(Deflater.BEST_COMPRESSION); + } + }) { + gzip.write(data); + } + return bos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Compression failed", e); + } + } + + byte[] decompress(byte[] compressed) { + try (ByteArrayInputStream bis = new ByteArrayInputStream(compressed); + GZIPInputStream gzip = new GZIPInputStream(bis); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int len; + while ((len = gzip.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + return bos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Decompression failed", e); + } + } + + void verify(byte[] original, byte[] recovered) { + if (original.length != recovered.length) { + throw new AssertionError("Decompressed length mismatch: expected " + original.length + " got " + recovered.length); + } + for (int i = 0; i < original.length; i++) { + if (original[i] != recovered[i]) { + throw new AssertionError("Decompressed data mismatch at index " + i); + } + } + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/CryptoWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/CryptoWorkload.java new file mode 100644 index 0000000..c29d201 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/CryptoWorkload.java @@ -0,0 +1,107 @@ +package fish.payara.trader.pressure.workload; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import jakarta.enterprise.context.ApplicationScoped; +import java.security.MessageDigest; +import java.util.concurrent.ThreadLocalRandom; + +@ApplicationScoped +public class CryptoWorkload extends AbstractCpuWorkload { + + private static final int PAYLOAD_SIZE = 4096; + private static final String AES_ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + @Override + public String name() { + return "CRYPTO"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + byte[] key256 = new byte[32]; + byte[] key128 = new byte[16]; + rng.nextBytes(key256); + rng.nextBytes(key128); + + byte[] payload = new byte[PAYLOAD_SIZE]; + rng.nextBytes(payload); + + sha256Digest(payload); + bytesAllocated.addAndGet(32L); + operationsCompleted.incrementAndGet(); + + hmacSha256(key256, payload); + bytesAllocated.addAndGet(32L); + operationsCompleted.incrementAndGet(); + + byte[] iv = new byte[GCM_IV_LENGTH]; + rng.nextBytes(iv); + byte[] ciphertext = aesGcmEncrypt(key128, iv, payload); + bytesAllocated.addAndGet(ciphertext.length + 16L); + operationsCompleted.incrementAndGet(); + + byte[] recovered = aesGcmDecrypt(key128, iv, ciphertext); + bytesAllocated.addAndGet(recovered.length); + + if (payload.length != recovered.length) { + throw new AssertionError("AES-GCM round-trip length mismatch"); + } + for (int i = 0; i < payload.length; i++) { + if (payload[i] != recovered[i]) { + throw new AssertionError("AES-GCM round-trip data mismatch at index " + i); + } + } + operationsCompleted.incrementAndGet(); + } + + byte[] sha256Digest(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(data); + } catch (Exception e) { + throw new RuntimeException("SHA-256 digest failed", e); + } + } + + byte[] hmacSha256(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (Exception e) { + throw new RuntimeException("HMAC-SHA256 failed", e); + } + } + + byte[] aesGcmEncrypt(byte[] key, byte[] iv, byte[] plaintext) { + try { + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), spec); + return cipher.doFinal(plaintext); + } catch (Exception e) { + throw new RuntimeException("AES-GCM encrypt failed", e); + } + } + + byte[] aesGcmDecrypt(byte[] key, byte[] iv, byte[] ciphertext) { + try { + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), spec); + return cipher.doFinal(ciphertext); + } catch (Exception e) { + throw new RuntimeException("AES-GCM decrypt failed", e); + } + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/SerializationWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/SerializationWorkload.java new file mode 100644 index 0000000..d28719b --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/SerializationWorkload.java @@ -0,0 +1,113 @@ +package fish.payara.trader.pressure.workload; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonReader; +import jakarta.json.JsonWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.concurrent.ThreadLocalRandom; + +@ApplicationScoped +public class SerializationWorkload extends AbstractCpuWorkload { + + @Override + public String name() { + return "SERIALIZATION"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + JsonObject original = buildJsonDocument(rng); + + String json = serializeToString(original); + bytesAllocated.addAndGet(json.length() * 2L); + + JsonObject parsed = parseFromString(json); + + verify(original, parsed); + operationsCompleted.incrementAndGet(); + } + + private JsonObject buildJsonDocument(ThreadLocalRandom rng) { + JsonObjectBuilder builder = Json.createObjectBuilder(); + + builder.add("orderId", rng.nextLong(1_000_000_000L)); + builder.add("symbol", "SYM-" + rng.nextInt(1000)); + builder.add("side", rng.nextBoolean() ? "BUY" : "SELL"); + builder.add("quantity", rng.nextInt(1, 10_000)); + builder.add("price", rng.nextDouble(1.0, 1000.0)); + builder.add("timestamp", System.nanoTime()); + + JsonArrayBuilder tags = Json.createArrayBuilder(); + for (int i = 0; i < 5; i++) { + tags.add("tag-" + rng.nextInt(100)); + } + builder.add("tags", tags); + + JsonObjectBuilder metadata = Json.createObjectBuilder(); + metadata.add("exchange", "EXCH-" + rng.nextInt(10)); + metadata.add("currency", "USD"); + metadata.add("settlementDate", "2026-03-18"); + metadata.add("commission", rng.nextDouble(0.01, 5.0)); + builder.add("metadata", metadata); + + builder.add("execType", "FILL"); + builder.add("leavesQty", 0); + builder.add("cumQty", rng.nextInt(1, 10_000)); + builder.add("avgPrice", rng.nextDouble(1.0, 1000.0)); + builder.add("tradeId", rng.nextLong(1_000_000L)); + + JsonArrayBuilder allocations = Json.createArrayBuilder(); + for (int i = 0; i < 3; i++) { + allocations.add(Json.createObjectBuilder() + .add("account", "ACC-" + rng.nextInt(100)) + .add("allocQty", rng.nextInt(100, 5000)) + .add("allocPrice", rng.nextDouble(1.0, 1000.0))); + } + builder.add("allocations", allocations); + + builder.add("status", "ACCEPTED"); + builder.add("text", "Executed via matching engine v" + rng.nextInt(1, 10)); + builder.add("transactTime", System.nanoTime()); + builder.add("senderCompId", "SENDER-" + rng.nextInt(50)); + builder.add("targetCompId", "TARGET-" + rng.nextInt(50)); + builder.add("clOrdId", rng.nextLong(100_000L, 999_999L)); + builder.add("origClOrdId", rng.nextLong(100_000L, 999_999L)); + builder.add("minQty", rng.nextInt(1, 100)); + builder.add("maxFloor", rng.nextInt(100, 10_000)); + + return builder.build(); + } + + String serializeToString(JsonObject json) { + StringWriter writer = new StringWriter(); + try (JsonWriter jsonWriter = Json.createWriter(writer)) { + jsonWriter.writeObject(json); + } + return writer.toString(); + } + + JsonObject parseFromString(String json) { + try (JsonReader reader = Json.createReader(new StringReader(json))) { + return reader.readObject(); + } + } + + void verify(JsonObject original, JsonObject parsed) { + if (original.size() != parsed.size()) { + throw new AssertionError("Parsed JSON field count mismatch"); + } + if (!original.getString("symbol").equals(parsed.getString("symbol"))) { + throw new AssertionError("Symbol mismatch after round-trip"); + } + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/StringWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/StringWorkload.java new file mode 100644 index 0000000..2646b61 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/StringWorkload.java @@ -0,0 +1,130 @@ +package fish.payara.trader.pressure.workload; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@ApplicationScoped +public class StringWorkload extends AbstractCpuWorkload { + + private final Pattern pricePattern = Pattern.compile("\\$([0-9]+\\.[0-9]{2})"); + private final Pattern symbolPattern = Pattern.compile("\\b[A-Z]{3,5}\\b"); + private final Pattern datePattern = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + private final Pattern quantityPattern = Pattern.compile("(\\d{1,3}(?:,\\d{3})*(?:\\.\\d+)?)"); + + @Override + public String name() { + return "STRING"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + int count = config.payloadSizeBytes() / 256; + if (count < 1) { + count = 1; + } + + int op = rng.nextInt(4); + switch (op) { + case 0 -> executeRegexWork(count, rng); + case 1 -> executeSubstringWork(count, rng); + case 2 -> executeConcatWork(count, rng); + case 3 -> executeInternWork(count, rng); + default -> throw new AssertionError("Unexpected op: " + op); + } + } + + void executeRegexWork(int count, ThreadLocalRandom rng) { + for (int i = 0; i < count; i++) { + String input = generateRandomTradeString(rng); + bytesAllocated.addAndGet(input.length() * 2L); + + findMatches(pricePattern, input); + findMatches(symbolPattern, input); + findMatches(datePattern, input); + findMatches(quantityPattern, input); + + operationsCompleted.incrementAndGet(); + } + } + + private void findMatches(Pattern pattern, String input) { + Matcher matcher = pattern.matcher(input); + while (matcher.find()) { + String match = matcher.group(); + bytesAllocated.addAndGet(match.length() * 2L); + } + } + + void executeSubstringWork(int count, ThreadLocalRandom rng) { + for (int i = 0; i < count; i++) { + String base = generateRandomTradeString(rng); + bytesAllocated.addAndGet(base.length() * 2L); + + for (int j = 0; j < 10; j++) { + int start = rng.nextInt(0, base.length() / 2); + int end = rng.nextInt(start + 1, base.length()); + String sub = base.substring(start, end); + bytesAllocated.addAndGet(sub.length() * 2L); + } + + operationsCompleted.incrementAndGet(); + } + } + + void executeConcatWork(int count, ThreadLocalRandom rng) { + for (int i = 0; i < count; i++) { + StringBuilder sb = new StringBuilder(); + for (int j = 0; j < 50; j++) { + sb.append(generateRandomTradeString(rng)); + if (j < 49) { + sb.append(" | "); + } + } + String result = sb.toString(); + bytesAllocated.addAndGet(result.length() * 2L + 50L * 24L); + operationsCompleted.incrementAndGet(); + } + } + + void executeInternWork(int count, ThreadLocalRandom rng) { + for (int i = 0; i < count; i++) { + String symbol = "SYM-" + rng.nextInt(1000); + bytesAllocated.addAndGet(symbol.length() * 2L); + symbol.intern(); + + String currency = "CUR-" + rng.nextInt(100); + bytesAllocated.addAndGet(currency.length() * 2L); + currency.intern(); + + String exchange = "EXCH-" + rng.nextInt(50); + bytesAllocated.addAndGet(exchange.length() * 2L); + exchange.intern(); + + operationsCompleted.incrementAndGet(); + } + } + + String generateRandomTradeString(ThreadLocalRandom rng) { + int size = 1024 + rng.nextInt(9 * 1024); + StringBuilder sb = new StringBuilder(size); + sb.append("SYM-").append(rng.nextInt(1000)).append(" "); + sb.append("$").append(String.format("%.2f", rng.nextDouble(1.0, 1000.0))).append(" "); + sb.append(String.format("%,d", rng.nextInt(100, 10_000))).append(" "); + sb.append("2026-").append(String.format("%02d-%02d", rng.nextInt(1, 13), rng.nextInt(1, 29))).append(" "); + sb.append(rng.nextBoolean() ? "BUY" : "SELL").append(" "); + + int remaining = size - sb.length(); + for (int i = 0; i < remaining; i++) { + sb.append((char) ('a' + rng.nextInt(26))); + } + + return sb.toString(); + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/TechnicalAnalysisWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/TechnicalAnalysisWorkload.java new file mode 100644 index 0000000..7a295c1 --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/TechnicalAnalysisWorkload.java @@ -0,0 +1,158 @@ +package fish.payara.trader.pressure.workload; + +import fish.payara.trader.pressure.WorkloadConfig; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeriesBuilder; +import org.ta4j.core.bars.TimeBarBuilder; +import org.ta4j.core.indicators.ATRIndicator; +import org.ta4j.core.indicators.MACDIndicator; +import org.ta4j.core.indicators.RSIIndicator; +import org.ta4j.core.indicators.averages.EMAIndicator; +import org.ta4j.core.indicators.averages.SMAIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsLowerIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsMiddleIndicator; +import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator; +import org.ta4j.core.indicators.helpers.ClosePriceIndicator; +import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; +import org.ta4j.core.num.Num; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Workload that exercises ta4j technical analysis library to generate GC pressure from indicator object allocations. Creates synthetic bar series and computes + * multiple indicator families repeatedly. + */ +@ApplicationScoped +public class TechnicalAnalysisWorkload extends AbstractCpuWorkload { + + private static final int[] SMA_PERIODS = {10, 20, 50, 100, 200}; + private static final int[] EMA_PERIODS = {12, 20, 26, 50}; + private static final int[] RSI_PERIODS = {7, 14, 21}; + private static final int BB_PERIOD = 20; + private static final int ATR_PERIOD = 14; + private static final int MACD_FAST = 12; + private static final int MACD_SLOW = 26; + + @Inject + private WorkloadConfig config; + + @Override + public String name() { + return "TECHNICAL_ANALYSIS"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + BarSeries series = createSyntheticSeries(rng); + computeAllIndicators(series); + + // Estimate bytes allocated: + // - BarSeries with 500 bars: ~40KB (each bar ~80 bytes) + // - Each indicator: ~200-500 bytes object allocation + // - Computing 15+ indicators: ~5KB + // - Num objects (DecimalNum): cached but creates wrapper objects + // Total per iteration: ~50KB + bytesAllocated.addAndGet(50_000); + operationsCompleted.incrementAndGet(); + } + + private BarSeries createSyntheticSeries(ThreadLocalRandom rng) { + int barCount = 200 + rng.nextInt(300); // 200-500 bars + BarSeries series = new BaseBarSeriesBuilder().withName("SYNTH_" + rng.nextInt(1000)).build(); + + Instant now = Instant.now(); + double price = 100 + rng.nextDouble() * 900; // Starting price 100-1000 + + for (int i = 0; i < barCount; i++) { + double volatility = 0.02 + rng.nextDouble() * 0.03; // 2-5% volatility + double change = (rng.nextDouble() * 2 - 1) * volatility; + double open = price; + double close = price * (1 + change); + double high = Math.max(open, close) * (1 + rng.nextDouble() * 0.01); + double low = Math.min(open, close) * (1 - rng.nextDouble() * 0.01); + double volume = 100_000 + rng.nextDouble() * 900_000; + + var bar = new TimeBarBuilder().timePeriod(Duration.ofMinutes(1)) + .endTime(now.minusSeconds((long) (barCount - i) * 60)) + .openPrice(open) + .highPrice(high) + .lowPrice(low) + .closePrice(close) + .volume(volume) + .build(); + + series.addBar(bar); + price = close; + } + + return series; + } + + private void computeAllIndicators(BarSeries series) { + if (series.getEndIndex() < 200) { + return; + } + + ClosePriceIndicator closePrice = new ClosePriceIndicator(series); + int lastIndex = series.getEndIndex(); + + // SMA indicators + for (int period : SMA_PERIODS) { + if (series.getEndIndex() >= period) { + SMAIndicator sma = new SMAIndicator(closePrice, period); + sma.getValue(lastIndex); + } + } + + // EMA indicators + for (int period : EMA_PERIODS) { + if (series.getEndIndex() >= period) { + EMAIndicator ema = new EMAIndicator(closePrice, period); + ema.getValue(lastIndex); + } + } + + // RSI indicators + for (int period : RSI_PERIODS) { + if (series.getEndIndex() >= period) { + RSIIndicator rsi = new RSIIndicator(closePrice, period); + rsi.getValue(lastIndex); + } + } + + // MACD + if (series.getEndIndex() >= MACD_SLOW) { + MACDIndicator macd = new MACDIndicator(closePrice, MACD_FAST, MACD_SLOW); + macd.getValue(lastIndex); + } + + // Bollinger Bands + if (series.getEndIndex() >= BB_PERIOD) { + SMAIndicator sma = new SMAIndicator(closePrice, BB_PERIOD); + StandardDeviationIndicator sd = new StandardDeviationIndicator(closePrice, BB_PERIOD); + BollingerBandsMiddleIndicator bbMiddle = new BollingerBandsMiddleIndicator(sma); + Num k = series.numFactory().numOf(2.0); + BollingerBandsLowerIndicator bbLower = new BollingerBandsLowerIndicator(bbMiddle, sd, k); + BollingerBandsUpperIndicator bbUpper = new BollingerBandsUpperIndicator(bbMiddle, sd, k); + + bbMiddle.getValue(lastIndex); + bbLower.getValue(lastIndex); + bbUpper.getValue(lastIndex); + } + + // ATR + if (series.getEndIndex() >= ATR_PERIOD) { + ATRIndicator atr = new ATRIndicator(series, ATR_PERIOD); + atr.getValue(lastIndex); + } + } +} diff --git a/src/main/java/fish/payara/trader/pressure/workload/TradingMatchingWorkload.java b/src/main/java/fish/payara/trader/pressure/workload/TradingMatchingWorkload.java new file mode 100644 index 0000000..0b34aec --- /dev/null +++ b/src/main/java/fish/payara/trader/pressure/workload/TradingMatchingWorkload.java @@ -0,0 +1,82 @@ +package fish.payara.trader.pressure.workload; + +import fish.payara.trader.matching.engine.MatchingEngine; +import fish.payara.trader.matching.model.OrderRequest; +import fish.payara.trader.matching.model.OrderType; +import fish.payara.trader.matching.model.Side; +import fish.payara.trader.matching.model.TimeInForce; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Workload that exercises the matching engine to generate GC pressure from Order, Execution, and OrderBookEntry object allocations. + */ +@ApplicationScoped +public class TradingMatchingWorkload extends AbstractCpuWorkload { + + private static final String[] SYMBOLS = {"AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX"}; + + private static final OrderType[] ORDER_TYPES = {OrderType.MARKET, OrderType.LIMIT, OrderType.LIMIT, OrderType.LIMIT, OrderType.STOP_LIMIT}; + + @Inject + private MatchingEngine matchingEngine; + + @Override + public String name() { + return "TRADING_MATCHING"; + } + + @Override + public void execute(int iterations) { + int numThreads = config.threadsPerWorkload(); + executeMultiThreaded(iterations, numThreads, this::executeSingleIteration); + } + + private void executeSingleIteration(ThreadLocalRandom rng) { + // Generate random order parameters + String symbol = SYMBOLS[rng.nextInt(SYMBOLS.length)]; + Side side = rng.nextBoolean() ? Side.BUY : Side.SELL; + OrderType type = ORDER_TYPES[rng.nextInt(ORDER_TYPES.length)]; + Long quantity = 100L + rng.nextInt(9901); // 100-10000 + double basePrice = 100 + rng.nextDouble() * 900; // 100-1000 + Double price = Math.round(basePrice * 100) / 100.0; + TimeInForce tif = TimeInForce.values()[rng.nextInt(TimeInForce.values().length)]; + + OrderRequest request; + if (type == OrderType.STOP_LIMIT) { + Double stopPrice = price * (1 + (rng.nextDouble() * 0.1 - 0.05)); // +/- 5% + request = new OrderRequest(symbol, side, type, price, quantity, stopPrice, tif, null, 0.0); + } else if (type == OrderType.LIMIT) { + request = new OrderRequest(symbol, side, type, price, quantity, null, tif, null, 0.0); + } else { + request = new OrderRequest(symbol, side, type, null, quantity, null, tif, null, 0.0); + } + + try { + matchingEngine.submitOrder(request); + + // Estimate bytes allocated per order: + // - Order record: ~200 bytes (fields + object header) + // - OrderRequest: ~100 bytes + // - OrderBookEntry (if resting): ~50 bytes + // - Price objects: ~32 bytes each + // - Execution objects (if matched): ~150 bytes each + // Average ~400 bytes per order submission + bytesAllocated.addAndGet(400); + operationsCompleted.incrementAndGet(); + + // Periodically cancel some orders to exercise that path + if (rng.nextInt(100) < 10) { + long randomOrderId = System.currentTimeMillis() * 10000 + rng.nextInt(10000); + matchingEngine.cancelOrder(randomOrderId); + bytesAllocated.addAndGet(100); + } + + } catch (Exception e) { + // Order validation errors are expected - still counts as work + operationsCompleted.incrementAndGet(); + } + } +} diff --git a/src/main/java/fish/payara/trader/rest/ApplicationConfig.java b/src/main/java/fish/payara/trader/rest/ApplicationConfig.java index 528ef7e..d668bd9 100644 --- a/src/main/java/fish/payara/trader/rest/ApplicationConfig.java +++ b/src/main/java/fish/payara/trader/rest/ApplicationConfig.java @@ -3,10 +3,6 @@ import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.core.Application; -/** - * JAX-RS application configuration - */ @ApplicationPath("/api") public class ApplicationConfig extends Application { - // All REST resources will be available at /api/* } diff --git a/src/main/java/fish/payara/trader/rest/BusinessImpactResource.java b/src/main/java/fish/payara/trader/rest/BusinessImpactResource.java new file mode 100644 index 0000000..047b5b4 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/BusinessImpactResource.java @@ -0,0 +1,60 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.dto.BusinessImpactResponse; +import fish.payara.trader.impact.BusinessImpactCalculator; +import fish.payara.trader.impact.BusinessImpactConfig; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.Map; +import java.util.HashMap; +import java.util.logging.Logger; + +/** + * REST endpoints for business impact calculations. Provides missed trades and revenue at risk metrics. + */ +@Path("/business") +public class BusinessImpactResource { + + private static final Logger LOGGER = Logger.getLogger(BusinessImpactResource.class.getName()); + + @Inject + private BusinessImpactCalculator calculator; + + @Inject + private BusinessImpactConfig config; + + @GET + @Path("/impact") + @Produces(MediaType.APPLICATION_JSON) + public Response getImpact() { + LOGGER.info("GET /api/business/impact - Calculating business impact"); + BusinessImpactResponse impact = calculator.calculateImpact(); + return Response.ok(impact).build(); + } + + @POST + @Path("/reset") + @Produces(MediaType.APPLICATION_JSON) + public Response resetImpact() { + LOGGER.info("POST /api/business/reset - Resetting impact calculations"); + calculator.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } + + @GET + @Path("/config") + @Produces(MediaType.APPLICATION_JSON) + public Response getConfig() { + LOGGER.info("GET /api/business/config - Getting business impact configuration"); + Map configMap = new HashMap<>(); + configMap.put("tradeValue", config.tradeValue()); + configMap.put("currency", config.currency()); + configMap.put("windowSeconds", config.windowSeconds()); + configMap.put("instancesNeededC4", config.instancesNeededC4()); + configMap.put("instancesNeededG1", config.instancesNeededG1()); + return Response.ok(configMap).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/CorsFilter.java b/src/main/java/fish/payara/trader/rest/CorsFilter.java new file mode 100644 index 0000000..8279868 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/CorsFilter.java @@ -0,0 +1,27 @@ +package fish.payara.trader.rest; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.ext.Provider; + +/** + * CORS filter for cross-origin requests during comparison demo. Allows frontend on one port to fetch data from the other cluster. + */ +@Provider +public class CorsFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) { + String origin = requestContext.getHeaderString("Origin"); + + // Allow requests from localhost for demo purposes + if (origin != null && (origin.contains("localhost") || origin.contains("127.0.0.1"))) { + responseContext.getHeaders().add("Access-Control-Allow-Origin", origin); + responseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + responseContext.getHeaders().add("Access-Control-Allow-Headers", "Content-Type, Authorization"); + responseContext.getHeaders().add("Access-Control-Max-Age", "86400"); + responseContext.getHeaders().add("Access-Control-Allow-Credentials", "true"); + } + } +} diff --git a/src/main/java/fish/payara/trader/rest/DemoPresetResource.java b/src/main/java/fish/payara/trader/rest/DemoPresetResource.java new file mode 100644 index 0000000..f2b3741 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/DemoPresetResource.java @@ -0,0 +1,108 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.demo.DemoPresetService; +import fish.payara.trader.demo.DemoPresetService.PresetExecutionContext; +import fish.payara.trader.dto.DemoPresetResponse; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; + +/** + * REST endpoints for demo preset management and execution. Presets are executed client-side via step-by-step REST calls (hybrid approach). + */ +@Path("/demo") +public class DemoPresetResource { + + private static final Logger LOGGER = Logger.getLogger(DemoPresetResource.class.getName()); + + @Inject + private DemoPresetService presetService; + + @GET + @Path("/presets") + @Produces(MediaType.APPLICATION_JSON) + public Response getAllPresets() { + LOGGER.info("GET /api/demo/presets - Listing all demo presets"); + return Response.ok(presetService.getAllPresets()).build(); + } + + @GET + @Path("/preset/{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response getPreset(@PathParam("id") String id) { + LOGGER.info("GET /api/demo/preset/" + id + " - Getting demo preset"); + DemoPresetResponse preset = presetService.getPreset(id); + if (preset == null) { + return Response.status(Response.Status.NOT_FOUND).entity(error("Preset not found: " + id)).build(); + } + return Response.ok(preset).build(); + } + + @POST + @Path("/preset/{id}/start") + @Produces(MediaType.APPLICATION_JSON) + public Response startPreset(@PathParam("id") String id) { + LOGGER.info("POST /api/demo/preset/" + id + "/start - Starting demo preset"); + PresetExecutionContext context = presetService.initializeExecution(id); + if (context == null) { + return Response.status(Response.Status.NOT_FOUND).entity(error("Preset not found: " + id)).build(); + } + return Response.ok(toExecutionMap(context)).build(); + } + + @POST + @Path("/execution/{executionId}/step/{stepIndex}") + @Produces(MediaType.APPLICATION_JSON) + public Response executeStep(@PathParam("executionId") String executionId, @PathParam("stepIndex") int stepIndex) { + LOGGER.info("POST /api/demo/execution/" + executionId + "/step/" + stepIndex + " - Executing step"); + boolean success = presetService.executeStep(executionId, stepIndex); + if (!success) { + return Response.status(Response.Status.BAD_REQUEST).entity(error("Failed to execute step " + stepIndex)).build(); + } + PresetExecutionContext context = presetService.getExecution(executionId); + return Response.ok(toExecutionMap(context)).build(); + } + + @GET + @Path("/execution/{executionId}") + @Produces(MediaType.APPLICATION_JSON) + public Response getExecution(@PathParam("executionId") String executionId) { + LOGGER.info("GET /api/demo/execution/" + executionId + " - Getting execution status"); + PresetExecutionContext context = presetService.getExecution(executionId); + if (context == null) { + return Response.status(Response.Status.NOT_FOUND).entity(error("Execution not found: " + executionId)).build(); + } + return Response.ok(toExecutionMap(context)).build(); + } + + @POST + @Path("/execution/{executionId}/cancel") + @Produces(MediaType.APPLICATION_JSON) + public Response cancelExecution(@PathParam("executionId") String executionId) { + LOGGER.info("POST /api/demo/execution/" + executionId + "/cancel - Cancelling execution"); + presetService.cancelExecution(executionId); + return Response.ok(Map.of("status", "cancelled")).build(); + } + + private Map toExecutionMap(PresetExecutionContext context) { + Map map = new HashMap<>(); + map.put("executionId", context.executionId()); + map.put("presetId", context.presetId()); + map.put("startTime", context.startTime()); + map.put("currentStepIndex", context.currentStepIndex()); + map.put("totalSteps", context.preset().steps().size()); + map.put("isComplete", context.isComplete()); + map.put("isCancelled", context.isCancelled()); + map.put("completedSteps", context.completedSteps()); + return map; + } + + private Map error(String message) { + return Map.of("error", message); + } +} diff --git a/src/main/java/fish/payara/trader/rest/ExecutionResource.java b/src/main/java/fish/payara/trader/rest/ExecutionResource.java new file mode 100644 index 0000000..c4dcc66 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/ExecutionResource.java @@ -0,0 +1,29 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.matching.engine.MatchingEngine; +import fish.payara.trader.matching.history.ExecutionHistoryQuery; +import fish.payara.trader.matching.model.Execution; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.logging.Logger; + +@Path("/matching/executions") +public class ExecutionResource { + + private static final Logger LOGGER = Logger.getLogger(ExecutionResource.class.getName()); + + @Inject + private MatchingEngine matchingEngine; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getExecutions(@QueryParam("symbol") String symbol, @QueryParam("limit") @DefaultValue("100") int limit) { + LOGGER.info("GET /api/matching/executions - symbol=" + symbol + ", limit=" + limit); + ExecutionHistoryQuery query = new ExecutionHistoryQuery(symbol, null, null, limit); + List executions = matchingEngine.getExecutions(query); + return Response.ok(executions).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/GCStatsResource.java b/src/main/java/fish/payara/trader/rest/GCStatsResource.java new file mode 100644 index 0000000..f00d11b --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/GCStatsResource.java @@ -0,0 +1,109 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.aeron.MarketDataPublisher; +import fish.payara.trader.dto.GCComparisonResponse; +import fish.payara.trader.gc.GCStats; +import fish.payara.trader.gc.GCStatsService; +import fish.payara.trader.monitoring.GCPauseMonitor; +import fish.payara.trader.monitoring.SLAMonitorService; +import fish.payara.trader.pressure.AllocationMode; +import fish.payara.trader.pressure.MemoryPressureService; +import fish.payara.trader.util.InstanceUtils; +import fish.payara.trader.util.InstanceUtils.JvmMetadata; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/** REST endpoint for GC statistics monitoring */ +@Path("/gc") +public class GCStatsResource { + + private static final Logger LOGGER = Logger.getLogger(GCStatsResource.class.getName()); + + @Inject + private GCStatsService gcStatsService; + + @Inject + private MemoryPressureService memoryPressureService; + + @Inject + private MarketDataPublisher publisher; + + @Inject + private SLAMonitorService slaMonitor; + + @Inject + private GCPauseMonitor gcPauseMonitor; + + @GET + @Path("/sla") + @Produces(MediaType.APPLICATION_JSON) + public Response getSLAStats() { + return Response.ok(slaMonitor.getStats()).build(); + } + + @POST + @Path("/sla/reset") + public Response resetSLAStats() { + slaMonitor.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } + + @GET + @Path("/pauses") + @Produces(MediaType.APPLICATION_JSON) + public Response getGCPauseStats() { + return Response.ok(gcPauseMonitor.getStats()).build(); + } + + @POST + @Path("/pauses/reset") + public Response resetGCPauseStats() { + gcPauseMonitor.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } + + @GET + @Path("/comparison") + @Produces(MediaType.APPLICATION_JSON) + public Response getComparison() { + String instanceName = InstanceUtils.getInstanceName(); + JvmMetadata jvm = InstanceUtils.getJvmMetadata(); + + AllocationMode currentMode = memoryPressureService.getCurrentMode(); + List gcStats = gcStatsService.collectGCStats(); + GCPauseMonitor.GCPauseStats pauseStats = gcPauseMonitor.getStats(); + + GCComparisonResponse response = GCComparisonResponse.from(instanceName, jvm.vendor(), jvm.name(), jvm.gcCollectors(), jvm.isAzulC4(), + Runtime.getRuntime().maxMemory() / (1024 * 1024), currentMode.name(), currentMode.getAllocationRateMBPerSec(), + publisher.getMessagesPublished(), gcStats, pauseStats); + + return Response.ok(response).build(); + } + + @GET + @Path("/stats") + @Produces(MediaType.APPLICATION_JSON) + public Response getGCStats() { + List stats = gcStatsService.collectGCStats(); + LOGGER.info("GET /api/gc/stats - Returned " + stats.size() + " GC collector stats"); + return Response.ok(stats).build(); + } + + @POST + @Path("/reset") + @Produces(MediaType.APPLICATION_JSON) + public Response resetStats() { + LOGGER.info("POST /api/gc/reset - Resetting GC statistics"); + gcStatsService.resetStats(); + return Response.ok().entity("{\"status\":\"reset\"}").build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/HealthResource.java b/src/main/java/fish/payara/trader/rest/HealthResource.java new file mode 100644 index 0000000..b998f73 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/HealthResource.java @@ -0,0 +1,107 @@ +package fish.payara.trader.rest; + +import com.hazelcast.core.HazelcastInstance; +import fish.payara.trader.aeron.MarketDataPublisher; +import fish.payara.trader.monitoring.GCPauseMonitor; +import fish.payara.trader.util.InstanceUtils; +import fish.payara.trader.util.InstanceUtils.JvmMetadata; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.OperatingSystemMXBean; +import java.util.HashMap; +import java.util.Map; + +/** + * REST endpoint for health checks and pre-demo validation. Ensures all components are operational before starting a live demo. + */ +@Path("/health") +public class HealthResource { + + @Inject + private MarketDataPublisher publisher; + + @Inject + private GCPauseMonitor gcPauseMonitor; + + @Inject + private HazelcastInstance hazelcastInstance; + + @GET + @Path("/check") + @Produces(MediaType.APPLICATION_JSON) + public Response healthCheck() { + Map health = new HashMap<>(); + boolean allHealthy = true; + + boolean publisherHealthy = publisher != null && publisher.isRunning(); + health.put("publisher", publisherHealthy ? "healthy" : "unhealthy"); + if (!publisherHealthy) { + allHealthy = false; + } + + boolean gcMonitorHealthy = gcPauseMonitor != null; + health.put("gcMonitor", gcMonitorHealthy ? "healthy" : "unhealthy"); + if (!gcMonitorHealthy) { + allHealthy = false; + } + + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + long heapUsed = memoryBean.getHeapMemoryUsage().getUsed(); + long heapMax = memoryBean.getHeapMemoryUsage().getMax(); + double heapPercent = (heapUsed * 100.0) / heapMax; + boolean memoryHealthy = heapPercent < 90; + health.put("memory", memoryHealthy ? "healthy" : "warning"); + health.put("memoryUsedPercent", String.format("%.1f", heapPercent)); + if (!memoryHealthy) { + allHealthy = false; + } + + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + health.put("availableProcessors", osBean.getAvailableProcessors()); + + JvmMetadata jvm = InstanceUtils.getJvmMetadata(); + health.put("jvmVendor", jvm.vendor()); + health.put("jvmName", jvm.name()); + health.put("gcCollectors", jvm.gcCollectors()); + health.put("javaVersion", System.getProperty("java.version")); + + boolean clusterMode = hazelcastInstance != null && !hazelcastInstance.getCluster().getMembers().isEmpty(); + health.put("clusterMode", clusterMode); + + health.put("status", allHealthy ? "healthy" : "unhealthy"); + health.put("readyForDemo", allHealthy); + + int status = allHealthy ? Response.Status.OK.getStatusCode() : Response.Status.SERVICE_UNAVAILABLE.getStatusCode(); + return Response.status(status).entity(health).build(); + } + + @GET + @Path("/ready") + @Produces(MediaType.APPLICATION_JSON) + public Response readiness() { + Map ready = new HashMap<>(); + + boolean publisherRunning = publisher != null && publisher.isRunning(); + ready.put("publisher", publisherRunning); + ready.put("ready", publisherRunning); + + return Response.ok(ready).build(); + } + + @GET + @Path("/live") + @Produces(MediaType.APPLICATION_JSON) + public Response liveness() { + Map live = new HashMap<>(); + live.put("status", "alive"); + live.put("uptime", ManagementFactory.getRuntimeMXBean().getUptime()); + return Response.ok(live).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/IndicatorResource.java b/src/main/java/fish/payara/trader/rest/IndicatorResource.java new file mode 100644 index 0000000..1cd537b --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/IndicatorResource.java @@ -0,0 +1,46 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.analysis.IndicatorService; +import fish.payara.trader.analysis.model.IndicatorSnapshot; +import fish.payara.trader.dto.IndicatorResponse; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +@Path("/analysis") +public class IndicatorResource { + + private static final Logger LOGGER = Logger.getLogger(IndicatorResource.class.getName()); + + @Inject + private IndicatorService indicatorService; + + @GET + @Path("/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + public Response getIndicators(@PathParam("symbol") String symbol) { + LOGGER.info("GET /api/analysis/" + symbol); + IndicatorSnapshot snapshot = indicatorService.getSnapshot(symbol); + if (snapshot == null) { + return Response.status(Response.Status.NO_CONTENT).entity(Map.of("symbol", symbol, "error", "Insufficient data")).build(); + } + IndicatorResponse response = new IndicatorResponse(snapshot.symbol(), snapshot.timestamp(), snapshot.values()); + return Response.ok(response).build(); + } + + @GET + @Path("/{symbol}/history") + @Produces(MediaType.APPLICATION_JSON) + public Response getHistoricalIndicators(@PathParam("symbol") String symbol, @QueryParam("bars") @DefaultValue("50") int bars) { + LOGGER.info("GET /api/analysis/" + symbol + "/history?bars=" + bars); + List snapshots = indicatorService.getHistoricalSnapshots(symbol, bars); + if (snapshots.isEmpty()) { + return Response.status(Response.Status.NO_CONTENT).entity(Map.of("symbol", symbol, "error", "Insufficient data")).build(); + } + return Response.ok(snapshots).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/JfrRecordingResource.java b/src/main/java/fish/payara/trader/rest/JfrRecordingResource.java new file mode 100644 index 0000000..47f6367 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/JfrRecordingResource.java @@ -0,0 +1,395 @@ +package fish.payara.trader.rest; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jdk.jfr.Configuration; +import jdk.jfr.FlightRecorder; +import jdk.jfr.Recording; +import jdk.jfr.RecordingState; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.attribute.BasicFileAttributes; +import java.text.ParseException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * REST endpoint for Java Flight Recorder (JFR) management. + * + *

+ * Provides endpoints for: + *

    + *
  • Checking JFR availability and recording status
  • + *
  • Starting ad-hoc recordings with configurable duration
  • + *
  • Listing active and stopped recordings
  • + *
  • Downloading recordings as .jfr files
  • + *
+ * + *

+ * Requires JDK Flight Recorder to be enabled (included with Azul Platform Prime and Oracle JDK). + */ +@Path("/jfr") +public class JfrRecordingResource { + + private static final Logger LOGGER = Logger.getLogger(JfrRecordingResource.class.getName()); + private static final java.nio.file.Path RECORDINGS_DIR = java.nio.file.Paths.get("/opt/payara/recordings"); + private static final java.nio.file.Path JFC_SETTINGS_DIR = java.nio.file.Paths.get("/opt/payara/jfr-settings"); + + /** Get JFR availability and recording status */ + @GET + @Path("/status") + @Produces(MediaType.APPLICATION_JSON) + public Response getStatus() { + Map status = new HashMap<>(); + + boolean available = FlightRecorder.isAvailable(); + boolean initialized = FlightRecorder.isInitialized(); + + status.put("jfrAvailable", available); + status.put("jfrInitialized", initialized); + + if (!available) { + status.put("message", "Flight Recorder is not available on this JVM"); + status.put("suggestion", "Use Azul Platform Prime or Oracle JDK with -XX:+FlightRecorder"); + return Response.ok(status).build(); + } + + List> recordings = FlightRecorder.getFlightRecorder() + .getRecordings() + .stream() + .map(this::recordingToMap) + .collect(Collectors.toList()); + + status.put("recordings", recordings); + status.put("activeRecordingCount", (int) recordings.stream().filter(r -> "RUNNING".equals(r.get("state"))).count()); + + return Response.ok(status).build(); + } + + /** + * Start a new JFR recording. + * + * @param name + * human-readable recording name; used as the dumped filename prefix + * @param durationSeconds + * wall-clock duration before auto-stop and dump to disk + * @param maxSizeBytes + * hard cap on recording size; older events are evicted past this + * @param settings + * optional JFC profile: 'default', 'profile', or a workshop name such as 'tradestream-workshop' loaded from /opt/payara/jfr-settings. When + * omitted, an opinionated workshop event set is enabled programmatically. + */ + @POST + @Path("/recording/start") + @Produces(MediaType.APPLICATION_JSON) + public Response startRecording(@QueryParam("name") @DefaultValue("ad-hoc") String name, + @QueryParam("durationSeconds") @DefaultValue("60") int durationSeconds, + @QueryParam("maxSize") @DefaultValue("1073741824") long maxSizeBytes, @QueryParam("settings") String settings) { + + if (!FlightRecorder.isAvailable()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(Map.of("error", "Flight Recorder is not available")).build(); + } + + Recording recording; + try { + recording = createRecording(name, settings); + } catch (IOException | ParseException e) { + LOGGER.log(Level.WARNING, "Failed to load JFC settings: " + settings, e); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Failed to load settings", "settings", settings, "message", e.getMessage())) + .build(); + } + + recording.setMaxSize(maxSizeBytes); + recording.setDuration(Duration.ofSeconds(durationSeconds)); + recording.start(); + + final long recordingId = recording.getId(); + CompletableFuture.runAsync(() -> { + try { + Thread.sleep((durationSeconds + 2) * 1000L); + Recording r = findRecording(recordingId); + if (r == null) { + return; + } + if (r.getState() == RecordingState.RUNNING) { + r.stop(); + } + if (r.getState() == RecordingState.STOPPED) { + dumpRecording(r); + LOGGER.info("Dumped recording: " + r.getName() + " (" + recordingId + ")"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.warning("Recording stop interrupted for: " + name); + } + }); + + LOGGER.info("Started JFR recording: " + name + " (" + recordingId + ") for " + durationSeconds + "s, settings=" + + (settings == null ? "" : settings)); + + Map response = new HashMap<>(); + response.put("recordingId", recordingId); + response.put("name", name); + response.put("durationSeconds", durationSeconds); + response.put("maxSizeBytes", maxSizeBytes); + response.put("settings", settings == null ? "workshop-default" : settings); + response.put("state", "RUNNING"); + return Response.ok(response).build(); + } + + /** + * Build a recording with either a named JFC profile or the workshop default event set. + * + *

+ * Resolution order for {@code settings}: + *

    + *
  1. {@code null} or {@code "workshop-default"} — programmatic event list (back-compat).
  2. + *
  3. {@code "default"} or {@code "profile"} — JDK-bundled JFC profile via {@link Configuration#getConfiguration(String)}.
  4. + *
  5. Anything else — looked up as {@code /opt/payara/jfr-settings/.jfc}.
  6. + *
+ */ + private Recording createRecording(String name, String settings) throws IOException, ParseException { + if (settings == null || settings.isBlank() || "workshop-default".equalsIgnoreCase(settings)) { + Recording recording = new Recording(); + recording.setName(name); + applyWorkshopDefaultEvents(recording); + return recording; + } + + if ("default".equalsIgnoreCase(settings) || "profile".equalsIgnoreCase(settings)) { + Recording recording = new Recording(Configuration.getConfiguration(settings.toLowerCase(Locale.ROOT))); + recording.setName(name); + return recording; + } + + String safeName = settings.endsWith(".jfc") ? settings : settings + ".jfc"; + java.nio.file.Path jfcPath = JFC_SETTINGS_DIR.resolve(safeName).normalize(); + if (!jfcPath.startsWith(JFC_SETTINGS_DIR.normalize())) { + throw new IOException("Settings path escapes the JFC settings directory: " + settings); + } + if (!Files.exists(jfcPath)) { + throw new IOException("JFC settings file not found: " + jfcPath); + } + try (InputStream in = Files.newInputStream(jfcPath)) { + Recording recording = new Recording(Configuration.create(new java.io.InputStreamReader(in))); + recording.setName(name); + return recording; + } + } + + private void applyWorkshopDefaultEvents(Recording recording) { + recording.enable("jdk.CPUInformation"); + recording.enable("jdk.GCPhaseParallel"); + recording.enable("jdk.ObjectAllocationInNewTLAB"); + recording.enable("jdk.ObjectAllocationOutsideTLAB"); + recording.enable("jdk.VirtualThreadStart"); + recording.enable("jdk.VirtualThreadEnd"); + recording.enable("jdk.ExecutionSample").with("period", "10 ms"); + + recording.enable("trade.published"); + recording.enable("quote.published"); + recording.enable("marketdepth.published"); + recording.enable("message.batch.processed"); + recording.enable("websocket.broadcast"); + recording.enable("sbe.encode"); + recording.enable("sbe.decode"); + recording.enable("gc.sla.violation"); + recording.enable("aeron.backpressure"); + recording.enable("burst.mode.activated"); + } + + /** Stop a running recording */ + @POST + @Path("/recording/stop") + @Produces(MediaType.APPLICATION_JSON) + public Response stopRecording(@QueryParam("id") long recordingId) { + if (!FlightRecorder.isAvailable()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(Map.of("error", "Flight Recorder is not available")).build(); + } + + Recording recording = findRecording(recordingId); + if (recording == null) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("error", "Recording not found", "recordingId", recordingId)).build(); + } + + if (recording.getState() != RecordingState.RUNNING) { + return Response.ok(Map.of("recordingId", recordingId, "state", recording.getState().toString(), "message", "Recording is not running")).build(); + } + + recording.stop(); + dumpRecording(recording); + + return Response.ok(Map.of("recordingId", recordingId, "state", "STOPPED", "message", "Recording stopped and dumped to disk")).build(); + } + + /** List all recordings with their metadata */ + @GET + @Path("/recordings") + @Produces(MediaType.APPLICATION_JSON) + public Response listRecordings() { + if (!FlightRecorder.isAvailable()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(Map.of("error", "Flight Recorder is not available")).build(); + } + + List> recordings = FlightRecorder.getFlightRecorder().getRecordings().stream().map(this::recordingToMap).toList(); + + return Response.ok(Map.of("recordings", recordings)).build(); + } + + /** Get statistics about recorded events */ + @GET + @Path("/stats") + @Produces(MediaType.APPLICATION_JSON) + public Response getEventStats() { + if (!FlightRecorder.isAvailable()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(Map.of("error", "Flight Recorder is not available")).build(); + } + + Map stats = new HashMap<>(); + FlightRecorder recorder = FlightRecorder.getFlightRecorder(); + + stats.put("recordingCount", recorder.getRecordings().size()); + stats.put("eventTypes", recorder.getEventTypes().size()); + + long activeCount = recorder.getRecordings().stream().filter(r -> r.getState() == RecordingState.RUNNING).count(); + stats.put("activeRecordings", activeCount); + + return Response.ok(stats).build(); + } + + /** List all .jfr files available for download */ + @GET + @Path("/files") + @Produces(MediaType.APPLICATION_JSON) + public Response listFiles() { + try { + if (!Files.exists(RECORDINGS_DIR)) { + return Response.ok(Map.of("files", List.of(), "message", "Recordings directory does not exist")).build(); + } + + List> files = Files.list(RECORDINGS_DIR).filter(p -> p.toString().endsWith(".jfr")).map(p -> { + try { + BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class); + Map file = new HashMap<>(); + file.put("filename", p.getFileName().toString()); + file.put("size", attrs.size()); + file.put("sizeFormatted", formatBytes(attrs.size())); + file.put("lastModified", attrs.lastModifiedTime().toMillis()); + file.put("lastModifiedFormatted", Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()).toString()); + file.put("downloadUrl", "/api/jfr/download/" + p.getFileName().toString()); + return file; + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to read attributes for: " + p, e); + return null; + } + }).filter(Objects::nonNull).toList(); + + return Response.ok(Map.of("files", files, "count", files.size())).build(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to list recording files", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(Map.of("error", "Failed to list files", "message", e.getMessage())).build(); + } + } + + /** Download a specific JFR file */ + @GET + @Path("/download/{filename}") + public Response downloadFile(@PathParam("filename") String filename) { + if (filename == null || filename.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "Filename is required")).type(MediaType.APPLICATION_JSON).build(); + } + + if (!filename.endsWith(".jfr")) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "Only .jfr files are allowed")).type(MediaType.APPLICATION_JSON).build(); + } + + java.nio.file.Path filePath = RECORDINGS_DIR.resolve(filename).normalize(); + if (!filePath.startsWith(RECORDINGS_DIR.normalize())) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of("error", "Filename escapes the recordings directory", "filename", filename)) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + if (!Files.exists(filePath)) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "File not found", "filename", filename)) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + try { + byte[] fileContent = Files.readAllBytes(filePath); + String contentDisposition = "attachment; filename=\"" + filename + "\""; + + return Response.ok(fileContent) + .type("application/octet-stream") + .header("Content-Disposition", contentDisposition) + .header("Content-Length", fileContent.length) + .build(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to read file: " + filename, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to read file", "message", e.getMessage())) + .type(MediaType.APPLICATION_JSON) + .build(); + } + } + + /** Format bytes to human-readable size */ + private String formatBytes(long bytes) { + if (bytes < 1024) + return bytes + " B"; + if (bytes < 1024 * 1024) + return String.format("%.2f KB", bytes / 1024.0); + if (bytes < 1024 * 1024 * 1024) + return String.format("%.2f MB", bytes / (1024.0 * 1024.0)); + return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + + /** Dump a recording to disk */ + private void dumpRecording(Recording recording) { + try { + Files.createDirectories(RECORDINGS_DIR); + java.nio.file.Path outputPath = RECORDINGS_DIR.resolve(recording.getName() + "-" + recording.getId() + ".jfr"); + recording.dump(outputPath); + LOGGER.info("Recording dumped to: " + outputPath); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to dump recording: " + recording.getName(), e); + } + } + + /** Find a recording by ID */ + private Recording findRecording(long recordingId) { + Optional found = FlightRecorder.getFlightRecorder().getRecordings().stream().filter(r -> r.getId() == recordingId).findFirst(); + return found.orElse(null); + } + + /** Convert Recording to Map for JSON serialization */ + private Map recordingToMap(Recording recording) { + Map map = new HashMap<>(); + map.put("id", recording.getId()); + map.put("name", recording.getName()); + map.put("state", recording.getState().toString()); + map.put("duration", recording.getMaxAge() != null ? recording.getMaxAge().toMillis() + "ms" : "unlimited"); + map.put("maxSize", recording.getMaxSize() + " bytes"); + map.put("startTime", recording.getStartTime()); + map.put("size", recording.getSize()); + return map; + } +} diff --git a/src/main/java/fish/payara/trader/rest/MatchingPositionResource.java b/src/main/java/fish/payara/trader/rest/MatchingPositionResource.java new file mode 100644 index 0000000..52c7000 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/MatchingPositionResource.java @@ -0,0 +1,38 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.matching.engine.MatchingEngine; +import fish.payara.trader.matching.model.Position; +import fish.payara.trader.matching.model.Price; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +@Path("/matching/positions") +public class MatchingPositionResource { + + private static final Logger LOGGER = Logger.getLogger(MatchingPositionResource.class.getName()); + + @Inject + private MatchingEngine matchingEngine; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getAllPositions() { + LOGGER.info("GET /api/matching/positions"); + Map positions = matchingEngine.getPositions(); + return Response.ok(positions).build(); + } + + @GET + @Path("/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + public Response getPosition(@PathParam("symbol") String symbol) { + LOGGER.info("GET /api/matching/positions/" + symbol); + Optional position = matchingEngine.getPosition(symbol); + return Response.ok(position.orElse(new Position(symbol, 0, Price.ZERO, 0, Price.ZERO, 0))).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java b/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java new file mode 100644 index 0000000..a8fba20 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java @@ -0,0 +1,70 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.dto.PressureStatusResponse; +import fish.payara.trader.pressure.AllocationMode; +import fish.payara.trader.pressure.MemoryPressureService; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; + +@Path("/pressure") +public class MemoryPressureResource { + + private static final Logger LOGGER = Logger.getLogger(MemoryPressureResource.class.getName()); + + @Inject + private MemoryPressureService pressureService; + + @GET + @Path("/status") + @Produces(MediaType.APPLICATION_JSON) + public Response getStatus() { + PressureStatusResponse status = PressureStatusResponse.from(pressureService.getCurrentMode(), pressureService.isRunning()); + return Response.ok(status).build(); + } + + @POST + @Path("/mode/{mode}") + @Produces(MediaType.APPLICATION_JSON) + public Response setMode(@PathParam("mode") String modeStr) { + try { + AllocationMode mode = AllocationMode.valueOf(modeStr.toUpperCase()); + LOGGER.info("POST /api/pressure/mode/" + modeStr + " - Setting memory pressure mode to: " + mode.name()); + + pressureService.setAllocationMode(mode); + + PressureStatusResponse result = PressureStatusResponse.from(mode, true); + return Response.ok(result).build(); + } catch (IllegalArgumentException e) { + LOGGER.warning("POST /api/pressure/mode/" + modeStr + " - Invalid mode requested"); + + Map error = new HashMap<>(); + error.put("success", false); + error.put("error", "Invalid mode: " + modeStr); + StringBuilder validModes = new StringBuilder(); + for (AllocationMode m : AllocationMode.values()) { + validModes.append(m.name()).append(", "); + } + error.put("validModes", validModes.toString()); + return Response.status(Response.Status.BAD_REQUEST).entity(error).build(); + } + } + + @GET + @Path("/modes") + @Produces(MediaType.APPLICATION_JSON) + public Response getModes() { + LOGGER.fine("GET /api/pressure/modes - Listing all allocation modes"); + + Map modes = new HashMap<>(); + for (AllocationMode mode : AllocationMode.values()) { + modes.put(mode.name(), PressureStatusResponse.from(mode, false)); + } + + return Response.ok(modes).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/OrderBookResource.java b/src/main/java/fish/payara/trader/rest/OrderBookResource.java new file mode 100644 index 0000000..4b41d5a --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/OrderBookResource.java @@ -0,0 +1,33 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.matching.engine.MatchingEngine; +import fish.payara.trader.matching.model.OrderBookSnapshot; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Collections; +import java.util.Optional; +import java.util.logging.Logger; + +@Path("/matching/order-book") +public class OrderBookResource { + + private static final Logger LOGGER = Logger.getLogger(OrderBookResource.class.getName()); + + @Inject + private MatchingEngine matchingEngine; + + @GET + @Path("/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + public Response getOrderBook(@PathParam("symbol") String symbol, @QueryParam("depth") @DefaultValue("10") int depth) { + LOGGER.info("GET /api/matching/order-book/" + symbol + "?depth=" + depth); + Optional snapshot = matchingEngine.getBook(symbol); + if (snapshot.isEmpty()) { + OrderBookSnapshot empty = new OrderBookSnapshot(symbol, System.currentTimeMillis(), Collections.emptyList(), Collections.emptyList(), 0, 0); + return Response.ok(empty).build(); + } + return Response.ok(snapshot.get()).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/OrderResource.java b/src/main/java/fish/payara/trader/rest/OrderResource.java new file mode 100644 index 0000000..6643cd6 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/OrderResource.java @@ -0,0 +1,71 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.matching.engine.MatchingEngine; +import fish.payara.trader.matching.model.Order; +import fish.payara.trader.matching.model.OrderRequest; +import fish.payara.trader.matching.exception.OrderValidationException; +import fish.payara.trader.matching.exception.OrderRejectedException; +import fish.payara.trader.matching.book.CancelResult; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +@Path("/matching/orders") +public class OrderResource { + + private static final Logger LOGGER = Logger.getLogger(OrderResource.class.getName()); + + @Inject + private MatchingEngine matchingEngine; + + @POST + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response submitOrder(OrderRequest request) { + try { + LOGGER.info("POST /api/matching/orders - Submitting " + request.type() + " " + request.side() + " order for " + request.symbol()); + Order order = matchingEngine.submitOrder(request); + return Response.ok(Map.of("status", "ACCEPTED", "orderId", order.orderId())).build(); + } catch (OrderValidationException e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("status", "REJECTED", "reason", e.reason())).build(); + } catch (OrderRejectedException e) { + return Response.status(422).entity(Map.of("status", "REJECTED", "reason", e.reason())).build(); + } + } + + @DELETE + @Path("/{orderId}") + @Produces(MediaType.APPLICATION_JSON) + public Response cancelOrder(@PathParam("orderId") long orderId) { + LOGGER.info("DELETE /api/matching/orders/" + orderId); + CancelResult result = matchingEngine.cancelOrder(orderId); + if (!result.canceled()) { + return Response.status(Response.Status.NOT_FOUND).entity(Map.of("status", "NOT_FOUND", "orderId", orderId, "reason", result.reason())).build(); + } + return Response.ok(Map.of("status", "CANCELED", "orderId", orderId)).build(); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getOrders(@QueryParam("symbol") String symbol) { + LOGGER.info("GET /api/matching/orders - symbol=" + symbol); + var book = matchingEngine.getBook(symbol); + if (book.isEmpty()) { + return Response.ok(Map.of("symbol", symbol, "levels", List.of())).build(); + } + return Response.ok(Map.of("symbol", symbol, "snapshot", book.get())).build(); + } + + @GET + @Path("/book/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + public Response getOrderBook(@PathParam("symbol") String symbol) { + LOGGER.info("GET /api/matching/orders/book/" + symbol); + var snapshot = matchingEngine.getBook(symbol); + return Response.ok(snapshot.orElse(null)).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/PortfolioResource.java b/src/main/java/fish/payara/trader/rest/PortfolioResource.java new file mode 100644 index 0000000..7c0f984 --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/PortfolioResource.java @@ -0,0 +1,70 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.dto.PortfolioResponse; +import fish.payara.trader.portfolio.PortfolioService; +import fish.payara.trader.portfolio.model.PerformanceMetrics; +import fish.payara.trader.portfolio.model.PortfolioSnapshot; +import fish.payara.trader.portfolio.model.RebalancePlan; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Map; +import java.util.logging.Logger; + +@Path("/portfolio") +public class PortfolioResource { + + private static final Logger LOGGER = Logger.getLogger(PortfolioResource.class.getName()); + + @Inject + private PortfolioService portfolioService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getPortfolio() { + LOGGER.info("GET /api/portfolio"); + PortfolioSnapshot snapshot = portfolioService.getSnapshot(); + PerformanceMetrics metrics = portfolioService.calculateMetrics(); + RebalancePlan plan = portfolioService.generateRebalancePlan(Map.of()); + PortfolioResponse response = new PortfolioResponse(snapshot, metrics, plan); + return Response.ok(response).build(); + } + + @GET + @Path("/metrics") + @Produces(MediaType.APPLICATION_JSON) + public Response getMetrics() { + LOGGER.info("GET /api/portfolio/metrics"); + PerformanceMetrics metrics = portfolioService.calculateMetrics(); + return Response.ok(metrics).build(); + } + + @GET + @Path("/snapshot") + @Produces(MediaType.APPLICATION_JSON) + public Response getSnapshot() { + LOGGER.info("GET /api/portfolio/snapshot"); + PortfolioSnapshot snapshot = portfolioService.getSnapshot(); + return Response.ok(snapshot).build(); + } + + @POST + @Path("/rebalance") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response getRebalancePlan(Map targetWeights) { + LOGGER.info("POST /api/portfolio/rebalance"); + RebalancePlan plan = portfolioService.generateRebalancePlan(targetWeights); + return Response.ok(plan).build(); + } + + @POST + @Path("/reset") + @Produces(MediaType.APPLICATION_JSON) + public Response reset() { + LOGGER.info("POST /api/portfolio/reset"); + portfolioService.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/RiskResource.java b/src/main/java/fish/payara/trader/rest/RiskResource.java new file mode 100644 index 0000000..f3d8f7f --- /dev/null +++ b/src/main/java/fish/payara/trader/rest/RiskResource.java @@ -0,0 +1,137 @@ +package fish.payara.trader.rest; + +import fish.payara.trader.dto.RiskResponse; +import fish.payara.trader.dto.TradingRiskMetrics; +import fish.payara.trader.risk.RiskEngine; +import fish.payara.trader.risk.model.ExposureSummary; +import fish.payara.trader.risk.model.RiskSnapshot; +import fish.payara.trader.risk.model.StressResult; +import fish.payara.trader.risk.model.VarResult; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +@Path("/risk") +public class RiskResource { + + private static final Logger LOGGER = Logger.getLogger(RiskResource.class.getName()); + + private static final String[] SYMBOLS = {"AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX"}; + + @Inject + private RiskEngine riskEngine; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getFullRiskSnapshot() { + LOGGER.info("GET /api/risk"); + List positions = new ArrayList<>(); + for (String symbol : SYMBOLS) { + positions.add(riskEngine.getRiskSnapshot(symbol)); + } + ExposureSummary exposure = riskEngine.getExposureSummary(); + VarResult historicalVar = riskEngine.calculateHistoricalVaR(); + VarResult parametricVar = riskEngine.calculateParametricVaR(); + List stressResults = riskEngine.runAllStressTests(); + + RiskResponse response = new RiskResponse(positions, exposure, historicalVar, parametricVar, stressResults); + return Response.ok(response).build(); + } + + @GET + @Path("/metrics") + @Produces(MediaType.APPLICATION_JSON) + public Response getTradingMetrics() { + LOGGER.info("GET /api/risk/metrics"); + + try { + ExposureSummary exposure = riskEngine.getExposureSummary(); + VarResult varResult = riskEngine.calculateHistoricalVaR(); + List stressResults = riskEngine.runAllStressTests(); + + double var95 = varResult != null ? varResult.varValue() : 0; + double totalExposure = exposure != null ? exposure.grossExposure() : 0; + double netDelta = exposure != null ? exposure.netDelta() : 0; + + // Calculate max drawdown (simplified - use VaR as proxy) + double maxDrawdown = var95 > 0 ? var95 / Math.max(totalExposure, 1) : 0; + + // Extract stress test impacts + double flashCrash = 0; + double volSpike = 0; + if (stressResults != null) { + for (StressResult sr : stressResults) { + if (sr.scenarioName() != null) { + if (sr.scenarioName().toLowerCase().contains("flash") || sr.scenarioName().toLowerCase().contains("crash")) { + flashCrash = sr.portfolioImpact(); + } + if (sr.scenarioName().toLowerCase().contains("vol") || sr.scenarioName().toLowerCase().contains("spike")) { + volSpike = sr.portfolioImpact(); + } + } + } + } + + TradingRiskMetrics metrics = new TradingRiskMetrics(var95, totalExposure, netDelta, maxDrawdown, + new TradingRiskMetrics.StressTestMetrics(flashCrash, volSpike)); + + return Response.ok(metrics).build(); + } catch (Exception e) { + LOGGER.warning("Error calculating risk metrics: " + e.getMessage()); + return Response.ok(TradingRiskMetrics.empty()).build(); + } + } + + @GET + @Path("/var") + @Produces(MediaType.APPLICATION_JSON) + public Response getVaR() { + LOGGER.info("GET /api/risk/var"); + VarResult historical = riskEngine.calculateHistoricalVaR(); + VarResult parametric = riskEngine.calculateParametricVaR(); + return Response.ok(Map.of("historical", historical, "parametric", parametric)).build(); + } + + @GET + @Path("/stress") + @Produces(MediaType.APPLICATION_JSON) + public Response getStressTestResults() { + LOGGER.info("GET /api/risk/stress"); + List results = riskEngine.runAllStressTests(); + return Response.ok(results).build(); + } + + @GET + @Path("/exposure") + @Produces(MediaType.APPLICATION_JSON) + public Response getExposure() { + LOGGER.info("GET /api/risk/exposure"); + ExposureSummary exposure = riskEngine.getExposureSummary(); + return Response.ok(exposure).build(); + } + + @GET + @Path("/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + public Response getRiskForSymbol(@PathParam("symbol") String symbol) { + LOGGER.info("GET /api/risk/" + symbol); + RiskSnapshot snapshot = riskEngine.getRiskSnapshot(symbol); + return Response.ok(snapshot).build(); + } + + @POST + @Path("/limits/{symbol}") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public Response updatePositionLimit(@PathParam("symbol") String symbol, Map body) { + double limit = body.getOrDefault("limit", 10000.0); + LOGGER.info("POST /api/risk/limits/" + symbol + " - Setting limit to " + limit); + riskEngine.updatePositionLimit(symbol, limit); + return Response.ok(Map.of("symbol", symbol, "limit", limit)).build(); + } +} diff --git a/src/main/java/fish/payara/trader/rest/StatusResource.java b/src/main/java/fish/payara/trader/rest/StatusResource.java index 06f01ed..7093c1f 100644 --- a/src/main/java/fish/payara/trader/rest/StatusResource.java +++ b/src/main/java/fish/payara/trader/rest/StatusResource.java @@ -1,5 +1,6 @@ package fish.payara.trader.rest; +import com.hazelcast.core.HazelcastInstance; import fish.payara.trader.aeron.AeronSubscriberBean; import fish.payara.trader.aeron.MarketDataPublisher; import fish.payara.trader.websocket.MarketDataBroadcaster; @@ -9,13 +10,11 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - import java.util.HashMap; import java.util.Map; +import java.util.stream.Collectors; -/** - * REST endpoint for monitoring system status - */ +/** REST endpoint for monitoring system status */ @Path("/status") public class StatusResource { @@ -28,22 +27,68 @@ public class StatusResource { @Inject private MarketDataBroadcaster broadcaster; + @Inject + private HazelcastInstance hazelcastInstance; + @GET @Produces(MediaType.APPLICATION_JSON) public Response getStatus() { Map status = new HashMap<>(); + String instanceName = System.getenv("PAYARA_INSTANCE_NAME"); + if (instanceName == null) { + instanceName = "standalone"; + } + status.put("application", "TradeStreamEE"); status.put("description", "High-frequency trading dashboard with Aeron and SBE"); + status.put("instance", instanceName); status.put("subscriber", subscriber.getStatus()); - status.put("publisher", Map.of( - "messagesPublished", publisher.getMessagesPublished() - )); - status.put("websocket", Map.of( - "activeSessions", broadcaster.getSessionCount() - )); + + Map publisherStats = new HashMap<>(); + publisherStats.put("localMessagesPublished", publisher.getMessagesPublished()); + publisherStats.put("clusterMessagesPublished", publisher.getClusterMessagesPublished()); + status.put("publisher", publisherStats); + + status.put("websocket", Map.of("activeSessions", broadcaster.getSessionCount())); status.put("status", "UP"); return Response.ok(status).build(); } + + /** Get cluster status and membership information */ + @GET + @Path("/cluster") + @Produces(MediaType.APPLICATION_JSON) + public Response getClusterStatus() { + Map clusterInfo = new HashMap<>(); + + try { + if (hazelcastInstance == null) { + clusterInfo.put("clustered", false); + clusterInfo.put("message", "Running in standalone mode (Hazelcast not available)"); + return Response.ok(clusterInfo).build(); + } + + clusterInfo.put("clustered", true); + clusterInfo.put("clusterSize", hazelcastInstance.getCluster().getMembers().size()); + clusterInfo.put("clusterTime", hazelcastInstance.getCluster().getClusterTime()); + clusterInfo.put("localMemberUuid", hazelcastInstance.getCluster().getLocalMember().getUuid().toString()); + + clusterInfo.put("members", + hazelcastInstance.getCluster() + .getMembers() + .stream() + .map(member -> Map.of("address", member.getAddress().toString(), "uuid", member.getUuid().toString(), "localMember", + member.localMember(), "liteMember", member.isLiteMember())) + .collect(Collectors.toList())); + + return Response.ok(clusterInfo).build(); + + } catch (Exception e) { + clusterInfo.put("clustered", false); + clusterInfo.put("error", e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(clusterInfo).build(); + } + } } diff --git a/src/main/java/fish/payara/trader/risk/PositionTracker.java b/src/main/java/fish/payara/trader/risk/PositionTracker.java new file mode 100644 index 0000000..74f8603 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/PositionTracker.java @@ -0,0 +1,133 @@ +package fish.payara.trader.risk; + +import fish.payara.trader.risk.model.Position; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Tracks positions per symbol. Thread-safe via ConcurrentHashMap. Handles fills by updating average entry price and computing realized P&L on closing + * (reducing) trades. + */ +@ApplicationScoped +public class PositionTracker { + + private static final Logger LOGGER = Logger.getLogger(PositionTracker.class.getName()); + + private static final Position ZERO = new Position(""); + + private final ConcurrentHashMap positions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap positionLimits = new ConcurrentHashMap<>(); + + @Inject + private RiskConfig config; + + /** + * Represents trade side. Local definition since no shared Side enum exists. + */ + public enum Side { + BUY, SELL + } + + /** + * Process a fill and update the position. + * + * @param symbol + * the instrument + * @param quantity + * absolute fill quantity + * @param price + * fill price + * @param side + * BUY or SELL + */ + public void onFill(String symbol, long quantity, double price, Side side) { + positions.compute(symbol, (key, existing) -> { + Position pos = existing != null ? existing : new Position(symbol); + + long signedQty = side == Side.BUY ? quantity : -quantity; + long oldQty = pos.quantity(); + long newQty = oldQty + signedQty; + + double realizedPnl = pos.realizedPnl(); + double avgEntry = pos.averageEntryPrice(); + + if (oldQty > 0 && signedQty < 0) { + long closingQty = Math.min(Math.abs(signedQty), oldQty); + realizedPnl += closingQty * (price - avgEntry); + long remaining = oldQty - closingQty; + if (remaining == 0) { + avgEntry = 0.0; + } else { + avgEntry = avgEntry; + } + } else if (oldQty < 0 && signedQty > 0) { + long closingQty = Math.min(signedQty, Math.abs(oldQty)); + realizedPnl += closingQty * (avgEntry - price); + long remaining = Math.abs(oldQty) - closingQty; + if (remaining == 0) { + avgEntry = 0.0; + } + } + + if ((oldQty >= 0 && newQty > oldQty) || (oldQty <= 0 && newQty < oldQty)) { + double totalCost = avgEntry * Math.abs(oldQty) + price * Math.abs(signedQty); + long totalQty = Math.abs(newQty); + avgEntry = totalQty > 0 ? totalCost / totalQty : 0.0; + } + + long tradeCount = pos.tradeCount() + 1; + return new Position(symbol, newQty, avgEntry, realizedPnl, tradeCount); + }); + } + + /** + * Returns the position for the given symbol, or a zero-initialized default. + */ + public Position getPosition(String symbol) { + return positions.getOrDefault(symbol, new Position(symbol)); + } + + /** + * Returns all tracked positions. + */ + public Map getAllPositions() { + return Map.copyOf(positions); + } + + /** + * Checks whether adding the given quantity would breach the position limit. + */ + public boolean wouldBreachLimit(String symbol, long additionalQty) { + double limit = positionLimits.getOrDefault(symbol, config.defaultPositionLimit()); + Position pos = getPosition(symbol); + double price = pos.averageEntryPrice(); + double newNotional = (Math.abs(pos.quantity()) + Math.abs(additionalQty)) * price; + return newNotional > limit; + } + + /** + * Sets a per-symbol position limit. + */ + public void updatePositionLimit(String symbol, double limit) { + positionLimits.put(symbol, limit); + } + + /** + * Returns the effective position limit for the given symbol. + */ + public double getPositionLimit(String symbol) { + return positionLimits.getOrDefault(symbol, config.defaultPositionLimit()); + } + + /** + * Clears all positions and limits. + */ + public void reset() { + positions.clear(); + positionLimits.clear(); + LOGGER.info("PositionTracker reset"); + } +} diff --git a/src/main/java/fish/payara/trader/risk/RiskConfig.java b/src/main/java/fish/payara/trader/risk/RiskConfig.java new file mode 100644 index 0000000..2feb3d3 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/RiskConfig.java @@ -0,0 +1,60 @@ +package fish.payara.trader.risk; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * MicroProfile configuration for risk management parameters. + */ +@ApplicationScoped +public class RiskConfig { + + @Inject + @ConfigProperty(name = "risk.position.limit.default", defaultValue = "10000") + private double defaultPositionLimit; + + @Inject + @ConfigProperty(name = "risk.var.lookback.bars", defaultValue = "252") + private int varLookbackBars; + + @Inject + @ConfigProperty(name = "risk.var.confidence", defaultValue = "0.95") + private double varConfidence; + + @Inject + @ConfigProperty(name = "risk.stress.crash.percent", defaultValue = "20") + private double stressCrashPercent; + + @Inject + @ConfigProperty(name = "risk.stress.vol.spike", defaultValue = "3.0") + private double stressVolSpike; + + @Inject + @ConfigProperty(name = "risk.stress.liquidity.widen", defaultValue = "50") + private double stressLiquidityWiden; + + public double defaultPositionLimit() { + return defaultPositionLimit; + } + + public int varLookbackBars() { + return varLookbackBars; + } + + public double varConfidence() { + return varConfidence; + } + + public double stressCrashPercent() { + return stressCrashPercent; + } + + public double stressVolSpike() { + return stressVolSpike; + } + + public double stressLiquidityWiden() { + return stressLiquidityWiden; + } +} diff --git a/src/main/java/fish/payara/trader/risk/RiskEngine.java b/src/main/java/fish/payara/trader/risk/RiskEngine.java new file mode 100644 index 0000000..00b31ae --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/RiskEngine.java @@ -0,0 +1,284 @@ +package fish.payara.trader.risk; + +import fish.payara.trader.analysis.BarAggregator; +import fish.payara.trader.risk.model.ExposureSummary; +import fish.payara.trader.risk.model.Position; +import fish.payara.trader.risk.model.RiskSnapshot; +import fish.payara.trader.risk.model.StressResult; +import fish.payara.trader.risk.model.StressScenario; +import fish.payara.trader.risk.model.StressScenario.CorrelationBreakdown; +import fish.payara.trader.risk.model.StressScenario.FlashCrash; +import fish.payara.trader.risk.model.StressScenario.InterestRateShock; +import fish.payara.trader.risk.model.StressScenario.LiquidityFreeze; +import fish.payara.trader.risk.model.StressScenario.VolatilitySpike; +import fish.payara.trader.risk.model.VarResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import org.ta4j.core.BarSeries; + +/** + * Central risk engine computing per-symbol risk snapshots, portfolio-level exposure, Value at Risk (historical and parametric), and stress tests. + */ +@ApplicationScoped +public class RiskEngine { + + private static final Logger LOGGER = Logger.getLogger(RiskEngine.class.getName()); + + @Inject + private PositionTracker positionTracker; + + @Inject + private BarAggregator barAggregator; + + @Inject + private RiskConfig config; + + /** + * Computes a per-symbol risk snapshot. + */ + public RiskSnapshot getRiskSnapshot(String symbol) { + Position position = positionTracker.getPosition(symbol); + double currentPrice = getLastPrice(symbol); + double notional = position.notionalValue(currentPrice); + double unrealizedPnl = position.unrealizedPnl(currentPrice); + double limit = positionTracker.getPositionLimit(symbol); + double utilization = limit > 0 ? (notional / limit) * 100.0 : 0.0; + + return new RiskSnapshot(symbol, position, currentPrice, notional, unrealizedPnl, limit, utilization); + } + + /** + * Aggregates portfolio-level exposure across all positions. + */ + public ExposureSummary getExposureSummary() { + Map allPositions = positionTracker.getAllPositions(); + double totalNotional = 0.0; + double longExposure = 0.0; + double shortExposure = 0.0; + double netDelta = 0.0; + + for (Map.Entry entry : allPositions.entrySet()) { + Position pos = entry.getValue(); + double price = getLastPrice(entry.getKey()); + double notional = pos.notionalValue(price); + + totalNotional += notional; + netDelta += pos.delta(); + + if (pos.isLong()) { + longExposure += notional; + } else if (pos.isShort()) { + shortExposure += notional; + } + } + + return new ExposureSummary(totalNotional, netDelta, longExposure + shortExposure, longExposure, shortExposure, allPositions.size()); + } + + /** + * Historical VaR: sort portfolio returns, pick the percentile. + */ + public VarResult calculateHistoricalVaR() { + double[] returns = computePortfolioReturns(); + + if (returns.length < 2) { + return new VarResult("historical", 0.0, config.varConfidence(), 0, System.currentTimeMillis()); + } + + Arrays.sort(returns); + + int index = (int) Math.ceil((1.0 - config.varConfidence()) * returns.length) - 1; + index = Math.max(0, Math.min(index, returns.length - 1)); + + double varReturn = returns[index]; + double portfolioValue = getPortfolioValue(); + double varValue = Math.abs(varReturn * portfolioValue); + + return new VarResult("historical", varValue, config.varConfidence(), config.varLookbackBars(), System.currentTimeMillis()); + } + + /** + * Parametric VaR: mean - z * sigma (normal distribution). + */ + public VarResult calculateParametricVaR() { + double[] returns = computePortfolioReturns(); + + if (returns.length < 2) { + return new VarResult("parametric", 0.0, config.varConfidence(), 0, System.currentTimeMillis()); + } + + double mean = 0.0; + for (double r : returns) { + mean += r; + } + mean /= returns.length; + + double variance = 0.0; + for (double r : returns) { + variance += (r - mean) * (r - mean); + } + variance /= (returns.length - 1); + double stddev = Math.sqrt(variance); + + double z = inverseNormalCdf(config.varConfidence()); + double portfolioValue = getPortfolioValue(); + double varValue = portfolioValue * Math.abs(mean - z * stddev); + + return new VarResult("parametric", varValue, config.varConfidence(), config.varLookbackBars(), System.currentTimeMillis()); + } + + /** + * Runs a single stress test scenario across all positions. + */ + public StressResult runStressTest(StressScenario scenario) { + Map allPositions = positionTracker.getAllPositions(); + double totalImpact = 0.0; + int breached = 0; + double maxLoss = 0.0; + List breachDetails = new ArrayList<>(); + + for (Map.Entry entry : allPositions.entrySet()) { + Position pos = entry.getValue(); + double price = getLastPrice(entry.getKey()); + double impact = scenario.apply(pos, price); + + totalImpact += impact; + + if (impact < 0) { + double loss = Math.abs(impact); + if (loss > maxLoss) { + maxLoss = loss; + } + double limit = positionTracker.getPositionLimit(entry.getKey()); + double utilization = limit > 0 ? (pos.notionalValue(price) / limit) * 100.0 : 0.0; + if (utilization > 80.0) { + breached++; + breachDetails.add("%s: %.2f loss, %.1f%% utilized".formatted(entry.getKey(), loss, utilization)); + } + } + } + + return new StressResult(scenario.name(), totalImpact, breached, maxLoss, breachDetails); + } + + /** + * Runs all built-in stress scenarios. + */ + public List runAllStressTests() { + List scenarios = List.of(new FlashCrash(config.stressCrashPercent()), new VolatilitySpike(config.stressVolSpike()), + new LiquidityFreeze(config.stressLiquidityWiden()), new CorrelationBreakdown(30.0), new InterestRateShock(50.0)); + + List results = new ArrayList<>(); + for (StressScenario scenario : scenarios) { + results.add(runStressTest(scenario)); + } + return results; + } + + /** + * Checks whether the additional quantity at the given price would breach the limit. + */ + public boolean wouldBreachLimit(String symbol, long additionalQty, double price) { + Position pos = positionTracker.getPosition(symbol); + double limit = positionTracker.getPositionLimit(symbol); + double newNotional = (Math.abs(pos.quantity()) + Math.abs(additionalQty)) * price; + return newNotional > limit; + } + + /** + * Delegates to PositionTracker for per-symbol limit updates. + */ + public void updatePositionLimit(String symbol, double limit) { + positionTracker.updatePositionLimit(symbol, limit); + } + + private double getLastPrice(String symbol) { + BarSeries series = barAggregator.getSeries(symbol); + if (series.isEmpty()) { + return 0.0; + } + return series.getLastBar().getClosePrice().doubleValue(); + } + + private double getPortfolioValue() { + double value = 0.0; + Map allPositions = positionTracker.getAllPositions(); + for (Map.Entry entry : allPositions.entrySet()) { + value += entry.getValue().notionalValue(getLastPrice(entry.getKey())); + } + return value; + } + + /** + * Computes equal-weighted portfolio returns from bar data. Returns a single array of portfolio returns over time. + */ + private double[] computePortfolioReturns() { + Map allPositions = positionTracker.getAllPositions(); + List symbols = new ArrayList<>(allPositions.keySet()); + + if (symbols.isEmpty()) { + return new double[0]; + } + + int lookback = config.varLookbackBars(); + int minBars = Integer.MAX_VALUE; + + for (String symbol : symbols) { + BarSeries series = barAggregator.getSeries(symbol); + int bars = Math.min(series.getBarCount(), lookback); + minBars = Math.min(minBars, bars); + } + + if (minBars < 2) { + return new double[0]; + } + + int numReturns = minBars - 1; + double[] portfolioReturns = new double[numReturns]; + + for (int t = 0; t < numReturns; t++) { + double portfolioReturn = 0.0; + int count = 0; + + for (String symbol : symbols) { + BarSeries series = barAggregator.getSeries(symbol); + int baseIndex = series.getEndIndex() - minBars + 1; + + double prevPrice = series.getBar(baseIndex + t).getClosePrice().doubleValue(); + double currPrice = series.getBar(baseIndex + t + 1).getClosePrice().doubleValue(); + + if (prevPrice > 0) { + portfolioReturn += (currPrice - prevPrice) / prevPrice; + count++; + } + } + + if (count > 0) { + portfolioReturns[t] = portfolioReturn / count; + } + } + + return portfolioReturns; + } + + /** + * Approximate inverse of the standard normal CDF using the rational approximation. Used for parametric VaR z-score computation. + */ + private static double inverseNormalCdf(double p) { + if (p <= 0.0 || p >= 1.0) { + throw new IllegalArgumentException("p must be in (0, 1), got: " + p); + } + if (p < 0.5) { + return -inverseNormalCdf(1.0 - p); + } + double[] a = {0.0, 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429, 0.3275911}; + double t = 1.0 / (1.0 + a[1] * Math.sqrt(-2.0 * Math.log(1.0 - p))); + double y = 1.0 - (((((a[5] * t + a[4]) * t) + a[3]) * t + a[2]) * t + a[1]) * t * Math.exp(-t * t / 2.0); + return y; + } +} diff --git a/src/main/java/fish/payara/trader/risk/model/ExposureSummary.java b/src/main/java/fish/payara/trader/risk/model/ExposureSummary.java new file mode 100644 index 0000000..33931ee --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/ExposureSummary.java @@ -0,0 +1,7 @@ +package fish.payara.trader.risk.model; + +/** + * Portfolio-level exposure aggregation across all positions. + */ +public record ExposureSummary(double totalNotional, double netDelta, double grossExposure, double longExposure, double shortExposure, int symbolCount) { +} diff --git a/src/main/java/fish/payara/trader/risk/model/Position.java b/src/main/java/fish/payara/trader/risk/model/Position.java new file mode 100644 index 0000000..b9ebee4 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/Position.java @@ -0,0 +1,38 @@ +package fish.payara.trader.risk.model; + +/** + * Tracks an open position for a single symbol. Quantity is positive for longs, negative for shorts. + */ +public record Position(String symbol, long quantity, double averageEntryPrice, double realizedPnl, long tradeCount) { + + public Position(String symbol) { + this(symbol, 0, 0.0, 0.0, 0); + } + + public double notionalValue(double currentPrice) { + return Math.abs(quantity) * currentPrice; + } + + public double unrealizedPnl(double currentPrice) { + if (quantity == 0 || averageEntryPrice == 0.0) { + return 0.0; + } + return quantity > 0 ? (currentPrice - averageEntryPrice) * quantity : (averageEntryPrice - currentPrice) * Math.abs(quantity); + } + + public double delta() { + return quantity; + } + + public boolean isFlat() { + return quantity == 0; + } + + public boolean isLong() { + return quantity > 0; + } + + public boolean isShort() { + return quantity < 0; + } +} diff --git a/src/main/java/fish/payara/trader/risk/model/RiskSnapshot.java b/src/main/java/fish/payara/trader/risk/model/RiskSnapshot.java new file mode 100644 index 0000000..a147441 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/RiskSnapshot.java @@ -0,0 +1,8 @@ +package fish.payara.trader.risk.model; + +/** + * Per-symbol risk snapshot combining position, price, and limit utilization. + */ +public record RiskSnapshot(String symbol, Position position, double currentPrice, double notionalValue, double unrealizedPnl, double maxPositionLimit, + double utilizationPercent) { +} diff --git a/src/main/java/fish/payara/trader/risk/model/StressResult.java b/src/main/java/fish/payara/trader/risk/model/StressResult.java new file mode 100644 index 0000000..2d518d3 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/StressResult.java @@ -0,0 +1,9 @@ +package fish.payara.trader.risk.model; + +import java.util.List; + +/** + * Result of running a stress test scenario across all positions. + */ +public record StressResult(String scenarioName, double portfolioImpact, int positionsBreached, double maxPositionLoss, List breachDetails) { +} diff --git a/src/main/java/fish/payara/trader/risk/model/StressScenario.java b/src/main/java/fish/payara/trader/risk/model/StressScenario.java new file mode 100644 index 0000000..efbefb4 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/StressScenario.java @@ -0,0 +1,83 @@ +package fish.payara.trader.risk.model; + +/** + * Sealed interface for stress test scenarios. Each variant applies a different shock to positions and returns the resulting P&L impact. + */ +public sealed interface StressScenario permits StressScenario.FlashCrash, StressScenario.VolatilitySpike, StressScenario.LiquidityFreeze, + StressScenario.CorrelationBreakdown, StressScenario.InterestRateShock { + + String name(); + + /** + * Apply the stress scenario to a position at the given price. Returns the P&L impact (negative means loss). + */ + double apply(Position position, double price); + + record FlashCrash(double crashPercent) implements StressScenario { + @Override + public String name() { + return "Flash Crash (%.1f%%)".formatted(crashPercent); + } + + @Override + public double apply(Position position, double price) { + double shockedPrice = price * (1.0 - crashPercent / 100.0); + return position.unrealizedPnl(shockedPrice) - position.unrealizedPnl(price); + } + } + + record VolatilitySpike(double volMultiplier) implements StressScenario { + @Override + public String name() { + return "Volatility Spike (%.1fx)".formatted(volMultiplier); + } + + @Override + public double apply(Position position, double price) { + double shockPercent = (volMultiplier - 1.0) * 10.0; + double shockedPrice = price * (1.0 - shockPercent / 100.0); + return position.unrealizedPnl(shockedPrice) - position.unrealizedPnl(price); + } + } + + record LiquidityFreeze(double bidAskWidenPercent) implements StressScenario { + @Override + public String name() { + return "Liquidity Freeze (spread +%.1f%%)".formatted(bidAskWidenPercent); + } + + @Override + public double apply(Position position, double price) { + double halfSpread = price * (bidAskWidenPercent / 200.0); + double liquidationPrice = position.isLong() ? price - halfSpread : price + halfSpread; + return position.unrealizedPnl(liquidationPrice) - position.unrealizedPnl(price); + } + } + + record CorrelationBreakdown(double decorrelationPercent) implements StressScenario { + @Override + public String name() { + return "Correlation Breakdown (%.1f%%)".formatted(decorrelationPercent); + } + + @Override + public double apply(Position position, double price) { + double shockPercent = decorrelationPercent / 100.0 * 5.0; + double shockedPrice = price * (1.0 - shockPercent); + return position.unrealizedPnl(shockedPrice) - position.unrealizedPnl(price); + } + } + + record InterestRateShock(double basisPoints) implements StressScenario { + @Override + public String name() { + return "Interest Rate Shock (%.0f bps)".formatted(basisPoints); + } + + @Override + public double apply(Position position, double price) { + double impact = position.notionalValue(price) * (basisPoints / 10_000.0) * 0.1; + return -impact; + } + } +} diff --git a/src/main/java/fish/payara/trader/risk/model/VarResult.java b/src/main/java/fish/payara/trader/risk/model/VarResult.java new file mode 100644 index 0000000..7caaf86 --- /dev/null +++ b/src/main/java/fish/payara/trader/risk/model/VarResult.java @@ -0,0 +1,7 @@ +package fish.payara.trader.risk.model; + +/** + * Value at Risk result from either historical or parametric computation. + */ +public record VarResult(String method, double varValue, double confidenceLevel, int lookbackBars, long timestamp) { +} diff --git a/src/main/java/fish/payara/trader/util/InstanceUtils.java b/src/main/java/fish/payara/trader/util/InstanceUtils.java new file mode 100644 index 0000000..4fe1097 --- /dev/null +++ b/src/main/java/fish/payara/trader/util/InstanceUtils.java @@ -0,0 +1,89 @@ +package fish.payara.trader.util; + +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.util.List; + +/** + * Utility methods for instance-level operations. Consolidates commonly-used code across the application. + */ +public final class InstanceUtils { + + private static final String PAYARA_INSTANCE_NAME_ENV = "PAYARA_INSTANCE_NAME"; + private static final String DEFAULT_INSTANCE_NAME = "standalone"; + + private InstanceUtils() { + } + + /** + * Returns the Payara instance name from environment variable, or "standalone" if not set. + */ + public static String getInstanceName() { + String name = System.getenv(PAYARA_INSTANCE_NAME_ENV); + return name != null ? name : DEFAULT_INSTANCE_NAME; + } + + /** + * Calculates percentile value from a sorted list of values. + * + * @param sortedValues + * list of values sorted in ascending order + * @param percentile + * percentile to calculate (0.0 to 1.0) + * @return the value at the requested percentile + */ + public static long percentile(List sortedValues, double percentile) { + int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; + index = Math.max(0, Math.min(index, sortedValues.size() - 1)); + return sortedValues.get(index); + } + + /** + * JVM metadata containing vendor, name, and garbage collector information. + */ + public static final class JvmMetadata { + private final String vendor; + private final String name; + private final String gcCollectors; + private final boolean isAzulC4; + + public JvmMetadata(String vendor, String name, String gcCollectors, boolean isAzulC4) { + this.vendor = vendor; + this.name = name; + this.gcCollectors = gcCollectors; + this.isAzulC4 = isAzulC4; + } + + public String vendor() { + return vendor; + } + + public String name() { + return name; + } + + public String gcCollectors() { + return gcCollectors; + } + + public boolean isAzulC4() { + return isAzulC4; + } + } + + /** + * Extracts JVM metadata from system properties and GC beans. + * + * @return JVM metadata including vendor, name, GC collectors, and C4 detection + */ + public static JvmMetadata getJvmMetadata() { + String vendor = System.getProperty("java.vm.vendor"); + String name = System.getProperty("java.vm.name"); + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + String gcCollectors = gcBeans.stream().map(GarbageCollectorMXBean::getName).reduce((a, b) -> a + ", " + b).orElse(""); + + boolean isAzulC4 = gcCollectors.toLowerCase().contains("c4") || name.toLowerCase().contains("zing"); + + return new JvmMetadata(vendor, name, gcCollectors, isAzulC4); + } +} diff --git a/src/main/java/fish/payara/trader/websocket/ExecutionWebSocket.java b/src/main/java/fish/payara/trader/websocket/ExecutionWebSocket.java new file mode 100644 index 0000000..74ddff2 --- /dev/null +++ b/src/main/java/fish/payara/trader/websocket/ExecutionWebSocket.java @@ -0,0 +1,45 @@ +package fish.payara.trader.websocket; + +import fish.payara.trader.matching.websocket.ExecutionBroadcaster; +import jakarta.inject.Inject; +import jakarta.websocket.*; +import jakarta.websocket.server.ServerEndpoint; +import java.util.logging.Level; +import java.util.logging.Logger; + +@ServerEndpoint("/executions") +public class ExecutionWebSocket { + + private static final Logger LOGGER = Logger.getLogger(ExecutionWebSocket.class.getName()); + + @Inject + private ExecutionBroadcaster broadcaster; + + @OnOpen + public void onOpen(Session session) { + LOGGER.info("Execution WebSocket opened: " + session.getId()); + broadcaster.addSession(session); + try { + session.getBasicRemote().sendText("{\"type\":\"info\",\"message\":\"Connected to execution feed\"}"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send execution welcome message", e); + } + } + + @OnClose + public void onClose(Session session, CloseReason closeReason) { + LOGGER.info("Execution WebSocket closed: " + session.getId()); + broadcaster.removeSession(session); + } + + @OnError + public void onError(Session session, Throwable throwable) { + LOGGER.log(Level.WARNING, "Execution WebSocket error: " + session.getId(), throwable); + broadcaster.removeSession(session); + } + + @OnMessage + public void onMessage(String message, Session session) { + LOGGER.fine("Execution WebSocket received: " + message); + } +} diff --git a/src/main/java/fish/payara/trader/websocket/IndicatorWebSocket.java b/src/main/java/fish/payara/trader/websocket/IndicatorWebSocket.java new file mode 100644 index 0000000..423d29a --- /dev/null +++ b/src/main/java/fish/payara/trader/websocket/IndicatorWebSocket.java @@ -0,0 +1,44 @@ +package fish.payara.trader.websocket; + +import jakarta.inject.Inject; +import jakarta.websocket.*; +import jakarta.websocket.server.ServerEndpoint; +import java.util.logging.Level; +import java.util.logging.Logger; + +@ServerEndpoint("/indicators") +public class IndicatorWebSocket { + + private static final Logger LOGGER = Logger.getLogger(IndicatorWebSocket.class.getName()); + + @Inject + private MarketDataBroadcaster broadcaster; + + @OnOpen + public void onOpen(Session session) { + LOGGER.info("Indicator WebSocket opened: " + session.getId()); + broadcaster.addSession(session); + try { + session.getBasicRemote().sendText("{\"type\":\"info\",\"message\":\"Connected to indicator feed\"}"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send indicator welcome message", e); + } + } + + @OnClose + public void onClose(Session session, CloseReason closeReason) { + LOGGER.info("Indicator WebSocket closed: " + session.getId()); + broadcaster.removeSession(session); + } + + @OnError + public void onError(Session session, Throwable throwable) { + LOGGER.log(Level.WARNING, "Indicator WebSocket error: " + session.getId(), throwable); + broadcaster.removeSession(session); + } + + @OnMessage + public void onMessage(String message, Session session) { + LOGGER.fine("Indicator WebSocket received: " + message); + } +} diff --git a/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java b/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java index 0e2bdd7..64f1165 100644 --- a/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java +++ b/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java @@ -1,9 +1,12 @@ package fish.payara.trader.websocket; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.topic.ITopic; +import fish.payara.trader.jfr.MarketDataEvents; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; import jakarta.websocket.Session; - -import java.io.IOException; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; @@ -12,115 +15,193 @@ /** * Broadcaster for market data WebSocket messages. * - * Maintains a set of active WebSocket sessions and broadcasts - * JSON messages to all connected clients. + *

+ * Maintains a set of active WebSocket sessions and broadcasts JSON messages to all connected clients. + * + *

+ * In clustered mode, uses Hazelcast distributed topics to broadcast messages across all cluster members, ensuring all WebSocket clients receive data regardless + * of which instance they connect to. * - * Note: JSON string creation intentionally generates garbage - * to stress-test Azul's Pauseless GC (C4). + *

+ * Note: JSON string creation intentionally generates garbage to stress-test Azul's Pauseless GC (C4). */ @ApplicationScoped public class MarketDataBroadcaster { private static final Logger LOGGER = Logger.getLogger(MarketDataBroadcaster.class.getName()); + private static final String TOPIC_NAME = "market-data-broadcast"; private final Set sessions = ConcurrentHashMap.newKeySet(); - // Statistics + @Inject + private HazelcastInstance hazelcastInstance; + + @Inject + private fish.payara.trader.monitoring.SLAMonitorService slaMonitor; + + private ITopic clusterTopic; + private long messagesSent = 0; private long lastStatsTime = System.currentTimeMillis(); - /** - * Register a new WebSocket session - */ + /** Initialize Hazelcast topic subscription for cluster-wide broadcasting */ + @PostConstruct + public void init() { + try { + if (hazelcastInstance != null) { + clusterTopic = hazelcastInstance.getTopic(TOPIC_NAME); + clusterTopic.addMessageListener(message -> { + broadcastLocal(message.getMessageObject()); + }); + LOGGER.info("Subscribed to Hazelcast topic: " + TOPIC_NAME + " (clustered mode)"); + } else { + LOGGER.info("Hazelcast not available - running in standalone mode"); + } + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to initialize Hazelcast topic subscription", e); + // Continue without clustering + } + } + + /** Register a new WebSocket session */ public void addSession(Session session) { sessions.add(session); LOGGER.info("WebSocket session added. Total sessions: " + sessions.size()); } - /** - * Unregister a WebSocket session - */ public void removeSession(Session session) { sessions.remove(session); LOGGER.info("WebSocket session removed. Total sessions: " + sessions.size()); } /** - * Broadcast JSON message to all connected clients + * Broadcast JSON message to all connected clients across the cluster. + * + *

+ * In clustered mode, publishes to Hazelcast topic which distributes to all cluster members. Each member then broadcasts to its local WebSocket sessions. + * + *

+ * In standalone mode, broadcasts directly to local sessions. * - * This method intentionally creates string allocations to - * generate garbage and stress the garbage collector. + *

+ * This method intentionally creates string allocations to generate garbage and stress the garbage collector. */ public void broadcast(String jsonMessage) { + if (clusterTopic != null) { + try { + clusterTopic.publish(jsonMessage); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to publish to Hazelcast topic, falling back to local broadcast", e); + broadcastLocal(jsonMessage); + } + } else { + broadcastLocal(jsonMessage); + } + } + + /** + * Broadcast message to local WebSocket sessions only. Called either in standalone mode or by Hazelcast topic listener. + */ + private void broadcastLocal(String jsonMessage) { if (sessions.isEmpty()) { return; } - // Iterate through all sessions and send the message + long startTime = System.currentTimeMillis(); + int sessionCount = sessions.size(); + + String messageType = extractMessageType(jsonMessage); + sessions.removeIf(session -> { if (!session.isOpen()) { - LOGGER.fine("Removing closed session: " + session.getId()); return true; } - try { - // Send async to avoid blocking session.getAsyncRemote().sendText(jsonMessage); - messagesSent++; return false; - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to send message to session: " + session.getId(), e); - return true; // Remove problematic session + LOGGER.log(Level.WARNING, "Failed to send message", e); + return true; } }); + long latency = System.currentTimeMillis() - startTime; + + MarketDataEvents.WebSocketBroadcast wsEvent = new MarketDataEvents.WebSocketBroadcast(); + if (wsEvent.isEnabled()) { + wsEvent.clientCount = sessionCount; + wsEvent.messageSizeBytes = jsonMessage.length(); + wsEvent.messageType = messageType; + wsEvent.commit(); + } + + if (slaMonitor != null) { + slaMonitor.recordOperation(latency); + } + logStatistics(); } + /** + * Extract message type from JSON for JFR event. Parses {"type":"..."} pattern from the message. + */ + private String extractMessageType(String jsonMessage) { + if (jsonMessage == null || jsonMessage.isEmpty()) { + return "unknown"; + } + int typeStart = jsonMessage.indexOf("\"type\":"); + if (typeStart == -1) { + return "unknown"; + } + int valueStart = jsonMessage.indexOf("\"", typeStart + 7); + if (valueStart == -1) { + return "unknown"; + } + int valueEnd = jsonMessage.indexOf("\"", valueStart + 1); + if (valueEnd == -1) { + return "unknown"; + } + return jsonMessage.substring(valueStart + 1, valueEnd); + } + /** * Broadcast to all clients with additional garbage generation for GC stress. * - * This method wraps the original message in a larger JSON envelope with padding. - * This increases both memory allocation (String construction) and network bandwidth usage, - * simulating a heavier protocol or inefficient data packaging. + *

+ * This method wraps the original message in a larger JSON envelope with padding. This increases both memory allocation (String construction) and network + * bandwidth usage, simulating a heavier protocol or inefficient data packaging. */ public void broadcastWithArtificialLoad(String jsonMessage) { - // Generate 1KB of padding to increase payload size and allocation String padding = "X".repeat(1024); // Wrap the original message in a new JSON structure // We use StringBuilder to explicitly construct the new JSON string - String enrichedMessage = new StringBuilder(jsonMessage.length() + padding.length() + 100) - .append("{\"wrapped\":true,") - .append("\"timestamp\":").append(System.nanoTime()).append(",") - .append("\"padding\":\"").append(padding).append("\",") - .append("\"data\":").append(jsonMessage) - .append("}") - .toString(); + String enrichedMessage = new StringBuilder(jsonMessage.length() + padding.length() + 100).append("{\"wrapped\":true,") + .append("\"timestamp\":") + .append(System.nanoTime()) + .append(",") + .append("\"padding\":\"") + .append(padding) + .append("\",") + .append("\"data\":") + .append(jsonMessage) + .append("}") + .toString(); broadcast(enrichedMessage); } - /** - * Get count of active sessions - */ + /** Get count of active sessions */ public int getSessionCount() { return sessions.size(); } - /** - * Log statistics periodically - */ + /** Log statistics periodically */ private void logStatistics() { long now = System.currentTimeMillis(); - if (now - lastStatsTime > 10000) { // Log every 10 seconds - LOGGER.info(String.format( - "WebSocket Stats - Active sessions: %d, Messages sent: %,d (%.1f msg/sec)", - sessions.size(), - messagesSent, - messagesSent / ((now - lastStatsTime) / 1000.0) - )); + if (now - lastStatsTime > 10000) { + LOGGER.info(String.format("WebSocket Stats - Active sessions: %d, Messages sent: %,d (%.1f msg/sec)", sessions.size(), messagesSent, + messagesSent / ((now - lastStatsTime) / 1000.0))); lastStatsTime = now; messagesSent = 0; } diff --git a/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java b/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java index e222e34..2a4840e 100644 --- a/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java +++ b/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java @@ -3,16 +3,15 @@ import jakarta.inject.Inject; import jakarta.websocket.*; import jakarta.websocket.server.ServerEndpoint; -import org.eclipse.microprofile.config.inject.ConfigProperty; - import java.util.logging.Level; import java.util.logging.Logger; +import org.eclipse.microprofile.config.inject.ConfigProperty; /** * WebSocket endpoint for streaming market data to clients. * - * Clients connect to ws://host:port/context/market-data - * and receive real-time JSON market data updates. + *

+ * Clients connect to ws://host:port/context/market-data and receive real-time JSON market data updates. */ @ServerEndpoint("/market-data") public class MarketDataWebSocket { @@ -31,12 +30,9 @@ public void onOpen(Session session) { LOGGER.info("WebSocket connection opened: " + session.getId()); broadcaster.addSession(session); - // Send welcome message with current mode try { - String welcomeJson = String.format( - "{\"type\":\"info\",\"message\":\"Connected to TradeStreamEE market data feed\",\"mode\":\"%s\"}", - ingestionMode - ); + String welcomeJson = String.format("{\"type\":\"info\",\"message\":\"Connected to TradeStreamEE market data feed\",\"mode\":\"%s\"}", + ingestionMode); session.getBasicRemote().sendText(welcomeJson); } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to send welcome message", e); @@ -45,8 +41,7 @@ public void onOpen(Session session) { @OnClose public void onClose(Session session, CloseReason closeReason) { - LOGGER.info("WebSocket connection closed: " + session.getId() + - ", reason: " + closeReason.getReasonPhrase()); + LOGGER.info("WebSocket connection closed: " + session.getId() + ", reason: " + closeReason.getReasonPhrase()); broadcaster.removeSession(session); } @@ -58,14 +53,10 @@ public void onError(Session session, Throwable throwable) { @OnMessage public void onMessage(String message, Session session) { - // Handle client messages if needed (e.g., subscription requests) LOGGER.fine("Received message from client: " + message); - // Echo back for now try { - session.getBasicRemote().sendText( - "{\"type\":\"ack\",\"message\":\"Message received\"}" - ); + session.getBasicRemote().sendText("{\"type\":\"ack\",\"message\":\"Message received\"}"); } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to send acknowledgment", e); } diff --git a/src/main/java/fish/payara/trader/websocket/SLAWebSocket.java b/src/main/java/fish/payara/trader/websocket/SLAWebSocket.java new file mode 100644 index 0000000..c811298 --- /dev/null +++ b/src/main/java/fish/payara/trader/websocket/SLAWebSocket.java @@ -0,0 +1,76 @@ +package fish.payara.trader.websocket; + +import fish.payara.trader.util.InstanceUtils; +import jakarta.inject.Inject; +import jakarta.websocket.*; +import jakarta.websocket.server.ServerEndpoint; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * WebSocket endpoint for SLA violation alerts. Pushes real-time notifications when GC pauses exceed thresholds. + * + *

+ * Connect to ws://host:port/context/sla to receive violation alerts. + * + *

+ * Message format: + * + *

+ * {
+ *   "type": "sla-violation",
+ *   "pauseTimeMs": 45,
+ *   "threshold": ">10ms",
+ *   "timestamp": 1234567890,
+ *   "instanceName": "c4-1"
+ * }
+ * 
+ * + *

+ * NOTE: Manual JSON construction via STR templates is intentional - it generates garbage to stress-test the garbage collector for demo purposes. + */ +@ServerEndpoint("/sla") +public class SLAWebSocket { + + private static final Logger LOGGER = Logger.getLogger(SLAWebSocket.class.getName()); + + @Inject + private MarketDataBroadcaster broadcaster; + + @OnOpen + public void onOpen(Session session) { + LOGGER.info("SLA WebSocket connection opened: " + session.getId()); + broadcaster.addSession(session); + + try { + String instanceName = InstanceUtils.getInstanceName(); + String welcomeJson = "{\"type\":\"info\",\"message\":\"Connected to TradeStreamEE SLA monitoring\",\"instance\":\"" + instanceName + "\"}"; + session.getBasicRemote().sendText(welcomeJson); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send SLA welcome message", e); + } + } + + @OnClose + public void onClose(Session session, CloseReason closeReason) { + LOGGER.info("SLA WebSocket connection closed: " + session.getId() + ", reason: " + closeReason.getReasonPhrase()); + broadcaster.removeSession(session); + } + + @OnError + public void onError(Session session, Throwable throwable) { + LOGGER.log(Level.WARNING, "SLA WebSocket error for session: " + session.getId(), throwable); + broadcaster.removeSession(session); + } + + @OnMessage + public void onMessage(String message, Session session) { + LOGGER.fine("Received message on SLA WebSocket: " + message); + + try { + session.getBasicRemote().sendText("{\"type\":\"ack\",\"message\":\"Message received\"}"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send acknowledgment", e); + } + } +} diff --git a/src/main/resources/META-INF/microprofile-config.properties b/src/main/resources/META-INF/microprofile-config.properties index cb0c65d..e152823 100644 --- a/src/main/resources/META-INF/microprofile-config.properties +++ b/src/main/resources/META-INF/microprofile-config.properties @@ -1,2 +1,9 @@ # Define your configuration properties here -defaultName=Hello, World! \ No newline at end of file +defaultName=Hello, World! + +# Demo and Business Impact Configuration +business.impact.per.trade=25000 +business.impact.currency=USD +business.impact.window.seconds=60 +business.impact.instances.c4=3 +business.impact.instances.g1=5 \ No newline at end of file diff --git a/src/main/resources/demo-presets.yml b/src/main/resources/demo-presets.yml new file mode 100644 index 0000000..62576fe --- /dev/null +++ b/src/main/resources/demo-presets.yml @@ -0,0 +1,81 @@ +# Demo Preset Configuration for TradeStreamEE +# Each preset chains memory pressure scenarios to demonstrate runtime behaviour +# under realistic trading workloads. + +presets: + - id: MARKET_OPEN_SPIKE + name: "Market Open Spike" + description: "Simulates NYSE open with 5x allocation surge. Concurrent collectors absorb the burst; STW collectors stutter." + durationSeconds: 30 + expectedImpact: "High" + steps: + - mode: STEADY_LOAD + durationSeconds: 5 + description: "Baseline warmup" + - mode: INTRADAY_POSITION_GROWTH + durationSeconds: 15 + description: "5x burst allocation - simulates market open surge" + - mode: STEADY_LOAD + durationSeconds: 10 + description: "Return to baseline" + + - id: EXECUTIVE_30_SEC + name: "Executive 30-Sec" + description: "Quick demo for executive meetings. Shows concurrent-vs-STW runtime difference in 30 seconds." + durationSeconds: 30 + expectedImpact: "High" + steps: + - mode: EARNINGS_SPIKE + durationSeconds: 30 + description: "Post-earnings surge - tests how the runtime absorbs sudden directional flow" + + - id: TRADING_DAY_STRESS + name: "Trading Day Stress" + description: "Full trading day simulation with mixed scenarios. Tests how the runtime sustains long-running mixed pressure." + durationSeconds: 180 + expectedImpact: "Very High" + steps: + - mode: STEADY_LOAD + durationSeconds: 60 + description: "Morning steady trading" + - mode: EARNINGS_SPIKE + durationSeconds: 60 + description: "Lunch volume spike - tests how the runtime handles directional flow" + - mode: MULTI_VENUE_QUOTE_CHURN + durationSeconds: 60 + description: "Afternoon quote churn stress" + + - id: QUOTE_CHURN_HELL + name: "Quote Churn Hell" + description: "Worst-case scenario for region-based STW collectors: pure short-lived object churn. Concurrent compacting collectors absorb it." + durationSeconds: 120 + expectedImpact: "High" + steps: + - mode: MULTI_VENUE_QUOTE_CHURN + durationSeconds: 120 + description: "Small objects with random lifetimes - tests compaction" + + - id: FULL_DAY + name: "Full Day Simulation" + description: "Complete 6.5 hour trading session condensed to 5 minutes. Comprehensive picture." + durationSeconds: 300 + expectedImpact: "Very High" + steps: + - mode: STEADY_LOAD + durationSeconds: 60 + description: "Pre-market warmup" + - mode: INTRADAY_POSITION_GROWTH + durationSeconds: 60 + description: "Market open - growing position book" + - mode: EARNINGS_SPIKE + durationSeconds: 60 + description: "Mid-morning earnings spike" + - mode: STEADY_LOAD + durationSeconds: 30 + description: "Mid-day lull" + - mode: LONG_HORIZON_POSITION_BOOK + durationSeconds: 60 + description: "Afternoon cross-gen reference stress on the runtime" + - mode: MULTI_VENUE_QUOTE_CHURN + durationSeconds: 30 + description: "Close: multi-venue quote churn" diff --git a/src/main/resources/hazelcast-config.xml b/src/main/resources/hazelcast-config.xml new file mode 100644 index 0000000..c586391 --- /dev/null +++ b/src/main/resources/hazelcast-config.xml @@ -0,0 +1,77 @@ + + + + + payara-trader-cluster + + + + 5701 + + + + + + + + + + trader-stream-1 + trader-stream-2 + trader-stream-3 + + + trader-stream-1:5701 + trader-stream-2:5701 + trader-stream-3:5701 + + + + + + + + + + + 10 + BLOCK + true + + + + + + + + + 5 + + + + + + + + jdk + + + 4 + 3 + + + NOISY + 30 + + + false + + + 300 + 120 + + + diff --git a/src/main/resources/sbe/market-data.xml b/src/main/resources/sbe/market-data.xml index 9634703..bdde8e1 100644 --- a/src/main/resources/sbe/market-data.xml +++ b/src/main/resources/sbe/market-data.xml @@ -31,6 +31,16 @@ + + + + + + + + + + 0 @@ -41,6 +51,9 @@ 0 1 2 + 3 + 4 + 5 @@ -49,6 +62,63 @@ 2 3 4 + 5 + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + + + + 0 + 1 + 2 + 3 + 4 + + + + 0 + 1 + 2 + 99 + + + + 0 + 1 + 2 + + + + 0 + 1 + 2 + 3 + 4 + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + 0 + 1 + 2 + 3 @@ -115,4 +185,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/analysis-checklist.html b/src/main/webapp/analysis-checklist.html new file mode 100644 index 0000000..dfb3d46 --- /dev/null +++ b/src/main/webapp/analysis-checklist.html @@ -0,0 +1,696 @@ + + + + + + JFR Analysis Checklist + + + + + + + + + + +

+ Printable Reference +

JFR Analysis Checklist

+

Two pages. Print double-sided. Keep at your desk.

+
+ +
+ +
+

Capture

+
    +
  • Recording length: at least 10 minutes for stable percentiles; longer for rare events.
  • +
  • Settings profile: default.jfc in production (~1%), profile.jfc for dev (~2%), workshop .jfc only when reproducing a specific scenario.
  • +
  • maxsize and maxage set; recording is circular, not unbounded.
  • +
  • Application is in steady state when capture begins (JIT warmed, caches populated). Skip the first 60 seconds.
  • +
  • If you control the workload, freeze it during capture. If you don't, note the request rate at start and end.
  • +
  • Filename includes timestamp, host, and recording purpose.
  • +
+
+ +
+ +
+

Triage: Read in This Order

+
    +
  1. Recording duration and event coverage. Outline → General → Recording Information. Confirm the timeline matches what you expected.
  2. +
  3. GC pause headline. Outline → General → Garbage Collections, sort Duration descending. Read the top 5 rows.
  4. +
  5. Heap shape. Outline → Memory → Heap Usage After GC. Is old gen growing? Plateau? Sawtooth?
  6. +
  7. Allocation hot stacks. Outline → Memory → Allocations → Hot Methods. Top 3 methods by bytes allocated.
  8. +
  9. Custom application events. Event Browser → Custom. Are domain events firing at expected rates?
  10. +
+ +
+

If you only have time for one view, choose #2.

+
+
+ +
+ +
+

Pause Time Distributions

+ +
+ + + + + + + + + +
QuantileWhat It Tells You
P50"Typical pause." Mostly informational.
P95"Routine bad pause." Should be near the SLA threshold.
P99"What 1 in 100 GCs cost." Most operational alerts live here.
P99.9"What 1 in 1000 GCs cost." Where incidents come from.
Max"Worst single pause." Investigate even one if it exceeds your SLA.
+
+ +
+

Sample size matters. P99 from 10 collections is not statistically meaningful. Aim for hundreds of pause events in the window.

+
+
+ +
+ +
+

Pathology Triage Tree

+
Pause time > SLA? +├─ Yes → check Name column: +│ ├─ "G1 Young Generation (Normal)" +│ │ └─ check Allocation rate. > 500 MB/s? → reduce allocation. +│ │ Otherwise → check survivor sizing. +│ ├─ "G1 Young Generation (Mixed)" +│ │ └─ check old gen growth. Filling fast? → PROMOTION STORM. +│ │ check region count being mixed. High? → FRAGMENTATION. +│ ├─ "G1 Full GC" +│ │ └─ EvacuationFailed events present? → to-space exhausted. +│ │ PromotionFailed present? → old gen full. +│ │ Neither? → System.gc() called, or metaspace. +│ └─ Pause's biggest phase (jdk.GCPhasePauseLevel1): +│ ├─ "Update RS" → cross-gen ref pressure +│ ├─ "Scan RS" → cross-gen ref pressure +│ ├─ "Evacuate CS" → promotion / object copy cost +│ ├─ "Code Roots" → many classes being recompiled +│ └─ "Ref Proc" → too many soft/weak/phantom refs +└─ No → recording shows healthy GC. If app feels slow, look elsewhere + (lock contention, virtual-thread pinning, I/O).
+
+ +
+ +
+

Pathology Signatures

+ +
+

Promotion Storm

+
    +
  • jdk.PromoteObjectOutsidePLAB count climbs across recording.
  • +
  • Old gen usage curve rises monotonically.
  • +
  • Mixed collections appear and lengthen over time.
  • +
  • Allocation rate is high (> 200 MB/s) AND survival rate is high (> 30%).
  • +
+

Fixes, in order of preference:

+
    +
  1. Reduce allocation in the survival-heavy code path (object pooling, primitive arrays).
  2. +
  3. Increase young gen so more objects die young (-XX:NewRatio, region count).
  4. +
  5. Switch to a concurrent collector (ZGC, Shenandoah).
  6. +
+
+ +
+

Fragmentation

+
    +
  • Many small objects (jdk.ObjectAllocationSample, size < 1 KB).
  • +
  • jdk.G1HeapRegionTypeChange events frequent.
  • +
  • Pause time correlates with region count being evacuated, not heap size.
  • +
  • jdk.GCPhasePauseLevel2 → Object Copy is the dominant phase.
  • +
+

Fixes:

+
    +
  1. Switch to a compacting concurrent collector (ZGC defragments less, C4 compacts continuously).
  2. +
  3. Consolidate small objects into arrays or off-heap buffers.
  4. +
  5. Increase G1HeapRegionSize so fewer regions hold equivalent data.
  6. +
+
+ +
+

Cross-generational References

+
    +
  • jdk.GCPhasePauseLevel1 events with name "Update RS" or "Scan RS" dominating pause time.
  • +
  • ExecutionSample stacks pointing into G1BarrierSet::write_ref_field_post.
  • +
  • Old-gen objects holding references to young-gen objects (mutable singletons, caches).
  • +
+

Fixes:

+
    +
  1. Make old-gen holders immutable or copy-on-write.
  2. +
  3. Switch to a collector with cheaper barriers (ZGC's load barrier) or without remembered sets (C4).
  4. +
  5. Reduce write rate to old-gen mutable fields.
  6. +
+
+ +
+

Evacuation Failure

+
    +
  • jdk.EvacuationFailed events present.
  • +
  • May be followed by a full GC if the heap remains constrained; JDK 21's Tenuring Anywhere change reduces this likelihood by letting subsequent young collections reclaim old-gen regions.
  • +
  • All available heap regions are committed and none remain free for promotion or survivor copy when the failure fires.
  • +
+

Fixes:

+
    +
  1. Increase MaxHeapSize.
  2. +
  3. Reduce allocation rate.
  4. +
  5. Lower InitiatingHeapOccupancyPercent so concurrent marking starts earlier.
  6. +
+
+ +
+

Virtual Thread Red Flags

+
    +
  • jdk.VirtualThreadPinned events with duration over 100 ms.
  • +
  • Pinned stacks usually point to synchronized blocks or JNI calls.
  • +
+

Fixes:

+
    +
  1. Replace synchronized with ReentrantLock where the carrier thread would otherwise be pinned.
  2. +
  3. Move JNI-heavy work off virtual threads (use platform threads for native code paths).
  4. +
+
+
+ +
+ +
+

CLI Quick Reference

+ +
+
+ bash + +
+
# Summary
+jfr summary recording.jfr
+
+# All GC events
+jfr print --events jdk.GarbageCollection recording.jfr
+
+# Custom events
+jfr print --events 'trade.*,gc.sla.violation' recording.jfr
+
+# Compare two recordings (workshop helper)
+./workshop/scripts/compare-recordings.sh zgc.jfr g1.jfr
+
+# Open in JMC
+jmc -open recording.jfr
+
+
+ +
+ +
+

Don't

+
    +
  • Don't conclude from a 60-second recording that you have a GC problem in production. Take a 10-minute recording first.
  • +
  • Don't enable jdk.ObjectAllocationInNewTLAB with stack traces in production. It is a development-only tool.
  • +
  • Don't change collector before reducing allocation. The cheapest fix is almost always upstream.
  • +
  • Don't tune MaxGCPauseMillis to a value below your P99 measurement. G1 will respond by collecting more often, not faster.
  • +
+
+ +
+ + + + + diff --git a/src/main/webapp/blog.html b/src/main/webapp/blog.html new file mode 100644 index 0000000..ceae92f --- /dev/null +++ b/src/main/webapp/blog.html @@ -0,0 +1,443 @@ + + + + + + Azul C4 and Payara Micro: GC Performance in Financial Applications - TradeStreamEE Blog + + + + +
+
+ + + + + Back to Dashboard + +

Azul C4 and Payara Micro: GC Performance in Financial Applications

+

Measuring garbage collector behavior in a low-latency trading system built on Jakarta EE

+
+
+ + + + 8 min read +
+
+ + + + Azul C4, Payara Micro, Jakarta EE, HFT +
+
+
+ +
+

In high-frequency trading (HFT) and financial services, latency determines profitability. Java has been viewed with skepticism in low-latency finance, primarily due to its stop-the-world garbage collection pauses that introduce unpredictable latency spikes.

+ +

TradeStreamEE measures GC behavior in a realistic trading workload built on Jakarta EE. By combining Azul Platform Prime with the C4 garbage collector and Payara Micro, the system produces measurable data on pause characteristics under controlled stress conditions.

+ +

The Evidence: Scenario-Based GC Stress Testing

+ +

TradeStreamEE uses scenario-based testing that targets specific garbage collector weaknesses rather than applying generic high-allocation pressure. Each scenario exposes fundamental algorithmic differences between G1 and C4.

+ +

The test workloads include:

+ +
    +
  • STEADY_LOAD: 200 MB/sec allocation with 512 MB stable live set. Tests baseline young generation collection behavior.
  • +
  • INTRADAY_POSITION_GROWTH: 150 MB/sec with position book growing from 100 MB to 2 GB over 60 seconds. Tests mixed collection pause scaling.
  • +
  • EARNINGS_SPIKE: 300 MB/sec with 50% order survival rate. Tests old generation collection efficiency under high promotion.
  • +
  • MULTI_VENUE_QUOTE_CHURN: 200 MB/sec with small objects (100-1000 bytes). Tests compaction behavior under short-lived quote churn.
  • +
  • LONG_HORIZON_POSITION_BOOK: 150 MB/sec with 800 MB in old generation holding references to young objects. Tests remembered set overhead.
  • +
  • TRADING_MATCHING: 400 MB/sec from real order matching engine operations. Tests GC under Order/Execution object churn with a live price-time priority order book.
  • +
  • TECHNICAL_ANALYSIS: 300 MB/sec from ta4j indicator computation (SMA, EMA, RSI, MACD, Bollinger Bands, ATR). Tests GC under technical analysis object allocation.
  • +
+ +

Beyond these, five additional CPU-intensive workloads (compression, serialization, crypto, collections, string interning) exercise the GC under realistic compute pressure. See the documentation for the complete list.

+ +

Each scenario uses 4 parallel threads to generate allocation load, mirroring production web servers and trading systems where multiple threads compete for heap space. The allocation rates (100-300 MB/sec) are calibrated to show normal GC behavior rather than pathological thrashing, ensuring consistent results across 2GB-4GB heap configurations.

+ +

Here is the raw JSON returned from our Payara endpoints during a EARNINGS_SPIKE scenario:

+ +
{
+  "running": true,
+  "currentMode": "EARNINGS_SPIKE",
+  "description": "High promotion rate (50% survival) - Tests old gen collection efficiency",
+  "allocationRateMBPerSec": 300,
+  "liveSetSizeMB": 1024,
+  "scenarioType": "PROMOTION",
+  "workloadType": "NONE",
+  "percentiles": {
+    "max": 1,
+    "p50": 0,
+    "p99": 1,
+    "p999": 1
+  }
+}
+ +
+

The Results

+

Under EARNINGS_SPIKE (300 MB/sec with high old generation traffic), the application's garbage collection pauses peaked at 1 millisecond. The 50th percentile (median) was 0ms. In comparable testing, G1 showed significantly higher pause times as mixed collections struggled to keep pace with promotion rates.

+
+ +

Payara Micro: Jakarta EE Runtime

+ +

Payara Micro provides the Jakarta EE runtime for the application. The runtime weighs approximately 100MB and starts in seconds, making it suitable for containerized deployment. It provides the full capabilities of Jakarta EE 11, including Jakarta CDI for dependency injection, WebSocket support for real-time communication, and Jakarta REST for RESTful APIs.

+ +

Built-in Clustering with Hazelcast

+ +

Payara embeds Hazelcast for automatic data replication across cluster nodes. This allows building distributed trading systems without a separate message broker. A cluster-wide counter tracks throughput with no custom consensus code:

+ +
// Payara + Hazelcast: cluster-wide metrics via CP subsystem
+@Inject private HazelcastInstance hazelcastInstance;
+
+public void init() {
+    clusterMessageCounter =
+        hazelcastInstance.getCPSubsystem().getAtomicLong("cluster-message-count");
+}
+ +

Architecture: TradeStreamEE

+ +

TradeStreamEE combines an Aeron/SBE data pipeline, an order matching engine, risk calculations, and GC stress testing into a single Jakarta EE deployment.

+ +

Core Technology: Zero-Copy SBE

+ +

At the core of the system is Aeron IPC with Simple Binary Encoding (SBE), technologies that enable zero-copy message processing. Instead of creating new Java objects for every market data message, the system uses "flyweight" objects that act as views over shared memory buffers. This eliminates object allocation during the critical path of message processing.

+ +
// SBE Flyweight pattern - zero object allocation during processing
+private final TradeDecoder tradeDecoder = new TradeDecoder();
+private final MessageHeaderDecoder headerDecoder = new MessageHeaderDecoder();
+
+@Override
+public void onFragment(DirectBuffer buffer, int offset, int length, Header header) {
+    headerDecoder.wrap(buffer, offset);
+
+    // Move the "view" to the data without allocating a new object
+    tradeDecoder.wrap(buffer, offset + headerDecoder.encodedLength(),
+                     headerDecoder.blockLength(), headerDecoder.version());
+
+    // Extract data directly from the buffer
+    final long timestamp = tradeDecoder.timestamp();
+    final long price = tradeDecoder.price();
+    final long quantity = tradeDecoder.quantity();
+}
+ +

Stress Testing: Scenario-Based Memory Pressure

+ +

To show collector behavior under specific pathological conditions, the MemoryPressureService implements 12 distinct workloads across two categories. Five memory scenarios target known G1 weaknesses, and seven CPU-intensive workloads stress the GC under realistic compute pressure. Each dispatches via CDI from a central workload registry.

+ +

The LONG_HORIZON_POSITION_BOOK scenario creates old generation objects that hold references to newly allocated young objects, triggering G1's write barriers and remembered set maintenance:

+ +
// Cross-generational reference stress test
+private static class RefHolder {
+    volatile Object youngRef;  // Updated frequently to trigger write barriers
+    final byte[] padding;      // 1MB padding to establish old gen presence
+}
+
+private void createCrossGenerationalRefsMultiThreaded(int totalBytes, int numThreads) {
+    RefHolder[] holders = crossRefHolders.toArray(new RefHolder[0]);
+
+    for (int t = 0; t < numThreads; t++) {
+        futures[t] = CompletableFuture.runAsync(() -> {
+            int remaining = bytesPerThread;
+            while (remaining > 0) {
+                byte[] youngObj = new byte[size];
+                // This write triggers G1's post-write barrier
+                holders[idx].youngRef = youngObj;
+                remaining -= size;
+            }
+        }, executorService);
+    }
+}
+ +

G1 must track every old-to-young reference update via card tables and remembered sets. During young collection, G1 scans these remembered sets to find roots, adding overhead proportional to cross-reference volume. C4 has no generational boundaries, eliminating this overhead entirely.

+ +

The Business Impact

+ +

Predictable latency affects the bottom line. A 100ms GC pause at 10,000 trades/second delays 1,000 trades, each executing at a worse price than intended.

+ +

Operational complexity decreases with C4. Traditional Java performance tuning requires effort to optimize JVM parameters (-XX:NewRatio, -XX:SurvivorRatio). C4 adapts to workload patterns without manual parameter tuning.

+ +

In testing, a single Payara Micro instance handled sustained ingestion exceeding 30,000 messages per second with zero GC pauses exceeding 1ms.

+ +

Conclusion

+ +

Azul C4 produces sub-millisecond GC pauses under conditions that cause G1 to exceed 10ms. Payara Micro provides the Jakarta EE runtime without adding latency overhead.

+ +

The data from 12 adversarial workloads shows that the GC choice, not the application code, determines whether Java meets financial SLAs. The same application deployed on G1 and C4 produces measurably different pause profiles.

+ +

Java can handle low-latency financial workloads. The collector matters more than the language.

+ +
+

Resources

+
    +
  • Source Code: TradeStreamEE on GitHub. Full implementation including matching engine, risk engine, and all 12 stress workloads.
  • +
  • Documentation: Help & Documentation covers all workloads, the REST API (40+ endpoints), configuration reference, and deployment guides.
  • +
  • Payara Micro: Download Payara Micro. Jakarta EE 11 container with built-in Hazelcast clustering.
  • +
  • Payara Qube: Payara Qube (formerly Payara Cloud) for managed cloud deployment.
  • +
  • Azul Platform Prime: Request a trial to run C4 on your own hardware and compare results.
  • +
  • Run Your Own Tests: 12 workloads across two categories (5 memory scenarios targeting G1 weaknesses, 7 CPU workloads including real matching engine and ta4j analysis) allow direct comparison between G1 and C4 behavior under controlled conditions.
  • +
+
+
+
+ + + + diff --git a/src/main/webapp/comparison.html b/src/main/webapp/comparison.html new file mode 100644 index 0000000..f850a10 --- /dev/null +++ b/src/main/webapp/comparison.html @@ -0,0 +1,1278 @@ + + + + + + TradeStreamEE - C4 vs G1GC Comparison + + + + +
+
+
+

Comparison

+

C4 vs G1GC - Side-by-Side Performance

+
+ +
+ +
+
+
+
+ C4 Cluster (8080) +
+
+
+ G1 Cluster (9080) +
+
+
+ Connecting to both clusters... +
+
+ +
+
+
Demo Presets
+
+ + + + +
+
+
+ +
+
Manual Scenario Control
+
+ +
+ +
+ + +
+
+
+ +
+
+
+
+ Azul C4 + Concurrent GC +
+
+
+
+
P99 Pause
+
-
+
+
+
SLA Violations
+
-
+
+
+
Msg Rate
+
-
+
+
+
Heap Used
+
-
+
+
+
+ +
+
+ +
+
+
+ G1GC + Stop-the-World +
+
+
+
+
P99 Pause
+
-
+
+
+
SLA Violations
+
-
+
+
+
Msg Rate
+
-
+
+
+
Heap Used
+
-
+
+
+
+ +
+
+
+ +
+
Performance Comparison
+
+
+
P99 Pause Ratio
+
-
+
Waiting for data...
+
+
+
SLA Violation Delta
+
-
+
Waiting for data...
+
+
+
Pause Reduction
+
-
+
Lower is better
+
+
+
+ + +
+ + + + + diff --git a/src/main/webapp/health.html b/src/main/webapp/health.html new file mode 100644 index 0000000..3e3dbc3 --- /dev/null +++ b/src/main/webapp/health.html @@ -0,0 +1,554 @@ + + + + + + TradeStreamEE - Pre-Demo Health Check + + + +
+
+
+

Pre-Demo Health Check

+

Verify all systems are operational before starting your demo

+
+ +
+ +
+
+ Checking system health... +
+ +
+

Local Instance Health

+
+
+ Message Publisher +
+ + Checking... +
+
+
+ GC Monitor +
+ + Checking... +
+
+
+ Memory Usage +
+ + Checking... +
+
+
+ +
+
+
JVM Vendor
+
-
+
+
+
JVM
+
-
+
+
+
GC Collectors
+
-
+
+
+
Java Version
+
-
+
+
+
Available Processors
+
-
+
+
+
Memory Used
+
-
+
+
+
+ +
+

Cluster Connectivity

+
+
+
+ + C4 Cluster +
+
Checking...
+
+
+
+ + G1 Cluster +
+
Checking...
+
+
+
+ +
+ + +
+
+ + + + diff --git a/src/main/webapp/help.html b/src/main/webapp/help.html new file mode 100644 index 0000000..a69c6db --- /dev/null +++ b/src/main/webapp/help.html @@ -0,0 +1,1195 @@ + + + + + + TradeStreamEE - Help & Documentation + + + +
+ +
+ ← Back to Dashboard +

Help & Documentation

+

Reference guide for TradeStreamEE: architecture, terminology, API, configuration, and deployment.

+
+ + + +
+ + +

What is TradeStreamEE?

+ + +

TradeStreamEE is a GC benchmarking platform built on Jakarta EE 11 and Payara Micro 7. It generates realistic allocation patterns by running an actual trading system under load, then measures how different garbage collectors (Azul C4 vs G1) handle the pressure.

+ +
+

The "trading" domain is not window dressing. An order matching engine creates and tears down Order, Execution, and OrderBookEntry objects at HFT rates. A risk engine computes Value-at-Risk with Monte Carlo simulation. A portfolio manager tracks positions and calculates Sharpe ratios. This produces real allocation patterns from real business logic.

+
+ +

Subsystem Overview

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SubsystemPackageWhat It Does
Market Data PipelineaeronPublishes synthetic trades/quotes via Aeron IPC with SBE encoding. Subscribes via fragment handler, decodes, broadcasts via Hazelcast ITopic.
Matching EnginematchingPrice-time priority order book. Matches incoming orders against resting orders. Supports limit orders, stop orders, iceberg orders. Tracks execution history.
Risk EngineriskValue-at-Risk (parametric, historical, Monte Carlo), position limits, stress testing (crash, volatility spike, liquidity crisis). Tracks per-symbol exposure.
Portfolio ManagerportfolioTracks cash balance, positions, NAV. Computes performance metrics (Sharpe ratio, max drawdown, returns). Generates rebalance plans.
Technical AnalysisanalysisAggregates ticks into OHLCV bars via BarAggregator. Computes indicators (SMA, EMA, RSI, MACD, Bollinger Bands, ATR) using ta4j.
GC Stress Enginepressure12 workloads (5 memory scenarios + 7 CPU workloads) that generate adversarial allocation patterns. Dispatched via CDI from MemoryPressureService.
+ + + +

Trading Terminology

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TermDefinition
BidThe highest price a buyer is willing to pay for a security at a given moment. In an order book, bids are sorted in descending order (best bid = highest).
Ask (Offer)The lowest price a seller is willing to accept. Asks are sorted in ascending order (best ask = lowest).
SpreadThe difference between the best ask and best bid. A tight spread indicates high liquidity. A wide spread indicates low liquidity or high volatility.
Order BookA data structure containing all outstanding buy and sell orders for a symbol. The matching engine maintains two sides: bids (buyers) and asks (sellers), each sorted by price-time priority.
Order MatchingThe process of pairing incoming orders with resting orders in the book. When a buy order's price meets or exceeds a sell order's price, they match and generate an Execution.
FillAn execution of an order. A partial fill means only some of the order quantity was matched. A full fill means the entire quantity was matched.
Limit OrderAn order to buy or sell at a specified price or better. A buy limit at $100 executes only at $100 or lower. A sell limit at $100 executes only at $100 or higher.
Market OrderAn order that executes immediately at the best available price in the book. No price guarantee.
Iceberg OrderA large order where only a fraction (the "display quantity") is visible in the book. As the visible portion is filled, more is revealed. Used to avoid market impact.
Stop OrderAn order that activates when the price reaches a specified stop price. Becomes a market order once triggered. Used for stop-loss or breakout strategies.
PositionNet holdings in a symbol. Positive = long (you own it). Negative = short (you sold borrowed shares). The matching engine tracks positions in PositionService.
PnLRealized PnL: profit/loss from closed positions. Unrealized PnL: mark-to-market gain/loss on open positions.
Price-Time PriorityThe matching rule used by this engine. The best price always matches first. Within the same price level, the earliest order (time priority) matches first.
+ +

Technical Analysis Indicators

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IndicatorTypeWhat It Measures
SMATrendSimple Moving Average: arithmetic mean of closing prices over N periods. Smooths short-term noise to reveal trend direction.
EMATrendExponential Moving Average: gives more weight to recent prices. Reacts faster to price changes than SMA.
RSIMomentumRelative Strength Index: bounded oscillator (0-100). Above 70 = overbought. Below 30 = oversold. Measures speed and magnitude of price changes.
MACDTrend / MomentumMoving Average Convergence Divergence: difference between a fast EMA (12) and slow EMA (26). A signal line (9-period EMA of MACD) generates buy/sell signals on crossovers.
Bollinger BandsVolatilityThree lines: middle (SMA), upper (SMA + k * stddev), lower (SMA - k * stddev, default k=2). Bands widen when volatility increases, contract when it decreases.
ATRVolatilityAverage True Range: measures the average range between high and low prices over N periods. Used to set stop-loss distances and assess volatility.
+ +

Risk Metrics

+ + + + + + + + + + + + + + + + + + + + + + +
MetricDefinition
VaR (Value-at-Risk)The maximum expected loss over a time horizon at a confidence level. Example: 95% 1-day VaR of $10K means "on the worst 5% of days, we expect to lose more than $10K." Computed via parametric (variance-covariance), historical simulation, or Monte Carlo.
Sharpe RatioRisk-adjusted return: (portfolio return - risk-free rate) / portfolio standard deviation. Higher = more return per unit of risk. Above 1.0 is good; above 2.0 is excellent.
Max DrawdownThe largest peak-to-trough decline in portfolio value, expressed as a percentage. Measures the worst-case loss an investor would have experienced.
Stress TestingSimulating extreme market scenarios to estimate potential losses. This system implements three: crash (sudden price drop), volatility spike (3x normal volatility), and liquidity crisis (50% wider spreads).
+ + + +

GC Stress Testing

+ + +

The stress testing framework generates adversarial allocation patterns to expose GC weaknesses. 12 workloads in two categories:

+ +

Memory Scenarios 5 workloads

+

Directly manipulate byte arrays to target specific G1 architectural weaknesses. All defined in AllocationMode.java.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioRateLive SetG1 Weakness
STEADY_LOAD200 MB/s512 MBYoung GC pause frequency
INTRADAY_POSITION_GROWTH150 MB/s100 MB → 2 GBMixed collection pause scaling
EARNINGS_SPIKE300 MB/s1 GB (50% survival)Old generation collection
MULTI_VENUE_QUOTE_CHURN200 MB/s1 GBCompaction pauses
LONG_HORIZON_POSITION_BOOK150 MB/s800 MB old genRemembered set overhead
+ +

CPU Workloads 7 workloads

+

CDI beans implementing the Workload interface. Each runs real computation that generates short-lived objects as a side effect.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WorkloadRateWhat It Stresses
COMPRESSION_CPU300 MB/sGzip compress/decompress temp buffers
SERIALIZATION_CPU250 MB/sJakarta JSON object graph materialization
CRYPTO_CPU200 MB/sSHA-256, HMAC-SHA256, AES-GCM encrypt/decrypt
COLLECTION_CPU300 MB/sHashMap/TreeMap insert/lookup/remove churn
STRING_CPU350 MB/sRegex, substring, StringBuilder, String.intern
TRADING_MATCHING400 MB/sOrder/Execution object churn via matching engine
TECHNICAL_ANALYSIS300 MB/sta4j indicator objects (SMA, EMA, RSI, MACD, BB, ATR)
+ +
+

Dispatch mechanism: MemoryPressureService.setAllocationMode(mode) dispatches to either a memory scenario (direct byte array manipulation) or a CPU workload (CDI bean via Instance<Workload>). Adding a new workload requires only: (1) a class implementing Workload, (2) an enum value in WorkloadType, (3) an entry in AllocationMode.

+
+ + + +

REST API Reference

+ + +

All endpoints are prefixed with /trader-stream-ee/api. Responses are JSON.

+ +
+

System & Health

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/statusInstance name, uptime, JVM info, GC type
GET/status/clusterCluster membership (Hazelcast members)
GET/health/checkCombined health (publisher, subscriber, WebSocket)
GET/health/readyReadiness probe (is publisher running?)
GET/health/liveLiveness probe
+
+ +
+

GC & Pressure Control

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/gc/statsGC statistics: pause times, counts, allocation rates
GET/gc/pausesRecent GC pause events (p50/p95/p99/max)
GET/gc/slaSLA violation counts by threshold (10ms, 50ms, 100ms)
GET/gc/comparisonSide-by-side C4 vs G1 stats (fetches from both clusters)
POST/pressure/mode/{mode}Activate a stress workload. {mode} = any AllocationMode enum value (e.g. STEADY_LOAD, TRADING_MATCHING)
GET/pressure/statusCurrent active mode, iteration count, bytes allocated
GET/pressure/modesList all available workload modes
+
+ +
+

Matching & Orders

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/matching/order-book/{symbol}Order book snapshot (bid/ask levels with depth)
GET/matching/positionsAll positions (net quantity, average price, unrealized PnL)
GET/matching/positions/{symbol}Single position detail
GET/matching/executionsExecution history (paginated)
POST/matching/orders/submitSubmit a new order (JSON body: symbol, side, type, price, quantity)
DELETE/matching/orders/{orderId}Cancel an existing order
+
+ +
+

Technical Analysis

+ + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/analysis/indicatorsAll symbols with current indicator values
GET/analysis/indicators/{symbol}Indicators for a symbol (SMA, EMA, RSI, MACD, BB, ATR)
GET/analysis/indicators/{symbol}/historyHistorical indicator snapshots (query param: count)
+
+ +
+

Portfolio

+ + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/portfolio/snapshotFull portfolio snapshot (cash, positions, NAV, performance metrics)
GET/portfolio/metricsPerformance metrics (Sharpe ratio, max drawdown, returns)
POST/portfolio/rebalanceGenerate a rebalance plan (JSON body: target weights map)
POST/portfolio/resetReset portfolio to initial state ($1M cash, no positions)
+
+ +
+

Risk

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/risk/snapshotRisk snapshot for all tracked symbols
GET/risk/{symbol}Risk detail for a symbol (VaR, position, limits)
GET/risk/var/{symbol}Value-at-Risk for a symbol (query param: method = PARAMETRIC, HISTORICAL, MONTE_CARLO)
GET/risk/stressRun all stress scenarios (crash, volatility spike, liquidity crisis)
GET/risk/exposureExposure summary across all positions
POST/risk/limits/{symbol}Set position limit for a symbol (JSON body: limit value)
+
+ +
+

Business Impact

+ + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/business/impactEstimated revenue lost to GC pauses
GET/business/configBusiness impact configuration (per-trade value, window)
POST/business/resetReset business impact counters
+
+ +
+

Demo & JFR

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/demo/presetsAvailable demo presets (YAML choreographies)
POST/demo/preset/{id}/startStart a demo preset sequence
POST/demo/execution/{id}/step/{n}Advance a demo preset to step n
GET/jfr/statusJFR recording status
POST/jfr/recording/startStart a JFR recording
POST/jfr/recording/stopStop the current JFR recording
GET/jfr/download/{filename}Download a JFR recording file
+
+ + + +

WebSocket Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathPurposeMessage Format
/market-dataStreams trades, quotes, and market depth updates in real timeJSON: trade/quote/depth events (1-in-50 sampling at high rates)
/slaPushes GC pause violation alertsJSON: warning (>10ms), critical (>50ms), severe (>100ms)
/executionsBroadcasts order match events via Hazelcast ITopicJSON: execution reports with orderId, price, quantity, side
/indicatorsPushes technical analysis indicator snapshotsJSON: SMA, EMA, RSI, MACD, BB, ATR values per symbol
+ + + +

Configuration

+ + +

All configuration uses MicroProfile Config. Override via environment variables or microprofile-config.properties.

+ +

Ingestion & Publisher

+ + + + + + + + + + + + + + +
PropertyDefaultDescription
TRADER_INGESTION_MODEAERONData ingestion mode: AERON (shared memory IPC) or DIRECT (in-process, no Aeron)
ENABLE_PUBLISHERtrueWhether this instance publishes market data. Set to false for pure distribution nodes.
+ +

Matching Engine

+ + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
matching.price.scale10000Price decimal precision. 10000 means prices are stored as cents (4 decimal places).
matching.max.book.depth50Maximum price levels returned in order book snapshots.
matching.max.history.size10000Maximum execution history entries retained.
matching.default.tifGTCDefault time-in-force for orders (GTC = Good Till Cancelled).
+ +

Technical Analysis

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
analysis.sma.period20SMA lookback period
analysis.ema.period12EMA lookback period
analysis.rsi.period14RSI lookback period
analysis.macd.fast12MACD fast EMA period
analysis.macd.slow26MACD slow EMA period
analysis.bb.period20Bollinger Bands SMA period
analysis.bb.stddev2.0Bollinger Bands standard deviation multiplier
analysis.atr.period14ATR lookback period
analysis.bar.duration.seconds60OHLCV bar aggregation window
analysis.max.bars500Maximum bars retained per symbol
+ +

Risk Engine

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
risk.var.confidence0.95VaR confidence level (95% = 1-in-20 chance of exceeding)
risk.var.lookback.bars252Number of bars for historical VaR calculation (252 = 1 year of daily bars)
risk.position.limit.default10000Default position notional limit per symbol
risk.stress.crash.percent20Crash stress: sudden price drop percentage
risk.stress.vol.spike3.0Volatility spike: multiplier on normal volatility
risk.stress.liquidity.widen50Liquidity crisis: spread widening percentage
+ +

Portfolio

+ + + + + + + + + + + + + + +
PropertyDefaultDescription
portfolio.initial.capital1000000Starting cash balance ($1,000,000)
portfolio.risk.free.rate0.02Risk-free rate for Sharpe ratio calculation (2%)
+ +

Business Impact

+ + + + + + + + + + + + + + + + + + + +
PropertyDefaultDescription
business.impact.per.trade25000Average revenue per trade ($25,000)
business.impact.window.seconds60Sliding window for missed trade calculation
business.impact.currencyUSDCurrency for revenue display
+ + + +

Deployment

+ + +

Docker Compose Variants

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileModeUse Case
docker-compose.ymlAeron IPCProduction mode: shared memory transport between publisher and subscriber. Requires shm_size: 512m.
docker-compose-standard.ymlDirect (no Aeron)Simplified mode: publisher writes directly to subscriber via method call. No shared memory needed.
docker-compose-scale.ymlTwo-cluster comparisonRuns two instances (C4 on :8080, G1 on :9080) for side-by-side GC comparison.
docker-compose-g1.ymlG1GCSingle instance running Eclipse Temurin with G1GC.
docker-compose-c4.ymlC4/GPGCSingle instance running Azul Platform Prime with C4.
+ +

Quick Start

+
# Build and run (Aeron mode, C4 GC)
+docker compose up --build -d
+
+# Build and run (standard mode, no Aeron)
+docker compose -f docker-compose-standard.yml up --build -d
+
+# Two-cluster comparison (C4 vs G1 side-by-side)
+docker compose -f docker-compose-scale.yml up --build -d
+
+# Apply a GC stress scenario
+curl -X POST http://localhost:8080/trader-stream-ee/api/pressure/mode/STEADY_LOAD
+
+# Check GC stats after 30 seconds
+sleep 30
+curl -s http://localhost:8080/trader-stream-ee/api/gc/stats | jq
+
+# Stop the stress generator
+curl -X POST http://localhost:8080/trader-stream-ee/api/pressure/mode/OFF
+ +
+

Aeron IPC requires shared memory. The Docker Compose files set shm_size: 512m. On macOS with Docker Desktop, this is handled automatically. On Linux, ensure /dev/shm is large enough or adjust the value.

+
+ + + +

Technology Stack

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentTechnologyVersionRole
RuntimeAzul Platform Prime21C4/GPGC pauseless GC
ComparisonEclipse Temurin21G1GC baseline
App ServerPayara Micro7.2025.2Jakarta EE 11 container
Jakarta EEJakarta EE11.0.0CDI, WebSocket, JAX-RS, Concurrency
TransportAeron1.50.0Shared memory IPC messaging
EncodingSBE1.34.0Zero-copy binary serialization
ClusteringHazelcast5.5.0Distributed topics + CP subsystem
TA Libraryta4j-core0.22.4Technical analysis indicators
MonitoringPrometheus + GrafanaLatestJMX metrics and dashboards
+ +
+ +
+

TradeStreamEE — A GC Benchmarking Platform. Built with Payara Micro 7 + Jakarta EE 11.

+
+ +
+ + diff --git a/src/main/webapp/images/dashboard.png b/src/main/webapp/images/dashboard.png new file mode 100644 index 0000000..ca0cb70 Binary files /dev/null and b/src/main/webapp/images/dashboard.png differ diff --git a/src/main/webapp/images/payara.png b/src/main/webapp/images/payara.png new file mode 100644 index 0000000..a5554c0 Binary files /dev/null and b/src/main/webapp/images/payara.png differ diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html index 0896ab6..a75ef18 100644 --- a/src/main/webapp/index.html +++ b/src/main/webapp/index.html @@ -1,22 +1,47 @@ + TradeStreamEE - Market Data Dashboard - + - - -
-
-
-

TradeStreamEE

-

High-Frequency Trading Dashboard • Aeron + SBE + Payara Micro + Azul Platform Prime

-
-
-
-
-
- Disconnected - -
-
-
0
Messages
-
0
Msg/sec
-
- -
- -
- -
-
-
- Hiccup Monitor - Waiting... - Click to learn -
-
- -
-
-
-

Hiccup Monitor

-

Tracks inter-arrival time gaps between messages. Spikes indicate "Stop-the-World" GC pauses where the application freezes.

-

With Azul C4's pauseless GC, you should see a flat line even at 100k+ msg/sec. Standard OpenJDK G1GC shows frequent spikes as it struggles to clean up heap.

-
- Measured: Performance.now() delta between WebSocket messages -
-
-
- - -
-
-
- GC Statistics - -- - Click to learn -
-
-
-
Collections
-
- Total: --
- Delta: --/sec -
-
-
-
Collection Time
-
- Total: -- ms
- Avg: -- ms -
-
-
-
Heap Usage
-
- -- / -- MB
- --% utilized -
-
-
-
-
-

GC Statistics

-

Real-time garbage collection metrics. Collection count matters less than pause duration.

-

G1GC: Fewer collections (48), but longer pauses (4ms avg). At 100k msg/sec, a 4ms pause = 400 dropped messages.

-

Azul C4: More collections (500), but sub-millisecond pauses (0.1ms avg). Same load = only 10 dropped messages.

-

C4 does work concurrently while G1GC batches cleanup into painful stops.

-
- Source: /metrics endpoint (Prometheus format)
- Key Metric: Avg pause time, not collection count -
-
-
- -
-
-
- Real-Time Price Monitor (Live Feed) - Click to learn -
-
- -
+ .gc-comparison-panel { + background: white; + padding: 24px 32px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); + margin-bottom: 24px; + } + + .gc-comparison-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 16px; + } + + .gc-comparison-header h2 { + font-size: 1.5em; + color: #1e293b; + font-weight: 700; + } + + .jvm-info { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.95em; + } + + .badge { + padding: 4px 12px; + border-radius: 12px; + font-weight: 600; + font-size: 0.85em; + } + + .badge.azul { + background: #dbeafe; + color: #1e40af; + } + + .badge.standard { + background: #dbeafe; + color: #1e40af; + } + + .metrics-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 24px; + } + + .metric-card { + background: #f8fafc; + padding: 20px; + border-radius: 8px; + border: 1px solid #e2e8f0; + text-align: center; + } + + .metric-card h3 { + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 8px; + } + + .metric-value { + font-size: 2em; + font-weight: 800; + color: #0f172a; + display: block; + } + + .metric-unit { + font-size: 0.8em; + color: #94a3b8; + } + + .status-indicator.excellent { + color: #10b981; + } + + .status-indicator.good { + color: #3b82f6; + } + + .status-indicator.warning { + color: #f59e0b; + } + + .status-indicator.critical { + color: #ef4444; + font-weight: bold; + } + + .stress-scenarios { + margin-bottom: 16px; + background: white; + padding: 16px 32px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .scenario-select { + flex: 1; + max-width: 360px; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 8px 12px; + border-radius: 6px; + font-size: 0.9em; + cursor: pointer; + } + + .apply-scenario-btn { + background: #3b82f6; + color: white; + border: none; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.2s; + } + + .apply-scenario-btn:hover { + background: #2563eb; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .reset-scenario-btn { + background: #6c757d; + color: white; + border: none; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.2s; + } + + .reset-scenario-btn:hover { + background: #5a6268; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + /* Comparison View Styles */ + .comparison-toggle { + background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); + border: 1px solid #7c3aed; + } + + .comparison-toggle:hover { + background: linear-gradient(135deg, #6d28d9 0%, #5b21b6 100%); + } + + .comparison-view { + display: none; + } + + .comparison-view.active { + display: block; + } + + .single-view.hidden { + display: none; + } + + .comparison-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 24px; + } + + .comparison-panel { + background: var(--card-bg); + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px var(--shadow), 0 1px 2px var(--shadow); + border: 2px solid transparent; + transition: all 0.3s; + } + + .comparison-panel.c4 { + border-color: #10b981; + } + + .comparison-panel.g1 { + border-color: #f59e0b; + } + + .comparison-panel h3 { + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .comparison-panel .badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 0.85em; + font-weight: 600; + } + + .comparison-panel.c4 .badge { + background: #d1fae5; + color: #065f46; + } + + .comparison-panel.g1 .badge { + background: #fef3c7; + color: #92400e; + } + + /* SLA Violation Flash Animation */ + @keyframes slaFlash { + 0%, 100% { background-color: transparent; } + 50% { background-color: rgba(239, 68, 68, 0.2); } + } + + .sla-flash { + animation: slaFlash 0.3s ease-in-out 2; + } + + .sla-flash-indicator { + position: fixed; + top: 20px; + right: 20px; + background: #ef4444; + color: white; + padding: 16px 24px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); + z-index: 9999; + display: none; + font-weight: 600; + } + + .sla-flash-indicator.show { + display: block; + animation: slideIn 0.3s ease-out; + } + + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + /* Demo Presets Styles */ + .demo-presets-section { + background: var(--card-bg); + padding: 20px 32px; + border-radius: 8px; + box-shadow: 0 2px 4px var(--shadow), 0 1px 2px var(--shadow); + margin-bottom: 16px; + } + + .demo-presets-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .demo-presets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + } + + .preset-btn { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border: none; + color: white; + padding: 12px 16px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 4px; + } + + .preset-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); + } + + .preset-btn.running { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + } + + .preset-btn .hint { + font-size: 0.75em; + opacity: 0.9; + } + + /* Business Impact Panel */ + .business-impact-panel { + background: var(--card-bg); + color: var(--text-primary); + padding: 24px 32px; + border-radius: 8px; + box-shadow: 0 2px 4px var(--shadow), 0 1px 2px var(--shadow); + margin-bottom: 16px; + } + + .business-impact-panel h3 { + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .impact-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 24px; + } + + .impact-metric { + text-align: center; + } + + .impact-metric .value { + font-size: 2.5em; + font-weight: 800; + color: #10b981; + } + + .impact-metric .value.at-risk { + color: #ef4444; + } + + .impact-metric .label { + font-size: 0.85em; + opacity: 0.8; + margin-top: 8px; + } + + .impact-metric .sublabel { + font-size: 0.75em; + opacity: 0.6; + } + + /* Dual Cluster Business Impact */ + .dual-cluster-impact { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + } + + .impact-cluster-section { + padding: 20px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); + } + + .impact-cluster-section h4 { + margin: 0 0 16px 0; + font-size: 1em; + font-weight: 700; + } + + /* Projector Mode */ + body.projector-mode { + font-size: 18px; + } + + body.projector-mode h1 { + font-size: 3em; + } + + body.projector-mode .subtitle { + font-size: 1.1em; + } + + body.projector-mode .stat-value { + font-size: 2.5em; + } + + body.projector-mode .stat-label { + font-size: 0.9em; + } + + body.projector-mode .pressure-btn { + font-size: 1em; + padding: 14px 20px; + } + + body.projector-mode .nav-link { + font-size: 1em; + padding: 10px 18px; + } + + /* High contrast for projectors */ + body.projector-mode .stat-value.good { + color: #059669; + font-weight: 800; + } + + body.projector-mode .stat-value.danger { + color: #dc2626; + font-weight: 800; + } + + body.projector-mode .stat-value.warning { + color: #d97706; + font-weight: 800; + } + + body.projector-mode #projector-btn { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + } + + + + +
+
+
+

TradeStreamEE

+

High-Frequency Trading Dashboard • Aeron + SBE + Payara Micro

+
+ +
+ +
+
+
+
+
0
+
Local Msg/sec
+
+
+
0
+
Local Total
+
+
+
+
+
0
+
Cluster Total
+
+
+
0
+
Cluster Msg/sec
+
+
+
+
+
0
+
UI Msg/sec
+
+
+
0
+
UI Received
+
+
+
+
+ +
+
+
+ Disconnected +
+ + + +
+ +
+ +
+

Test Scenarios

+
+ +
+ + +
+ + +
+
+

Demo Presets

+ +
+
+ + + + + +
+
+ + +
+

+ 💰 Business Impact + Real-time ROI calculation +

+
+
+
0
+
Missed Trades
+
Due to GC pauses >10ms
+
+
+
$0
+
Revenue at Risk
+
At $25,000 per trade
+
+
+
100%
+
SLA Compliance
+
Target: <10ms pauses
+
+
+
40%
+
Infrastructure Savings
+
3 C4 nodes vs 5 G1 nodes
+
+
+ +
+ + +
+ ⚠️ SLA VIOLATION
+ Pause exceeded 10ms threshold +
+ + +
+
+
+

GC Performance Comparison

+
+ Instance: + Unknown + JVM: + Detecting... + -- +
+
+ +
+
+

+ P99 Percentile +

+ - + ms +
+ (500 samples) +
+
+
+ +
+

Last GC Pause

+ - + ms +
Most recent pause
+
+ +
+

Pause Time Max

+ - + ms +
All-time since startup
+
+ +
+

SLA Violations

+ - + pauses > 10ms +
Total since startup
+
+ +
+

Allocation Rate

+ - + MB/sec +
+
+ +
+
+ P99 Window: + 500 pauses (rolling) +
+ +
+
+ +
+
+
+
+ GC Pause Time (Live) + -- + Click to learn
-
-

Price Monitor

-

Displays the last 20 trade prices received via WebSocket. Updates in real-time as messages stream in.

-

Watch for smooth, continuous updates with Aeron mode. Any stuttering indicates GC pauses blocking the ingestion pipeline.

-
- Chart: Chart.js (Bar)
- Updates: On each Trade message -
+
+ +
+
+
+

GC Pause Time Monitor

+

Real-time visualization of garbage collection pause times. Lower is better.

+

Azul C4 (Pauseless): Flat line near zero. Collections happen concurrently + without stopping application threads.

+

G1GC (Stop-the-World): Visible spikes when GC pauses occur. Higher message rates + = higher pause times.

+

This chart demonstrates why C4 is ideal for latency-sensitive applications - predictable, + consistent performance without pause spikes.

+
+ API: /api/gc/stats
+ Metric: Last pause duration (ms)
+ Update Rate: Every 3 seconds
+
- -
-
-
Recent Trades
-
+
+
+
+ GC Statistics + -- + Click to learn
-
-

Trade Messages

-

Shows the 5 most recent trade executions decoded from SBE binary format. Each trade includes timestamp, symbol, price, and quantity.

-
- SBE Template: Trade (ID: 1)
- Decoding: Zero-copy Flyweight pattern +
+
+
Collections
+
+ Total: --
+ Delta: --/sec +
+
+
+
Collection Time
+
+ Total: -- ms
+ Avg: -- ms +
+
+
+
Heap Usage
+
+ -- / -- MB
+ --% utilized +
+
+
+
+

GC Statistics

+

Real-time garbage collection metrics. Collection count matters less than + pause duration. +

+

G1GC: Fewer collections (48), but longer pauses (4ms avg). At 100k msg/sec, a + 4ms pause = 400 dropped messages.

+

Azul C4: More collections (500), but sub-millisecond pauses (0.1ms avg). Same + load = only 10 dropped messages.

+

C4 does work concurrently while G1GC batches cleanup into painful stops.

+
+ Source: /metrics endpoint (Prometheus format)
+ Key Metric: Avg pause time, not collection count +
+
+
-
-
-
Market Quotes
-
+
+
+
+ GC Scenario Control + OFF + Click to learn
-
-

Quote Messages

-

Displays bid/ask spreads for active symbols. Generated synthetically by the publisher and encoded using SBE.

-
- SBE Template: Quote (ID: 2)
- Fields: Bid Price, Ask Price, Bid Size, Ask Size +
+
+
Allocation Rate
+
+ 0 MB/sec +
+
+
+
Active Scenario
+
+ None +
+
+
+
+
+

GC Stress Scenarios

+

Select a scenario to target specific GC weaknesses:

+

Steady Load: Baseline 200MB/s. Tests young gen collection latency.

+

Growing Heap: Fills live set to 2GB. Tests mixed/major collection behavior as heap fills.

+

Promotion Storm: High survival rate. Floods old generation to force expensive collections.

+

Fragmentation: (Via Top Bar) Creates Swiss-cheese heap to force compaction pauses.

+
+ Goal: Compare G1 vs C4 latency under specific pathological conditions.
+ C4 Advantage: Should remain pauseless in all scenarios.
+
-
-
-
Market Depth
-
+
+
+
+ Hiccup Monitor + Waiting... + Click to learn
-
-

Market Depth

-

Level 2 order book data showing buy/sell orders at different price levels. Demonstrates SBE's ability to encode complex nested structures.

-
- SBE Template: MarketDepth (ID: 3)
- Structure: Repeating groups for each price level -
+
+ +
+
+
+

Hiccup Monitor

+

Tracks inter-arrival time gaps between messages. Spikes indicate "Stop-the-World" GC pauses where + the application freezes.

+

With Azul C4's pauseless GC, you should see a flat line even at 100k+ msg/sec. Standard OpenJDK + G1GC shows frequent spikes as it struggles to clean up heap.

+
+ Measured: Performance.now() delta between WebSocket messages +
+
+
+ +
+
+
+ Real-Time Price Monitor (Live Feed) + Click to learn +
+
+ +
+
+
+

Price Monitor

+

Displays the last 20 trade prices received via WebSocket. Updates in real-time as messages stream + in.

+

Watch for smooth, continuous updates with Aeron mode. Any stuttering indicates GC pauses blocking + the ingestion pipeline.

+
+ Chart: Chart.js (Bar)
+ Updates: On each Trade message +
+
+
+ +
+
+
Recent Trades
+
+
+
+

Trade Messages

+

Shows the 5 most recent trade executions decoded from SBE binary format. Each trade includes + timestamp, symbol, price, and quantity.

+
+ SBE Template: Trade (ID: 1)
+ Decoding: Zero-copy Flyweight pattern +
+
+
+ +
+
+
Market Quotes
+
+
+
+

Quote Messages

+

Displays bid/ask spreads for active symbols. Generated synthetically by the publisher and encoded + using SBE.

+
+ SBE Template: Quote (ID: 2)
+ Fields: Bid Price, Ask Price, Bid Size, Ask Size +
+
+
+ +
+
+
Market Depth
+
+
+
+

Market Depth

+

Level 2 order book data showing buy/sell orders at different price levels. Demonstrates SBE's + ability to encode complex nested structures.

+
+ SBE Template: MarketDepth (ID: 3)
+ Structure: Repeating groups for each price level
+
-
-
-
System Log
-
+
+
+
System Log
+
+
+
+

System Log

+

Connection events and diagnostic messages. Logs WebSocket lifecycle and ingestion mode switches. +

+
+ Purpose: Debugging and monitoring
+ Events: Connect, Disconnect, Mode changes +
+
+
+
+
+ + +
+
+ +
+

+ 🟢 C4 Cluster (Azul Platform Prime) + localhost:8080 +

+
+
+
-
+
P99 Pause (ms)
+
+
+
-
+
Max Pause (ms)
+
+
+
0
+
SLA Violations
+
+
+
-
+
Msg Rate
+
-
-

System Log

-

Connection events and diagnostic messages. Logs WebSocket lifecycle and ingestion mode switches.

-
- Purpose: Debugging and monitoring
- Events: Connect, Disconnect, Mode changes +
+ + +
+

+ 🟡 G1 Cluster (OpenJDK) + localhost:9080 +

+
+
+
-
+
P99 Pause (ms)
+
+
+
-
+
Max Pause (ms)
+
+
+
0
+
SLA Violations
+
+
+
-
+
Msg Rate
+ + +
+
Real-time Delta
+
Waiting for data from both clusters...
+
+
+
+ +