OrderBookService implemented and tested

This commit is contained in:
2026-06-22 12:02:26 +02:00
parent a5dcababf5
commit b7fa3d887e
8 changed files with 436 additions and 6 deletions

View File

@@ -0,0 +1,92 @@
package com.frankenergie.smartasset.domain
import com.frankenergie.smartasset.model.DeliveryPeriod
import com.frankenergie.smartasset.model.OrderSide
import java.math.BigDecimal
import java.util.TreeMap
import java.util.concurrent.ConcurrentHashMap
data class PriceLevel(
val price: BigDecimal,
val quantity: BigDecimal
)
class OrderBook {
private val bidsPerPeriod: ConcurrentHashMap<DeliveryPeriod, TreeMap<BigDecimal, BigDecimal>> = ConcurrentHashMap()
private val asksPerPeriod: ConcurrentHashMap<DeliveryPeriod, TreeMap<BigDecimal, BigDecimal>> = ConcurrentHashMap()
fun processOrder(period: DeliveryPeriod, side: OrderSide, quantity: BigDecimal, price: BigDecimal): BigDecimal {
val bids = bidsPerPeriod.computeIfAbsent(period) { TreeMap(Comparator.reverseOrder()) }
val asks = asksPerPeriod.computeIfAbsent(period) { TreeMap() }
return when (side) {
OrderSide.BUY -> processBuyOrder(bids, asks, quantity, price)
OrderSide.SELL -> processSellOrder(bids, asks, quantity, price)
}
}
private fun processBuyOrder(
bids: TreeMap<BigDecimal, BigDecimal>,
asks: TreeMap<BigDecimal, BigDecimal>,
quantity: BigDecimal,
price: BigDecimal
): BigDecimal {
var remaining = quantity
val iterator = asks.entries.iterator()
while (iterator.hasNext() && remaining > BigDecimal.ZERO) {
val (askPrice, askQty) = iterator.next()
if (askPrice > price) break
val matched = remaining.min(askQty)
remaining -= matched
val newQty = askQty - matched
if (newQty <= BigDecimal.ZERO) iterator.remove() else asks[askPrice] = newQty
}
if (remaining > BigDecimal.ZERO) {
bids.merge(price, remaining) { old, new -> old + new }
}
return quantity - remaining
}
private fun processSellOrder(
bids: TreeMap<BigDecimal, BigDecimal>,
asks: TreeMap<BigDecimal, BigDecimal>,
quantity: BigDecimal,
price: BigDecimal
): BigDecimal {
var remaining = quantity
val iterator = bids.entries.iterator()
while (iterator.hasNext() && remaining > BigDecimal.ZERO) {
val (bidPrice, bidQty) = iterator.next()
if (bidPrice < price) break
val matched = remaining.min(bidQty)
remaining -= matched
val newQty = bidQty - matched
if (newQty <= BigDecimal.ZERO) iterator.remove() else bids[bidPrice] = newQty
}
if (remaining > BigDecimal.ZERO) {
asks.merge(price, remaining) { old, new -> old + new }
}
return quantity - remaining
}
fun getBids(period: DeliveryPeriod): Map<BigDecimal, BigDecimal> = bidsPerPeriod[period]?.toMap() ?: emptyMap()
fun getAsks(period: DeliveryPeriod): Map<BigDecimal, BigDecimal> = asksPerPeriod[period]?.toMap() ?: emptyMap()
fun getBestBid(period: DeliveryPeriod): PriceLevel? =
bidsPerPeriod[period]?.firstEntry()?.let { PriceLevel(it.key, it.value) }
fun getBestAsk(period: DeliveryPeriod): PriceLevel? =
asksPerPeriod[period]?.firstEntry()?.let { PriceLevel(it.key, it.value) }
fun getAllPeriods(): Set<DeliveryPeriod> = bidsPerPeriod.keys + asksPerPeriod.keys
}

View File

