diff --git a/.env.example b/.env.example index f301082..a545c29 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,11 @@ APPLE_TEAM_ID= APPLE_KEY_ID= APPLE_PRIVATE_KEY= APPLE_OAUTH_ANDROID_REDIRECT_URI= + +# Observability integrations +AI_SERVICE_BASE_URL= +AI_SERVICE_API_KEY= +N8N_RETRAIN_URL= +N8N_API_KEY= +OBSERVABILITY_CONNECT_TIMEOUT=PT2S +OBSERVABILITY_READ_TIMEOUT=PT10S diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/LearningErrorCode.kt b/src/main/kotlin/team/cklob/arena/domain/learning/LearningErrorCode.kt new file mode 100644 index 0000000..06cdf20 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/LearningErrorCode.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.domain.learning + +import org.springframework.http.HttpStatus +import team.cklob.arena.global.exception.ErrorCode + +enum class LearningErrorCode( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + MODEL_VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "모델 버전을 찾을 수 없습니다."), + MODEL_STATUS_CONFLICT(HttpStatus.CONFLICT, "Challenger 상태의 모델만 승격할 수 있습니다."), + DECISION_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "결정 로그를 찾을 수 없습니다."), + EXTERNAL_SERVICE_UNAVAILABLE(HttpStatus.INTERNAL_SERVER_ERROR, "외부 AI 서비스를 호출할 수 없습니다."), +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/GetDecisionLogsService.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetDecisionLogsService.kt new file mode 100644 index 0000000..7f84ba7 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetDecisionLogsService.kt @@ -0,0 +1,12 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.DecisionLogResult +import team.cklob.arena.domain.market.domain.type.MarketType + +interface GetDecisionLogsService { + fun execute( + challengeId: Long?, + market: MarketType?, + modelVersion: String?, + ): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/GetModelVersionsService.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetModelVersionsService.kt new file mode 100644 index 0000000..88d8ae5 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetModelVersionsService.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.ModelVersionResult +import team.cklob.arena.domain.learning.domain.type.ModelStatus + +interface GetModelVersionsService { + fun execute(status: ModelStatus?): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationService.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationService.kt new file mode 100644 index 0000000..3fbfdbb --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationService.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult + +interface GetShapExplanationService { + fun execute(decisionId: Long): ShapExplanationResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/ObservabilityClient.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/ObservabilityClient.kt new file mode 100644 index 0000000..6f95966 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/ObservabilityClient.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.RetrainingJobResult +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult + +interface ObservabilityClient { + fun requestRetraining(): RetrainingJobResult + + fun getShapExplanation(decisionId: Long): ShapExplanationResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/PromoteModelService.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/PromoteModelService.kt new file mode 100644 index 0000000..6285433 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/PromoteModelService.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.PromotedModelResult + +interface PromoteModelService { + fun execute(modelVersionId: Long): PromotedModelResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/RequestRetrainingService.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/RequestRetrainingService.kt new file mode 100644 index 0000000..c374879 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/RequestRetrainingService.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.learning.application + +import team.cklob.arena.domain.learning.application.result.RetrainingJobResult + +interface RequestRetrainingService { + fun execute(): RetrainingJobResult +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetDecisionLogsServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetDecisionLogsServiceImpl.kt new file mode 100644 index 0000000..589d1a2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetDecisionLogsServiceImpl.kt @@ -0,0 +1,22 @@ +package team.cklob.arena.domain.learning.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.arena.domain.learning.application.GetDecisionLogsService +import team.cklob.arena.domain.learning.application.result.DecisionLogResult +import team.cklob.arena.domain.learning.domain.repository.DecisionLogRepository +import team.cklob.arena.domain.market.domain.type.MarketType + +@Service +class GetDecisionLogsServiceImpl( + private val decisionLogRepository: DecisionLogRepository, +) : GetDecisionLogsService { + @Transactional(readOnly = true) + override fun execute( + challengeId: Long?, + market: MarketType?, + modelVersion: String?, + ): List = + decisionLogRepository.findAllByFilters(challengeId, market, modelVersion?.trim()?.takeIf(String::isNotEmpty)) + .map(DecisionLogResult::from) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetModelVersionsServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetModelVersionsServiceImpl.kt new file mode 100644 index 0000000..066ea0a --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetModelVersionsServiceImpl.kt @@ -0,0 +1,30 @@ +package team.cklob.arena.domain.learning.application.impl + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.arena.domain.learning.application.GetModelVersionsService +import team.cklob.arena.domain.learning.application.result.ModelVersionResult +import team.cklob.arena.domain.learning.domain.repository.ModelVersionRepository +import team.cklob.arena.domain.learning.domain.type.ModelStatus + +@Service +class GetModelVersionsServiceImpl( + private val modelVersionRepository: ModelVersionRepository, + private val objectMapper: ObjectMapper, +) : GetModelVersionsService { + @Transactional(readOnly = true) + override fun execute(status: ModelStatus?): List = + ( + status?.let(modelVersionRepository::findAllByStatusOrderByTrainedAtDesc) + ?: modelVersionRepository.findAllByOrderByTrainedAtDesc() + ).map { model -> + ModelVersionResult( + modelVersionId = requireNotNull(model.id), + versionTag = model.versionTag, + trainedAt = model.trainedAt, + performanceMetrics = objectMapper.readTree(model.performanceMetrics), + status = model.status, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetShapExplanationServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetShapExplanationServiceImpl.kt new file mode 100644 index 0000000..4280a9e --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/GetShapExplanationServiceImpl.kt @@ -0,0 +1,20 @@ +package team.cklob.arena.domain.learning.application.impl + +import org.springframework.stereotype.Service +import team.cklob.arena.domain.learning.LearningErrorCode +import team.cklob.arena.domain.learning.application.GetShapExplanationService +import team.cklob.arena.domain.learning.application.ObservabilityClient +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult +import team.cklob.arena.domain.learning.domain.repository.DecisionLogRepository +import team.cklob.arena.global.exception.ExpectedException + +@Service +class GetShapExplanationServiceImpl( + private val decisionLogRepository: DecisionLogRepository, + private val observabilityClient: ObservabilityClient, +) : GetShapExplanationService { + override fun execute(decisionId: Long): ShapExplanationResult { + if (!decisionLogRepository.existsById(decisionId)) throw ExpectedException(LearningErrorCode.DECISION_LOG_NOT_FOUND) + return observabilityClient.getShapExplanation(decisionId) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/PromoteModelServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/PromoteModelServiceImpl.kt new file mode 100644 index 0000000..2e8ef43 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/PromoteModelServiceImpl.kt @@ -0,0 +1,29 @@ +package team.cklob.arena.domain.learning.application.impl + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import team.cklob.arena.domain.learning.LearningErrorCode +import team.cklob.arena.domain.learning.application.PromoteModelService +import team.cklob.arena.domain.learning.application.result.PromotedModelResult +import team.cklob.arena.domain.learning.domain.repository.ModelVersionRepository +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import team.cklob.arena.global.exception.ExpectedException + +@Service +class PromoteModelServiceImpl( + private val modelVersionRepository: ModelVersionRepository, +) : PromoteModelService { + @Transactional + override fun execute(modelVersionId: Long): PromotedModelResult { + // ponytail: locks the small model registry; use a DB advisory lock if model volume becomes large. + val models = modelVersionRepository.findAllForUpdate() + val target = + models.firstOrNull { it.id == modelVersionId } + ?: throw ExpectedException(LearningErrorCode.MODEL_VERSION_NOT_FOUND) + if (target.status != ModelStatus.CHALLENGER) throw ExpectedException(LearningErrorCode.MODEL_STATUS_CONFLICT) + + models.filter { it.status == ModelStatus.CHAMPION }.forEach { it.retire() } + target.promote() + return PromotedModelResult(requireNotNull(target.id), target.status) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/RequestRetrainingServiceImpl.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/RequestRetrainingServiceImpl.kt new file mode 100644 index 0000000..454c511 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/impl/RequestRetrainingServiceImpl.kt @@ -0,0 +1,13 @@ +package team.cklob.arena.domain.learning.application.impl + +import org.springframework.stereotype.Service +import team.cklob.arena.domain.learning.application.ObservabilityClient +import team.cklob.arena.domain.learning.application.RequestRetrainingService +import team.cklob.arena.domain.learning.application.result.RetrainingJobResult + +@Service +class RequestRetrainingServiceImpl( + private val observabilityClient: ObservabilityClient, +) : RequestRetrainingService { + override fun execute(): RetrainingJobResult = observabilityClient.requestRetraining() +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/DecisionLogResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/DecisionLogResult.kt new file mode 100644 index 0000000..fb6c8a7 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/DecisionLogResult.kt @@ -0,0 +1,30 @@ +package team.cklob.arena.domain.learning.application.result + +import team.cklob.arena.domain.learning.domain.entity.DecisionLog +import team.cklob.arena.domain.learning.domain.type.TradingAction +import team.cklob.arena.domain.market.domain.type.MarketType +import java.math.BigDecimal +import java.time.LocalDateTime + +data class DecisionLogResult( + val decisionId: Long, + val symbolCode: String, + val market: MarketType, + val action: TradingAction, + val probability: BigDecimal, + val modelVersion: String, + val decidedAt: LocalDateTime, +) { + companion object { + fun from(decision: DecisionLog): DecisionLogResult = + DecisionLogResult( + decisionId = requireNotNull(decision.id), + symbolCode = decision.symbolCode, + market = decision.market, + action = decision.action, + probability = decision.probability, + modelVersion = decision.modelVersion, + decidedAt = decision.decidedAt, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ModelVersionResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ModelVersionResult.kt new file mode 100644 index 0000000..f3133b7 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ModelVersionResult.kt @@ -0,0 +1,13 @@ +package team.cklob.arena.domain.learning.application.result + +import com.fasterxml.jackson.databind.JsonNode +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import java.time.LocalDateTime + +data class ModelVersionResult( + val modelVersionId: Long, + val versionTag: String, + val trainedAt: LocalDateTime, + val performanceMetrics: JsonNode, + val status: ModelStatus, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/PromotedModelResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/PromotedModelResult.kt new file mode 100644 index 0000000..061e92e --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/PromotedModelResult.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.learning.application.result + +import team.cklob.arena.domain.learning.domain.type.ModelStatus + +data class PromotedModelResult( + val modelVersionId: Long, + val status: ModelStatus, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/RetrainingJobResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/RetrainingJobResult.kt new file mode 100644 index 0000000..0f9e16d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/RetrainingJobResult.kt @@ -0,0 +1,5 @@ +package team.cklob.arena.domain.learning.application.result + +data class RetrainingJobResult( + val jobId: String, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapContributionResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapContributionResult.kt new file mode 100644 index 0000000..bb75603 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapContributionResult.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.learning.application.result + +import java.math.BigDecimal + +data class ShapContributionResult( + val featureName: String, + val contribution: BigDecimal, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapExplanationResult.kt b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapExplanationResult.kt new file mode 100644 index 0000000..904eb4d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/application/result/ShapExplanationResult.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.domain.learning.application.result + +import java.math.BigDecimal + +data class ShapExplanationResult( + val decisionId: Long, + val baseValue: BigDecimal, + val contributions: List, + val finalProbability: BigDecimal, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/DecisionLog.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/DecisionLog.kt new file mode 100644 index 0000000..0f49375 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/DecisionLog.kt @@ -0,0 +1,58 @@ +package team.cklob.arena.domain.learning.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.Index +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import team.cklob.arena.domain.learning.domain.type.TradingAction +import team.cklob.arena.domain.market.domain.type.MarketType +import java.math.BigDecimal +import java.time.LocalDateTime + +@Entity +@Table( + name = "decision_logs", + indexes = [ + Index(name = "idx_decision_logs_challenge_id", columnList = "challenge_id"), + Index(name = "idx_decision_logs_market", columnList = "market"), + Index(name = "idx_decision_logs_model_version", columnList = "model_version"), + Index(name = "idx_decision_logs_decided_at", columnList = "decided_at"), + ], +) +class DecisionLog( + @Column(name = "challenge_id", nullable = false) + var challengeId: Long, + @Column(name = "symbol_code", nullable = false, length = 20) + var symbolCode: String, + @Enumerated(EnumType.STRING) + @Column(name = "market", nullable = false, length = 10) + var market: MarketType, + @Enumerated(EnumType.STRING) + @Column(name = "action", nullable = false, length = 10) + var action: TradingAction, + @Column(name = "probability", nullable = false, precision = 5, scale = 4) + var probability: BigDecimal, + @Column(name = "model_version", nullable = false, length = 100) + var modelVersion: String, + @Column(name = "decided_at", nullable = false) + var decidedAt: LocalDateTime, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: LocalDateTime? = null + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime? = null +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/ModelVersion.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/ModelVersion.kt new file mode 100644 index 0000000..aacd3e2 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/entity/ModelVersion.kt @@ -0,0 +1,52 @@ +package team.cklob.arena.domain.learning.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 org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import java.time.LocalDateTime + +@Entity +@Table( + name = "model_versions", + uniqueConstraints = [UniqueConstraint(name = "uq_model_versions_version_tag", columnNames = ["version_tag"])], +) +class ModelVersion( + @Column(name = "version_tag", nullable = false, length = 100) + var versionTag: String, + @Column(name = "trained_at", nullable = false) + var trainedAt: LocalDateTime, + @Column(name = "performance_metrics", nullable = false, columnDefinition = "TEXT") + var performanceMetrics: String, + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + var status: ModelStatus, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: LocalDateTime? = null + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime? = null + + fun promote() { + status = ModelStatus.CHAMPION + } + + fun retire() { + status = ModelStatus.RETIRED + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/DecisionLogRepository.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/DecisionLogRepository.kt new file mode 100644 index 0000000..0320be5 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/DecisionLogRepository.kt @@ -0,0 +1,24 @@ +package team.cklob.arena.domain.learning.domain.repository + +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.learning.domain.entity.DecisionLog +import team.cklob.arena.domain.market.domain.type.MarketType + +interface DecisionLogRepository : JpaRepository { + @Query( + """ + select decision from DecisionLog decision + where (:challengeId is null or decision.challengeId = :challengeId) + and (:market is null or decision.market = :market) + and (:modelVersion is null or decision.modelVersion = :modelVersion) + order by decision.decidedAt desc + """, + ) + fun findAllByFilters( + @Param("challengeId") challengeId: Long?, + @Param("market") market: MarketType?, + @Param("modelVersion") modelVersion: String?, + ): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/ModelVersionRepository.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/ModelVersionRepository.kt new file mode 100644 index 0000000..ccf1fd8 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/repository/ModelVersionRepository.kt @@ -0,0 +1,18 @@ +package team.cklob.arena.domain.learning.domain.repository + +import jakarta.persistence.LockModeType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import team.cklob.arena.domain.learning.domain.entity.ModelVersion +import team.cklob.arena.domain.learning.domain.type.ModelStatus + +interface ModelVersionRepository : JpaRepository { + fun findAllByOrderByTrainedAtDesc(): List + + fun findAllByStatusOrderByTrainedAtDesc(status: ModelStatus): List + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select model from ModelVersion model order by model.id") + fun findAllForUpdate(): List +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/ModelStatus.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/ModelStatus.kt new file mode 100644 index 0000000..f3d801b --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/ModelStatus.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.learning.domain.type + +enum class ModelStatus { + CHAMPION, + CHALLENGER, + RETIRED, +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/TradingAction.kt b/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/TradingAction.kt new file mode 100644 index 0000000..c3ade7c --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/domain/type/TradingAction.kt @@ -0,0 +1,7 @@ +package team.cklob.arena.domain.learning.domain.type + +enum class TradingAction { + BUY, + SELL, + HOLD, +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClient.kt b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClient.kt new file mode 100644 index 0000000..882c28d --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClient.kt @@ -0,0 +1,87 @@ +package team.cklob.arena.domain.learning.infrastructure + +import org.springframework.http.HttpStatus +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.learning.LearningErrorCode +import team.cklob.arena.domain.learning.application.ObservabilityClient +import team.cklob.arena.domain.learning.application.result.RetrainingJobResult +import team.cklob.arena.domain.learning.application.result.ShapContributionResult +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult +import team.cklob.arena.domain.learning.infrastructure.dto.RetrainingJobDto +import team.cklob.arena.domain.learning.infrastructure.dto.ShapExplanationDto +import team.cklob.arena.domain.learning.infrastructure.property.ObservabilityProperties +import team.cklob.arena.global.exception.ExpectedException +import java.net.http.HttpClient + +@Component +class ObservabilityHttpClient( + restClientBuilder: RestClient.Builder, + private val properties: ObservabilityProperties, +) : ObservabilityClient { + private val requestFactory = + JdkClientHttpRequestFactory( + HttpClient.newBuilder().connectTimeout(properties.connectTimeout).build(), + ).apply { setReadTimeout(properties.readTimeout) } + private val aiClient = + restClientBuilder.clone() + .requestFactory(requestFactory) + .baseUrl(properties.aiBaseUrl) + .build() + private val n8nClient = + restClientBuilder.clone() + .requestFactory(requestFactory) + .build() + + override fun requestRetraining(): RetrainingJobResult = + executeRequest { + val response = + n8nClient.post() + .uri(requireValue(properties.n8nRetrainUrl)) + .header(INTERNAL_API_KEY_HEADER, requireValue(properties.n8nApiKey)) + .retrieve() + .onStatus({ status -> status != HttpStatus.ACCEPTED }) { _, _ -> unavailable() } + .body(RetrainingJobDto::class.java) + ?: unavailable() + RetrainingJobResult(response.jobId.takeIf(String::isNotBlank) ?: unavailable()) + } + + override fun getShapExplanation(decisionId: Long): ShapExplanationResult = + executeRequest { + val response = + aiClient.get() + .uri("/internal/ai/decisions/{decisionId}/shap", decisionId) + .header(INTERNAL_API_KEY_HEADER, requireValue(properties.aiApiKey)) + .retrieve() + .body(ShapExplanationDto::class.java) + ?: unavailable() + if (response.decisionId != decisionId) unavailable() + ShapExplanationResult( + response.decisionId, + response.baseValue, + response.contributions.map { ShapContributionResult(it.featureName, it.contribution) }, + response.finalProbability, + ) + } + + private fun requireValue(value: String): String = value.takeIf(String::isNotBlank) ?: unavailable() + + 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(LearningErrorCode.EXTERNAL_SERVICE_UNAVAILABLE) + + private companion object { + const val INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key" + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/RetrainingJobDto.kt b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/RetrainingJobDto.kt new file mode 100644 index 0000000..7da8ee4 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/RetrainingJobDto.kt @@ -0,0 +1,5 @@ +package team.cklob.arena.domain.learning.infrastructure.dto + +data class RetrainingJobDto( + val jobId: String, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapContributionDto.kt b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapContributionDto.kt new file mode 100644 index 0000000..1b83431 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapContributionDto.kt @@ -0,0 +1,8 @@ +package team.cklob.arena.domain.learning.infrastructure.dto + +import java.math.BigDecimal + +data class ShapContributionDto( + val featureName: String, + val contribution: BigDecimal, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapExplanationDto.kt b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapExplanationDto.kt new file mode 100644 index 0000000..ac76478 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/dto/ShapExplanationDto.kt @@ -0,0 +1,10 @@ +package team.cklob.arena.domain.learning.infrastructure.dto + +import java.math.BigDecimal + +data class ShapExplanationDto( + val decisionId: Long, + val baseValue: BigDecimal, + val contributions: List, + val finalProbability: BigDecimal, +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/property/ObservabilityProperties.kt b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/property/ObservabilityProperties.kt new file mode 100644 index 0000000..64b1f32 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/infrastructure/property/ObservabilityProperties.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.domain.learning.infrastructure.property + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.time.Duration + +@ConfigurationProperties(prefix = "external.observability") +data class ObservabilityProperties( + val aiBaseUrl: String = "", + val aiApiKey: String = "", + val n8nRetrainUrl: String = "", + val n8nApiKey: String = "", + val connectTimeout: Duration = Duration.ofSeconds(2), + val readTimeout: Duration = Duration.ofSeconds(10), +) diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetDecisionLogsController.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetDecisionLogsController.kt new file mode 100644 index 0000000..0fe30fb --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetDecisionLogsController.kt @@ -0,0 +1,30 @@ +package team.cklob.arena.domain.learning.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.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.learning.application.GetDecisionLogsService +import team.cklob.arena.domain.learning.presentation.response.DecisionLogsResponse +import team.cklob.arena.domain.market.domain.type.MarketType + +@RestController +class GetDecisionLogsController( + private val getDecisionLogsService: GetDecisionLogsService, +) { + @GetMapping("/observability/decisions") + @Operation(summary = "결정 로그 목록 조회", security = [SecurityRequirement(name = "bearerAuth")]) + @ApiResponses( + ApiResponse(responseCode = "200", description = "결정 로그 조회 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ) + fun execute( + @RequestParam(required = false) challengeId: Long?, + @RequestParam(required = false) market: MarketType?, + @RequestParam(required = false) modelVersion: String?, + ): DecisionLogsResponse = DecisionLogsResponse.from(getDecisionLogsService.execute(challengeId, market, modelVersion)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetModelVersionsController.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetModelVersionsController.kt new file mode 100644 index 0000000..2b75ecc --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetModelVersionsController.kt @@ -0,0 +1,28 @@ +package team.cklob.arena.domain.learning.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.RequestParam +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.learning.application.GetModelVersionsService +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import team.cklob.arena.domain.learning.presentation.response.ModelVersionsResponse + +@RestController +class GetModelVersionsController( + private val getModelVersionsService: GetModelVersionsService, +) { + @GetMapping("/observability/models") + @Operation(summary = "모델 버전 목록 조회", security = [SecurityRequirement(name = "bearerAuth")]) + @ApiResponses( + ApiResponse(responseCode = "200", description = "모델 버전 조회 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ) + fun execute( + @RequestParam(required = false) status: ModelStatus?, + ): ModelVersionsResponse = ModelVersionsResponse.from(getModelVersionsService.execute(status)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetShapExplanationController.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetShapExplanationController.kt new file mode 100644 index 0000000..aa82613 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/GetShapExplanationController.kt @@ -0,0 +1,29 @@ +package team.cklob.arena.domain.learning.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.learning.application.GetShapExplanationService +import team.cklob.arena.domain.learning.presentation.response.ShapExplanationResponse + +@RestController +class GetShapExplanationController( + private val getShapExplanationService: GetShapExplanationService, +) { + @GetMapping("/observability/decisions/{decisionId}/shap") + @Operation(summary = "결정 SHAP 설명 조회", security = [SecurityRequirement(name = "bearerAuth")]) + @ApiResponses( + ApiResponse(responseCode = "200", description = "SHAP 설명 조회 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ApiResponse(responseCode = "404", description = "결정 로그 없음"), + ApiResponse(responseCode = "500", description = "외부 서비스 호출 실패"), + ) + fun execute( + @PathVariable decisionId: Long, + ): ShapExplanationResponse = ShapExplanationResponse.from(getShapExplanationService.execute(decisionId)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/PromoteModelController.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/PromoteModelController.kt new file mode 100644 index 0000000..e72cc29 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/PromoteModelController.kt @@ -0,0 +1,29 @@ +package team.cklob.arena.domain.learning.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.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.learning.application.PromoteModelService +import team.cklob.arena.domain.learning.presentation.response.PromoteModelResponse + +@RestController +class PromoteModelController( + private val promoteModelService: PromoteModelService, +) { + @PatchMapping("/observability/models/{modelVersionId}/promote") + @Operation(summary = "모델 승격", security = [SecurityRequirement(name = "bearerAuth")]) + @ApiResponses( + ApiResponse(responseCode = "200", description = "모델 승격 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ApiResponse(responseCode = "404", description = "모델 버전 없음"), + ApiResponse(responseCode = "409", description = "승격할 수 없는 모델 상태"), + ) + fun execute( + @PathVariable modelVersionId: Long, + ): PromoteModelResponse = PromoteModelResponse.from(promoteModelService.execute(modelVersionId)) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/RequestRetrainingController.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/RequestRetrainingController.kt new file mode 100644 index 0000000..bbeecf3 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/controller/RequestRetrainingController.kt @@ -0,0 +1,28 @@ +package team.cklob.arena.domain.learning.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.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import team.cklob.arena.domain.learning.application.RequestRetrainingService +import team.cklob.arena.domain.learning.presentation.response.RetrainingResponse + +@RestController +class RequestRetrainingController( + private val requestRetrainingService: RequestRetrainingService, +) { + @PostMapping("/observability/models/retrain") + @ResponseStatus(HttpStatus.ACCEPTED) + @Operation(summary = "모델 재학습 수동 트리거", security = [SecurityRequirement(name = "bearerAuth")]) + @ApiResponses( + ApiResponse(responseCode = "202", description = "재학습 요청 접수"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "403", description = "관리자 권한 없음"), + ApiResponse(responseCode = "500", description = "외부 서비스 호출 실패"), + ) + fun execute(): RetrainingResponse = RetrainingResponse.from(requestRetrainingService.execute()) +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogResponse.kt new file mode 100644 index 0000000..dc3cc94 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogResponse.kt @@ -0,0 +1,30 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.DecisionLogResult +import team.cklob.arena.domain.learning.domain.type.TradingAction +import team.cklob.arena.domain.market.domain.type.MarketType +import java.math.BigDecimal +import java.time.LocalDateTime + +data class DecisionLogResponse( + val decisionId: Long, + val symbolCode: String, + val market: MarketType, + val action: TradingAction, + val probability: BigDecimal, + val modelVersion: String, + val decidedAt: LocalDateTime, +) { + companion object { + fun from(result: DecisionLogResult): DecisionLogResponse = + DecisionLogResponse( + result.decisionId, + result.symbolCode, + result.market, + result.action, + result.probability, + result.modelVersion, + result.decidedAt, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogsResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogsResponse.kt new file mode 100644 index 0000000..7ee7fec --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/DecisionLogsResponse.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.DecisionLogResult + +data class DecisionLogsResponse( + val decisions: List, +) { + companion object { + fun from(results: List): DecisionLogsResponse = DecisionLogsResponse(results.map(DecisionLogResponse::from)) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionResponse.kt new file mode 100644 index 0000000..45b0faf --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionResponse.kt @@ -0,0 +1,25 @@ +package team.cklob.arena.domain.learning.presentation.response + +import com.fasterxml.jackson.databind.JsonNode +import team.cklob.arena.domain.learning.application.result.ModelVersionResult +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import java.time.LocalDateTime + +data class ModelVersionResponse( + val modelVersionId: Long, + val versionTag: String, + val trainedAt: LocalDateTime, + val performanceMetrics: JsonNode, + val status: ModelStatus, +) { + companion object { + fun from(result: ModelVersionResult): ModelVersionResponse = + ModelVersionResponse( + result.modelVersionId, + result.versionTag, + result.trainedAt, + result.performanceMetrics, + result.status, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionsResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionsResponse.kt new file mode 100644 index 0000000..6947d61 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ModelVersionsResponse.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.ModelVersionResult + +data class ModelVersionsResponse( + val models: List, +) { + companion object { + fun from(results: List): ModelVersionsResponse = ModelVersionsResponse(results.map(ModelVersionResponse::from)) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/PromoteModelResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/PromoteModelResponse.kt new file mode 100644 index 0000000..aa679b9 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/PromoteModelResponse.kt @@ -0,0 +1,13 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.PromotedModelResult +import team.cklob.arena.domain.learning.domain.type.ModelStatus + +data class PromoteModelResponse( + val modelVersionId: Long, + val status: ModelStatus, +) { + companion object { + fun from(result: PromotedModelResult): PromoteModelResponse = PromoteModelResponse(result.modelVersionId, result.status) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/RetrainingResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/RetrainingResponse.kt new file mode 100644 index 0000000..a948f63 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/RetrainingResponse.kt @@ -0,0 +1,11 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.RetrainingJobResult + +data class RetrainingResponse( + val jobId: String, +) { + companion object { + fun from(result: RetrainingJobResult): RetrainingResponse = RetrainingResponse(result.jobId) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapContributionResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapContributionResponse.kt new file mode 100644 index 0000000..4233aef --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapContributionResponse.kt @@ -0,0 +1,14 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.ShapContributionResult +import java.math.BigDecimal + +data class ShapContributionResponse( + val featureName: String, + val contribution: BigDecimal, +) { + companion object { + fun from(result: ShapContributionResult): ShapContributionResponse = + ShapContributionResponse(result.featureName, result.contribution) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapExplanationResponse.kt b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapExplanationResponse.kt new file mode 100644 index 0000000..28fd992 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/learning/presentation/response/ShapExplanationResponse.kt @@ -0,0 +1,21 @@ +package team.cklob.arena.domain.learning.presentation.response + +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult +import java.math.BigDecimal + +data class ShapExplanationResponse( + val decisionId: Long, + val baseValue: BigDecimal, + val contributions: List, + val finalProbability: BigDecimal, +) { + companion object { + fun from(result: ShapExplanationResult): ShapExplanationResponse = + ShapExplanationResponse( + result.decisionId, + result.baseValue, + result.contributions.map(ShapContributionResponse::from), + result.finalProbability, + ) + } +} diff --git a/src/main/kotlin/team/cklob/arena/domain/user/domain/entity/User.kt b/src/main/kotlin/team/cklob/arena/domain/user/domain/entity/User.kt index 7ad6010..260ae07 100644 --- a/src/main/kotlin/team/cklob/arena/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/team/cklob/arena/domain/user/domain/entity/User.kt @@ -13,6 +13,7 @@ import org.hibernate.annotations.ColumnDefault import org.hibernate.annotations.CreationTimestamp import team.cklob.arena.domain.user.domain.type.InvestmentExperience import team.cklob.arena.domain.user.domain.type.OauthProvider +import team.cklob.arena.domain.user.domain.type.UserRole import java.time.LocalDateTime @Entity @@ -44,6 +45,10 @@ class User( @Column(name = "auth_version", nullable = false) @ColumnDefault("0") var authVersion: Long = 0, + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + @ColumnDefault("'USER'") + var role: UserRole = UserRole.USER, ) { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/kotlin/team/cklob/arena/domain/user/domain/repository/UserRepository.kt b/src/main/kotlin/team/cklob/arena/domain/user/domain/repository/UserRepository.kt index 36d2bbf..89e7c3a 100644 --- a/src/main/kotlin/team/cklob/arena/domain/user/domain/repository/UserRepository.kt +++ b/src/main/kotlin/team/cklob/arena/domain/user/domain/repository/UserRepository.kt @@ -29,10 +29,10 @@ interface UserRepository : JpaRepository { fun findByIdAndDeletedAtIsNull(id: Long): User? - fun existsByIdAndDeletedAtIsNullAndAuthVersion( + fun findByIdAndDeletedAtIsNullAndAuthVersion( id: Long, authVersion: Long, - ): Boolean + ): User? @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select user from User user where user.id = :id") diff --git a/src/main/kotlin/team/cklob/arena/domain/user/domain/type/UserRole.kt b/src/main/kotlin/team/cklob/arena/domain/user/domain/type/UserRole.kt new file mode 100644 index 0000000..dc38365 --- /dev/null +++ b/src/main/kotlin/team/cklob/arena/domain/user/domain/type/UserRole.kt @@ -0,0 +1,6 @@ +package team.cklob.arena.domain.user.domain.type + +enum class UserRole { + USER, + ADMIN, +} diff --git a/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt index 3e9346c..9c62543 100644 --- a/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/JwtAuthenticationFilter.kt @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpHeaders import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.filter.OncePerRequestFilter import team.cklob.arena.domain.user.domain.repository.UserRepository @@ -37,11 +38,17 @@ class JwtAuthenticationFilter( try { val identity = jwtTokenProvider.getIdentity(token, JwtPurpose.ACCESS) - if (!userRepository.existsByIdAndDeletedAtIsNullAndAuthVersion(identity.userId, identity.authVersion)) { + val user = userRepository.findByIdAndDeletedAtIsNullAndAuthVersion(identity.userId, identity.authVersion) + if (user == null) { securityErrorHandler.write(response, SecurityErrorCode.INVALID_TOKEN) return } - val authentication = UsernamePasswordAuthenticationToken(identity.userId, null, emptyList()) + val authentication = + UsernamePasswordAuthenticationToken( + identity.userId, + null, + listOf(SimpleGrantedAuthority("ROLE_${user.role.name}")), + ) SecurityContextHolder.getContext().authentication = authentication filterChain.doFilter(request, response) } catch (exception: ExpiredJwtException) { diff --git a/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt index 8beaee0..2e10aeb 100644 --- a/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt +++ b/src/main/kotlin/team/cklob/arena/global/security/SecurityConfig.kt @@ -41,6 +41,7 @@ class SecurityConfig { "/actuator/health", "/error", ).permitAll() + it.requestMatchers("/observability/**").hasRole("ADMIN") it.anyRequest().authenticated() }.addFilterBefore( JwtAuthenticationFilter(jwtTokenProvider, securityErrorHandler, userRepository), diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9786027..ff55ff0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -56,3 +56,10 @@ external: api-key: ${TWELVE_DATA_API_KEY:} connect-timeout: PT2S read-timeout: PT4S + observability: + ai-base-url: ${AI_SERVICE_BASE_URL:} + ai-api-key: ${AI_SERVICE_API_KEY:} + n8n-retrain-url: ${N8N_RETRAIN_URL:} + n8n-api-key: ${N8N_API_KEY:} + connect-timeout: ${OBSERVABILITY_CONNECT_TIMEOUT:PT2S} + read-timeout: ${OBSERVABILITY_READ_TIMEOUT:PT10S} diff --git a/src/test/kotlin/team/cklob/arena/domain/learning/LearningRepositoryTest.kt b/src/test/kotlin/team/cklob/arena/domain/learning/LearningRepositoryTest.kt new file mode 100644 index 0000000..45735c7 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/learning/LearningRepositoryTest.kt @@ -0,0 +1,82 @@ +package team.cklob.arena.domain.learning + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import team.cklob.arena.domain.learning.domain.entity.DecisionLog +import team.cklob.arena.domain.learning.domain.entity.ModelVersion +import team.cklob.arena.domain.learning.domain.repository.DecisionLogRepository +import team.cklob.arena.domain.learning.domain.repository.ModelVersionRepository +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import team.cklob.arena.domain.learning.domain.type.TradingAction +import team.cklob.arena.domain.market.domain.type.MarketType +import java.math.BigDecimal +import java.time.LocalDateTime + +@SpringBootTest +class LearningRepositoryTest( + private val decisionLogRepository: DecisionLogRepository, + private val modelVersionRepository: ModelVersionRepository, +) : DescribeSpec({ + extension(SpringExtension) + + beforeEach { + decisionLogRepository.deleteAll() + modelVersionRepository.deleteAll() + } + + it("결정 로그를 복합 조건으로 최신순 조회한다") { + decisionLogRepository.saveAll( + listOf( + decision(1L, MarketType.US, "v1", LocalDateTime.of(2026, 8, 1, 10, 0)), + decision(1L, MarketType.US, "v1", LocalDateTime.of(2026, 8, 1, 11, 0)), + decision(2L, MarketType.COIN, "v2", LocalDateTime.of(2026, 8, 1, 12, 0)), + ), + ) + + val result = decisionLogRepository.findAllByFilters(1L, MarketType.US, "v1") + + result shouldHaveSize 2 + result.map(DecisionLog::decidedAt) shouldBe result.map(DecisionLog::decidedAt).sortedDescending() + } + + it("모델 버전을 상태로 필터링해 학습 최신순 조회한다") { + modelVersionRepository.saveAll( + listOf( + model("v1", ModelStatus.CHAMPION, LocalDateTime.of(2026, 7, 1, 0, 0)), + model("v2", ModelStatus.CHALLENGER, LocalDateTime.of(2026, 8, 1, 0, 0)), + model("v3", ModelStatus.CHALLENGER, LocalDateTime.of(2026, 8, 2, 0, 0)), + ), + ) + + modelVersionRepository.findAllByStatusOrderByTrainedAtDesc(ModelStatus.CHALLENGER) + .map(ModelVersion::versionTag) shouldBe listOf("v3", "v2") + } + }) { + override fun extensions() = listOf(SpringExtension) + + companion object { + private fun decision( + challengeId: Long, + market: MarketType, + modelVersion: String, + decidedAt: LocalDateTime, + ) = DecisionLog( + challengeId, + "AAPL", + market, + TradingAction.BUY, + BigDecimal("0.7500"), + modelVersion, + decidedAt, + ) + + private fun model( + versionTag: String, + status: ModelStatus, + trainedAt: LocalDateTime, + ) = ModelVersion(versionTag, trainedAt, "{\"accuracy\":0.8}", status) + } +} diff --git a/src/test/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationServiceImplTest.kt b/src/test/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationServiceImplTest.kt new file mode 100644 index 0000000..174d667 --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/learning/application/GetShapExplanationServiceImplTest.kt @@ -0,0 +1,40 @@ +package team.cklob.arena.domain.learning.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 team.cklob.arena.domain.learning.LearningErrorCode +import team.cklob.arena.domain.learning.application.impl.GetShapExplanationServiceImpl +import team.cklob.arena.domain.learning.application.result.ShapExplanationResult +import team.cklob.arena.domain.learning.domain.repository.DecisionLogRepository +import team.cklob.arena.global.exception.ExpectedException +import java.math.BigDecimal + +class GetShapExplanationServiceImplTest : DescribeSpec({ + val repository = mockk() + val client = mockk() + val service = GetShapExplanationServiceImpl(repository, client) + + beforeEach { + clearMocks(repository, client) + } + + it("결정 로그가 존재하면 외부 SHAP 설명을 반환한다") { + val expected = ShapExplanationResult(1L, BigDecimal("0.5"), emptyList(), BigDecimal("0.7")) + every { repository.existsById(1L) } returns true + every { client.getShapExplanation(1L) } returns expected + + service.execute(1L) shouldBe expected + } + + it("결정 로그가 없으면 외부 API를 호출하지 않는다") { + every { repository.existsById(1L) } returns false + + shouldThrow { service.execute(1L) }.errorCode shouldBe LearningErrorCode.DECISION_LOG_NOT_FOUND + verify(exactly = 0) { client.getShapExplanation(any()) } + } +}) diff --git a/src/test/kotlin/team/cklob/arena/domain/learning/application/PromoteModelServiceImplTest.kt b/src/test/kotlin/team/cklob/arena/domain/learning/application/PromoteModelServiceImplTest.kt new file mode 100644 index 0000000..bfa7dbb --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/learning/application/PromoteModelServiceImplTest.kt @@ -0,0 +1,50 @@ +package team.cklob.arena.domain.learning.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 team.cklob.arena.domain.learning.LearningErrorCode +import team.cklob.arena.domain.learning.application.impl.PromoteModelServiceImpl +import team.cklob.arena.domain.learning.domain.entity.ModelVersion +import team.cklob.arena.domain.learning.domain.repository.ModelVersionRepository +import team.cklob.arena.domain.learning.domain.type.ModelStatus +import team.cklob.arena.global.exception.ExpectedException +import java.time.LocalDateTime + +class PromoteModelServiceImplTest : DescribeSpec({ + val repository = mockk() + val service = PromoteModelServiceImpl(repository) + + it("Challenger를 승격하고 기존 Champion을 퇴역시킨다") { + val champion = model(1L, ModelStatus.CHAMPION) + val challenger = model(2L, ModelStatus.CHALLENGER) + every { repository.findAllForUpdate() } returns listOf(champion, challenger) + + val result = service.execute(2L) + + result.status shouldBe ModelStatus.CHAMPION + champion.status shouldBe ModelStatus.RETIRED + challenger.status shouldBe ModelStatus.CHAMPION + } + + it("Challenger가 아닌 모델은 승격하지 않는다") { + every { repository.findAllForUpdate() } returns listOf(model(1L, ModelStatus.CHAMPION)) + + shouldThrow { service.execute(1L) }.errorCode shouldBe LearningErrorCode.MODEL_STATUS_CONFLICT + } + + it("존재하지 않는 모델은 404 오류로 처리한다") { + every { repository.findAllForUpdate() } returns emptyList() + + shouldThrow { service.execute(99L) }.errorCode shouldBe LearningErrorCode.MODEL_VERSION_NOT_FOUND + } +}) { + companion object { + private fun model( + id: Long, + status: ModelStatus, + ) = ModelVersion("v$id", LocalDateTime.of(2026, 8, 1, 0, 0), "{}", status).apply { this.id = id } + } +} diff --git a/src/test/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClientTest.kt b/src/test/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClientTest.kt new file mode 100644 index 0000000..272a13b --- /dev/null +++ b/src/test/kotlin/team/cklob/arena/domain/learning/infrastructure/ObservabilityHttpClientTest.kt @@ -0,0 +1,78 @@ +package team.cklob.arena.domain.learning.infrastructure + +import com.sun.net.httpserver.HttpServer +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.web.client.RestClient +import team.cklob.arena.domain.learning.LearningErrorCode +import team.cklob.arena.domain.learning.infrastructure.property.ObservabilityProperties +import team.cklob.arena.global.exception.ExpectedException +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap + +class ObservabilityHttpClientTest : DescribeSpec({ + val responses = ConcurrentHashMap>() + val apiKeys = ConcurrentHashMap() + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/") { exchange -> + apiKeys[exchange.requestURI.path] = exchange.requestHeaders.getFirst("X-Internal-Api-Key") + val (status, body) = responses[exchange.requestURI.path] ?: (500 to "{}") + val bytes = body.toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.start() + val baseUrl = "http://localhost:${server.address.port}" + val client = + ObservabilityHttpClient( + RestClient.builder(), + ObservabilityProperties( + aiBaseUrl = baseUrl, + aiApiKey = "ai-key", + n8nRetrainUrl = "$baseUrl/retrain", + n8nApiKey = "n8n-key", + ), + ) + + afterSpec { server.stop(0) } + + beforeEach { + responses.clear() + apiKeys.clear() + } + + it("n8n 재학습 요청의 jobId를 반환한다") { + responses["/retrain"] = 202 to "{\"jobId\":\"job-1\"}" + + client.requestRetraining().jobId shouldBe "job-1" + apiKeys["/retrain"] shouldBe "n8n-key" + } + + it("FastAPI SHAP 응답을 내부 결과로 변환한다") { + responses["/internal/ai/decisions/1/shap"] = + 200 to + """{"decisionId":1,"baseValue":0.4,"contributions":[{"featureName":"rsi","contribution":0.1}],"finalProbability":0.5}""" + + val result = client.getShapExplanation(1L) + + result.contributions.single().featureName shouldBe "rsi" + apiKeys["/internal/ai/decisions/1/shap"] shouldBe "ai-key" + } + + it("외부 오류를 도메인 오류로 변환한다") { + responses["/retrain"] = 500 to "{}" + + shouldThrow { client.requestRetraining() }.errorCode shouldBe + LearningErrorCode.EXTERNAL_SERVICE_UNAVAILABLE + } + + it("n8n이 202 이외 상태를 반환하면 실패로 처리한다") { + responses["/retrain"] = 200 to "{\"jobId\":\"job-1\"}" + + shouldThrow { client.requestRetraining() }.errorCode shouldBe + LearningErrorCode.EXTERNAL_SERVICE_UNAVAILABLE + } +}) diff --git a/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt index a61ebef..22b93e6 100644 --- a/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt +++ b/src/test/kotlin/team/cklob/arena/global/security/SecurityIntegrationTest.kt @@ -35,6 +35,7 @@ import team.cklob.arena.domain.user.domain.repository.UserRepository import team.cklob.arena.domain.user.domain.type.ClientPlatform import team.cklob.arena.domain.user.domain.type.InvestmentExperience import team.cklob.arena.domain.user.domain.type.OauthProvider +import team.cklob.arena.domain.user.domain.type.UserRole import team.cklob.arena.domain.user.infrastructure.OAuthProviderClient import team.cklob.arena.domain.user.infrastructure.dto.OAuthProfile import team.cklob.arena.global.common.RequestLoggingFilter @@ -129,6 +130,41 @@ class SecurityIntegrationTest( } } + it("Observability API는 관리자만 호출할 수 있다") { + val user = + userRepository.save( + User( + nickname = "user-role", + investmentExperience = InvestmentExperience.BEGINNER, + oauthProvider = OauthProvider.GOOGLE, + oauthProviderUserId = UUID.randomUUID().toString(), + ), + ) + val admin = + userRepository.save( + User( + nickname = "admin-role", + investmentExperience = InvestmentExperience.BEGINNER, + oauthProvider = OauthProvider.GOOGLE, + oauthProviderUserId = UUID.randomUUID().toString(), + role = UserRole.ADMIN, + ), + ) + + mockMvc.get("/observability/models") { + header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(requireNotNull(user.id))}") + }.andExpect { + status { isForbidden() } + jsonPath("$.code") { value("FORBIDDEN") } + } + mockMvc.get("/observability/models") { + header("Authorization", "Bearer ${jwtTokenProvider.createAccessToken(requireNotNull(admin.id))}") + }.andExpect { + status { isOk() } + jsonPath("$.data.models") { isArray() } + } + } + it("refresh JWT로 보호 API를 호출하면 공통 401 응답을 반환한다") { mockMvc.get("/test/protected") { header("Authorization", "Bearer ${jwtTokenProvider.createRefreshToken(1L, 1L)}")