diff --git a/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt index 7eb2082..b0645bd 100644 --- a/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt +++ b/src/main/kotlin/team/cklob/arena/domain/challenge/Challenge.kt @@ -15,7 +15,7 @@ import jakarta.persistence.OneToOne import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint import org.springframework.data.jpa.repository.JpaRepository -import team.cklob.arena.domain.market.MarketType +import team.cklob.arena.domain.market.domain.type.MarketType import team.cklob.arena.domain.user.domain.entity.User import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/domain/market/Market.kt b/src/main/kotlin/team/cklob/arena/domain/market/Market.kt deleted file mode 100644 index b59a365..0000000 --- a/src/main/kotlin/team/cklob/arena/domain/market/Market.kt +++ /dev/null @@ -1,72 +0,0 @@ -package team.cklob.arena.domain.market - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.FetchType -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id -import jakarta.persistence.Index -import jakarta.persistence.JoinColumn -import jakarta.persistence.ManyToOne -import jakarta.persistence.Table -import jakarta.persistence.UniqueConstraint -import org.springframework.data.jpa.repository.JpaRepository -import java.math.BigDecimal -import java.time.LocalDateTime - -enum class MarketType { - KR, - US, - COIN, -} - -@Entity -@Table( - name = "symbols", - uniqueConstraints = [ - UniqueConstraint(name = "uq_symbols_market_code", columnNames = ["market", "code"]), - ], -) -class Symbol( - @Enumerated(EnumType.STRING) - @Column(name = "market", nullable = false, length = 10) - var market: MarketType, - @Column(name = "code", nullable = false, length = 20) - var code: String, - @Column(name = "name", nullable = false, length = 100) - var name: String, - @Column(name = "is_active", nullable = false) - var isActive: Boolean = true, -) { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null -} - -interface SymbolRepository : JpaRepository - -@Entity -@Table( - name = "price_snapshots", - indexes = [ - Index(name = "idx_price_snapshots_symbol_snapshot", columnList = "symbol_id, snapshot_at"), - ], -) -class PriceSnapshot( - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "symbol_id", nullable = false) - var symbol: Symbol, - @Column(name = "price", nullable = false, precision = 18, scale = 4) - var price: BigDecimal, - @Column(name = "snapshot_at", nullable = false) - var snapshotAt: LocalDateTime, -) { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - var id: Long? = null -} - -interface PriceSnapshotRepository : JpaRepository diff --git a/src/main/kotlin/team/cklob/arena/domain/market/MarketErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/market/MarketErrorCode.kt new file mode 100644 index 0000000..e8e20b8 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/MarketErrorCode.kt @@ -0,0 +1,16 @@ +package team.cklob.arena.domain.market + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class MarketErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + MARKET_NOT_ACTIVE(HttpStatus.CONFLICT, "현재 이용할 수 없는 시장입니다."), + SYMBOL_NOT_FOUND(HttpStatus.NOT_FOUND, "종목을 찾을 수 없습니다."), + SYMBOL_NOT_ACTIVE(HttpStatus.CONFLICT, "현재 이용할 수 없는 종목입니다."), + MARKET_DATA_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "시세 정보를 일시적으로 조회할 수 없습니다."), + SYMBOL_SYNC_REJECTED(HttpStatus.SERVICE_UNAVAILABLE, "종목 동기화 응답이 불완전하여 기존 데이터를 유지합니다."), + INVALID_PRICE_HISTORY_RANGE(HttpStatus.BAD_REQUEST, "가격 조회 기간은 최대 7일이며 시작일이 종료일보다 늦을 수 없습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceService.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceService.kt new file mode 100644 index 0000000..d98e8c4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceService.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.CurrentPriceResult + +interface GetCurrentPriceService { + fun execute(symbolId: Long): CurrentPriceResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryService.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryService.kt new file mode 100644 index 0000000..57d2790 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryService.kt @@ -0,0 +1,12 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.PriceHistoryResult +import java.time.LocalDate + +interface GetPriceHistoryService { + fun execute( + symbolId: Long, + from: LocalDate, + to: LocalDate, + ): PriceHistoryResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/GetSymbolsService.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/GetSymbolsService.kt new file mode 100644 index 0000000..088865e --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/GetSymbolsService.kt @@ -0,0 +1,12 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.SymbolPageResult +import team.cklob.arena.domain.market.domain.type.MarketType + +interface GetSymbolsService { + fun execute( + market: MarketType, + page: Int, + size: Int, + ): SymbolPageResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataCache.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataCache.kt new file mode 100644 index 0000000..d9db123 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataCache.kt @@ -0,0 +1,22 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.CurrentPriceResult +import team.cklob.arena.domain.market.application.result.PriceHistoryResult +import team.cklob.arena.domain.market.domain.type.MarketType + +interface MarketDataCache { + fun findCurrentPrice(symbolId: Long): CurrentPriceResult? + + fun saveCurrentPrice( + market: MarketType, + result: CurrentPriceResult, + ) + + fun findPriceHistory( + symbolId: Long, + from: String, + to: String, + ): PriceHistoryResult? + + fun savePriceHistory(result: PriceHistoryResult) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataClient.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataClient.kt new file mode 100644 index 0000000..e10d619 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketDataClient.kt @@ -0,0 +1,18 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.ExternalSymbolResult +import team.cklob.arena.domain.market.application.result.PricePointResult +import team.cklob.arena.domain.market.application.result.PriceQuoteResult +import java.time.LocalDate + +interface MarketDataClient { + fun fetchSymbols(): List + + fun fetchCurrentPrice(code: String): PriceQuoteResult + + fun fetchPriceHistory( + code: String, + from: LocalDate, + to: LocalDate, + ): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/MarketValidator.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketValidator.kt new file mode 100644 index 0000000..7480f93 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/MarketValidator.kt @@ -0,0 +1,26 @@ +package team.cklob.arena.domain.market.application + +import org.springframework.stereotype.Component +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException + +@Component +class MarketValidator( + private val symbolRepository: SymbolRepository, + private val marketProperties: MarketProperties, +) { + fun requireActiveMarket(market: MarketType) { + if (market !in marketProperties.activeMarkets) throw ExpectedException(MarketErrorCode.MARKET_NOT_ACTIVE) + } + + fun findActiveSymbol(symbolId: Long): Symbol { + val symbol = symbolRepository.findById(symbolId).orElseThrow { ExpectedException(MarketErrorCode.SYMBOL_NOT_FOUND) } + requireActiveMarket(symbol.market) + if (!symbol.isActive) throw ExpectedException(MarketErrorCode.SYMBOL_NOT_ACTIVE) + return symbol + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/SearchSymbolsService.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/SearchSymbolsService.kt new file mode 100644 index 0000000..6ef64bb --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/SearchSymbolsService.kt @@ -0,0 +1,13 @@ +package team.cklob.arena.domain.market.application + +import team.cklob.arena.domain.market.application.result.SymbolPageResult +import team.cklob.arena.domain.market.domain.type.MarketType + +interface SearchSymbolsService { + fun execute( + market: MarketType, + keyword: String, + page: Int, + size: Int, + ): SymbolPageResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsService.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsService.kt new file mode 100644 index 0000000..5a637ff --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsService.kt @@ -0,0 +1,5 @@ +package team.cklob.arena.domain.market.application + +interface SyncSymbolsService { + fun execute() +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetCurrentPriceServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetCurrentPriceServiceImpl.kt new file mode 100644 index 0000000..f68b502 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetCurrentPriceServiceImpl.kt @@ -0,0 +1,25 @@ +package team.cklob.arena.domain.market.application.impl + +import org.springframework.stereotype.Service +import team.cklob.arena.domain.market.application.GetCurrentPriceService +import team.cklob.arena.domain.market.application.MarketDataCache +import team.cklob.arena.domain.market.application.MarketDataClient +import team.cklob.arena.domain.market.application.MarketValidator +import team.cklob.arena.domain.market.application.result.CurrentPriceResult + +@Service +class GetCurrentPriceServiceImpl( + private val marketValidator: MarketValidator, + private val marketDataCache: MarketDataCache, + private val marketDataClient: MarketDataClient, +) : GetCurrentPriceService { + override fun execute(symbolId: Long): CurrentPriceResult { + val symbol = marketValidator.findActiveSymbol(symbolId) + + return marketDataCache.findCurrentPrice(symbolId) ?: marketDataClient.fetchCurrentPrice(symbol.code).let { + CurrentPriceResult(symbolId, it.price, it.snapshotAt).also { result -> + marketDataCache.saveCurrentPrice(symbol.market, result) + } + } + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetPriceHistoryServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetPriceHistoryServiceImpl.kt new file mode 100644 index 0000000..af9d667 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetPriceHistoryServiceImpl.kt @@ -0,0 +1,41 @@ +package team.cklob.arena.domain.market.application.impl + +import org.springframework.stereotype.Service +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.GetPriceHistoryService +import team.cklob.arena.domain.market.application.MarketDataCache +import team.cklob.arena.domain.market.application.MarketDataClient +import team.cklob.arena.domain.market.application.MarketValidator +import team.cklob.arena.domain.market.application.result.PriceHistoryResult +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +@Service +class GetPriceHistoryServiceImpl( + private val marketValidator: MarketValidator, + private val marketDataCache: MarketDataCache, + private val marketDataClient: MarketDataClient, + private val marketProperties: MarketProperties, +) : GetPriceHistoryService { + override fun execute( + symbolId: Long, + from: LocalDate, + to: LocalDate, + ): PriceHistoryResult { + val days = ChronoUnit.DAYS.between(from, to) + 1 + if (days !in 1..marketProperties.historyMaxDays) { + throw ExpectedException(MarketErrorCode.INVALID_PRICE_HISTORY_RANGE) + } + val symbol = marketValidator.findActiveSymbol(symbolId) + + return marketDataCache.findPriceHistory(symbolId, from.toString(), to.toString()) + ?: PriceHistoryResult( + symbolId = symbolId, + from = from.toString(), + to = to.toString(), + prices = marketDataClient.fetchPriceHistory(symbol.code, from, to).sortedBy { it.snapshotAt }, + ).also(marketDataCache::savePriceHistory) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetSymbolsServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetSymbolsServiceImpl.kt new file mode 100644 index 0000000..6259b93 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/GetSymbolsServiceImpl.kt @@ -0,0 +1,28 @@ +package team.cklob.arena.domain.market.application.impl + +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.arena.domain.market.application.GetSymbolsService +import team.cklob.arena.domain.market.application.MarketValidator +import team.cklob.arena.domain.market.application.result.SymbolPageResult +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType + +@Service +class GetSymbolsServiceImpl( + private val symbolRepository: SymbolRepository, + private val marketValidator: MarketValidator, +) : GetSymbolsService { + @Transactional(readOnly = true) + override fun execute( + market: MarketType, + page: Int, + size: Int, + ): SymbolPageResult { + marketValidator.requireActiveMarket(market) + val pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "code")) + return SymbolPageResult.from(symbolRepository.findAllByMarketAndIsActiveTrue(market, pageable)) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SearchSymbolsServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SearchSymbolsServiceImpl.kt new file mode 100644 index 0000000..09e64d5 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SearchSymbolsServiceImpl.kt @@ -0,0 +1,39 @@ +package team.cklob.arena.domain.market.application.impl + +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.arena.domain.market.application.MarketValidator +import team.cklob.arena.domain.market.application.SearchSymbolsService +import team.cklob.arena.domain.market.application.result.SymbolPageResult +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.global.exception.CommonErrorCode +import team.cklob.arena.global.exception.ExpectedException + +@Service +class SearchSymbolsServiceImpl( + private val symbolRepository: SymbolRepository, + private val marketValidator: MarketValidator, +) : SearchSymbolsService { + @Transactional(readOnly = true) + override fun execute( + market: MarketType, + keyword: String, + page: Int, + size: Int, + ): SymbolPageResult { + marketValidator.requireActiveMarket(market) + val normalizedKeyword = + keyword.trim().takeIf { it.isNotEmpty() && it.length <= 100 } + ?: throw ExpectedException(CommonErrorCode.INVALID_REQUEST) + val pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "code")) + return SymbolPageResult.from(symbolRepository.searchActive(market, normalizedKeyword.escapeLikePattern(), pageable)) + } + + private fun String.escapeLikePattern(): String = + replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SyncSymbolsServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SyncSymbolsServiceImpl.kt new file mode 100644 index 0000000..e1f9c26 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/impl/SyncSymbolsServiceImpl.kt @@ -0,0 +1,61 @@ +package team.cklob.arena.domain.market.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.MarketDataClient +import team.cklob.arena.domain.market.application.SyncSymbolsService +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException + +@Service +class SyncSymbolsServiceImpl( + private val marketDataClient: MarketDataClient, + private val symbolRepository: SymbolRepository, + private val transactionTemplate: TransactionTemplate, + private val marketProperties: MarketProperties, +) : SyncSymbolsService { + override fun execute() { + val synchronized = + marketDataClient.fetchSymbols() + .map { it.copy(code = it.code.trim().uppercase(), name = it.name.trim()) } + .filter { it.code.isNotEmpty() && it.name.isNotEmpty() } + .distinctBy { it.market to it.code } + .groupBy { it.market } + + require(synchronized[MarketType.US].orEmpty().isNotEmpty()) + require(synchronized[MarketType.COIN].orEmpty().isNotEmpty()) + + transactionTemplate.executeWithoutResult { + val existingByMarket = synchronized.keys.associateWith { symbolRepository.findAllByMarket(it).associateBy(Symbol::code) } + synchronized.forEach { (market, incoming) -> + validateDeactivationRatio(existingByMarket.getValue(market).values, incoming.mapTo(mutableSetOf()) { it.code }) + } + + synchronized.forEach { (market, incoming) -> + val existing = existingByMarket.getValue(market) + val incomingCodes = incoming.mapTo(mutableSetOf()) { it.code } + incoming.forEach { external -> + existing[external.code]?.synchronize(external.name) + ?: symbolRepository.save(Symbol(external.market, external.code, external.name)) + } + existing.values.filterNot { it.code in incomingCodes }.forEach(Symbol::deactivate) + } + } + } + + private fun validateDeactivationRatio( + existing: Collection, + incomingCodes: Set, + ) { + val active = existing.filter(Symbol::isActive) + if (active.isEmpty()) return + val ratio = active.count { it.code !in incomingCodes }.toDouble() / active.size + if (ratio > marketProperties.symbolSyncMaxDeactivationRatio) { + throw ExpectedException(MarketErrorCode.SYMBOL_SYNC_REJECTED) + } + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/CurrentPriceResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/CurrentPriceResult.kt new file mode 100644 index 0000000..fd0c72c --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/CurrentPriceResult.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.domain.market.application.result + +import java.math.BigDecimal +import java.time.Instant + +data class CurrentPriceResult( + val symbolId: Long, + val price: BigDecimal, + val snapshotAt: Instant, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/ExternalSymbolResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/ExternalSymbolResult.kt new file mode 100644 index 0000000..4414ac2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/ExternalSymbolResult.kt @@ -0,0 +1,9 @@ +package team.cklob.arena.domain.market.application.result + +import team.cklob.arena.domain.market.domain.type.MarketType + +data class ExternalSymbolResult( + val market: MarketType, + val code: String, + val name: String, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceHistoryResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceHistoryResult.kt new file mode 100644 index 0000000..8658a42 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceHistoryResult.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.market.application.result + +data class PriceHistoryResult( + val symbolId: Long, + val from: String, + val to: String, + val prices: List, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/PricePointResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PricePointResult.kt new file mode 100644 index 0000000..9f698aa --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PricePointResult.kt @@ -0,0 +1,9 @@ +package team.cklob.arena.domain.market.application.result + +import java.math.BigDecimal +import java.time.Instant + +data class PricePointResult( + val price: BigDecimal, + val snapshotAt: Instant, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceQuoteResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceQuoteResult.kt new file mode 100644 index 0000000..b1610cb --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/PriceQuoteResult.kt @@ -0,0 +1,9 @@ +package team.cklob.arena.domain.market.application.result + +import java.math.BigDecimal +import java.time.Instant + +data class PriceQuoteResult( + val price: BigDecimal, + val snapshotAt: Instant, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolPageResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolPageResult.kt new file mode 100644 index 0000000..5ee83e2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolPageResult.kt @@ -0,0 +1,23 @@ +package team.cklob.arena.domain.market.application.result + +import org.springframework.data.domain.Page +import team.cklob.arena.domain.market.domain.entity.Symbol + +data class SymbolPageResult( + val symbols: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) { + companion object { + fun from(symbols: Page): SymbolPageResult = + SymbolPageResult( + symbols = symbols.content.map(SymbolSummaryResult::from), + page = symbols.number, + size = symbols.size, + totalElements = symbols.totalElements, + totalPages = symbols.totalPages, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolSummaryResult.kt b/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolSummaryResult.kt new file mode 100644 index 0000000..d8b80ad --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/application/result/SymbolSummaryResult.kt @@ -0,0 +1,20 @@ +package team.cklob.arena.domain.market.application.result + +import team.cklob.arena.domain.market.domain.entity.Symbol + +data class SymbolSummaryResult( + val symbolId: Long, + val code: String, + val name: String, + val isActive: Boolean, +) { + companion object { + fun from(symbol: Symbol): SymbolSummaryResult = + SymbolSummaryResult( + symbolId = requireNotNull(symbol.id), + code = symbol.code, + name = symbol.name, + isActive = symbol.isActive, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/PriceSnapshot.kt b/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/PriceSnapshot.kt new file mode 100644 index 0000000..dc17877 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/PriceSnapshot.kt @@ -0,0 +1,35 @@ +package team.cklob.arena.domain.market.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.math.BigDecimal +import java.time.LocalDateTime + +@Entity +@Table( + name = "price_snapshots", + indexes = [ + Index(name = "idx_price_snapshots_symbol_snapshot", columnList = "symbol_id, snapshot_at"), + ], +) +class PriceSnapshot( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "symbol_id", nullable = false) + var symbol: Symbol, + @Column(name = "price", nullable = false, precision = 18, scale = 4) + var price: BigDecimal, + @Column(name = "snapshot_at", nullable = false) + var snapshotAt: LocalDateTime, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/Symbol.kt b/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/Symbol.kt new file mode 100644 index 0000000..f2b03e9 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/domain/entity/Symbol.kt @@ -0,0 +1,44 @@ +package team.cklob.arena.domain.market.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import team.cklob.arena.domain.market.domain.type.MarketType + +@Entity +@Table( + name = "symbols", + uniqueConstraints = [ + UniqueConstraint(name = "uq_symbols_market_code", columnNames = ["market", "code"]), + ], +) +class Symbol( + @Enumerated(EnumType.STRING) + @Column(name = "market", nullable = false, length = 10) + val market: MarketType, + @Column(name = "code", nullable = false, length = 30) + val code: String, + @Column(name = "name", nullable = false, length = 150) + var name: String, + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + fun synchronize(name: String) { + this.name = name + isActive = true + } + + fun deactivate() { + isActive = false + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/PriceSnapshotRepository.kt b/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/PriceSnapshotRepository.kt new file mode 100644 index 0000000..c3bfc01 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/PriceSnapshotRepository.kt @@ -0,0 +1,6 @@ +package team.cklob.arena.domain.market.domain.repository + +import org.springframework.data.jpa.repository.JpaRepository +import team.cklob.arena.domain.market.domain.entity.PriceSnapshot + +interface PriceSnapshotRepository : JpaRepository diff --git a/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/SymbolRepository.kt b/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/SymbolRepository.kt new file mode 100644 index 0000000..59ce1ed --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/domain/repository/SymbolRepository.kt @@ -0,0 +1,34 @@ +package team.cklob.arena.domain.market.domain.repository + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.type.MarketType + +interface SymbolRepository : JpaRepository { + fun findAllByMarketAndIsActiveTrue( + market: MarketType, + pageable: Pageable, + ): Page + + @Query( + """ + SELECT symbol + FROM Symbol symbol + WHERE symbol.market = :market + AND symbol.isActive = true + AND (LOWER(symbol.code) LIKE LOWER(CONCAT('%', :keyword, '%')) ESCAPE '\' + OR LOWER(symbol.name) LIKE LOWER(CONCAT('%', :keyword, '%')) ESCAPE '\') + """, + ) + fun searchActive( + @Param("market") market: MarketType, + @Param("keyword") keyword: String, + pageable: Pageable, + ): Page + + fun findAllByMarket(market: MarketType): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/domain/type/MarketType.kt b/src/main/kotlin/team/cklob/arena/domain/market/domain/type/MarketType.kt new file mode 100644 index 0000000..5bfc2b4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/domain/type/MarketType.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.market.domain.type + +enum class MarketType { + KR, + US, + COIN, +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCache.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCache.kt new file mode 100644 index 0000000..3d6d7c4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCache.kt @@ -0,0 +1,78 @@ +package team.cklob.arena.domain.market.infrastructure + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.dao.DataAccessException +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import team.cklob.arena.domain.market.application.MarketDataCache +import team.cklob.arena.domain.market.application.result.CurrentPriceResult +import team.cklob.arena.domain.market.application.result.PriceHistoryResult +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import java.time.Duration + +@Component +class RedisMarketDataCache( + private val redisTemplate: StringRedisTemplate, + private val objectMapper: ObjectMapper, + private val properties: MarketProperties, +) : MarketDataCache { + private val log = LoggerFactory.getLogger(javaClass) + + override fun findCurrentPrice(symbolId: Long): CurrentPriceResult? = read(priceKey(symbolId), CurrentPriceResult::class.java) + + override fun saveCurrentPrice( + market: MarketType, + result: CurrentPriceResult, + ) { + write(priceKey(result.symbolId), result, properties.quoteTtl[market] ?: Duration.ofSeconds(60)) + } + + override fun findPriceHistory( + symbolId: Long, + from: String, + to: String, + ): PriceHistoryResult? = read(historyKey(symbolId, from, to), PriceHistoryResult::class.java) + + override fun savePriceHistory(result: PriceHistoryResult) { + write(historyKey(result.symbolId, result.from, result.to), result, properties.historyTtl) + } + + private fun read( + key: String, + type: Class, + ): T? = + try { + redisTemplate.opsForValue().get(key)?.let { objectMapper.readValue(it, type) } + } catch (exception: DataAccessException) { + log.warn("Market cache read failed key={}", key, exception) + null + } catch (exception: JsonProcessingException) { + log.warn("Market cache value is invalid key={}", key, exception) + null + } + + private fun write( + key: String, + value: Any, + ttl: Duration, + ) { + try { + redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl) + } catch (exception: DataAccessException) { + log.warn("Market cache write failed key={}", key, exception) + } catch (exception: JsonProcessingException) { + log.warn("Market cache serialization failed key={}", key, exception) + } + } + + private fun priceKey(symbolId: Long) = "market:price:$symbolId" + + private fun historyKey( + symbolId: Long, + from: String, + to: String, + ) = "market:history:$symbolId:$from:$to:1h" +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/SymbolSyncTrigger.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/SymbolSyncTrigger.kt new file mode 100644 index 0000000..203a063 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/SymbolSyncTrigger.kt @@ -0,0 +1,29 @@ +package team.cklob.arena.domain.market.infrastructure + +import org.slf4j.LoggerFactory +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import team.cklob.arena.domain.market.application.SyncSymbolsService +import team.cklob.arena.domain.market.domain.repository.SymbolRepository + +@Component +@ConditionalOnProperty(prefix = "external.twelve-data", name = ["enabled"], havingValue = "true") +class SymbolSyncTrigger( + private val syncSymbolsService: SyncSymbolsService, + private val symbolRepository: SymbolRepository, +) : ApplicationRunner { + private val log = LoggerFactory.getLogger(javaClass) + + override fun run(args: ApplicationArguments) { + if (symbolRepository.count() == 0L) synchronize() + } + + @Scheduled(cron = "\${market.sync.cron:0 0 4 * * *}", zone = "\${market.sync.zone:Asia/Seoul}") + fun synchronize() { + runCatching(syncSymbolsService::execute) + .onFailure { log.error("Symbol synchronization failed", it) } + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClient.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClient.kt new file mode 100644 index 0000000..21978d2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClient.kt @@ -0,0 +1,148 @@ +package team.cklob.arena.domain.market.infrastructure + +import org.springframework.http.client.JdkClientHttpRequestFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestClientException +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.MarketDataClient +import team.cklob.arena.domain.market.application.result.ExternalSymbolResult +import team.cklob.arena.domain.market.application.result.PricePointResult +import team.cklob.arena.domain.market.application.result.PriceQuoteResult +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.dto.TwelveDataQuoteResponse +import team.cklob.arena.domain.market.infrastructure.dto.TwelveDataSymbol +import team.cklob.arena.domain.market.infrastructure.dto.TwelveDataSymbolListResponse +import team.cklob.arena.domain.market.infrastructure.dto.TwelveDataTimeSeriesResponse +import team.cklob.arena.domain.market.infrastructure.property.TwelveDataProperties +import team.cklob.arena.global.exception.ExpectedException +import java.net.http.HttpClient +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +@Component +class TwelveDataMarketClient( + restClientBuilder: RestClient.Builder, + private val properties: TwelveDataProperties, +) : MarketDataClient { + private val restClient = + restClientBuilder + .clone() + .baseUrl(properties.baseUrl) + .requestFactory( + JdkClientHttpRequestFactory( + HttpClient.newBuilder().connectTimeout(properties.connectTimeout).build(), + ).apply { setReadTimeout(properties.readTimeout) }, + ).build() + + override fun fetchSymbols(): List = + executeRequest { + val stocks = + fetchSymbolList("/stocks", "country" to "United States", "type" to "Common Stock") + .mapNotNull { symbol -> + val code = symbol.symbol?.trim()?.takeIf(String::isNotEmpty) ?: return@mapNotNull null + val name = symbol.name?.trim()?.takeIf(String::isNotEmpty) ?: return@mapNotNull null + ExternalSymbolResult(MarketType.US, code, name) + } + val coins = + fetchSymbolList("/cryptocurrencies").mapNotNull { symbol -> + val code = + symbol.symbol?.trim()?.takeIf { it.endsWith("/USD", ignoreCase = true) } + ?: return@mapNotNull null + val name = + symbol.currencyBase?.trim()?.takeIf(String::isNotEmpty) + ?: symbol.name?.trim()?.takeIf(String::isNotEmpty) + ?: code.substringBefore('/') + ExternalSymbolResult(MarketType.COIN, code, name) + } + (stocks + coins).takeIf(List::isNotEmpty) ?: unavailable() + } + + override fun fetchCurrentPrice(code: String): PriceQuoteResult = + executeRequest { + val response = + restClient.get() + .uri { builder -> + builder.path("/quote") + .queryParam("symbol", code) + .queryParam("apikey", requireApiKey()) + .build() + }.retrieve() + .body(TwelveDataQuoteResponse::class.java) + ?: unavailable() + if (response.status == "error") unavailable() + PriceQuoteResult( + price = response.close?.toBigDecimalOrNull() ?: unavailable(), + snapshotAt = response.timestamp?.let(Instant::ofEpochSecond) ?: unavailable(), + ) + } + + override fun fetchPriceHistory( + code: String, + from: LocalDate, + to: LocalDate, + ): List = + executeRequest { + val response = + restClient.get() + .uri { builder -> + builder.path("/time_series") + .queryParam("symbol", code) + .queryParam("interval", "1h") + .queryParam("start_date", from) + .queryParam("end_date", to) + .queryParam("timezone", "UTC") + .queryParam("order", "ASC") + .queryParam("apikey", requireApiKey()) + .build() + }.retrieve() + .body(TwelveDataTimeSeriesResponse::class.java) + ?: unavailable() + if (response.status == "error") unavailable() + response.values.orEmpty().mapNotNull { value -> + val price = value.close?.toBigDecimalOrNull() ?: return@mapNotNull null + val snapshotAt = value.datetime?.let(::parseInstant) ?: return@mapNotNull null + PricePointResult(price, snapshotAt) + }.takeIf(List::isNotEmpty) ?: unavailable() + } + + private fun fetchSymbolList( + path: String, + vararg filters: Pair, + ): List = + restClient.get() + .uri { builder -> + builder.path(path).apply { + filters.forEach { (name, value) -> queryParam(name, value) } + }.queryParam("apikey", requireApiKey()).build() + }.retrieve() + .body(TwelveDataSymbolListResponse::class.java) + ?.takeUnless { it.status == "error" } + ?.data + ?.takeIf { it.isNotEmpty() } + ?: unavailable() + + private fun requireApiKey(): String = properties.apiKey.takeIf(String::isNotBlank) ?: unavailable() + + private fun parseInstant(value: String): Instant = LocalDateTime.parse(value, DATE_TIME_FORMATTER).toInstant(ZoneOffset.UTC) + + private fun executeRequest(block: () -> T): T = + try { + block() + } catch (exception: ExpectedException) { + throw exception + } catch (exception: RestClientException) { + unavailable() + } catch (exception: IllegalArgumentException) { + unavailable() + } + + private fun unavailable(): Nothing = throw ExpectedException(MarketErrorCode.MARKET_DATA_UNAVAILABLE) + + private companion object { + val DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataQuoteResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataQuoteResponse.kt new file mode 100644 index 0000000..f515daf --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataQuoteResponse.kt @@ -0,0 +1,9 @@ +package team.cklob.arena.domain.market.infrastructure.dto + +data class TwelveDataQuoteResponse( + val close: String?, + val timestamp: Long?, + val status: String?, + val code: Int?, + val message: String?, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbol.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbol.kt new file mode 100644 index 0000000..ac60954 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbol.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.domain.market.infrastructure.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class TwelveDataSymbol( + val symbol: String?, + val name: String?, + val country: String?, + val type: String?, + @field:JsonProperty("currency_base") + val currencyBase: String?, + @field:JsonProperty("currency_quote") + val currencyQuote: String?, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbolListResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbolListResponse.kt new file mode 100644 index 0000000..650f411 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataSymbolListResponse.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.market.infrastructure.dto + +data class TwelveDataSymbolListResponse( + val data: List?, + val status: String?, + val code: Int?, + val message: String?, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesResponse.kt new file mode 100644 index 0000000..e912ded --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesResponse.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.market.infrastructure.dto + +data class TwelveDataTimeSeriesResponse( + val values: List?, + val status: String?, + val code: Int?, + val message: String?, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesValue.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesValue.kt new file mode 100644 index 0000000..717874b --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/dto/TwelveDataTimeSeriesValue.kt @@ -0,0 +1,6 @@ +package team.cklob.arena.domain.market.infrastructure.dto + +data class TwelveDataTimeSeriesValue( + val datetime: String?, + val close: String?, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/MarketProperties.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/MarketProperties.kt new file mode 100644 index 0000000..c8ff8f3 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/MarketProperties.kt @@ -0,0 +1,18 @@ +package team.cklob.arena.domain.market.infrastructure.property + +import org.springframework.boot.context.properties.ConfigurationProperties +import team.cklob.arena.domain.market.domain.type.MarketType +import java.time.Duration + +@ConfigurationProperties(prefix = "market") +data class MarketProperties( + val activeMarkets: Set = setOf(MarketType.US, MarketType.COIN), + val quoteTtl: Map = + mapOf( + MarketType.US to Duration.ofSeconds(60), + MarketType.COIN to Duration.ofSeconds(15), + ), + val historyTtl: Duration = Duration.ofMinutes(15), + val historyMaxDays: Long = 7, + val symbolSyncMaxDeactivationRatio: Double = 0.2, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/TwelveDataProperties.kt b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/TwelveDataProperties.kt new file mode 100644 index 0000000..114db77 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/infrastructure/property/TwelveDataProperties.kt @@ -0,0 +1,13 @@ +package team.cklob.arena.domain.market.infrastructure.property + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration + +@ConfigurationProperties(prefix = "external.twelve-data") +data class TwelveDataProperties( + val enabled: Boolean = false, + val baseUrl: String = "https://api.twelvedata.com", + val apiKey: String = "", + val connectTimeout: Duration = Duration.ofSeconds(2), + val readTimeout: Duration = Duration.ofSeconds(4), +) diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetCurrentPriceController.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetCurrentPriceController.kt new file mode 100644 index 0000000..123eb0f --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetCurrentPriceController.kt @@ -0,0 +1,33 @@ +package team.cklob.arena.domain.market.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.market.application.GetCurrentPriceService +import team.cklob.arena.domain.market.presentation.response.CurrentPriceResponse + +@RestController +class GetCurrentPriceController( + private val getCurrentPriceService: GetCurrentPriceService, +) { + @GetMapping("/symbols/{symbolId}/price") + @Operation( + summary = "현재가 조회", + description = "Data provided by Twelve Data.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "현재가 조회 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "404", description = "종목 없음"), + ApiResponse(responseCode = "409", description = "비활성 시장 또는 종목"), + ApiResponse(responseCode = "503", description = "외부 시세 조회 실패"), + ) + fun execute( + @PathVariable symbolId: Long, + ): CurrentPriceResponse = CurrentPriceResponse.from(getCurrentPriceService.execute(symbolId)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetPriceHistoryController.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetPriceHistoryController.kt new file mode 100644 index 0000000..5096eca --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetPriceHistoryController.kt @@ -0,0 +1,39 @@ +package team.cklob.arena.domain.market.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.market.application.GetPriceHistoryService +import team.cklob.arena.domain.market.presentation.response.PriceHistoryResponse +import java.time.LocalDate + +@RestController +class GetPriceHistoryController( + private val getPriceHistoryService: GetPriceHistoryService, +) { + @GetMapping("/symbols/{symbolId}/price-history") + @Operation( + summary = "가격 히스토리 조회", + description = "UTC 기준 1시간봉을 최대 7일 조회합니다. Data provided by Twelve Data.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "가격 히스토리 조회 성공"), + ApiResponse(responseCode = "400", description = "잘못된 날짜 범위"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "404", description = "종목 없음"), + ApiResponse(responseCode = "409", description = "비활성 시장 또는 종목"), + ApiResponse(responseCode = "503", description = "외부 시세 조회 실패"), + ) + fun execute( + @PathVariable symbolId: Long, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate, + ): PriceHistoryResponse = PriceHistoryResponse.from(getPriceHistoryService.execute(symbolId, from, to)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetSymbolsController.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetSymbolsController.kt new file mode 100644 index 0000000..065d1d0 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/GetSymbolsController.kt @@ -0,0 +1,39 @@ +package team.cklob.arena.domain.market.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.market.application.GetSymbolsService +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.presentation.response.SymbolPageResponse + +@Validated +@RestController +class GetSymbolsController( + private val getSymbolsService: GetSymbolsService, +) { + @GetMapping("/symbols") + @Operation( + summary = "마켓별 종목 목록 조회", + description = "활성 종목을 코드 오름차순으로 조회합니다.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "종목 목록 조회 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "409", description = "비활성 시장"), + ) + fun execute( + @RequestParam market: MarketType, + @RequestParam(defaultValue = "0") @Min(0) page: Int, + @RequestParam(defaultValue = "50") @Min(1) @Max(100) size: Int, + ): SymbolPageResponse = SymbolPageResponse.from(getSymbolsService.execute(market, page, size)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/SearchSymbolsController.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/SearchSymbolsController.kt new file mode 100644 index 0000000..bfd48d0 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/controller/SearchSymbolsController.kt @@ -0,0 +1,40 @@ +package team.cklob.arena.domain.market.presentation.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.market.application.SearchSymbolsService +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.presentation.response.SymbolPageResponse + +@Validated +@RestController +class SearchSymbolsController( + private val searchSymbolsService: SearchSymbolsService, +) { + @GetMapping("/symbols/search") + @Operation( + summary = "종목 검색", + description = "종목 코드 또는 이름을 대소문자 구분 없이 부분 검색합니다.", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + ApiResponse(responseCode = "200", description = "종목 검색 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "409", description = "비활성 시장"), + ) + fun execute( + @RequestParam market: MarketType, + @RequestParam keyword: String, + @RequestParam(defaultValue = "0") @Min(0) page: Int, + @RequestParam(defaultValue = "50") @Min(1) @Max(100) size: Int, + ): SymbolPageResponse = SymbolPageResponse.from(searchSymbolsService.execute(market, keyword, page, size)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/CurrentPriceResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/CurrentPriceResponse.kt new file mode 100644 index 0000000..0198319 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/CurrentPriceResponse.kt @@ -0,0 +1,19 @@ +package team.cklob.arena.domain.market.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.arena.domain.market.application.result.CurrentPriceResult +import java.math.BigDecimal +import java.time.Instant + +data class CurrentPriceResponse( + @field:Schema(example = "1") + val symbolId: Long, + @field:Schema(example = "224.50") + val price: BigDecimal, + @field:Schema(example = "2026-08-02T09:00:00Z") + val snapshotAt: Instant, +) { + companion object { + fun from(result: CurrentPriceResult): CurrentPriceResponse = CurrentPriceResponse(result.symbolId, result.price, result.snapshotAt) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PriceHistoryResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PriceHistoryResponse.kt new file mode 100644 index 0000000..8d489e5 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PriceHistoryResponse.kt @@ -0,0 +1,15 @@ +package team.cklob.arena.domain.market.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.arena.domain.market.application.result.PriceHistoryResult + +data class PriceHistoryResponse( + @field:Schema(example = "1") + val symbolId: Long, + val prices: List, +) { + companion object { + fun from(result: PriceHistoryResult): PriceHistoryResponse = + PriceHistoryResponse(result.symbolId, result.prices.map(PricePointResponse::from)) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PricePointResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PricePointResponse.kt new file mode 100644 index 0000000..df623a9 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/PricePointResponse.kt @@ -0,0 +1,17 @@ +package team.cklob.arena.domain.market.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.arena.domain.market.application.result.PricePointResult +import java.math.BigDecimal +import java.time.Instant + +data class PricePointResponse( + @field:Schema(example = "224.50") + val price: BigDecimal, + @field:Schema(example = "2026-08-02T09:00:00Z") + val snapshotAt: Instant, +) { + companion object { + fun from(result: PricePointResult): PricePointResponse = PricePointResponse(result.price, result.snapshotAt) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolPageResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolPageResponse.kt new file mode 100644 index 0000000..afff901 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolPageResponse.kt @@ -0,0 +1,27 @@ +package team.cklob.arena.domain.market.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.arena.domain.market.application.result.SymbolPageResult + +data class SymbolPageResponse( + val symbols: List, + @field:Schema(example = "0") + val page: Int, + @field:Schema(example = "50") + val size: Int, + @field:Schema(example = "120") + val totalElements: Long, + @field:Schema(example = "3") + val totalPages: Int, +) { + companion object { + fun from(result: SymbolPageResult): SymbolPageResponse = + SymbolPageResponse( + symbols = result.symbols.map(SymbolSummaryResponse::from), + page = result.page, + size = result.size, + totalElements = result.totalElements, + totalPages = result.totalPages, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolSummaryResponse.kt b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolSummaryResponse.kt new file mode 100644 index 0000000..2080c8c --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/market/presentation/response/SymbolSummaryResponse.kt @@ -0,0 +1,20 @@ +package team.cklob.arena.domain.market.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema +import team.cklob.arena.domain.market.application.result.SymbolSummaryResult + +data class SymbolSummaryResponse( + @field:Schema(example = "1") + val symbolId: Long, + @field:Schema(example = "AAPL") + val code: String, + @field:Schema(example = "Apple Inc") + val name: String, + @field:Schema(example = "true") + val isActive: Boolean, +) { + companion object { + fun from(result: SymbolSummaryResult): SymbolSummaryResponse = + SymbolSummaryResponse(result.symbolId, result.code, result.name, result.isActive) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt index adba881..805c977 100644 --- a/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt +++ b/src/main/kotlin/team/cklob/arena/domain/recommendation/Recommendation.kt @@ -15,7 +15,7 @@ import jakarta.persistence.Table import org.hibernate.annotations.CreationTimestamp import org.springframework.data.jpa.repository.JpaRepository import team.cklob.arena.domain.challenge.Challenge -import team.cklob.arena.domain.market.Symbol +import team.cklob.arena.domain.market.domain.entity.Symbol import java.math.BigDecimal import java.time.LocalDateTime diff --git a/src/main/kotlin/team/cklob/arena/global/config/SchedulingConfig.kt b/src/main/kotlin/team/cklob/arena/global/config/SchedulingConfig.kt new file mode 100644 index 0000000..a6a981e --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/global/config/SchedulingConfig.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +class SchedulingConfig diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 61ab058..9786027 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,3 +36,23 @@ oauth: key-id: ${APPLE_KEY_ID:} private-key: ${APPLE_PRIVATE_KEY:} android-redirect-uri: ${APPLE_OAUTH_ANDROID_REDIRECT_URI:} + +market: + active-markets: US,COIN + quote-ttl: + US: PT1M + COIN: PT15S + history-ttl: PT15M + history-max-days: 7 + symbol-sync-max-deactivation-ratio: ${MARKET_SYMBOL_SYNC_MAX_DEACTIVATION_RATIO:0.2} + sync: + cron: ${MARKET_SYMBOL_SYNC_CRON:0 0 4 * * *} + zone: ${MARKET_SYMBOL_SYNC_ZONE:Asia/Seoul} + +external: + twelve-data: + enabled: ${TWELVE_DATA_ENABLED:false} + base-url: ${TWELVE_DATA_BASE_URL:https://api.twelvedata.com} + api-key: ${TWELVE_DATA_API_KEY:} + connect-timeout: PT2S + read-timeout: PT4S diff --git a/src/test/kotlin/team/cklob/arena/domain/market/SymbolRepositoryTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/SymbolRepositoryTest.kt new file mode 100644 index 0000000..2229ecc --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/SymbolRepositoryTest.kt @@ -0,0 +1,56 @@ +package team.cklob.arena.domain.market + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType + +@SpringBootTest +class SymbolRepositoryTest( + private val symbolRepository: SymbolRepository, +) : DescribeSpec({ + extension(SpringExtension) + + beforeEach { + symbolRepository.deleteAll() + symbolRepository.saveAll( + listOf( + Symbol(MarketType.US, "MSFT", "Microsoft Corporation"), + Symbol(MarketType.US, "AAPL", "Apple Inc"), + Symbol(MarketType.US, "OLD", "Old Inc", isActive = false), + Symbol(MarketType.COIN, "BTC/USD", "Bitcoin"), + ), + ) + } + + it("활성 종목만 코드순으로 페이지 조회한다") { + val result = + symbolRepository.findAllByMarketAndIsActiveTrue( + MarketType.US, + PageRequest.of(0, 10, Sort.by("code")), + ) + + result.content.map(Symbol::code) shouldBe listOf("AAPL", "MSFT") + } + + it("코드와 이름을 대소문자 구분 없이 검색한다") { + val result = symbolRepository.searchActive(MarketType.US, "micro", PageRequest.of(0, 10)) + + result.content.map(Symbol::code) shouldBe listOf("MSFT") + } + + it("LIKE 와일드카드를 일반 문자로 검색한다") { + symbolRepository.save(Symbol(MarketType.US, "A_PL", "Literal underscore")) + + val result = symbolRepository.searchActive(MarketType.US, "A\\_PL", PageRequest.of(0, 10)) + + result.content.map(Symbol::code) shouldBe listOf("A_PL") + } + }) { + override fun extensions() = listOf(SpringExtension) +} diff --git a/src/test/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceServiceImplTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceServiceImplTest.kt new file mode 100644 index 0000000..e0c0e1b --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/application/GetCurrentPriceServiceImplTest.kt @@ -0,0 +1,59 @@ +package team.cklob.arena.domain.market.application + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.impl.GetCurrentPriceServiceImpl +import team.cklob.arena.domain.market.application.result.CurrentPriceResult +import team.cklob.arena.domain.market.application.result.PriceQuoteResult +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException +import java.math.BigDecimal +import java.time.Instant +import java.util.Optional + +class GetCurrentPriceServiceImplTest : DescribeSpec({ + val symbolRepository = mockk() + val cache = mockk(relaxed = true) + val client = mockk() + val service = GetCurrentPriceServiceImpl(MarketValidator(symbolRepository, MarketProperties()), cache, client) + val symbol = Symbol(MarketType.US, "AAPL", "Apple Inc").apply { id = 1L } + val cached = CurrentPriceResult(1L, BigDecimal("220.10"), Instant.parse("2026-08-02T09:00:00Z")) + + beforeEach { + every { symbolRepository.findById(1L) } returns Optional.of(symbol) + } + + it("캐시된 현재가가 있으면 외부 API를 호출하지 않는다") { + every { cache.findCurrentPrice(1L) } returns cached + + service.execute(1L) shouldBe cached + + verify(exactly = 0) { client.fetchCurrentPrice(any()) } + } + + it("캐시 miss이면 외부 현재가를 조회해 캐시에 저장한다") { + val quote = PriceQuoteResult(BigDecimal("221.20"), Instant.parse("2026-08-02T09:01:00Z")) + every { cache.findCurrentPrice(1L) } returns null + every { client.fetchCurrentPrice("AAPL") } returns quote + + val result = service.execute(1L) + + result.price shouldBe quote.price + verify(exactly = 1) { cache.saveCurrentPrice(MarketType.US, result) } + } + + it("비활성 시장의 현재가 요청을 거절한다") { + val krSymbol = Symbol(MarketType.KR, "005930", "삼성전자").apply { id = 2L } + every { symbolRepository.findById(2L) } returns Optional.of(krSymbol) + + shouldThrow { service.execute(2L) }.errorCode shouldBe MarketErrorCode.MARKET_NOT_ACTIVE + } +}) diff --git a/src/test/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryServiceImplTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryServiceImplTest.kt new file mode 100644 index 0000000..19348d3 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/application/GetPriceHistoryServiceImplTest.kt @@ -0,0 +1,56 @@ +package team.cklob.arena.domain.market.application + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldBeSortedBy +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.impl.GetPriceHistoryServiceImpl +import team.cklob.arena.domain.market.application.result.PricePointResult +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate +import java.util.Optional + +class GetPriceHistoryServiceImplTest : DescribeSpec({ + val symbolRepository = mockk() + val cache = mockk(relaxed = true) + val client = mockk() + val service = GetPriceHistoryServiceImpl(MarketValidator(symbolRepository, MarketProperties()), cache, client, MarketProperties()) + val symbol = Symbol(MarketType.COIN, "BTC/USD", "Bitcoin").apply { id = 1L } + val from = LocalDate.of(2026, 7, 27) + val to = LocalDate.of(2026, 8, 2) + + beforeEach { + every { symbolRepository.findById(1L) } returns Optional.of(symbol) + } + + it("7일을 초과한 조회를 외부 호출 전에 거절한다") { + val exception = shouldThrow { service.execute(1L, from.minusDays(1), to) } + + exception.errorCode shouldBe MarketErrorCode.INVALID_PRICE_HISTORY_RANGE + verify(exactly = 0) { symbolRepository.findById(any()) } + } + + it("외부 히스토리를 시간 오름차순으로 캐시한다") { + every { cache.findPriceHistory(1L, from.toString(), to.toString()) } returns null + every { client.fetchPriceHistory("BTC/USD", from, to) } returns + listOf( + PricePointResult(BigDecimal("120"), Instant.parse("2026-08-02T10:00:00Z")), + PricePointResult(BigDecimal("110"), Instant.parse("2026-08-02T09:00:00Z")), + ) + + val result = service.execute(1L, from, to) + + result.prices.shouldBeSortedBy(PricePointResult::snapshotAt) + verify(exactly = 1) { cache.savePriceHistory(result) } + } +}) diff --git a/src/test/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsServiceImplTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsServiceImplTest.kt new file mode 100644 index 0000000..755d9e8 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/application/SyncSymbolsServiceImplTest.kt @@ -0,0 +1,89 @@ +package team.cklob.arena.domain.market.application + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.application.impl.SyncSymbolsServiceImpl +import team.cklob.arena.domain.market.application.result.ExternalSymbolResult +import team.cklob.arena.domain.market.domain.entity.Symbol +import team.cklob.arena.domain.market.domain.repository.SymbolRepository +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import team.cklob.arena.global.exception.ExpectedException +import java.util.function.Consumer + +class SyncSymbolsServiceImplTest : DescribeSpec({ + val client = mockk() + val repository = mockk(relaxed = true) + val transactionTemplate = mockk() + val service = + SyncSymbolsServiceImpl( + client, + repository, + transactionTemplate, + MarketProperties(symbolSyncMaxDeactivationRatio = 1.0), + ) + + beforeEach { + clearMocks(client, repository, transactionTemplate) + every { transactionTemplate.executeWithoutResult(any()) } answers { + firstArg>().accept(mockk()) + } + every { repository.save(any()) } answers { firstArg() } + } + + it("종목을 갱신하고 공급자에서 사라진 종목을 비활성화한다") { + val apple = Symbol(MarketType.US, "AAPL", "Old Apple") + val removed = Symbol(MarketType.US, "OLD", "Removed") + every { client.fetchSymbols() } returns + listOf( + ExternalSymbolResult(MarketType.US, "aapl", "Apple Inc"), + ExternalSymbolResult(MarketType.COIN, "btc/usd", "Bitcoin"), + ) + every { repository.findAllByMarket(MarketType.US) } returns listOf(apple, removed) + every { repository.findAllByMarket(MarketType.COIN) } returns emptyList() + + service.execute() + + apple.name shouldBe "Apple Inc" + apple.isActive shouldBe true + removed.isActive shouldBe false + verify(exactly = 1) { repository.save(match { it.market == MarketType.COIN && it.code == "BTC/USD" }) } + } + + it("한 시장의 응답이 비어 있으면 DB 트랜잭션을 시작하지 않는다") { + every { client.fetchSymbols() } returns listOf(ExternalSymbolResult(MarketType.US, "AAPL", "Apple Inc")) + + runCatching(service::execute) + + verify(exactly = 0) { transactionTemplate.executeWithoutResult(any()) } + } + + it("불완전한 응답이 활성 종목의 허용 비율을 초과해 누락시키면 동기화를 거절한다") { + val apple = Symbol(MarketType.US, "AAPL", "Apple Inc") + val microsoft = Symbol(MarketType.US, "MSFT", "Microsoft Corporation") + val bitcoin = Symbol(MarketType.COIN, "BTC/USD", "Bitcoin") + val ethereum = Symbol(MarketType.COIN, "ETH/USD", "Ethereum") + every { client.fetchSymbols() } returns + listOf( + ExternalSymbolResult(MarketType.US, "AAPL", "Apple Inc"), + ExternalSymbolResult(MarketType.COIN, "BTC/USD", "Bitcoin"), + ) + every { repository.findAllByMarket(MarketType.US) } returns listOf(apple, microsoft) + every { repository.findAllByMarket(MarketType.COIN) } returns listOf(bitcoin, ethereum) + val guardedService = SyncSymbolsServiceImpl(client, repository, transactionTemplate, MarketProperties()) + + shouldThrow { guardedService.execute() }.errorCode shouldBe MarketErrorCode.SYMBOL_SYNC_REJECTED + + microsoft.isActive shouldBe true + ethereum.isActive shouldBe true + verify(exactly = 0) { repository.save(any()) } + } +}) diff --git a/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCacheTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCacheTest.kt new file mode 100644 index 0000000..73cc095 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/RedisMarketDataCacheTest.kt @@ -0,0 +1,42 @@ +package team.cklob.arena.domain.market.infrastructure + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ValueOperations +import team.cklob.arena.domain.market.application.result.CurrentPriceResult +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.MarketProperties +import java.math.BigDecimal +import java.time.Duration +import java.time.Instant + +class RedisMarketDataCacheTest : DescribeSpec({ + val redisTemplate = mockk() + val valueOperations = mockk>(relaxed = true) + val objectMapper = ObjectMapper().registerKotlinModule().registerModule(JavaTimeModule()) + val cache = RedisMarketDataCache(redisTemplate, objectMapper, MarketProperties()) + val result = CurrentPriceResult(1L, BigDecimal("221.25"), Instant.parse("2026-08-02T09:00:00Z")) + + beforeEach { + every { redisTemplate.opsForValue() } returns valueOperations + } + + it("COIN 현재가를 15초 TTL로 저장한다") { + cache.saveCurrentPrice(MarketType.COIN, result) + + verify(exactly = 1) { valueOperations.set("market:price:1", any(), Duration.ofSeconds(15)) } + } + + it("현재가 캐시 값을 역직렬화한다") { + every { valueOperations.get("market:price:1") } returns objectMapper.writeValueAsString(result) + + cache.findCurrentPrice(1L) shouldBe result + } +}) diff --git a/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClientTest.kt b/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClientTest.kt new file mode 100644 index 0000000..4a76d2e --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/market/infrastructure/TwelveDataMarketClientTest.kt @@ -0,0 +1,73 @@ +package team.cklob.arena.domain.market.infrastructure + +import com.sun.net.httpserver.HttpServer +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.springframework.web.client.RestClient +import team.cklob.arena.domain.market.MarketErrorCode +import team.cklob.arena.domain.market.domain.type.MarketType +import team.cklob.arena.domain.market.infrastructure.property.TwelveDataProperties +import team.cklob.arena.global.exception.ExpectedException +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.time.LocalDate +import java.util.concurrent.ConcurrentHashMap + +class TwelveDataMarketClientTest : DescribeSpec({ + val responses = ConcurrentHashMap() + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/") { exchange -> + val body = responses[exchange.requestURI.path] ?: "{\"status\":\"error\"}" + val bytes = body.toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.start() + val client = + TwelveDataMarketClient( + RestClient.builder(), + TwelveDataProperties(baseUrl = "http://localhost:${server.address.port}", apiKey = "test-key"), + ) + + afterSpec { server.stop(0) } + + beforeEach { + responses.clear() + } + + it("미국 보통주와 USD 암호화폐 종목을 내부 결과로 변환한다") { + responses["/stocks"] = + """{"data":[{"symbol":"AAPL","name":"Apple Inc","country":"United States","type":"Common Stock"}]}""" + responses["/cryptocurrencies"] = + """ + {"data":[ + {"symbol":"BTC/USD","currency_base":"Bitcoin","currency_quote":"US Dollar"}, + {"symbol":"ETH/BTC","currency_base":"Ethereum","currency_quote":"Bitcoin"} + ]} + """.trimIndent() + + val symbols = client.fetchSymbols() + + symbols shouldHaveSize 2 + symbols.first { it.market == MarketType.COIN }.code shouldBe "BTC/USD" + } + + it("현재가와 UTC 1시간봉을 변환한다") { + responses["/quote"] = """{"close":"221.25","timestamp":1785661200}""" + responses["/time_series"] = + """{"values":[{"datetime":"2026-08-02 09:00:00","close":"220.10"},{"datetime":"2026-08-02 10:00:00","close":"221.25"}]}""" + + client.fetchCurrentPrice("AAPL").price.toPlainString() shouldBe "221.25" + client.fetchPriceHistory("AAPL", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)) shouldHaveSize 2 + } + + it("공급자 오류 응답을 503 도메인 오류로 변환한다") { + responses["/quote"] = """{"status":"error","code":429,"message":"rate limit"}""" + + shouldThrow { client.fetchCurrentPrice("AAPL") }.errorCode shouldBe + MarketErrorCode.MARKET_DATA_UNAVAILABLE + } +})