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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ out/

### Kotlin ###
.kotlin

### Env ###
.env

### Local context checkout ###
/context/
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# AGENTS.md — core-api local conventions

이 문서는 core-api 작업 트리의 로컬 규칙만 정의한다. 제품 도메인, ADR, API 계약의 정본은
외부 `context` 저장소이며, 이 저장소에는 동기화된 스냅샷만 둔다.

## 1. 문서와 계약

- 작업 전 다음 순서로 읽는다: `docs/AI_AGENT.md`, `docs/DECISIONS.md`,
`docs/DOMAIN.md`, `docs/ARCHITECTURE.md`, `docs/USECASES.md`,
`docs/GLOSSARY.md`, `docs/ROADMAP.md`, `docs/GIT_WORKFLOW.md`.
- `docs/`와 `openapi/`는 context 저장소에서 동기화되는 파일이다. 이 저장소에서 직접
수정하지 않는다. 변경이 필요하면 context 저장소에서 수정하고 동기화한다.
- 작업 트리의 `/context/`는 개발자가 편의상 둘 수 있는 로컬 checkout일 뿐이다. Git
submodule 또는 core-api의 추적 대상에 추가하지 않는다.
- Web 계약은 `openapi/core-api.yaml`, Compute 호출 계약은 `openapi/compute-api.yaml`을
기준으로 한다.

## 2. Feature-centric Gradle modules

- Feature는 `<feature>/{domain,port,application,adapter/<technology>}`로 구성한다.
- Gradle project path는 `:<feature>:<layer>` 또는
`:<feature>:adapter:<technology>`를 사용하며 `projectDir` 재매핑을 만들지 않는다.
- 각 모듈은 표준 `src/main/kotlin`, `src/test/kotlin` source layout을 사용한다.
- 순수 공통 primitive는 `:shared:kernel`, 공통 기술 구현은
`:shared:infrastructure`, 실행과 composition은 `:app`에 둔다.

## 3. Ports, services, and adapters

- Inbound use case와 Command/Query/Result는 `<feature>:port`에 기능 단위로 둔다.
전역 `command/`, `query/`, `result/`, `usecase/` 디렉터리는 만들지 않는다.
- Outbound capability interface도 `<feature>:port`에 둔다. Command persistence는
`{Domain}Store`, 독립적인 read-only 조회는 `{Domain}Reader`를 사용한다. Aggregate를
검증하기 위한 command-side 조회는 Store 책임이다.
- Application service는 `<feature>:application`에서 inbound port를 구현하고 outbound
port만 의존한다. concrete adapter, JPA, PostgreSQL, Web 기술을 직접 의존하지 않는다.
- HTTP adapter는 `<feature>:adapter:web`에 둔다. Controller는 resource 단위로 두고
inbound port만 의존한다. Request/Response는 web DTO이며 domain/JPA entity를 직접
노출하지 않는다.
- Persistence, Snowflake, Compute 등은 기술 또는 외부 시스템별 adapter 모듈로 분리한다.
Adapter를 하나의 포괄 모듈로 만들지 않는다.
- 기본 의존 방향은 `domain ← port ← application ← adapter`이다. 다른 feature의
application/service 구현체를 코드 재사용 목적으로 직접 의존하지 않는다.

## 4. Spring and identifiers

- 일반적인 application service와 adapter는 component scanning을 사용한다. `:app`은
Spring Boot 진입점과 composition root이며, 명시적 생성이 필요한 객체만 `@Bean`으로
등록한다.
- Domain은 typed ID만 소유하며 Snowflake 같은 생성 방식을 알지 않는다. Feature별 ID
generator port는 feature port에, Snowflake 구현은 `:shared:infrastructure`, feature ID
변환 adapter는 `<feature>:adapter:snowflake`에 둔다.

## 5. Gradle conventions