@@ -0,0 +1,8 @@
package com.frankenergie.smartasset.model
import java.time.LocalDateTime
data class DeliveryPeriod(
val startTime: LocalDateTime,
val endTime: LocalDateTime
)

View File

@@ -0,0 +1,28 @@
package com.frankenergie.smartasset.model
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
data class DeliveryQuarter(
val startTime: LocalDateTime
) {
val endTime: LocalDateTime = startTime.plusMinutes(15)
companion object {
fun from(dateTime: LocalDateTime): DeliveryQuarter {
val minute = (dateTime.minute / 15) * 15
val truncated = dateTime.truncatedTo(ChronoUnit.HOURS).plusMinutes(minute.toLong())
return DeliveryQuarter(truncated)
}
fun fromRange(start: LocalDateTime, end: LocalDateTime): List<DeliveryQuarter> {
val quarters = mutableListOf<DeliveryQuarter>()
var current = from(start)
while (current.startTime < end) {
quarters.add(current)
current = DeliveryQuarter(current.endTime)
}
return quarters
}
}
}

View File

@@ -0,0 +1,19 @@
package com.frankenergie.smartasset.model
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
data class Order(
val orderId: String,
val deliveryStartTime: LocalDateTime,
val deliveryEndTime: LocalDateTime,
val orderSide: OrderSide,
val originalQuantity: BigDecimal,
var remainingQuantity: BigDecimal,
val price: BigDecimal,
val createdAt: Instant
) {
val isFilled: Boolean
get() = remainingQuantity.compareTo(BigDecimal.ZERO) == 0
}

View File

@@ -0,0 +1,14 @@
package com.frankenergie.smartasset.model
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
data class Trade(
val tradeId: String,
val buyOrderId: String,
val sellOrderId: String,
val price: BigDecimal,
val quantity: BigDecimal,
val executedAt: Instant
)

View File

@@ -1,5 +1,7 @@
package com.frankenergie.smartasset.service package com.frankenergie.smartasset.service
import com.frankenergie.smartasset.domain.OrderBook
import com.frankenergie.smartasset.model.DeliveryPeriod
import com.frankenergie.smartasset.model.OrderUpdateRequest import com.frankenergie.smartasset.model.OrderUpdateRequest
import com.frankenergie.smartasset.model.OrderUpdateResponse import com.frankenergie.smartasset.model.OrderUpdateResponse
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -9,15 +11,24 @@ import java.util.UUID
@Service @Service
class OrderBookService { class OrderBookService {
private val orderBook = OrderBook()
fun processOrder(request: OrderUpdateRequest): OrderUpdateResponse { fun processOrder(request: OrderUpdateRequest): OrderUpdateResponse {
val orderId = UUID.randomUUID().toString() val orderId = UUID.randomUUID().toString()
// TODO: implement order book logic val period = DeliveryPeriod(request.deliveryStartTime, request.deliveryEndTime)
val matchedQuantity = orderBook.processOrder(period, request.orderSide, request.quantity, request.price)
return OrderUpdateResponse( val status = when {
orderId = orderId, matchedQuantity == request.quantity -> "FILLED"
status = "ACCEPTED", matchedQuantity > java.math.BigDecimal.ZERO -> "PARTIALLY_FILLED"
timestamp = Instant.now() else -> "ACCEPTED"
) }
// TODO: write this to a file
return OrderUpdateResponse(orderId = orderId, status = status, timestamp = Instant.now())
} }
fun getOrderBook(): OrderBook = orderBook
} }

View File

