diff --git a/client.http b/client.http new file mode 100644 index 0000000..906ff75 --- /dev/null +++ b/client.http @@ -0,0 +1,33 @@ +# https://www.jetbrains.com/help/idea/http-client-in-product-code-editor.html + + +### Place an order +POST http://localhost:8080/orders +Accept: application/json +Content-Type: application/json + +{ + "asset": "BTC", + "price": 43251.00, + "amount": 2.0, + "direction": "BUY" +} + +#### Get an order +GET http://localhost:8080/orders/1 +Accept: application/json +Content-Type: application/json + +### Cencel an order +POST http://localhost:8080/orders/1/cancel +Accept: application/json +Content-Type: application/json + +{ + "asset": "BTC", + "price": 43251.00, + "amount": 2.0, + "direction": "BUY" +} + +### \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..a16b543 --- /dev/null +++ b/mvnw @@ -0,0 +1,310 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# 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 +# +# https://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..c8d4337 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0105869 --- /dev/null +++ b/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.6.1 + + + io.bux + backend-assignment-starter-matching-engine + 0.0.1-SNAPSHOT + backend-assignment-starter-matching-engine + Simple matching engine + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework + spring-webflux + + + com.google.protobuf + protobuf-java + 3.21.0 + + + com.googlecode.protobuf-java-format + protobuf-java-format + 1.4 + + + org.springframework.boot + spring-boot-starter-actuator + + + io.projectreactor + reactor-core + 3.4.18 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webflux + + + io.projectreactor + reactor-test + 3.4.17 + test + + + org.junit.vintage + junit-vintage-engine + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + --enable-preview + + + + maven-surefire-plugin + + --enable-preview + + + + maven-failsafe-plugin + + --enable-preview + + + + com.github.os72 + protoc-jar-maven-plugin + 3.1.0.1 + + + generate-sources + + run + + + 3.1.0 + + src/main/resources + + + + + + + + + + Stefan Dragisic + + + diff --git a/src/main/java/io/bux/matchingengine/MatchingEngineApplication.java b/src/main/java/io/bux/matchingengine/MatchingEngineApplication.java new file mode 100644 index 0000000..cbf0d98 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/MatchingEngineApplication.java @@ -0,0 +1,28 @@ +package io.bux.matchingengine; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@SpringBootApplication +public class MatchingEngineApplication { + + @Bean + RestTemplate restTemplate(ProtobufHttpMessageConverter hmc) { + return new RestTemplate(List.of(hmc)); + } + + @Bean + ProtobufHttpMessageConverter protobufHttpMessageConverter() { + return new ProtobufHttpMessageConverter(); + } + + public static void main(String[] args) { + SpringApplication.run(MatchingEngineApplication.class, args); + } + +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/Aggregate.java b/src/main/java/io/bux/matchingengine/cqrs/Aggregate.java new file mode 100644 index 0000000..54cf6da --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/Aggregate.java @@ -0,0 +1,41 @@ +package io.bux.matchingengine.cqrs; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Interface to represent aggregate + * + * @author Stefan Dragisic + */ +public interface Aggregate { + + /** + * Aggregate identifier that is used to uniquely represent asset + * + * @return unique aggregate identifier + */ + String aggregateId(); + + /** + * Routes commands to corresponding handler + * + * @param command to handle + * @return event that has been materialized + */ + Mono routeCommand(Command command); + + /** + * Routes event to corresponding handler + * + * @param event to handle + */ + Mono routeEvent(SourcingEvent event); + + /** + * Hot stream that emits all aggregate events + * + * @return infinitive stream of events + */ + Flux aggregateEvents(); +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/AggregateRepository.java b/src/main/java/io/bux/matchingengine/cqrs/AggregateRepository.java new file mode 100644 index 0000000..ca3b3d4 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/AggregateRepository.java @@ -0,0 +1,13 @@ +package io.bux.matchingengine.cqrs; + +import reactor.core.publisher.Mono; + +/** + * Represents repository that stores all aggregates + * + * @author Stefan Dragisic + */ +public interface AggregateRepository { + + Mono load(String aggregateId); +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/Command.java b/src/main/java/io/bux/matchingengine/cqrs/Command.java new file mode 100644 index 0000000..290deaf --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/Command.java @@ -0,0 +1,25 @@ +package io.bux.matchingengine.cqrs; + +import java.util.UUID; + +/** + * Interface to define command + * + * @author Stefan Dragisic + */ +public interface Command { + + /** + * Aggregate identifier that is used to uniquely represent asset + * + * @return unique aggregate identifier + */ + String aggregateId(); + + /** + * Uniquely identifies command + * + * @return unique command identifier + */ + UUID commandId(); +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/Event.java b/src/main/java/io/bux/matchingengine/cqrs/Event.java new file mode 100644 index 0000000..5d26008 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/Event.java @@ -0,0 +1,16 @@ +package io.bux.matchingengine.cqrs; + +/** + * Interface to define event + * @author Stefan Dragisic + */ +public interface Event { + + /** + * Aggregate identifier that is used to uniquely represent asset + * + * @return unique aggregate identifier + */ + String aggregateId(); + +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/QueryRepository.java b/src/main/java/io/bux/matchingengine/cqrs/QueryRepository.java new file mode 100644 index 0000000..9901d70 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/QueryRepository.java @@ -0,0 +1,14 @@ +package io.bux.matchingengine.cqrs; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * @author Stefan Dragisic + */ +public interface QueryRepository { + + Mono updateProjection(Event e); + //Flux getBookOrders(String book); + Mono getOrder(long orderId); +} diff --git a/src/main/java/io/bux/matchingengine/cqrs/SourcingEvent.java b/src/main/java/io/bux/matchingengine/cqrs/SourcingEvent.java new file mode 100644 index 0000000..0008bf6 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/SourcingEvent.java @@ -0,0 +1,8 @@ +package io.bux.matchingengine.cqrs; + +/** + * Interface to define sourcing event - event that changes aggregate and projection state + * + * @author Stefan Dragisic + */ +public interface SourcingEvent extends Event { } diff --git a/src/main/java/io/bux/matchingengine/cqrs/UpdateEvent.java b/src/main/java/io/bux/matchingengine/cqrs/UpdateEvent.java new file mode 100644 index 0000000..21df70a --- /dev/null +++ b/src/main/java/io/bux/matchingengine/cqrs/UpdateEvent.java @@ -0,0 +1,8 @@ +package io.bux.matchingengine.cqrs; + +/** + * Interface to define update event - event that changes only projection state + * + * @author Stefan Dragisic + */ +public interface UpdateEvent extends Event { } diff --git a/src/main/java/io/bux/matchingengine/domain/Book.java b/src/main/java/io/bux/matchingengine/domain/Book.java new file mode 100644 index 0000000..436394c --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/Book.java @@ -0,0 +1,149 @@ +package io.bux.matchingengine.domain; + +import io.bux.matchingengine.cqrs.Aggregate; +import io.bux.matchingengine.cqrs.Command; +import io.bux.matchingengine.cqrs.Event; +import io.bux.matchingengine.cqrs.SourcingEvent; +import io.bux.matchingengine.domain.command.CancelOrderCommand; +import io.bux.matchingengine.domain.command.MakeOrderCommand; +import io.bux.matchingengine.domain.engine.MatchingEngine; +import io.bux.matchingengine.domain.events.CancellationRequestedEvent; +import io.bux.matchingengine.domain.events.OrderAcceptedEvent; +import io.bux.matchingengine.domain.events.OrderRejectedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + + +/** + * Book aggregate to model business domain. + * + * @author Stefan Dragisic + */ +public class Book implements Aggregate { + + private final Logger logger = LoggerFactory.getLogger(Book.class); + + private final String aggregateId; + + private final MatchingEngine matchingEngine; + private static final AtomicLong orderIdGenerator = new AtomicLong(); + Sinks.Many aggregateEventSink = Sinks.many().multicast().onBackpressureBuffer(); + Flux aggregateEventFlux = aggregateEventSink.asFlux() + .doOnNext(n -> logger.debug(n.toString())) + .publish() + .autoConnect(); + + public Book(String aggregateId, MatchingEngine matchingEngine) { + this.aggregateId = aggregateId; + this.matchingEngine = matchingEngine; + } + + public Book(String aggregateId) { + this.aggregateId = aggregateId; + this.matchingEngine = new MatchingEngine(); + } + + @Override + public String aggregateId() { + return aggregateId; + } + + @Override + public Flux aggregateEvents() { + return aggregateEventFlux + .mergeWith(matchingEngine.engineEvents()) + .cast(Event.class); + } + + //---------------------------COMMAND HANDLING--------------------------------- + + @Override + public Mono routeCommand(Command command) { + return switch (command) { + case MakeOrderCommand cmd -> handleMakeOrderCommand(cmd); + case CancelOrderCommand cmd -> handleCancelOrderCommand(cmd); + default -> Mono.error(new RuntimeException( + command.getClass().getSimpleName() + ": event not implemented!")); + }; + } + + public Mono handleMakeOrderCommand(MakeOrderCommand cmd) { + return Mono.defer(() -> { + + if (cmd.amount().compareTo(BigDecimal.ZERO) <= 0 || cmd.price().compareTo(BigDecimal.ZERO) <= 0) { + aggregateEventSink.tryEmitNext(new OrderRejectedEvent(cmd.aggregateId(), + UUID.randomUUID(), + cmd.type(), + cmd.amount(), + cmd.price(), + "Amount/Price needs to be larger then zero!")); + return Mono.error(new IllegalStateException("Amount/Price needs to be larger then zero!")); + } else { + OrderAcceptedEvent orderAcceptedEvent = new OrderAcceptedEvent(cmd.aggregateId(), + UUID.randomUUID(), + orderIdGenerator.incrementAndGet(), + cmd.type(), + cmd.amount(), + cmd.price(), + Instant.now()); + aggregateEventSink.tryEmitNext(orderAcceptedEvent); + return Mono.just(orderAcceptedEvent); + } + }); + } + + public Mono handleCancelOrderCommand(CancelOrderCommand cmd) { + return Mono.defer(() -> { + if (!cmd.cancelAll() && cmd.newAmount().compareTo(BigDecimal.ZERO) <= 0) { + return Mono.error(new IllegalStateException("Cancellation: new amount can't be <= 0!")); + } else { + CancellationRequestedEvent event = new CancellationRequestedEvent(cmd.aggregateId(), + UUID.randomUUID(), + cmd.orderId(), + cmd.cancelAll(), + cmd.newAmount()); + aggregateEventSink.tryEmitNext(event); + return Mono.just(event); + } + }); + } + + + //---------------------------EVENT HANDLING--------------------------------- + + @Override + public Mono routeEvent(SourcingEvent event) { + return switch (event) { + case OrderAcceptedEvent evt -> handleOrderAcceptedEvent(evt); + case CancellationRequestedEvent evt -> handleOrderCancellationRequestedEvent(evt); + default -> Mono.error(new RuntimeException(event.getClass().getSimpleName() + ": event not implemented!")); + }; + } + + private Mono handleOrderAcceptedEvent(OrderAcceptedEvent evt) { + return Mono.fromRunnable(() -> matchingEngine.placeOrder(evt.orderId(), + evt.aggregateId(), + evt.entryTimestamp(), + evt.type(), + evt.price(), + evt.amount())); + } + + private Mono handleOrderCancellationRequestedEvent(CancellationRequestedEvent evt) { + return Mono.fromRunnable(() -> { + if (evt.cancelAll()) { + matchingEngine.cancelAll(evt.orderId(), evt.aggregateId()); + } else { + matchingEngine.cancel(evt.orderId(),evt.aggregateId(), evt.newAmount()); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/io/bux/matchingengine/domain/BookAggregateRepository.java b/src/main/java/io/bux/matchingengine/domain/BookAggregateRepository.java new file mode 100644 index 0000000..5e8c050 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/BookAggregateRepository.java @@ -0,0 +1,43 @@ +package io.bux.matchingengine.domain; + +import io.bux.matchingengine.cqrs.AggregateRepository; +import io.bux.matchingengine.domain.Book; +import io.bux.matchingengine.domain.query.BookQueryRepository; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe implementation of {@link AggregateRepository} used to store Book aggregates + * + * @author Stefan Dragisic + */ +@Component("aggregateRepository") +public class BookAggregateRepository implements AggregateRepository { + + private final ConcurrentHashMap aggregates = new ConcurrentHashMap<>(); + private final BookQueryRepository bookQueryRepository; + + public BookAggregateRepository(BookQueryRepository bookQueryRepository) { + this.bookQueryRepository = bookQueryRepository; + } + + /** + * Loads aggregate from repository. + * For convenience of demo if aggregate is not found it will be automatically created and stored in repository. + * Once aggregate is created, query repository subscribes to its events. + * + * @param aggregateId / asset name to load or create from repository + * @return book aggregate + */ + @Override + public Mono load(String aggregateId) { + return Mono.fromCallable(() -> aggregates.computeIfAbsent(aggregateId, (k) -> { + Book book = new Book(aggregateId); + //subscribe query projection for book events + book.aggregateEvents().flatMap(bookQueryRepository::updateProjection).subscribe(); + return book; + })); + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/bus/CommandBus.java b/src/main/java/io/bux/matchingengine/domain/bus/CommandBus.java new file mode 100644 index 0000000..92519ee --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/bus/CommandBus.java @@ -0,0 +1,125 @@ +package io.bux.matchingengine.domain.bus; + +import io.bux.matchingengine.cqrs.Command; +import io.bux.matchingengine.cqrs.SourcingEvent; +import io.bux.matchingengine.domain.BookAggregateRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.function.Consumer; +import javax.annotation.PreDestroy; + +/** + * Routes commands to corresponding aggregate. + *