- 모든 Kotlin/JVM 모듈의 공통 설정과 테스트 의존성은 included build `build-logic`의
`kotlin-common-conventions`를 사용한다.
- Domain은 `domain-conventions`로 framework 의존성 금지를 강제한다. Spring adapter는
`spring-adapter-conventions`, Spring Boot 실행 모듈은
`spring-boot-application-conventions`를 사용한다.
- Java toolchain, Kotlin compiler option, JUnit Platform 설정을 개별 module build script에
반복하지 않는다. 버전과 공통 좌표는 `gradle/libs.versions.toml`에서 관리한다.
- JPA, AI SDK 등 기술 의존성은 공통 convention이 아니라 가장 좁은 technology adapter에
둔다.
16 changes: 16 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins { id("spring-boot-application-conventions") }
dependencies {
implementation(project(":strategy:application"))
implementation(project(":strategy:port"))
implementation(project(":strategy:adapter:snowflake"))
implementation(project(":strategy:adapter:web"))
implementation(project(":strategy:adapter:persistence"))
implementation(project(":shared:infrastructure"))
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.jdbc)
implementation(libs.spring.boot.starter.web)
implementation(libs.kotlin.reflect)
runtimeOnly(libs.postgresql)
testImplementation(libs.spring.boot.starter.test)
testRuntimeOnly(libs.h2)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.refinvest.core.config

import com.refinvest.core.shared.infrastructure.id.SnowflakeIdGenerator
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class IdGenerationConfiguration {
@Bean
fun snowflakeIdGenerator(
@Value("\${refinvest.id.node-id}") nodeId: Long,
): SnowflakeIdGenerator = SnowflakeIdGenerator(nodeId)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.refinvest.core.config

import com.refinvest.core.strategy.domain.MemberId
import com.refinvest.core.strategy.port.outbound.MemberIdProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock

@Configuration
class StrategyConfiguration {
@Bean
fun clock(): Clock = Clock.systemUTC()

@Bean
fun testMemberIdProvider(
@Value("\${refinvest.execution.test-member-id}") testMemberId: Long,
): MemberIdProvider = MemberIdProvider { MemberId(testMemberId) }

}
25 changes: 25 additions & 0 deletions app/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
spring:
application:
name: refinvest
datasource:
url: jdbc:postgresql://${DATABASE_HOST:localhost}:${DATABASE_PORT:5432}/${DATABASE_NAME}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
jpa:
hibernate:
ddl-auto: update

management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: always

refinvest:
id:
node-id: ${SNOWFLAKE_NODE_ID:0}
execution:
test-member-id: ${TEST_MEMBER_ID:1}
118 changes: 118 additions & 0 deletions app/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.refinvest

import com.refinvest.core.strategy.port.outbound.StrategyIdGenerator
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.test.context.TestPropertySource
import org.springframework.jdbc.core.JdbcTemplate
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(
properties = [
"spring.datasource.url=jdbc:h2:mem:refinvest;MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
],
)
class RefinvestApplicationTests(
@Autowired private val strategyIdGenerator: StrategyIdGenerator,
@Autowired private val jdbcTemplate: JdbcTemplate,
@LocalServerPort private val port: Int,
) {

@Test
fun contextLoads() {
}

@Test
fun snowflakeStrategyIdGeneratorIsWired() {
val first = strategyIdGenerator.next()
val second = strategyIdGenerator.next()

assertTrue(first.value > 0)
assertNotEquals(first, second)
}

@Test
fun `health includes datasource status`() {
val response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI("http://localhost:$port/actuator/health")).GET().build(),
HttpResponse.BodyHandlers.ofString(),
)

assertTrue(response.statusCode() == 200)
assertTrue(response.body().contains("\"status\":\"UP\""))
assertTrue(response.body().contains("\"db\""), response.body())
}

@Test
fun `creates a strategy through HTTP and persists it`() {
val response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI("http://localhost:$port/strategies"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"volatility hypothesis\"}"))
.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8),
)

assertTrue(response.statusCode() == 201, response.body())
val id = "\"id\":\"(\\d+)\"".toRegex().find(response.body())?.groupValues?.get(1)?.toLong()
assertTrue(id != null, response.body())
val persistedName = jdbcTemplate.queryForObject(
"select name from strategies where id = ?",
String::class.java,
id,
)
val persistedMemberId = jdbcTemplate.queryForObject(
"select member_id from strategies where id = ?",
Long::class.java,
id,
)
assertTrue(persistedName == "volatility hypothesis")
assertTrue(persistedMemberId == 1L)
}

@Test
fun `gets a strategy through HTTP after it is created`() {
val created = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI("http://localhost:$port/strategies"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"volatility hypothesis\"}"))
.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8),
)
val id = "\"id\":\"(\\d+)\"".toRegex().find(created.body())?.groupValues?.get(1)
assertTrue(created.statusCode() == 201 && id != null, created.body())

val response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI("http://localhost:$port/strategies/$id")).GET().build(),
HttpResponse.BodyHandlers.ofString(),
)

assertTrue(response.statusCode() == 200, response.body())
assertTrue(response.body().contains("\"id\":\"$id\""), response.body())
assertTrue(response.body().contains("\"name\":\"volatility hypothesis\""), response.body())
assertTrue(response.body().contains("\"versions\":[]"), response.body())
}

@Test
fun `returns not found for an unknown strategy`() {
val response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI("http://localhost:$port/strategies/999999999999999999")).GET().build(),
HttpResponse.BodyHandlers.ofString(),
)

assertTrue(response.statusCode() == 404, response.body())
}

}
4 changes: 4 additions & 0 deletions backtest/adapter/compute/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
plugins { id("kotlin-common-conventions") }
dependencies {
implementation(project(":backtest:port"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Compute integration adapter boundary. The ComputeClient implementation and polling are intentionally deferred.
*/
package com.refinvest.core.backtest.adapter.out.compute;
6 changes: 6 additions & 0 deletions backtest/adapter/snowflake/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugins { id("spring-adapter-conventions") }
dependencies {
api(project(":backtest:port"))
implementation(project(":shared:infrastructure"))
implementation(libs.spring.context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.refinvest.core.backtest.adapter.out.id

import com.refinvest.core.backtest.domain.BacktestRunId
import com.refinvest.core.backtest.port.outbound.BacktestRunIdGenerator
import com.refinvest.core.shared.infrastructure.id.SnowflakeIdGenerator
import org.springframework.stereotype.Component

@Component
class SnowflakeBacktestRunIdGenerator(
private val snowflakeIdGenerator: SnowflakeIdGenerator,
) : BacktestRunIdGenerator {
override fun next(): BacktestRunId = BacktestRunId(snowflakeIdGenerator.next())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.refinvest.core.backtest.adapter.out.id

import com.refinvest.core.shared.infrastructure.id.SnowflakeIdGenerator
import kotlin.test.Test
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue

class SnowflakeBacktestRunIdGeneratorTest {
@Test
fun `wraps a Snowflake value in a BacktestRunId`() {
val generator = SnowflakeBacktestRunIdGenerator(SnowflakeIdGenerator(nodeId = 1))

val first = generator.next()
val second = generator.next()

assertTrue(first.value > 0)
assertNotEquals(first, second)
}
}
5 changes: 5 additions & 0 deletions backtest/application/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
plugins { id("kotlin-common-conventions") }
dependencies {
implementation(project(":backtest:port"))
implementation(libs.spring.context)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.refinvest.core.backtest.application.backtest.run

import com.refinvest.core.backtest.domain.BacktestRun
import com.refinvest.core.backtest.port.inbound.backtest.run.RunBacktestCommand
import com.refinvest.core.backtest.port.inbound.backtest.run.RunBacktestResult
import com.refinvest.core.backtest.port.inbound.backtest.run.RunBacktestUseCase
import com.refinvest.core.backtest.port.outbound.BacktestRunIdGenerator
import com.refinvest.core.backtest.port.outbound.BacktestRunStore
import org.springframework.stereotype.Service
import java.time.Clock

@Service
class RunBacktestService(
private val backtestRunStore: BacktestRunStore,
private val backtestRunIdGenerator: BacktestRunIdGenerator,
private val clock: Clock,
) : RunBacktestUseCase {
override fun execute(command: RunBacktestCommand): RunBacktestResult {
val backtestRun = BacktestRun.createPending(
id = backtestRunIdGenerator.next(),
strategyVersionId = command.strategyVersionId,
requestedPeriod = command.period,
feeModel = command.feeModel,
createdAt = clock.instant(),
)
backtestRunStore.save(backtestRun)
return RunBacktestResult(backtestRun.id)
}
}
Loading