@@ -0,0 +1,188 @@
package com.frankenergie.smartasset.domain
import com.frankenergie.smartasset.model.DeliveryPeriod
import com.frankenergie.smartasset.model.OrderSide
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDateTime
class OrderBookTest {
private lateinit var orderBook: OrderBook
private lateinit var period: DeliveryPeriod
@BeforeEach
fun setUp() {
orderBook = OrderBook()
period = DeliveryPeriod(
LocalDateTime.of(2024, 1, 15, 10, 0),
LocalDateTime.of(2024, 1, 15, 10, 15)
)
}
@Test
fun `buy order added to empty book`() {
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
assertEquals(BigDecimal.ZERO, matched)
assertEquals(BigDecimal("10"), orderBook.getBids(period)[BigDecimal("50.00")])
assertTrue(orderBook.getAsks(period).isEmpty())
}
@Test
fun `sell order added to empty book`() {
val matched = orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("55.00"))
assertEquals(BigDecimal.ZERO, matched)
assertEquals(BigDecimal("10"), orderBook.getAsks(period)[BigDecimal("55.00")])
assertTrue(orderBook.getBids(period).isEmpty())
}
@Test
fun `buy order does not match higher priced sell order`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("55.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("50.00"))
assertEquals(BigDecimal.ZERO, matched)
assertEquals(BigDecimal("10"), orderBook.getAsks(period)[BigDecimal("55.00")])
assertEquals(BigDecimal("5"), orderBook.getBids(period)[BigDecimal("50.00")])
}
@Test
fun `buy order fully matches equal priced sell order`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertTrue(orderBook.getAsks(period).isEmpty())
assertTrue(orderBook.getBids(period).isEmpty())
}
@Test
fun `buy order fully matches lower priced sell order`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("48.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertTrue(orderBook.getAsks(period).isEmpty())
assertTrue(orderBook.getBids(period).isEmpty())
}
@Test
fun `buy order partially matches sell order - remaining added to bids`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("15"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertTrue(orderBook.getAsks(period).isEmpty())
assertEquals(BigDecimal("5"), orderBook.getBids(period)[BigDecimal("50.00")])
}
@Test
fun `buy order partially fills sell order - remaining stays in asks`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("15"), BigDecimal("50.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertEquals(BigDecimal("5"), orderBook.getAsks(period)[BigDecimal("50.00")])
assertTrue(orderBook.getBids(period).isEmpty())
}
@Test
fun `sell order fully matches higher priced buy order`() {
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("55.00"))
val matched = orderBook.processOrder(period, OrderSide.SELL, BigDecimal("10"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertTrue(orderBook.getBids(period).isEmpty())
assertTrue(orderBook.getAsks(period).isEmpty())
}
@Test
fun `sell order does not match lower priced buy order`() {
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
val matched = orderBook.processOrder(period, OrderSide.SELL, BigDecimal("5"), BigDecimal("55.00"))
assertEquals(BigDecimal.ZERO, matched)
assertEquals(BigDecimal("10"), orderBook.getBids(period)[BigDecimal("50.00")])
assertEquals(BigDecimal("5"), orderBook.getAsks(period)[BigDecimal("55.00")])
}
@Test
fun `buy order matches multiple price levels`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("5"), BigDecimal("48.00"))
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("5"), BigDecimal("49.00"))
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("5"), BigDecimal("52.00"))
val matched = orderBook.processOrder(period, OrderSide.BUY, BigDecimal("12"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertEquals(BigDecimal("2"), orderBook.getBids(period)[BigDecimal("50.00")])
assertEquals(BigDecimal("5"), orderBook.getAsks(period)[BigDecimal("52.00")])
}
@Test
fun `sell order matches multiple price levels`() {
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("52.00"))
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("51.00"))
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("48.00"))
val matched = orderBook.processOrder(period, OrderSide.SELL, BigDecimal("12"), BigDecimal("50.00"))
assertEquals(BigDecimal("10"), matched)
assertEquals(BigDecimal("2"), orderBook.getAsks(period)[BigDecimal("50.00")])
assertEquals(BigDecimal("5"), orderBook.getBids(period)[BigDecimal("48.00")])
}
@Test
fun `orders aggregate at same price level`() {
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("50.00"))
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("3"), BigDecimal("50.00"))
assertEquals(BigDecimal("8"), orderBook.getBids(period)[BigDecimal("50.00")])
}
@Test
fun `separate order books per delivery period`() {
val period1 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 0), LocalDateTime.of(2024, 1, 15, 10, 15))
val period2 = DeliveryPeriod(LocalDateTime.of(2024, 1, 15, 10, 15), LocalDateTime.of(2024, 1, 15, 10, 30))
orderBook.processOrder(period1, OrderSide.BUY, BigDecimal("10"), BigDecimal("50.00"))
orderBook.processOrder(period2, OrderSide.BUY, BigDecimal("20"), BigDecimal("55.00"))
assertEquals(BigDecimal("10"), orderBook.getBids(period1)[BigDecimal("50.00")])
assertEquals(BigDecimal("20"), orderBook.getBids(period2)[BigDecimal("55.00")])
}
@Test
fun `getBestBid returns highest bid`() {
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("5"), BigDecimal("50.00"))
orderBook.processOrder(period, OrderSide.BUY, BigDecimal("3"), BigDecimal("52.00"))
val best = orderBook.getBestBid(period)!!
assertEquals(BigDecimal("52.00"), best.price)
assertEquals(BigDecimal("3"), best.quantity)
}
@Test
fun `getBestAsk returns lowest ask`() {
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("5"), BigDecimal("55.00"))
orderBook.processOrder(period, OrderSide.SELL, BigDecimal("3"), BigDecimal("53.00"))
val best = orderBook.getBestAsk(period)!!
assertEquals(BigDecimal("53.00"), best.price)
assertEquals(BigDecimal("3"), best.quantity)
}
@Test
fun `getBestBid returns null for empty book`() {
assertNull(orderBook.getBestBid(period))
}
@Test
fun `getBestAsk returns null for empty book`() {
assertNull(orderBook.getBestAsk(period))
}
}

