diff --git a/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CandleData.kt b/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CandleData.kt new file mode 100644 index 000000000..bad2142ad --- /dev/null +++ b/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/CandleData.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class CandleData( + val openTime: LocalDateTime, + val closeTime: LocalDateTime, + val open: Double, + val close: Double, + val high: Double, + val low: Double, + val volume: Double, + val quoteAssetVolume: Double, + val trades: Int, + val takerBuyBaseAssetVolume: Double, + val takerBuyQuoteAssetVolume: Double, +) diff --git a/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketQueryHandler.kt b/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketQueryHandler.kt index c14938dc4..6bd4f7509 100644 --- a/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketQueryHandler.kt +++ b/Api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MarketQueryHandler.kt @@ -20,4 +20,12 @@ interface MarketQueryHandler { suspend fun lastPrice(symbol: String?): List + suspend fun getCandleInfo( + symbol: String, + interval: String, + startTime: Long?, + endTime: Long?, + limit: Int + ): List + } \ No newline at end of file diff --git a/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/config/SecurityConfig.kt b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/config/SecurityConfig.kt index 9d7bec883..f2cfb87e6 100644 --- a/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/config/SecurityConfig.kt +++ b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/config/SecurityConfig.kt @@ -30,6 +30,7 @@ class SecurityConfig(private val webClient: WebClient) { .pathMatchers("/v3/trades").permitAll() .pathMatchers("/v3/ticker/**").permitAll() .pathMatchers("/v3/exchangeInfo").permitAll() + .pathMatchers("/v3/klines").permitAll() .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .pathMatchers("/**").hasAuthority("SCOPE_trust") .anyExchange().authenticated() diff --git a/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/controller/MarketController.kt b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/controller/MarketController.kt index b0688a4ce..ea3a51f59 100644 --- a/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/controller/MarketController.kt +++ b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/controller/MarketController.kt @@ -2,13 +2,10 @@ package co.nilin.opex.port.api.binance.controller import co.nilin.opex.api.core.spi.MarketQueryHandler import co.nilin.opex.api.core.spi.SymbolMapper -import co.nilin.opex.port.api.binance.data.OrderBookResponse import co.nilin.opex.api.core.inout.PriceChangeResponse import co.nilin.opex.api.core.inout.PriceTickerResponse import co.nilin.opex.api.core.spi.AccountantProxy -import co.nilin.opex.port.api.binance.data.ExchangeInfoResponse -import co.nilin.opex.port.api.binance.data.ExchangeInfoSymbol -import co.nilin.opex.port.api.binance.data.RecentTradeResponse +import co.nilin.opex.port.api.binance.data.* import co.nilin.opex.utility.error.data.OpexError import co.nilin.opex.utility.error.data.OpexException import co.nilin.opex.utility.error.data.throwError @@ -23,8 +20,6 @@ import java.security.Principal import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId -import java.util.* -import java.util.concurrent.TimeUnit import kotlin.collections.ArrayList @RestController @@ -35,7 +30,7 @@ class MarketController( ) { private val orderBookValidLimits = arrayListOf(5, 10, 20, 50, 100, 500, 1000, 5000) - private val validDurations = arrayListOf("24h", "7d", "1m") + private val validDurations = arrayListOf("24h", "7d", "1M") // Limit - Weight // 5, 10, 20, 50, 100 - 1 @@ -107,7 +102,7 @@ class MarketController( } } - @GetMapping("/v3/ticker/{duration:24h|7d|1m}") + @GetMapping("/v3/ticker/{duration:24h|7d|1M}") suspend fun priceChange( @PathVariable("duration") duration: String, @@ -122,16 +117,7 @@ class MarketController( if (!validDurations.contains(duration)) throwError(OpexError.InvalidPriceChangeDuration) - val now = Date().time - val before = when (duration) { - "24h" -> Date(now - TimeUnit.DAYS.toMillis(1)) - "7d" -> Date(now - TimeUnit.DAYS.toMillis(7)) - "1m" -> Date(now - TimeUnit.DAYS.toMillis(31)) - else -> Date(now - TimeUnit.DAYS.toMillis(1)) - } - - val instant = Instant.ofEpochMilli(before.time) - val startDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) + val startDate = Interval.findByLabel(duration)?.getLocalDateTime() ?: Interval.Day.getLocalDateTime() return if (symbol.isNullOrEmpty()) marketQueryHandler.getTradeTickerData(startDate) @@ -173,4 +159,48 @@ class MarketController( return ExchangeInfoResponse(symbols = pairConfigs) } + // Weight(IP): 1 + @GetMapping("/v3/klines") + suspend fun klines( + @RequestParam("symbol") + symbol: String, + @RequestParam("interval") + interval: String, + @RequestParam("startTime", required = false) + startTime: Long?, + @RequestParam("endTime", required = false) + endTime: Long?, + @RequestParam("limit", required = false) + limit: Int? // Default 500; max 1000. + ): List> { + val validLimit = limit ?: 500 + val localSymbol = symbolMapper.unmap(symbol) ?: throw OpexException(OpexError.SymbolNotFound) + if (validLimit !in 1..1000) + throwError(OpexError.InvalidLimitForRecentTrades) + + val i = Interval.findByLabel(interval) ?: throw OpexException(OpexError.InvalidInterval) + + val list = ArrayList>() + marketQueryHandler.getCandleInfo(localSymbol, "${i.duration} ${i.unit}", startTime, endTime, validLimit) + .forEach { + list.add( + arrayListOf( + it.openTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + it.open.toString(), + it.high.toString(), + it.low.toString(), + it.close.toString(), + it.volume.toString(), + it.closeTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), + it.quoteAssetVolume.toString(), + it.trades, + it.takerBuyBaseAssetVolume.toString(), + it.takerBuyQuoteAssetVolume.toString(), + "0.0" + ) + ) + } + return list + } + } \ No newline at end of file diff --git a/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/data/Interval.kt b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/data/Interval.kt new file mode 100644 index 000000000..1adb613e9 --- /dev/null +++ b/Api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/port/api/binance/data/Interval.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.port.api.binance.data + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import java.util.concurrent.TimeUnit + +enum class Interval(val label: String, val unit: TimeUnit, val duration: Long) { + + Minute("1m", TimeUnit.MINUTES, 1), + ThreeMinutes("3m", TimeUnit.MINUTES, 3), + FiveMinutes("5m", TimeUnit.MINUTES, 5), + FifteenMinutes("15m", TimeUnit.MINUTES, 15), + ThirtyMinutes("30m", TimeUnit.MINUTES, 30), + Hour("1h", TimeUnit.HOURS, 1), + TwoHours("2h", TimeUnit.HOURS, 2), + FourHours("4h", TimeUnit.HOURS, 4), + SixHours("6h", TimeUnit.HOURS, 6), + EightHours("8h", TimeUnit.HOURS, 8), + TwelveHours("12h", TimeUnit.HOURS, 12), + TwentyFourHours("24h", TimeUnit.HOURS, 24), + Day("1d", TimeUnit.DAYS, 1), + ThreeDays("3d", TimeUnit.DAYS, 3), + Week("1w", TimeUnit.DAYS, 7), + Month("1M", TimeUnit.DAYS, 31); + + private fun getOffsetTime() = unit.toMillis(duration) + + fun getDate() = Date(Date().time - getOffsetTime()) + + fun getLocalDateTime(): LocalDateTime = with(Instant.ofEpochMilli(getDate().time)) { + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + } + + companion object { + fun findByLabel(label: String): Interval? { + return values().find { it.label == label } + } + } + +} \ No newline at end of file diff --git a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/config/PostgresConfig.kt b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/config/PostgresConfig.kt index c88b8f921..9ac3d700a 100644 --- a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/config/PostgresConfig.kt +++ b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/config/PostgresConfig.kt @@ -60,6 +60,18 @@ class PostgresConfig(db: DatabaseClient) { INSERT INTO symbol_maps(symbol, value) VALUES('btc_usdt', 'BTCUSDT') ON CONFLICT DO NOTHING; INSERT INTO symbol_maps(symbol, value) VALUES('eth_usdt', 'ETHUSDT') ON CONFLICT DO NOTHING; INSERT INTO symbol_maps(symbol, value) VALUES('eth_btc', 'ETHBTC') ON CONFLICT DO NOTHING; + + create or replace function interval_generator(start_ts timestamp without TIME ZONE, end_ts timestamp without TIME ZONE, round_interval INTERVAL) + returns TABLE(start_time timestamp without TIME ZONE, end_time timestamp without TIME ZONE) as $$ + BEGIN + return query + SELECT + (n) start_time, + (n + round_interval) end_time + FROM generate_series(date_trunc('minute', start_ts), end_ts, round_interval) n; + END + $$ + LANGUAGE 'plpgsql'; """ val initDb = db.sql { sql } initDb // initialize the database diff --git a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/dao/TradeRepository.kt b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/dao/TradeRepository.kt index 414f18091..9cd080227 100644 --- a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/dao/TradeRepository.kt +++ b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/dao/TradeRepository.kt @@ -1,5 +1,6 @@ package co.nilin.opex.port.api.postgres.dao +import co.nilin.opex.port.api.postgres.model.CandleInfoData import co.nilin.opex.port.api.postgres.model.TradeModel import co.nilin.opex.port.api.postgres.model.TradeTickerData import kotlinx.coroutines.flow.Flow @@ -106,4 +107,44 @@ interface TradeRepository : ReactiveCrudRepository { @Query("select * from trades where create_date in (select max(create_date) from trades group by symbol)") fun findAllGroupBySymbol(): Flux + + @Query( + """ + with intervals as (select * from interval_generator((:startTime), (:endTime), :interval ::INTERVAL)) + select + f.start_time as open_time, + f.end_time as close_time, + (select taker_price from trades tt where tt.create_date >= f.start_time and tt.create_date < f.end_time order by tt.create_date asc limit 1) as open, + max(t.taker_price) as high, + min(t.taker_price) as low, + (select taker_price from trades tt where tt.create_date >= f.start_time and tt.create_date < f.end_time order by tt.create_date desc limit 1) as close, + sum(t.matched_quantity) as volume, + count(id) as trades + from trades t + right join intervals f + on t.create_date >= f.start_time and t.create_date < f.end_time + where symbol = :symbol or symbol is null + group by f.start_time, f.end_time + order by f.end_time desc + limit :limit + """ + ) + suspend fun candleData( + @Param("symbol") + symbol: String, + @Param("interval") + interval: String, + @Param("startTime") + startTime: LocalDateTime, + @Param("endTime") + endTime: LocalDateTime, + @Param("limit") + limit: Int, + ): Flux + + @Query("select * from trades order by create_date desc limit 1") + suspend fun findLastByCreateDate(): Mono + + @Query("select * from trades order by create_date asc limit 1") + suspend fun findFirstByCreateDate(): Mono } \ No newline at end of file diff --git a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/impl/MarketQueryHandlerImpl.kt b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/impl/MarketQueryHandlerImpl.kt index 6d9f508b2..211087e87 100644 --- a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/impl/MarketQueryHandlerImpl.kt +++ b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/impl/MarketQueryHandlerImpl.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitFirstOrNull import org.springframework.stereotype.Component import java.lang.Exception +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.ZoneOffset @@ -125,6 +126,49 @@ class MarketQueryHandlerImpl( } + override suspend fun getCandleInfo( + symbol: String, + interval: String, + startTime: Long?, + endTime: Long?, + limit: Int + ): List { + val st = if (startTime == null) + tradeRepository.findFirstByCreateDate().awaitFirstOrNull()?.createDate + ?: LocalDateTime.now() + else + with(Instant.ofEpochMilli(startTime)) { + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + } + + val et = if (endTime == null) + tradeRepository.findLastByCreateDate().awaitFirstOrNull()?.createDate + ?: LocalDateTime.now() + else + with(Instant.ofEpochMilli(endTime)) { + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + } + + return tradeRepository.candleData(symbol, interval, st, et, limit) + .collectList() + .awaitFirstOrElse { emptyList() } + .map { + CandleData( + it.openTime, + it.closeTime, + it.open ?: 0.0, + it.close ?: 0.0, + it.high ?: 0.0, + it.low ?: 0.0, + it.volume ?: 0.0, + 0.0, + it.trades, + 0.0, + 0.0 + ) + } + } + private fun OrderModel.asQueryOrderResponse() = QueryOrderResponse( symbol, ouid, diff --git a/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/model/CandleInfoData.kt b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/model/CandleInfoData.kt new file mode 100644 index 000000000..a8d929499 --- /dev/null +++ b/Api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/port/api/postgres/model/CandleInfoData.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.port.api.postgres.model + +import org.springframework.data.relational.core.mapping.Column +import java.time.LocalDateTime + +data class CandleInfoData( + @Column("open_time") + val openTime: LocalDateTime, + @Column("close_time") + val closeTime: LocalDateTime, + val open: Double?, + val close: Double?, + val high: Double?, + val low: Double?, + val volume: Double?, + val trades: Int, +) \ No newline at end of file diff --git a/Utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt b/Utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt index d03fdfe8c..ae8d68406 100644 --- a/Utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt +++ b/Utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt @@ -33,7 +33,8 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus InvalidLimitForOrderBook(7003, "Valid limits: [5, 10, 20, 50, 100, 500, 1000, 5000]", HttpStatus.BAD_REQUEST), InvalidLimitForRecentTrades(7004, "Valid limits: 1 min - 1000 max", HttpStatus.BAD_REQUEST), InvalidPriceChangeDuration(7005, "Valid durations: [24h, 7d, 1m]", HttpStatus.BAD_REQUEST), - CancelOrderNotAllowed(7006, "Canceling this order is not allowed", HttpStatus.FORBIDDEN); + CancelOrderNotAllowed(7006, "Canceling this order is not allowed", HttpStatus.FORBIDDEN), + InvalidInterval(7007, "Invalid interval", HttpStatus.BAD_REQUEST); companion object { fun findByCode(code: Int?): OpexError? {