diff --git a/src/main/kotlin/com/frankenergie/smartasset/domain/OrderBook.kt b/src/main/kotlin/com/frankenergie/smartasset/domain/OrderBook.kt new file mode 100644 index 0000000..9560815 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/domain/OrderBook.kt @@ -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> = ConcurrentHashMap() + private val asksPerPeriod: ConcurrentHashMap> = 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, + asks: TreeMap, + 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, + asks: TreeMap, + 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 = bidsPerPeriod[period]?.toMap() ?: emptyMap() + + fun getAsks(period: DeliveryPeriod): Map = 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 = bidsPerPeriod.keys + asksPerPeriod.keys +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryPeriod.kt b/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryPeriod.kt new file mode 100644 index 0000000..121a448 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryPeriod.kt @@ -0,0 +1,8 @@ +package com.frankenergie.smartasset.model + +import java.time.LocalDateTime + +data class DeliveryPeriod( + val startTime: LocalDateTime, + val endTime: LocalDateTime +) diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryQuarter.kt b/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryQuarter.kt new file mode 100644 index 0000000..0722c1f --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/DeliveryQuarter.kt @@ -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 { + val quarters = mutableListOf() + var current = from(start) + while (current.startTime < end) { + quarters.add(current) + current = DeliveryQuarter(current.endTime) + } + return quarters + } + } +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/Order.kt b/src/main/kotlin/com/frankenergie/smartasset/model/Order.kt new file mode 100644 index 0000000..d84bbf2 --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/Order.kt @@ -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 +} diff --git a/src/main/kotlin/com/frankenergie/smartasset/model/Trade.kt b/src/main/kotlin/com/frankenergie/smartasset/model/Trade.kt new file mode 100644 index 0000000..ab892ec --- /dev/null +++ b/src/main/kotlin/com/frankenergie/smartasset/model/Trade.kt @@ -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 +) diff --git a/src/main/kotlin/com/frankenergie/smartasset/service/OrderBookService.kt b/src/main/kotlin/com/frankenergie/smartasset/service/OrderBookService.kt index 9261300..6a135eb 100644 --- a/src/main/kotlin/com/frankenergie/smartasset/service/OrderBookService.kt +++ b/src/main/kotlin/com/frankenergie/smartasset/service/OrderBookService.kt @@ -1,5 +1,7 @@ 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.OrderUpdateResponse import org.springframework.stereotype.Service @@ -9,15 +11,24 @@ import java.util.UUID @Service class OrderBookService { + private val orderBook = OrderBook() + fun processOrder(request: OrderUpdateRequest): OrderUpdateResponse { 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( - orderId = orderId, - status = "ACCEPTED", - timestamp = Instant.now() - ) + val status = when { + matchedQuantity == request.quantity -> "FILLED" + matchedQuantity > java.math.BigDecimal.ZERO -> "PARTIALLY_FILLED" + else -> "ACCEPTED" + } + + // TODO: write this to a file + + return OrderUpdateResponse(orderId = orderId, status = status, timestamp = Instant.now()) } + + fun getOrderBook(): OrderBook = orderBook } diff --git a/src/test/kotlin/com/frankenergie/smartasset/domain/OrderBookTest.kt b/src/test/kotlin/com/frankenergie/smartasset/domain/OrderBookTest.kt new file mode 100644 index 0000000..4b1d43d --- /dev/null +++ b/src/test/kotlin/com/frankenergie/smartasset/domain/OrderBookTest.kt @@ -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)) + } +} diff --git a/src/test/kotlin/com/frankenergie/smartasset/service/OrderBookServiceTest.kt b/src/test/kotlin/com/frankenergie/smartasset/service/OrderBookServiceTest.kt new file mode 100644 index 0000000..558f0a8 --- /dev/null +++ b/src/test/kotlin/com/frankenergie/smartasset/service/OrderBookServiceTest.kt @@ -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) + } +}