View File

@@ -0,0 +1,70 @@
package com.frankenergie.smartasset.service
import com.frankenergie.smartasset.model.OrderSide
import com.frankenergie.smartasset.model.OrderUpdateRequest
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDateTime
class OrderBookServiceTest {
private lateinit var service: OrderBookService
@BeforeEach
fun setUp() {
service = OrderBookService()
}
@Test
fun `processOrder returns ACCEPTED when no match`() {
val request = OrderUpdateRequest(
deliveryStartTime = LocalDateTime.of(2024, 1, 15, 10, 0),
deliveryEndTime = LocalDateTime.of(2024, 1, 15, 10, 15),
orderSide = OrderSide.BUY,
quantity = BigDecimal("10"),
price = BigDecimal("50.00")
)
val response = service.processOrder(request)
assertNotNull(response.orderId)
assertEquals("ACCEPTED", response.status)
assertNotNull(response.timestamp)
}
@Test
fun `processOrder returns FILLED when fully matched`() {
val sellRequest = OrderUpdateRequest(
deliveryStartTime = LocalDateTime.of(2024, 1, 15, 10, 0),
deliveryEndTime = LocalDateTime.of(2024, 1, 15, 10, 15),
orderSide = OrderSide.SELL,
quantity = BigDecimal("10"),
price = BigDecimal("50.00")
)
service.processOrder(sellRequest)
val buyRequest = sellRequest.copy(orderSide = OrderSide.BUY)
val response = service.processOrder(buyRequest)
assertEquals("FILLED", response.status)
}
@Test
fun `processOrder returns PARTIALLY_FILLED when partially matched`() {
val sellRequest = OrderUpdateRequest(
deliveryStartTime = LocalDateTime.of(2024, 1, 15, 10, 0),
deliveryEndTime = LocalDateTime.of(2024, 1, 15, 10, 15),
orderSide = OrderSide.SELL,
quantity = BigDecimal("5"),
price = BigDecimal("50.00")
)
service.processOrder(sellRequest)
val buyRequest = sellRequest.copy(orderSide = OrderSide.BUY, quantity = BigDecimal("10"))
val response = service.processOrder(buyRequest)
assertEquals("PARTIALLY_FILLED", response.status)
}
}