+ * Routes command for district assets/aggregates in parallel, but routes commands withing one aggregate sequentially. + *

+ * Locking is done by Reactor {@see tryAcquire} + *

+ * Is fire and forget, canceling subscription will not change execution flow, but subscriber has option to "stay" and + * get signaled once corresponding event has been materialized or if execution has failed. + * + * @author Stefan Dragisic + */ +@Component +public class CommandBus { + + private final Logger logger = LoggerFactory.getLogger(CommandBus.class); + + private final Sinks.Many commandExecutor = Sinks.many() + .unicast() + .onBackpressureBuffer(); + + private final Disposable commandExecutorDisposable; + + /** + * Instantiate command bus by subscribing to hot stream on which commands are published + * + * @param aggregateRepository + */ + public CommandBus(BookAggregateRepository aggregateRepository) { + + commandExecutorDisposable = commandExecutor.asFlux() + .doOnNext(n -> logger.debug("{} being executed....", + n.getCommand().getClass() + .getSimpleName())) + .groupBy(cw -> cw.getCommand().aggregateId()) + .flatMap(aggregateCommands -> aggregateCommands + .concatMap(cmd -> aggregateRepository + .load(cmd.getCommand().aggregateId()) + .flatMap(aggregate -> aggregate.routeCommand(cmd.getCommand()) //potential performance boost - flatMapSequential + .flatMap(event -> aggregate.routeEvent( + event) + .then(cmd.signalMaterialized( + event))) + .doOnError(cmd::signalError) + + + ))) + .subscribe(); + } + + /** + * Sends a command that is then routed to command handler at corresponding aggregate. Routes command for district + * assets/aggregates in parallel, but routes commands withing one aggregate sequentially. + *

+ * Is fire and forget - canceling execution does not change execution flow. + * + * @param command to send + * @return sourcing event once it has been materialized + */ + public Mono sendCommand(Command command) { + return Mono.defer(() -> { + Sinks.One actionResult = Sinks.one(); + return Mono.fromRunnable(() -> commandExecutor.emitNext(new CommandWrapper(command, + actionResult::tryEmitValue, + actionResult::tryEmitError), + (signalType, emitResult) -> emitResult + .equals(Sinks.EmitResult.FAIL_NON_SERIALIZED))) + .subscribeOn(Schedulers.parallel()) + .then(actionResult.asMono()); + }); + } + + /** + * Shutdown command bus on bean destruction + */ + @PreDestroy + public void destroy() { + commandExecutor.tryEmitComplete(); + commandExecutorDisposable.dispose(); + } + + private static class CommandWrapper { + + private final Command command; + private final Consumer signalDone; + private final Consumer signalError; + + public CommandWrapper(Command command, + Consumer action, + Consumer signalError) { + this.command = command; + this.signalDone = action; + this.signalError = signalError; + } + + public Command getCommand() { + return command; + } + + public Mono signalMaterialized(SourcingEvent event) { + return Mono.fromRunnable(() -> signalDone.accept(event)); + } + + public void signalError(Throwable t) { + signalError.accept(t); + } + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/command/CancelOrderCommand.java b/src/main/java/io/bux/matchingengine/domain/command/CancelOrderCommand.java new file mode 100644 index 0000000..4617002 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/command/CancelOrderCommand.java @@ -0,0 +1,28 @@ +package io.bux.matchingengine.domain.command; + +import io.bux.matchingengine.cqrs.Command; +import org.springframework.lang.NonNull; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Command to request order cancellation + * @author Stefan Dragisic + */ +public record CancelOrderCommand(String aggregateId, UUID commandId, long orderId, Boolean cancelAll, BigDecimal newAmount) + implements Command { + + public CancelOrderCommand( + @NonNull String aggregateId, + @NonNull UUID commandId, + @NonNull long orderId, + @NonNull Boolean cancelAll, + @NonNull BigDecimal newAmount) { + this.aggregateId = aggregateId; + this.commandId = commandId; + this.orderId = orderId; + this.cancelAll = cancelAll; + this.newAmount = newAmount; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/command/MakeOrderCommand.java b/src/main/java/io/bux/matchingengine/domain/command/MakeOrderCommand.java new file mode 100644 index 0000000..1c8ccad --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/command/MakeOrderCommand.java @@ -0,0 +1,31 @@ +package io.bux.matchingengine.domain.command; + +import io.bux.matchingengine.cqrs.Command; +import io.bux.matchingengine.domain.query.OrderType; +import org.springframework.lang.NonNull; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Command to place new order + * + * @author Stefan Dragisic + */ +public record MakeOrderCommand(String aggregateId, UUID commandId, + OrderType type, BigDecimal amount, BigDecimal price) + implements Command { + + public MakeOrderCommand( + @NonNull String aggregateId, + @NonNull UUID commandId, + @NonNull OrderType type, + @NonNull BigDecimal amount, + @NonNull BigDecimal price) { + this.aggregateId = aggregateId; + this.commandId = commandId; + this.type = type; + this.amount = amount; + this.price = price; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/engine/MatchingEngine.java b/src/main/java/io/bux/matchingengine/domain/engine/MatchingEngine.java new file mode 100644 index 0000000..c9c1710 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/engine/MatchingEngine.java @@ -0,0 +1,274 @@ +package io.bux.matchingengine.domain.engine; + +import io.bux.matchingengine.cqrs.UpdateEvent; +import io.bux.matchingengine.domain.query.OrderType; +import io.bux.matchingengine.domain.engine.events.OrderCanceledEvent; +import io.bux.matchingengine.domain.engine.events.OrderMatchedEvent; +import io.bux.matchingengine.domain.engine.events.OrderPlacedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Single threaded matching engine - any thread synchronization should be done external. + *

+ * Uses Max-Heap and Min-Heap {@link TreeSet} + *

+ * Time complexity for critical operations are as. + * - Add – O(log N) + * - Cancel – O(1) + *

+ * Asynchronous - no immediate return values, all events are publishes to local event bus {@link + * MatchingEngine#engineEventSink} + * + * @author Stefan Dragisic + */ +public class MatchingEngine { + + private final Logger logger = LoggerFactory.getLogger(MatchingEngine.class); + private final TreeSet bids; + private final TreeSet asks; + private final Map orders; + private final AtomicLong term; + Sinks.Many engineEventSink = Sinks.many().multicast().onBackpressureBuffer(); + Flux engineEventFlux = engineEventSink.asFlux() + .doOnNext(n -> logger.info(n.toString())) + .publish() + .autoConnect(); + + public MatchingEngine() { + this.bids = new TreeSet<>(MatchingEngine::compareBids); + this.asks = new TreeSet<>(MatchingEngine::compareAsks); + + this.orders = new HashMap<>(); + this.term = new AtomicLong(0); + } + + private static int compareBids(Order a, Order b) { + int result = b.getPrice().compareTo(a.getPrice()); + if (result != 0) { + return result; + } + + return Long.compare(a.getTerm(), b.getTerm()); + } + + private static int compareAsks(Order a, Order b) { + int result = a.getPrice().compareTo(b.getPrice()); + if (result != 0) { + return result; + } + + return Long.compare(a.getTerm(), b.getTerm()); + } + + /** + * All engine execution events are published to this bus + * + * @return local engine event bus - hot stream + */ + public Flux engineEvents() { + return engineEventFlux; + } + + /** + * Places order into matching engine + * + * @param orderId - order identifier + * @param aggregateId - asset name / aggregate identifier + * @param entryTimestamp - time when the system registered order + * @param type - direction - can be either "BUY" or "SELL" + * @param price - a price for limit order + * @param amount - amount of asset to fill by order + */ + public void placeOrder(long orderId, String aggregateId, Instant entryTimestamp, OrderType type, BigDecimal price, + BigDecimal amount) { + if (orders.containsKey(orderId)) { + return; + } + if (type == OrderType.BUY) { + buy(orderId, aggregateId, entryTimestamp, price, amount); + } else { + sell(orderId, aggregateId, entryTimestamp, price, amount); + } + } + + private void buy(long incomingId, String aggregateId, Instant entryTimestamp, BigDecimal incomingPrice, + BigDecimal incomingAmount) { + while (!asks.isEmpty()) { + Order resting = asks.first(); + + BigDecimal restingPrice = resting.getPrice(); + if (restingPrice.compareTo(incomingPrice) > 0) { + break; + } + + long restingId = resting.getId(); + + BigDecimal restingAmount = resting.getRemainingAmount(); + + if (restingAmount.compareTo(incomingAmount) > 0) { + resting.reduce(incomingAmount); + + engineEventSink.tryEmitNext(new OrderMatchedEvent(restingId, + aggregateId, + entryTimestamp, + incomingId, + OrderType.BUY, + incomingPrice, + restingPrice, + incomingAmount, + restingAmount, + resting.getRemainingAmount())); + + return; + } + + asks.remove(resting); + orders.remove(restingId); + + engineEventSink.tryEmitNext(new OrderMatchedEvent(restingId, + aggregateId, + entryTimestamp, + incomingId, + OrderType.BUY, + incomingPrice, + restingPrice, + incomingAmount, + restingAmount, + BigDecimal.ZERO)); + + + incomingAmount = incomingAmount.subtract(restingAmount); + + if (incomingAmount.compareTo(BigDecimal.ZERO) == 0) { + return; + } + } + + add(incomingId, aggregateId, entryTimestamp, OrderType.BUY, incomingPrice, incomingAmount, bids); + } + + private void sell(long incomingId, String aggregateId, Instant entryTimestamp, BigDecimal incomingPrice, + BigDecimal incomingAmount) { + while (!bids.isEmpty()) { + Order resting = bids.first(); + + BigDecimal restingPrice = resting.getPrice(); + if (restingPrice.compareTo(incomingPrice) < 0) { + break; + } + + long restingId = resting.getId(); + + BigDecimal restingAmount = resting.getRemainingAmount(); + if (restingAmount.compareTo(incomingAmount) > 0) { + resting.reduce(incomingAmount); + + engineEventSink.tryEmitNext(new OrderMatchedEvent(restingId, + aggregateId, + entryTimestamp, + incomingId, + OrderType.SELL, + incomingPrice, + restingPrice, + incomingAmount, + restingAmount, + resting.getRemainingAmount())); + + return; + } + + bids.remove(resting); + orders.remove(restingId); + + engineEventSink.tryEmitNext(new OrderMatchedEvent(restingId, + aggregateId, + entryTimestamp, + incomingId, + OrderType.SELL, + incomingPrice, + restingPrice, + incomingAmount, + restingAmount, + BigDecimal.ZERO)); + + incomingAmount = incomingAmount.subtract(restingAmount); + if (incomingAmount.compareTo(BigDecimal.ZERO) == 0) { + return; + } + } + + add(incomingId, aggregateId, entryTimestamp, OrderType.SELL, incomingPrice, incomingAmount, asks); + } + + private void add(long orderId, String aggregateId, Instant entryTimestamp, OrderType type, BigDecimal price, + BigDecimal amount, TreeSet queue) { + Order order = new Order(orderId, type, price, amount, term.incrementAndGet()); + + queue.add(order); + orders.put(orderId, order); + + engineEventSink.tryEmitNext(new OrderPlacedEvent(orderId, + aggregateId, + entryTimestamp, + type, + price, + amount)); + } + + /** + * Cancels full amount of order. + * + * @param orderId - order identifier + * @param aggregateId - asset name / aggregate identifier + */ + public void cancelAll(long orderId, String aggregateId) { + cancel(orderId, aggregateId, BigDecimal.ZERO); + } + + /** + * Partially cancels order and sets new amount to be filled + * + * @param orderId - order identifier + * @param aggregateId - asset name / aggregate identifier + * @param newAmount - new amount to replace previous amount + */ + public void cancel(long orderId, String aggregateId, BigDecimal newAmount) { + Order order = orders.get(orderId); + if (order == null) { + return; + } + + BigDecimal remainingAmount = order.getRemainingAmount(); + + if (newAmount.compareTo(remainingAmount) >= 0) { + return; + } + + if (newAmount.compareTo(BigDecimal.ZERO) > 0) { + order.resize(newAmount); + } else { + TreeSet queue = order.type() == OrderType.BUY ? bids : asks; + + queue.remove(order); + orders.remove(orderId); + } + + engineEventSink.tryEmitNext(new OrderCanceledEvent(orderId, + aggregateId, + order.type(), + remainingAmount.subtract(newAmount), + newAmount)); + } + + ; +} diff --git a/src/main/java/io/bux/matchingengine/domain/engine/Order.java b/src/main/java/io/bux/matchingengine/domain/engine/Order.java new file mode 100644 index 0000000..815e38d --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/engine/Order.java @@ -0,0 +1,56 @@ +package io.bux.matchingengine.domain.engine; + +import io.bux.matchingengine.domain.query.OrderType; + +import java.math.BigDecimal; + +/** + * Representation of order used by {@link MatchingEngine} + * + * @author Stefan Dragisic + */ +public class Order { + private final OrderType type; + private final BigDecimal price; + private final long term; + private final long id; + + private BigDecimal remainingAmount; + + public Order(long id, OrderType type, BigDecimal price, BigDecimal amount, long term) { + this.id = id; + this.type = type; + this.price = price; + this.term = term; + + this.remainingAmount = amount; + } + + public BigDecimal getPrice() { + return price; + } + + public OrderType type() { + return type; + } + + public BigDecimal getRemainingAmount() { + return remainingAmount; + } + + public long getTerm() { + return term; + } + + public void reduce(BigDecimal amount) { + remainingAmount = remainingAmount.subtract(amount); + } + + public long getId() { + return id; + } + + public void resize(BigDecimal newAmount) { + remainingAmount = newAmount; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/engine/events/OrderCanceledEvent.java b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderCanceledEvent.java new file mode 100644 index 0000000..14da837 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderCanceledEvent.java @@ -0,0 +1,20 @@ +package io.bux.matchingengine.domain.engine.events; + +import io.bux.matchingengine.cqrs.UpdateEvent; +import io.bux.matchingengine.domain.query.OrderType; + +import java.math.BigDecimal; + +/** + * Update event that signals that order has been canceled. + * + * @author Stefan Dragisic + */ +public record OrderCanceledEvent( + long orderId, + String aggregateId, + OrderType orderType, + BigDecimal canceledAmount, + BigDecimal remainingAmount) implements UpdateEvent { + +} \ No newline at end of file diff --git a/src/main/java/io/bux/matchingengine/domain/engine/events/OrderMatchedEvent.java b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderMatchedEvent.java new file mode 100644 index 0000000..073a212 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderMatchedEvent.java @@ -0,0 +1,26 @@ +package io.bux.matchingengine.domain.engine.events; + +import io.bux.matchingengine.cqrs.UpdateEvent; +import io.bux.matchingengine.domain.query.OrderType; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Update event that signals that order has been matched. + * + * @author Stefan Dragisic + */ +public record OrderMatchedEvent( + long restingId, + String aggregateId, + Instant entryTimestamp, + long incomingId, + OrderType orderType, + BigDecimal incomingPrice, + BigDecimal restingPrice, + BigDecimal incomingAmount, + BigDecimal previousRestingAmount, + BigDecimal restingRemainingAmount) implements UpdateEvent { + +} diff --git a/src/main/java/io/bux/matchingengine/domain/engine/events/OrderPlacedEvent.java b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderPlacedEvent.java new file mode 100644 index 0000000..6bfd51b --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/engine/events/OrderPlacedEvent.java @@ -0,0 +1,22 @@ +package io.bux.matchingengine.domain.engine.events; + +import io.bux.matchingengine.cqrs.UpdateEvent; +import io.bux.matchingengine.domain.query.OrderType; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * Update event that signals that order has been placed but not yet matched. + * + * @author Stefan Dragisic + */ +public record OrderPlacedEvent( + long orderId, + String aggregateId, + Instant timestamp, + OrderType orderType, + BigDecimal price, + BigDecimal amount) implements UpdateEvent { + +} diff --git a/src/main/java/io/bux/matchingengine/domain/events/CancellationRequestedEvent.java b/src/main/java/io/bux/matchingengine/domain/events/CancellationRequestedEvent.java new file mode 100644 index 0000000..8ed396a --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/events/CancellationRequestedEvent.java @@ -0,0 +1,32 @@ +package io.bux.matchingengine.domain.events; + +import io.bux.matchingengine.cqrs.SourcingEvent; +import org.springframework.lang.NonNull; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Event that user requested cancellation of order + * Not used - POC + * + * @author Stefan Dragisic + */ +public record CancellationRequestedEvent(String aggregateId, UUID eventId, + long orderId, + Boolean cancelAll, BigDecimal newAmount) + implements SourcingEvent { + + public CancellationRequestedEvent( + @NonNull String aggregateId, + @NonNull UUID eventId, + @NonNull long orderId, + @NonNull Boolean cancelAll, + @NonNull BigDecimal newAmount) { + this.aggregateId = aggregateId; + this.eventId = eventId; + this.orderId = orderId; + this.cancelAll = cancelAll; + this.newAmount = newAmount; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/events/OrderAcceptedEvent.java b/src/main/java/io/bux/matchingengine/domain/events/OrderAcceptedEvent.java new file mode 100644 index 0000000..67a23af --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/events/OrderAcceptedEvent.java @@ -0,0 +1,36 @@ +package io.bux.matchingengine.domain.events; + +import io.bux.matchingengine.cqrs.SourcingEvent; +import io.bux.matchingengine.domain.query.OrderType; +import org.springframework.lang.NonNull; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * Event that marks that order has passed validation phase, and order id is generated to be used for tracker. + * + * @author Stefan Dragisic + */ +public record OrderAcceptedEvent(String aggregateId, UUID eventId, long orderId, + OrderType type, BigDecimal amount, BigDecimal price, Instant entryTimestamp) + implements SourcingEvent { + + public OrderAcceptedEvent( + @NonNull String aggregateId, + @NonNull UUID eventId, + @NonNull long orderId, + @NonNull OrderType type, + @NonNull BigDecimal amount, + @NonNull BigDecimal price, + @NonNull Instant entryTimestamp) { + this.aggregateId = aggregateId; + this.orderId = orderId; + this.eventId = eventId; + this.type = type; + this.amount = amount; + this.price = price; + this.entryTimestamp = entryTimestamp; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/events/OrderRejectedEvent.java b/src/main/java/io/bux/matchingengine/domain/events/OrderRejectedEvent.java new file mode 100644 index 0000000..13a6370 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/events/OrderRejectedEvent.java @@ -0,0 +1,34 @@ +package io.bux.matchingengine.domain.events; + +import io.bux.matchingengine.cqrs.SourcingEvent; +import io.bux.matchingengine.domain.query.OrderType; +import org.springframework.lang.NonNull; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Event that marks that order didn't pass validation. + * Not used - POC + * + * @author Stefan Dragisic + */ +public record OrderRejectedEvent(String aggregateId, UUID eventId, + OrderType type, BigDecimal amount, BigDecimal price, String cause) + implements SourcingEvent { + + public OrderRejectedEvent( + @NonNull String aggregateId, + @NonNull UUID eventId, + @NonNull OrderType type, + @NonNull BigDecimal amount, + @NonNull BigDecimal price, + @NonNull String cause) { + this.aggregateId = aggregateId; + this.eventId = eventId; + this.type = type; + this.amount = amount; + this.price = price; + this.cause = cause; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/query/BookQueryRepository.java b/src/main/java/io/bux/matchingengine/domain/query/BookQueryRepository.java new file mode 100644 index 0000000..c7720a6 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/query/BookQueryRepository.java @@ -0,0 +1,100 @@ +package io.bux.matchingengine.domain.query; + +import io.bux.matchingengine.cqrs.Event; +import io.bux.matchingengine.cqrs.QueryRepository; +import io.bux.matchingengine.domain.engine.events.OrderCanceledEvent; +import io.bux.matchingengine.domain.engine.events.OrderMatchedEvent; +import io.bux.matchingengine.domain.engine.events.OrderPlacedEvent; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Thread-safe implementation of {@link QueryRepository} used to store order & book projections. + * + * @author Stefan Dragisic + */ +@Component +public class BookQueryRepository implements QueryRepository { + + private final ConcurrentHashMap projection = new ConcurrentHashMap<>(); + + /** + * Returns current order projection + * + * @param orderId - order identifier + * @return - materialized projection + */ + @Override + public Mono getOrder(long orderId) { + return Mono.fromCallable(() -> projection.get(orderId)); + } + + /** + * Updates projection with event. Repository uses this event to create/maintain projections. + * + * @param event - materialized event + */ + @Override + public Mono updateProjection(Event event) { + return (switch (event) { + case OrderPlacedEvent evt -> handleOrderPlacedEvent(evt); + case OrderMatchedEvent evt -> handleOrderMatchedEvent(evt); + case OrderCanceledEvent evt -> handleOrderCanceledEvent(evt); + default -> Mono.empty(); + }).subscribeOn(Schedulers.parallel()) + .then(); + } + + private Mono handleOrderPlacedEvent(OrderPlacedEvent evt) { + return Mono.fromCallable(() -> projection.computeIfAbsent(evt.orderId(), orderId -> + new OrderEntry(orderId, + evt.timestamp(), + evt.aggregateId(), + evt.price(), + evt.amount(), + evt.orderType(), + new CopyOnWriteArrayList<>(), + evt.amount()))); + } + + private Mono handleOrderMatchedEvent(OrderMatchedEvent evt) { + return Mono.fromCallable(() -> { + //update previous + projection.computeIfPresent(evt.restingId(), (key, order) -> { + order.setPendingAmount(evt.restingRemainingAmount()); + order.trades().add( + new OrderTradeEntry(evt.incomingId(), + evt.incomingAmount(), + evt.restingPrice())); + return order; + }); + //enter new + return projection.computeIfAbsent(evt.incomingId(), incomingId -> + new OrderEntry(incomingId, + evt.entryTimestamp(), + evt.aggregateId(), + evt.incomingPrice(), + evt.incomingAmount(), + evt.orderType(), + new CopyOnWriteArrayList<>(List.of(new OrderTradeEntry( + evt.restingId(), + evt.previousRestingAmount().subtract(evt.restingRemainingAmount()), + evt.restingPrice() + ))), + evt.incomingAmount() + .subtract(evt.previousRestingAmount().subtract(evt.restingRemainingAmount())))); + }); + } + + private Mono handleOrderCanceledEvent(OrderCanceledEvent evt) { + return Mono.fromCallable(() -> projection.computeIfPresent(evt.orderId(), (key, order) -> { + order.setPendingAmount(evt.remainingAmount()); + return order; + })); + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/query/OrderEntry.java b/src/main/java/io/bux/matchingengine/domain/query/OrderEntry.java new file mode 100644 index 0000000..adc4870 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/query/OrderEntry.java @@ -0,0 +1,78 @@ +package io.bux.matchingengine.domain.query; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +/** + * @author Stefan Dragisic + */ +public class OrderEntry { + + private final long orderId; + private final Instant entryTimestamp; + private final String asset; + private final BigDecimal price; + private final BigDecimal amount; + private final OrderType direction; + private final List trades; + private BigDecimal pendingAmount; + + + public OrderEntry( + long orderId, + Instant entryTimestamp, + String asset, + BigDecimal price, + BigDecimal amount, + OrderType direction, + List trades, + BigDecimal pendingAmount) { + this.orderId = orderId; + this.entryTimestamp = entryTimestamp; + this.asset = asset; + this.price = price; + this.amount = amount; + this.direction = direction; + this.trades = trades; + this.pendingAmount = pendingAmount; + } + + public void setPendingAmount(BigDecimal pendingAmount) { + this.pendingAmount = pendingAmount; + } + + public long orderId() { + return orderId; + } + + public Instant entryTimestamp() { + return entryTimestamp; + } + + public String asset() { + return asset; + } + + public BigDecimal price() { + return price; + } + + public BigDecimal amount() { + return amount; + } + + public OrderType direction() { + return direction; + } + + public List trades() { + return trades; + } + + public BigDecimal pendingAmount() { + return pendingAmount; + } + +} + diff --git a/src/main/java/io/bux/matchingengine/domain/query/OrderTradeEntry.java b/src/main/java/io/bux/matchingengine/domain/query/OrderTradeEntry.java new file mode 100644 index 0000000..d8ff48b --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/query/OrderTradeEntry.java @@ -0,0 +1,19 @@ +package io.bux.matchingengine.domain.query; + +import java.math.BigDecimal; + +public record OrderTradeEntry( + long orderId, + BigDecimal amount, + BigDecimal price +) { + + public OrderTradeEntry( + long orderId, + BigDecimal amount, + BigDecimal price) { + this.orderId = orderId; + this.price = price; + this.amount = amount; + } +} diff --git a/src/main/java/io/bux/matchingengine/domain/query/OrderType.java b/src/main/java/io/bux/matchingengine/domain/query/OrderType.java new file mode 100644 index 0000000..86b1c20 --- /dev/null +++ b/src/main/java/io/bux/matchingengine/domain/query/OrderType.java @@ -0,0 +1,9 @@ +package io.bux.matchingengine.domain.query; + +/** + * @author Stefan Dragisic + */ +public enum OrderType { + BUY, + SELL +} diff --git a/src/main/java/io/bux/matchingengine/web/TradingController.java b/src/main/java/io/bux/matchingengine/web/TradingController.java new file mode 100644 index 0000000..bf0f48f --- /dev/null +++ b/src/main/java/io/bux/matchingengine/web/TradingController.java @@ -0,0 +1,161 @@ +package io.bux.matchingengine.web; + +import io.bux.matchingengine.api.protobuf.OrderStatusResponse; +import io.bux.matchingengine.api.protobuf.PlaceOrderRequest; +import io.bux.matchingengine.api.protobuf.Trade; +import io.bux.matchingengine.cqrs.Event; +import io.bux.matchingengine.cqrs.SourcingEvent; +import io.bux.matchingengine.domain.Book; +import io.bux.matchingengine.domain.BookAggregateRepository; +import io.bux.matchingengine.domain.bus.CommandBus; +import io.bux.matchingengine.domain.command.CancelOrderCommand; +import io.bux.matchingengine.domain.command.MakeOrderCommand; +import io.bux.matchingengine.domain.events.OrderAcceptedEvent; +import io.bux.matchingengine.domain.query.BookQueryRepository; +import io.bux.matchingengine.domain.query.OrderEntry; +import io.bux.matchingengine.domain.query.OrderType; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Implements REST Endpoints to place, get or cancel order. + * + * @author Stefan Dragisic + * @author bux + */ +@RestController +public class TradingController { + + private final CommandBus commandBus; + private final BookAggregateRepository bookAggregateRepository; + private final BookQueryRepository bookQueryRepository; + + public TradingController(CommandBus commandBus, + BookAggregateRepository bookAggregateRepository, + BookQueryRepository bookQueryRepository) { + this.commandBus = commandBus; + this.bookAggregateRepository = bookAggregateRepository; + this.bookQueryRepository = bookQueryRepository; + } + + /** + * Places order into trading system + * + * @param request user request to place order + * @return order status + */ + @PostMapping("/orders") + public Mono placeOrder(@RequestBody PlaceOrderRequest request) { + return commandBus.sendCommand(toMakeOrderCommand(request)) + .cast(OrderAcceptedEvent.class) + .flatMap(this::getOrderProjection) + .map(this::toOrderStatus); + } + + /** + * Not used - POC + * Intended to UI or client applications to mantain their own projection + * + * @param asset + * @return streams all events from aggregate + */ + @GetMapping(value = "/book/{asset}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux bookEvents(@PathVariable String asset) { + return bookAggregateRepository.load(asset) + .flatMapMany(Book::aggregateEvents); + } + + /** + * Retrieves order from projection + * + * @param orderId - order identifier + * @return order status + */ + @GetMapping("/orders/{orderId}") + public Mono getOrder(@PathVariable Long orderId) { + return bookQueryRepository.getOrder(orderId) + .map(this::toOrderStatus); + } + + /** + * POC + * Cancels pending order from book. + * @param orderId - order identifier + * @return response OK or error with error message + */ + @PostMapping("/orders/{orderId}/cancel") + public Mono> cancelOrder(@PathVariable Long orderId) { + return bookQueryRepository.getOrder(orderId) + .flatMap(TradingController::validateOrderAmount) + .flatMap(this::sendCancelCommand) + .switchIfEmpty(Mono.error(new IllegalStateException( + "You can't cancel non-existing order."))) + .map(order -> ResponseEntity.accepted().body("OK")) + .onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(e.getMessage()))); + } + + private Mono sendCancelCommand(OrderEntry order) { + return commandBus.sendCommand(new CancelOrderCommand(order.asset(), + UUID.randomUUID(), + order.orderId(), + true, + BigDecimal.ZERO)); + } + + private Mono getOrderProjection(OrderAcceptedEvent ev) { + return bookQueryRepository.getOrder(ev.orderId()) + .repeatWhenEmpty(5, o -> o.delayElements( + Duration.ofMillis(50))); + } + + private MakeOrderCommand toMakeOrderCommand(PlaceOrderRequest request) { + return new MakeOrderCommand(request.getAsset(), + UUID.randomUUID(), + OrderType.valueOf(request.getDirection().name()), + BigDecimal.valueOf(request.getAmount()), + BigDecimal.valueOf(request.getPrice())); + } + + private OrderStatusResponse toOrderStatus(OrderEntry order) { + return OrderStatusResponse.newBuilder() + .setId(order.orderId()) + .setTimestamp(order.entryTimestamp().toString()) + .setAsset(order.asset()) + .setAmount(order.amount().doubleValue()) + .setPrice(order.price().doubleValue()) + .setDirection(io.bux.matchingengine.api.protobuf.OrderType.valueOf( + order.direction().name())) + .addAllTrades(order.trades().stream() + .map(t -> Trade.newBuilder() + .setOrderId(t.orderId()) + .setPrice(t.price() + .doubleValue()) + .setAmount(t.amount() + .doubleValue()) + .build()) + .collect(Collectors.toList())) + .setPendingAmount(order.pendingAmount().doubleValue()) + .build(); + } + + private static Mono validateOrderAmount(OrderEntry order) { + if (order.price().compareTo(BigDecimal.ZERO) > 0) { + return Mono.just(order); + } else { + return Mono.error(new IllegalStateException("Order already executed.")); + } + } + +} diff --git a/src/main/resources/api.proto b/src/main/resources/api.proto new file mode 100644 index 0000000..0c69d1b --- /dev/null +++ b/src/main/resources/api.proto @@ -0,0 +1,45 @@ +syntax = "proto3"; + +package io.bux.matchingengine.api; + +option java_package = "io.bux.matchingengine.api.protobuf"; +option java_multiple_files = true; + +enum OrderType { + BUY = 0; + SELL = 1; +} + +/** +DTO to carry order request + */ +message PlaceOrderRequest { + string asset = 1; + double price = 2; + double amount = 3; + OrderType direction = 4; +} + +/** +DTO to carry order status response + */ +message OrderStatusResponse { + int64 id = 1; + string timestamp = 2; + string asset = 3; + double price = 4; + double amount = 5; + OrderType direction = 6; + repeated Trade trades = 7; + double pendingAmount = 8; +} + +/** +DTO to carry trade response + */ +message Trade { + int64 orderId = 1; + double amount = 2; + double price = 3; +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/src/test/java/io/bux/matchingengine/cqrs/CommandBusTest.java b/src/test/java/io/bux/matchingengine/cqrs/CommandBusTest.java new file mode 100644 index 0000000..3ee59ff --- /dev/null +++ b/src/test/java/io/bux/matchingengine/cqrs/CommandBusTest.java @@ -0,0 +1,162 @@ +package io.bux.matchingengine.cqrs; + +import io.bux.matchingengine.domain.Book; +import io.bux.matchingengine.domain.BookAggregateRepository; +import io.bux.matchingengine.domain.query.OrderType; +import io.bux.matchingengine.domain.command.CancelOrderCommand; +import io.bux.matchingengine.domain.command.MakeOrderCommand; +import io.bux.matchingengine.domain.bus.CommandBus; +import io.bux.matchingengine.domain.engine.MatchingEngine; +import io.bux.matchingengine.domain.events.CancellationRequestedEvent; +import io.bux.matchingengine.domain.events.OrderAcceptedEvent; +import org.junit.jupiter.api.*; +import org.mockito.*; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * @author Stefan Dragisic + */ +class CommandBusTest { + + private CommandBus commandBus; + private MatchingEngine matchingEngineMock; + + @BeforeEach + public void setUp() { + BookAggregateRepository aggregateRepositoryMock = mock(BookAggregateRepository.class); + matchingEngineMock = mock(MatchingEngine.class); + Book book = new Book("instrumentId", matchingEngineMock); + when(aggregateRepositoryMock.load("instrumentId")).thenReturn(Mono.just(book)); + commandBus = new CommandBus(aggregateRepositoryMock); + } + + + @Test + public void testMakeOrderCommand() { + StepVerifier.create(commandBus.sendCommand(new MakeOrderCommand("instrumentId", + UUID.randomUUID(), + OrderType.BUY, + BigDecimal.ONE, + BigDecimal.valueOf(1)))) + .expectNextMatches(result -> result instanceof OrderAcceptedEvent && + result.aggregateId().equals("instrumentId") + && ((OrderAcceptedEvent) result).type() == OrderType.BUY + && ((OrderAcceptedEvent) result).amount().compareTo(BigDecimal.ONE) == 0 + && ((OrderAcceptedEvent) result).price().compareTo(BigDecimal.ONE) == 0) + .verifyComplete(); + verify(matchingEngineMock).placeOrder(anyLong(), + eq("instrumentId"), + any(Instant.class), + eq(OrderType.BUY), + eq(BigDecimal.ONE), + eq(BigDecimal.ONE)); + } + + @Test + public void testMakeOrderCommandMany() { + Mono sendCommands = commandBus.sendCommand(new MakeOrderCommand("instrumentId", + UUID.randomUUID(), + OrderType.BUY, + BigDecimal.ONE, + BigDecimal.ONE)) + .then(commandBus.sendCommand(new MakeOrderCommand("instrumentId", + UUID.randomUUID(), + OrderType.SELL, + BigDecimal.ONE, + BigDecimal.valueOf(2)))) + .then(commandBus.sendCommand(new MakeOrderCommand("instrumentId", + UUID.randomUUID(), + OrderType.BUY, + BigDecimal.valueOf(2), + BigDecimal.valueOf(2)))) + .then(); + + StepVerifier.create(sendCommands) + .verifyComplete(); + + ArgumentCaptor orderIdCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor orderTypeCaptor = ArgumentCaptor.forClass(OrderType.class); + ArgumentCaptor amountCaptor = ArgumentCaptor.forClass(BigDecimal.class); + ArgumentCaptor priceCaptor = ArgumentCaptor.forClass(BigDecimal.class); + verify(matchingEngineMock, times(3)).placeOrder(orderIdCaptor.capture(), + anyString(), + any(Instant.class), + orderTypeCaptor.capture(), + priceCaptor.capture(), + amountCaptor.capture()); + + assertEquals(orderIdCaptor.getAllValues(), List.of(1L, 2L, 3L)); + assertEquals(orderTypeCaptor.getAllValues(), List.of(OrderType.BUY, OrderType.SELL, OrderType.BUY)); + assertEquals(amountCaptor.getAllValues(), List.of(BigDecimal.ONE, BigDecimal.ONE, BigDecimal.valueOf(2))); + assertEquals(priceCaptor.getAllValues(), List.of(BigDecimal.ONE, BigDecimal.valueOf(2), BigDecimal.valueOf(2))); + } + + @Test + public void testMakeInvalidOrderCommand() { + StepVerifier.create(commandBus.sendCommand(new MakeOrderCommand("instrumentId", + UUID.randomUUID(), + OrderType.BUY, + BigDecimal.valueOf(-1), + BigDecimal.valueOf(-1)))) + .expectErrorMatches(err -> err instanceof IllegalStateException + && err.getMessage().startsWith("Amount/Price needs to be larger then zero!")) + .verify(); + verify(matchingEngineMock, times(0)).placeOrder(anyLong(), + anyString(), + any(Instant.class), + any(), any(BigDecimal.class), any(BigDecimal.class)); + } + + @Test + public void testCancelAllOrderCommand() { + StepVerifier.create(commandBus.sendCommand(new CancelOrderCommand("instrumentId", + UUID.randomUUID(), + 1, + true, + BigDecimal.ZERO))) + .expectNextMatches(result -> result instanceof CancellationRequestedEvent && + result.aggregateId().equals("instrumentId") + && ((CancellationRequestedEvent) result).orderId() == 1 + && ((CancellationRequestedEvent) result).cancelAll()) + .verifyComplete(); + verify(matchingEngineMock).cancelAll(1, "instrumentId"); + } + + @Test + public void testCancelPartialOrderCommand() { + StepVerifier.create(commandBus.sendCommand(new CancelOrderCommand("instrumentId", + UUID.randomUUID(), + 1, + false, + BigDecimal.TEN))) + .expectNextMatches(result -> result instanceof CancellationRequestedEvent && + result.aggregateId().equals("instrumentId") + && ((CancellationRequestedEvent) result).orderId() == 1 + && !((CancellationRequestedEvent) result).cancelAll() + && ((CancellationRequestedEvent) result).newAmount().compareTo(BigDecimal.TEN) == 0) + .verifyComplete(); + verify(matchingEngineMock).cancel(1,"instrumentId", BigDecimal.TEN); + } + + @Test + public void testCancelPartialInvalidOrderCommand() { + StepVerifier.create(commandBus.sendCommand(new CancelOrderCommand("instrumentId", + UUID.randomUUID(), + 1, + false, + BigDecimal.valueOf(-10)))) + .expectErrorMatches(err -> err instanceof IllegalStateException + && err.getMessage().startsWith("Cancellation: new amount can't be <= 0!")) + .verify(); + verify(matchingEngineMock, times(0)).cancel(anyLong(), anyString(), any(BigDecimal.class)); + } +} \ No newline at end of file diff --git a/src/test/java/io/bux/matchingengine/domain/BookAggregateRepositoryTest.java b/src/test/java/io/bux/matchingengine/domain/BookAggregateRepositoryTest.java new file mode 100644 index 0000000..50eb9d7 --- /dev/null +++ b/src/test/java/io/bux/matchingengine/domain/BookAggregateRepositoryTest.java @@ -0,0 +1,23 @@ +package io.bux.matchingengine.domain; + +import io.bux.matchingengine.domain.query.BookQueryRepository; +import org.junit.jupiter.api.*; +import reactor.test.StepVerifier; + +import static org.mockito.Mockito.*; + +/** + * @author Stefan Dragisic + */ +class BookAggregateRepositoryTest { + + private final BookAggregateRepository testSubject = new BookAggregateRepository(mock(BookQueryRepository.class)); + + @Test + public void loadOrCreate() { + StepVerifier.create(testSubject.load("instrumentId")) + .expectNextCount(1) + .verifyComplete(); + } + +} \ No newline at end of file diff --git a/src/test/java/io/bux/matchingengine/domain/BookQueryRepositoryTest.java b/src/test/java/io/bux/matchingengine/domain/BookQueryRepositoryTest.java new file mode 100644 index 0000000..75a4f76 --- /dev/null +++ b/src/test/java/io/bux/matchingengine/domain/BookQueryRepositoryTest.java @@ -0,0 +1,159 @@ +package io.bux.matchingengine.domain; + +import io.bux.matchingengine.domain.query.OrderType; +import io.bux.matchingengine.domain.query.BookQueryRepository; +import io.bux.matchingengine.domain.engine.events.OrderMatchedEvent; +import io.bux.matchingengine.domain.engine.events.OrderPlacedEvent; +import org.junit.jupiter.api.*; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * @author Stefan Dragisic + */ +class BookQueryRepositoryTest { + + private BookQueryRepository testSubject; + + @BeforeEach + void setUp() { + testSubject = new BookQueryRepository(); + } + + @Test + void testOrderPlacedProjection() { + StepVerifier.create(testSubject.updateProjection(new OrderPlacedEvent(1L, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(10), + BigDecimal.valueOf(100))) + .then(testSubject.getOrder(1L))) + .expectNextMatches(orderEntry -> orderEntry.orderId() == 1L + && orderEntry.entryTimestamp().equals(Instant.MIN) + && orderEntry.direction() == OrderType.SELL + && orderEntry.price().compareTo(BigDecimal.valueOf(10)) == 0 + && orderEntry.amount().compareTo(BigDecimal.valueOf(100)) == 0 + ) + .verifyComplete(); + } + + + @Test + void buxTest() { + StepVerifier.create(testSubject.updateProjection(new OrderPlacedEvent(0L, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf( + 43251.00), + BigDecimal.valueOf( + 1.0))) + .then(testSubject.getOrder(0L))) + .expectNextMatches(orderEntry -> orderEntry.orderId() == 0L + && orderEntry.entryTimestamp().equals(Instant.MIN) + && orderEntry.direction() == OrderType.SELL + && orderEntry.price().compareTo(BigDecimal.valueOf(43251.00)) + == 0 + && orderEntry.amount().compareTo(BigDecimal.valueOf(1.0)) == 0 + && orderEntry.pendingAmount().compareTo(BigDecimal.valueOf(1.0)) + == 0 + && orderEntry.trades().isEmpty() + ) + .expectComplete() + .verify(); + + StepVerifier.create(testSubject.updateProjection(new OrderPlacedEvent(1L, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf( + 43250.00), + BigDecimal.valueOf( + 0.25) + )) + .then(testSubject.getOrder(1L))) + .expectNextMatches(orderEntry -> orderEntry.orderId() == 1L + && orderEntry.entryTimestamp().equals(Instant.MIN) + && orderEntry.direction() == OrderType.BUY + && orderEntry.price().compareTo(BigDecimal.valueOf(43250.00)) == 0 + && orderEntry.amount().compareTo(BigDecimal.valueOf(0.25)) == 0 + && orderEntry.pendingAmount().compareTo(BigDecimal.valueOf(0.25)) + == 0 + && orderEntry.trades().isEmpty() + ) + .expectComplete() + .verify(); + + StepVerifier.create(testSubject.updateProjection(new OrderMatchedEvent(0L, + "BTC", + Instant.MAX, + 2L, + OrderType.BUY, + BigDecimal.valueOf( + 43250.00), + BigDecimal.valueOf( + 43251.00), + BigDecimal.valueOf( + 0.35), + BigDecimal.valueOf( + 1.0), + BigDecimal.valueOf( + 0.65) + )) + .then(testSubject.getOrder(0L))) + .expectNextMatches(orderEntry -> orderEntry.orderId() == 0L + && orderEntry.entryTimestamp().equals(Instant.MIN) + && orderEntry.direction() == OrderType.SELL + && orderEntry.price().compareTo(BigDecimal.valueOf(43251.00)) == 0 + && orderEntry.amount().compareTo(BigDecimal.valueOf(1.0)) == 0 + && orderEntry.pendingAmount().compareTo(BigDecimal.valueOf(0.65)) + == 0 + && orderEntry.trades().stream().allMatch(t -> t.orderId() == 2L + && t.amount().compareTo(BigDecimal.valueOf(0.35)) == 0 + && t.price().compareTo(BigDecimal.valueOf( + 43251.00)) == 0) + ) + .expectComplete() + .verify(); + + + StepVerifier.create(testSubject.updateProjection(new OrderMatchedEvent(0L, + "BTC", + Instant.MAX, + 3L, + OrderType.BUY, + BigDecimal.valueOf( + 43250.00), + BigDecimal.valueOf( + 43251.00), + BigDecimal.valueOf( + 0.65), + BigDecimal.valueOf( + 0.65), + BigDecimal.valueOf( + 0.0) + )) + .then(testSubject.getOrder(0L))) + .expectNextMatches(orderEntry -> orderEntry.orderId() == 0L + && orderEntry.entryTimestamp().equals(Instant.MIN) + && orderEntry.direction() == OrderType.SELL + && orderEntry.price().compareTo(BigDecimal.valueOf(43251.00)) == 0 + && orderEntry.amount().compareTo(BigDecimal.valueOf(1)) == 0 + && orderEntry.pendingAmount().compareTo(BigDecimal.valueOf(0.0)) + == 0 + && orderEntry.trades().stream().anyMatch(t -> t.orderId() == 2L + && t.amount().compareTo(BigDecimal.valueOf(0.35)) == 0 + && t.price().compareTo(BigDecimal.valueOf( + 43251.00)) == 0) + && orderEntry.trades().stream().anyMatch(t -> t.orderId() == 3L + && t.amount().compareTo(BigDecimal.valueOf(0.65)) == 0 + && t.price().compareTo(BigDecimal.valueOf( + 43251.00)) == 0) + ) + .expectComplete() + .verify(); + } +} diff --git a/src/test/java/io/bux/matchingengine/domain/engine/MatchingEngineTest.java b/src/test/java/io/bux/matchingengine/domain/engine/MatchingEngineTest.java new file mode 100644 index 0000000..368d0e5 --- /dev/null +++ b/src/test/java/io/bux/matchingengine/domain/engine/MatchingEngineTest.java @@ -0,0 +1,560 @@ +package io.bux.matchingengine.domain.engine; + + +import io.bux.matchingengine.domain.query.OrderType; +import io.bux.matchingengine.domain.engine.events.OrderCanceledEvent; +import io.bux.matchingengine.domain.engine.events.OrderMatchedEvent; +import io.bux.matchingengine.domain.engine.events.OrderPlacedEvent; +import org.junit.jupiter.api.*; +import reactor.test.StepVerifier; + +import java.math.BigDecimal; +import java.time.Instant; + +/** + * @author Stefan Dragisic + */ +class MatchingEngineTest { + + private final MatchingEngine testSubject = new MatchingEngine(); + + @Test + public void buxTest() { + StepVerifier.create(testSubject.engineEvents().take(9)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(10.05), + BigDecimal.valueOf(20))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(10.04), + BigDecimal.valueOf(20))) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(10.05), + BigDecimal.valueOf(40))) + .then(() -> testSubject.placeOrder(5, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(10.02), + BigDecimal.valueOf(40))) + .then(() -> testSubject.placeOrder(4, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(10.00), + BigDecimal.valueOf(20))) + .then(() -> testSubject.placeOrder(6, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(10.00), + BigDecimal.valueOf(40))) + .expectNextCount(6) + .then(() -> testSubject.placeOrder(7, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(10.06), + BigDecimal.valueOf(55))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 2 + && ((OrderMatchedEvent) orderEvent).previousRestingAmount().compareTo(BigDecimal.valueOf(20)) == 0 + ) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 3) + .expectComplete() + .verify(); + } + + @Test + public void buxTest2() { + StepVerifier.create(testSubject.engineEvents().take(4)) + .expectSubscription() + .then(() -> testSubject.placeOrder(0L, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(43251.00), + BigDecimal.valueOf(1.0))) + .then(() -> testSubject.placeOrder(1L, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(43250.00), + BigDecimal.valueOf(0.25))) + .then(() -> testSubject.placeOrder(2L, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(43253.00), + BigDecimal.valueOf(0.35))) + .then(() -> testSubject.placeOrder(4L, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(43251.00), + BigDecimal.valueOf(0.65))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent + && ((OrderPlacedEvent) orderEvent).orderId() == 0L + && ((OrderPlacedEvent) orderEvent).amount().compareTo(BigDecimal.valueOf(1.0)) == 0 + && ((OrderPlacedEvent) orderEvent).price().compareTo(BigDecimal.valueOf(43251.00)) == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent + && ((OrderPlacedEvent) orderEvent).orderId() == 1L + && ((OrderPlacedEvent) orderEvent).amount().compareTo(BigDecimal.valueOf(0.25)) == 0 + && ((OrderPlacedEvent) orderEvent).price().compareTo(BigDecimal.valueOf(43250.00)) == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 0L + && ((OrderMatchedEvent) orderEvent).incomingId() == 2L + && ((OrderMatchedEvent) orderEvent).orderType() == OrderType.BUY + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(43251.00)) == 0 + && ((OrderMatchedEvent) orderEvent).incomingAmount().compareTo(BigDecimal.valueOf(0.35)) == 0 + && ((OrderMatchedEvent) orderEvent).previousRestingAmount().compareTo(BigDecimal.valueOf(1.0)) == 0 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.valueOf(0.65)) == 0 + ) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 0L + && ((OrderMatchedEvent) orderEvent).incomingId() == 4L + && ((OrderMatchedEvent) orderEvent).orderType() == OrderType.BUY + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(43251.00)) == 0 + && ((OrderMatchedEvent) orderEvent).incomingAmount().compareTo(BigDecimal.valueOf(0.65)) == 0 + && ((OrderMatchedEvent) orderEvent).previousRestingAmount().compareTo(BigDecimal.valueOf(0.65)) == 0 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.valueOf(0.0)) == 0 + ) + .expectComplete() + .verify(); + } + + @Test + public void filledBuyTest() { + StepVerifier.create(testSubject.engineEvents().take(2)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(100.0), + BigDecimal.valueOf(1.0))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(100.0), + BigDecimal.valueOf(1.0))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).orderType() == OrderType.BUY) + .expectComplete() + .verify(); + } + + @Test + public void filledSellTest() { + StepVerifier.create(testSubject.engineEvents().take(2)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(100.10), + BigDecimal.valueOf(10.1))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(100.10), + BigDecimal.valueOf(10.1))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).orderType() == OrderType.SELL) + .expectComplete() + .verify(); + } + + @Test + public void multiBuy() { + StepVerifier.create(testSubject.engineEvents().take(5)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000.0), + BigDecimal.valueOf(1.00))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1001.0), + BigDecimal.valueOf(1.00))) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(999.0), + BigDecimal.valueOf(0.50))) + .then(() -> testSubject.placeOrder(4, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000.0), + BigDecimal.valueOf(1.00))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 3 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0 + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(999.0)) + == 0 + && ((OrderMatchedEvent) orderEvent).entryTimestamp().equals(Instant.MIN) + ) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(1000.0)) + == 0 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount() + .compareTo(BigDecimal.valueOf(0.50)) == 0) + .expectComplete() + .verify(); + } + + @Test + public void multiSell() { + StepVerifier.create(testSubject.engineEvents().take(5)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1.000), + BigDecimal.valueOf(1.0))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(0.999), + BigDecimal.valueOf(1.0))) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1.001), + BigDecimal.valueOf(0.5))) + .then(() -> testSubject.placeOrder(4, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1), + BigDecimal.valueOf(1.0))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 3 + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(1.001)) + == 0 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingPrice().compareTo(BigDecimal.valueOf(1)) == 0 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount() + .compareTo(BigDecimal.valueOf(0.5)) == 0) + .expectComplete() + .verify(); + } + + @Test + public void partialBuy() { + StepVerifier.create(testSubject.engineEvents().take(3)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent + && ((OrderPlacedEvent) orderEvent).orderId() == 2 + && ((OrderPlacedEvent) orderEvent).price().compareTo(BigDecimal.valueOf(1000)) == 0 + && ((OrderPlacedEvent) orderEvent).amount().compareTo(BigDecimal.valueOf(50)) == 0) + .expectComplete() + .verify(); + } + + @Test + public void partialSell() { + StepVerifier.create(testSubject.engineEvents().take(3)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent + && ((OrderPlacedEvent) orderEvent).orderId() == 2 + && ((OrderPlacedEvent) orderEvent).price().compareTo(BigDecimal.valueOf(1000)) == 0 + && ((OrderPlacedEvent) orderEvent).amount().compareTo(BigDecimal.valueOf(50)) == 0) + .expectComplete() + .verify(); + } + + @Test + public void partialBidFill() { + StepVerifier.create(testSubject.engineEvents().take(5)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(4, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(5, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && + ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.valueOf(50)) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectComplete() + .verify(); + } + + @Test + public void partialAskFill() { + StepVerifier.create(testSubject.engineEvents().take(4)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(4, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(50))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && + ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.valueOf(50)) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent + && ((OrderMatchedEvent) orderEvent).restingId() == 1 + && ((OrderMatchedEvent) orderEvent).restingRemainingAmount().compareTo(BigDecimal.ZERO) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectComplete() + .verify(); + } + + + @Test + public void cancel() { + StepVerifier.create(testSubject.engineEvents().take(3)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.cancelAll(1, "BTC")) + .then(() -> testSubject.placeOrder(3, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderCanceledEvent + && ((OrderCanceledEvent) orderEvent).canceledAmount().compareTo(BigDecimal.valueOf(100)) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectComplete() + .verify(); + } + + @Test + public void partialCancel() { + StepVerifier.create(testSubject.engineEvents().take(4)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.cancel(1,"BTC", BigDecimal.valueOf(75))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderCanceledEvent + && ((OrderCanceledEvent) orderEvent).canceledAmount().compareTo(BigDecimal.valueOf(25)) + == 0) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectComplete() + .verify(); + } + + @Test + public void ineffectiveCancel() { + StepVerifier.create(testSubject.engineEvents().take(2)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.cancel(1,"BTC", BigDecimal.valueOf(100))) + .then(() -> testSubject.cancel(1, "BTC",BigDecimal.valueOf(100))) + .then(() -> testSubject.cancel(1, "BTC",BigDecimal.valueOf(100))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent) + .expectComplete() + .verify(); + } + + @Test + public void cancelNonExisting() { + StepVerifier.create(testSubject.engineEvents().take(2)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.cancel(3,"BTC", BigDecimal.valueOf(50))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent) + .expectComplete() + .verify(); + } + + @Test + public void sameId() { + StepVerifier.create(testSubject.engineEvents().take(3)) + .expectSubscription() + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.placeOrder(2, + "BTC", + Instant.MIN, + OrderType.SELL, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .then(() -> testSubject.placeOrder(1, + "BTC", + Instant.MIN, + OrderType.BUY, + BigDecimal.valueOf(1000), + BigDecimal.valueOf(100))) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderMatchedEvent) + .expectNextMatches(orderEvent -> orderEvent instanceof OrderPlacedEvent) + .expectComplete() + .verify(); + } +} diff --git a/src/test/java/io/bux/matchingengine/integration/IntegrationTest.java b/src/test/java/io/bux/matchingengine/integration/IntegrationTest.java new file mode 100644 index 0000000..d5a7b23 --- /dev/null +++ b/src/test/java/io/bux/matchingengine/integration/IntegrationTest.java @@ -0,0 +1,269 @@ +package io.bux.matchingengine.integration; + +import io.bux.matchingengine.MatchingEngineApplication; +import io.bux.matchingengine.api.protobuf.OrderStatusResponse; +import io.bux.matchingengine.api.protobuf.OrderType; +import org.junit.*; +import org.junit.runner.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; + +/** + * @author Stefan Dragisic + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = MatchingEngineApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class IntegrationTest { + + private final Logger logger = LoggerFactory.getLogger(IntegrationTest.class); + + private final String SIMPLE_SELL_ORDER = """ + { + "asset": "BTC", + "price": 43251.00, + "amount": 1.0, + "direction": "SELL" + } + """.stripIndent(); + + private final String SIMPLE_BUY_ORDER = """ + { + "asset": "BTC", + "price": 43252.00, + "amount": 0.25, + "direction": "BUY" + } + """.stripIndent(); + private final String BTC_SELL_ORDER = """ + { + "asset": "BTC", + "price": 40000.00, + "amount": 1.0, + "direction": "SELL" + } + """.stripIndent(); + private final String BTC_BUY_ORDER = """ + { + "asset": "BTC", + "price": 40000.00, + "amount": 1, + "direction": "BUY" + } + """.stripIndent(); + private final String SOL_SELL_ORDER = """ + { + "asset": "BTC", + "price": 40000.00, + "amount": 1.0, + "direction": "SELL" + } + """.stripIndent(); + private final String SOL_BUY_ORDER = """ + { + "asset": "BTC", + "price": 40000.00, + "amount": 1, + "direction": "BUY" + } + """.stripIndent(); + @LocalServerPort + private int port; + private WebClient client; + + @Before + public void setUp() { + client = WebClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Test + public void simplePlaceAndGetOrder() { + AtomicLong id = new AtomicLong(); + StepVerifier.create((client.post() + .uri("/orders")) + .body(Mono.just(SIMPLE_SELL_ORDER), String.class).header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .doOnNext(or -> id.set(or.getId()))) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43251.00 + && response.getAmount() == 1.0 + && response.getDirection().equals(OrderType.SELL) + && response.getPendingAmount() == 1.0) + .verifyComplete(); + + StepVerifier.create((client.get() + .uri("/orders/" + id + "/")) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class)) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43251.00 + && response.getAmount() == 1.0 + && response.getDirection().equals(OrderType.SELL) + && response.getPendingAmount() == 1.0) + .verifyComplete(); + } + + @Test + public void simpleCancelOrder() { + AtomicLong id = new AtomicLong(); + StepVerifier.create((client.post() + .uri("/orders")) + .body(Mono.just(SIMPLE_SELL_ORDER), String.class).header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .doOnNext(or -> id.set(or.getId()))) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43251.00 + && response.getAmount() == 1.0 + && response.getDirection().equals(OrderType.SELL) + && response.getPendingAmount() == 1.0) + .verifyComplete(); + + StepVerifier.create((client.post() + .uri("/orders/" + id + "/cancel")) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(String.class)) + .expectNextMatches(response -> response.equals("OK")) + .verifyComplete(); + } + + @Test + public void simpleTradeTest() { + AtomicLong id = new AtomicLong(); + AtomicLong id2 = new AtomicLong(); + StepVerifier.create((client.post() + .uri("/orders")) + .body(Mono.just(SIMPLE_SELL_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .doOnNext(or -> id.set(or.getId()))) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43251.00 + && response.getAmount() == 1.0 + && response.getDirection().equals(OrderType.SELL) + && response.getPendingAmount() == 1.0) + .verifyComplete(); + + StepVerifier.create((client.post() + .uri("/orders")) + .body(Mono.just(SIMPLE_BUY_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .doOnNext(or -> id2.set(or.getId()))) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43252.00 + && response.getAmount() == 0.25 + && response.getDirection().equals(OrderType.BUY)) + .verifyComplete(); + + StepVerifier.create((client.get() + .uri("/orders/" + id + "/")) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class)) + .expectNextMatches(response -> + response.getAsset().equals("BTC") + && response.getPrice() == 43251.00 + && response.getAmount() == 1.0 + && response.getDirection().equals(OrderType.SELL) + && response.getPendingAmount() == 0.75 + && response.getTradesList().stream() + .anyMatch(t -> t.getOrderId() == id2.get() + && t.getAmount() == 0.25 + && t.getPrice() == 43251.0 + ) + ) + .verifyComplete(); + } + + @Test + public void stressTest() { + Mono btcSellOrderMono = WebClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .build().post() + .uri("/orders") + .body(Mono.just(BTC_SELL_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class).timeout( + Duration.ofSeconds(1)); + + Mono btcBuyOrderMono = WebClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .build().post() + .uri("/orders") + .body(Mono.just(BTC_BUY_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .subscribeOn(Schedulers.boundedElastic()).timeout( + Duration.ofSeconds(1)); + + Mono solSellOrderMono = WebClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .build().post() + .uri("/orders") + .body(Mono.just(SOL_SELL_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .subscribeOn(Schedulers.boundedElastic()).timeout( + Duration.ofSeconds(1)); + + Mono solBuyOrderMono = WebClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .build().post() + .uri("/orders") + .body(Mono.just(SOL_BUY_ORDER), String.class) + .header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE) + .retrieve().bodyToMono(OrderStatusResponse.class) + .subscribeOn(Schedulers.boundedElastic()).timeout( + Duration.ofSeconds(1)); + + Flux buySellAll = Flux.merge(btcBuyOrderMono, + solSellOrderMono, + btcSellOrderMono, + solBuyOrderMono); + + //16 threads + //2 district asset + //2 operations per asset (BUY/SELL) + //executed 1000 times + //total 4000 operations + Duration duration = StepVerifier.create(Flux.range(0, 1000).flatMap(unused -> buySellAll, 16)) + .expectNextCount(4000) + .verifyComplete(); + + logger.info("Stress test took: {} ms", duration.toMillis()); + logger.info("Average: {} ms / end to end rest call", duration.toMillis() / 4000); + } +} \ No newline at end of file