Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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 서비스를 호출할 수 없습니다."),
}
Original file line number Diff line number Diff line change
@@ -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<DecisionLogResult>
}
Original file line number Diff line number Diff line change
@@ -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<ModelVersionResult>
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<DecisionLogResult> =
decisionLogRepository.findAllByFilters(challengeId, market, modelVersion?.trim()?.takeIf(String::isNotEmpty))
.map(DecisionLogResult::from)
}
Original file line number Diff line number Diff line change
@@ -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<ModelVersionResult> =
(
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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package team.cklob.arena.domain.learning.application.result

data class RetrainingJobResult(
val jobId: String,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<ShapContributionResult>,
val finalProbability: BigDecimal,
)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading