OrderBookService implemented and tested
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.frankenergie.smartasset.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class DeliveryPeriod(
|
||||
val startTime: LocalDateTime,
|
||||
val endTime: LocalDateTime
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/main/kotlin/com/frankenergie/smartasset/model/Order.kt
Normal file
19
src/main/kotlin/com/frankenergie/smartasset/model/Order.kt
Normal 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
|
||||
}
|
||||
14
src/main/kotlin/com/frankenergie/smartasset/model/Trade.kt
Normal file
14
src/main/kotlin/com/frankenergie/smartasset/model/Trade.kt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user