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
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
}