From 2d4c19d15097686539d3b75357629718a5f66353 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:48:43 +0900 Subject: [PATCH 1/6] docs(contract): synchronize Core API documents and OpenAPI contracts Synchronize context-owned documentation and API contracts. Related: ADR-018; Use Cases DefineStrategyVersion, GetStrategy, RunBacktest. --- docs/AI_AGENT.md | 54 ++++ docs/ARCHITECTURE.md | 162 ++++++++++ docs/DECISIONS.md | 251 +++++++++++++++ docs/DOMAIN.md | 187 +++++++++++ docs/GIT_WORKFLOW.md | 82 +++++ docs/GLOSSARY.md | 57 ++++ docs/README.md | 8 + docs/ROADMAP.md | 114 +++++++ docs/USECASES.md | 59 ++++ openapi/compute-api.yaml | 651 +++++++++++++++++++++++++++++++++++++++ openapi/core-api.yaml | 643 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 2268 insertions(+) create mode 100644 docs/AI_AGENT.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/DOMAIN.md create mode 100644 docs/GIT_WORKFLOW.md create mode 100644 docs/GLOSSARY.md create mode 100644 docs/README.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/USECASES.md create mode 100644 openapi/compute-api.yaml create mode 100644 openapi/core-api.yaml diff --git a/docs/AI_AGENT.md b/docs/AI_AGENT.md new file mode 100644 index 0000000..66d3b9d --- /dev/null +++ b/docs/AI_AGENT.md @@ -0,0 +1,54 @@ +# AI_AGENT.md — 에이전트 작업 워크플로우 + +`AGENTS.md`를 먼저 읽었다는 전제로 작성한다. 이 문서는 "무엇을" 만들지가 아니라 "어떻게" 작업을 진행할지를 다룬다. + +--- + +## 1. 작업 시작 전 체크리스트 + +새 작업(이슈/티켓)을 받으면 다음을 순서대로 확인한다. + +1. **어느 레포의 작업인가?** — `core-api`(도메인 로직, API), `compute-api`(백테스트 계산, 데이터 적재), `web`(UI), `context`(문서/계약)인지 먼저 구분한다. 하나의 기능이 여러 레포에 걸치는 경우(예: 새 Condition Type 추가)가 흔하므로, 영향받는 레포를 전부 나열한 뒤 시작한다. +2. **`docs/DECISIONS.md`에 관련 ADR이 있는가?** — 있다면 그 결정을 따른다. 결정과 다르게 구현해야 할 이유가 있다면, 코드를 먼저 바꾸지 말고 ADR을 수정하는 PR을 먼저 제안한다. +3. **`docs/DOMAIN.md`의 관련 Aggregate 불변식을 확인한다.** — 특히 Strategy/BacktestRun의 상태 전이 규칙을 위반하지 않는지 확인한다. +4. **Core↔Compute 경계를 넘는 변경인가?** — API 계약(`context` 레포의 OpenAPI 스펙)이 바뀌어야 한다면, 계약을 먼저 수정하고 두 레포에 각각 반영한다. 한쪽 레포만 보고 임의로 응답 필드를 추측해 구현하지 않는다. + +## 2. Core (Kotlin + Spring Boot) 작업 규칙 + +- 패키지 구조는 `docs/ARCHITECTURE.md` §2의 모듈 경계를 따른다. `strategy`, `backtest`(오케스트레이션), `asset`, `user`, `subscription` 모듈 간 직접 참조 대신 각 모듈이 노출하는 Application Service를 통해서만 상호작용한다. 실제 Gradle 모듈/패키지 구조와 네이밍 컨벤션은 `core-api/AGENTS.md` §4를 따른다(`docs/DECISIONS.md` ADR-018). +- Compute를 호출하는 코드는 **오직 `backtest`와 `asset` 두 모듈**에만 존재한다. `backtest`는 백테스트 실행을, `asset`은 Asset/Calendar 참조 데이터 조회(`ListAssets`, `GetAssetAvailability`, `GetSeries`)를 각각 전담하며, 각 모듈은 자신의 기술 어댑터 계층(`core-api/AGENTS.md` §4 컨벤션)에 Compute 클라이언트를 격리한다. 다른 모듈(`strategy`, `user` 등)은 Compute를 직접 호출하지 않고 반드시 `backtest`/`asset` 모듈의 Use Case를 거친다. Compute의 URL, 응답 스키마를 이 어댑터 밖으로 노출하지 않는다. +- Core의 백테스트 실행 엔드포인트(Web이 호출하는 public API, `openapi/core-api.yaml`의 `POST /strategy-versions/{versionId}/backtests`)는 **비동기**다. 요청 즉시 `BacktestRun(status=PENDING)`을 반환하고, 실제 계산은 `backtest` 모듈이 Compute의 내부 API(`docs/ARCHITECTURE.md` §6, `POST /backtests`)에 위임한 뒤 상태를 폴링해 갱신한다. 절대 이 엔드포인트를 동기로 만들지 않는다 (Compute 응답을 기다리며 커넥션을 오래 잡고 있지 않는다). +- DSL 관련 필드(Condition, Signal Asset 등)는 `docs/GLOSSARY.md`의 네이밍을 그대로 코드 식별자로 사용한다. 임의로 축약하거나 다른 용어로 바꾸지 않는다. +- 테스트: Aggregate 불변식(예: "Primary Signal Asset은 정확히 1개") 위반 시나리오를 반드시 실패 테스트로 커버한다. + +## 3. Compute (Python + FastAPI) 작업 규칙 + +- **Backtest Engine**과 **Data Ingestion**은 같은 레포 안이라도 명확히 분리된 모듈/패키지로 유지한다 (`engine/`, `ingestion/`). Ingestion 실패가 Engine에 영향을 주면 안 된다. +- 시계열 계산(Return, Change, Relative, Lag)은 `docs/DECISIONS.md` **ADR-003(4대 Temporal Rule)**을 그대로 구현한다 — 이 규칙은 이 서비스의 핵심 계약이므로 임의로 최적화하며 규칙을 변형하지 않는다. 특히 Cross-Calendar Metric Anchor Rule(**ADR-004**)을 건드리는 변경은 반드시 해당 ADR을 함께 갱신한다. +- Dataset은 항상 **Immutable Snapshot**을 참조해서 계산한다. Ingestion이 만든 최신 스냅샷을 실행 중인 백테스트가 참조하다가 중간에 갱신되는 일이 없도록 스냅샷 ID를 파라미터로 명시적으로 받는다. +- 벡터화 연산(pandas/polars)을 사용하되, Point-in-Time Correctness를 해치는 방식(예: 전체 시계열을 미리 로드해두고 미래 인덱스에 실수로 접근하는 패턴)을 피하기 위해 계산 함수는 항상 "이 시점까지 확정된 데이터"만 인자로 받도록 설계한다. +- compute-api는 외부에 인증 없이 노출하지 않는다. core-api만 호출하는 내부망 서비스로 취급한다. 구체적인 인증 방식(API Key + 네트워크 격리)은 `docs/DECISIONS.md` ADR-017 참고. + +## 4. Web (Next.js) 작업 규칙 + +- Core API만 호출한다(`openapi/core-api.yaml`). Compute의 존재를 프론트가 알 필요가 없다. +- 백테스트 결과 대기는 **폴링**(2~3초 간격)으로 구현한다. WebSocket 등 실시간 채널은 지금 도입하지 않는다. +- Strategy Builder는 Progressive Disclosure(Level 1 → 2 → 3) 구조를 그대로 UI 상태로 반영한다. Level 3(Relative/Lag/Cross-Market)를 처음부터 노출하지 않는다. +- 차트는 `docs/ARCHITECTURE.md` §5에 명시된 라이브러리 선택을 따른다. + +## 5. PR 작성 시 확인할 것 + +> 브랜치/커밋 컨벤션, 어떤 git 작업까지 에이전트가 자율로 해도 되는지는 `docs/GIT_WORKFLOW.md` 참고. + +- 변경이 `docs/DECISIONS.md`의 기존 ADR과 충돌하지 않는지 명시한다. +- Core↔Compute API 계약을 바꿨다면 `context` 레포의 OpenAPI 스펙도 같은 PR 세트에 포함한다(레포가 다르더라도 PR 설명에 서로 링크). +- Determinism에 영향을 주는 변경(§ AGENTS.md 4항)은 PR 설명에 "Determinism 영향 없음" 또는 "영향 있음 + 마이그레이션 계획"을 명시한다. + +## 6. 모호할 때 + +스펙에 없는 엣지 케이스를 만나면, 영향 범위에 따라 다르게 대응한다. + +- **계산 엔진 핵심 로직**(Temporal Rule, Signal/Lag/Execution 판정, Determinism, Missing Session 처리 등 `docs/DECISIONS.md` ADR-002~010이 다루는 영역)에 영향을 준다면 — 임의로 구현을 정하지 않는다. 우선순위 기준(아래)으로 가장 타당한 안을 스스로 도출하되, **코드를 병합하기 전에** `docs/DECISIONS.md`에 그 판단과 근거를 담은 ADR 초안을 추가하고 사람의 리뷰를 요청한다. 여기서는 잘못된 기본값이 조용히 틀린 백테스트 결과로 이어질 수 있기 때문에 신중함이 우선한다. +- **그 외 영역**(API 필드 명명 세부사항, 에러 메시지 문구, 내부 함수 분리 방식 등 정정 비용이 낮은 결정)은 아래 우선순위 기준으로 스스로 판단해 진행하고, PR 설명에 어떤 근거로 그렇게 정했는지만 남긴다. 매번 멈추고 확인을 구하지 않는다. + +이 서비스의 우선순위는 **Data Correctness > Temporal Correctness > Execution Correctness > Statistical Interpretation > Research UX > Feature Count** 순이다. 트레이드오프가 생기면 이 순서를 기준으로 판단한다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a682106 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,162 @@ +# ARCHITECTURE.md — 시스템 아키텍처 + +--- + +## 1. 전체 구조 + +```text +┌─────────────────┐ +│ web │ Next.js +└────────┬─────────┘ + │ REST (항상 Core만 호출) + ↓ +┌─────────────────┐ +│ core-api │ Kotlin + Spring Boot +│ (도메인 상태 소유) │ +└────────┬─────────┘ + │ REST + 비동기 폴링 + ↓ +┌──────────────────────┐ +│ compute-api │ Python + FastAPI +│ (Backtest Engine + │ +│ Data Ingestion) │ +└──────────┬───────────┘ + ↓ + ┌───────┴────────┐ + ↓ ↓ +[Postgres] [Object Storage] +(공유 DB, (Dataset Snapshot, +테이블 소유권 Parquet) +문서로 구분) +``` + +`context`은 코드가 없는 문서/계약 전용 레포로, 이 문서들과 두 API 계약을 보관한다: `openapi/compute-api.yaml`(Core↔Compute), `openapi/core-api.yaml`(Web↔Core). + +--- + +## 2. Core (Kotlin + Spring Boot) 모듈 구조 + +Feature-centric Gradle 멀티모듈 + Hexagonal(Ports & Adapters)로 구성한다. **실제 모듈/패키지 구조, +네이밍 컨벤션(예: persistence 포트는 `{Domain}Store`/`{Domain}Reader`, `Repository` 명칭 금지)은 +`core-api/AGENTS.md` §4가 정본이다** — 이 문서에서 구조를 재설명하지 않는다. Gradle 멀티모듈을 택한 +이유와 트레이드오프는 `docs/DECISIONS.md` ADR-018 참고. + +모듈(feature) 목록: `strategy`, `backtest`(오케스트레이션), `asset`, `user`, `subscription`. +`shared`/`app`은 공통 kernel·infrastructure·composition root다. + +**모듈 간 규칙**(레포 구조와 무관하게 항상 성립): +- 모듈은 서로의 domain을 직접 참조하지 않는다. 다른 모듈의 기능이 필요하면 그 모듈이 노출하는 + Use Case(application 레이어)를 통해서만 호출한다. +- `backtest`와 `asset` 두 모듈만 `Compute`를 호출할 수 있다 — `backtest`는 백테스트 실행을, `asset`은 + Asset/Calendar 참조 데이터 조회(`ListAssets`, `GetAssetAvailability`, `GetSeries`)를 전담하며, 각자 + 자신의 기술 어댑터 계층 뒤로 Compute 클라이언트를 격리한다(구체적 모듈 경로는 `core-api/AGENTS.md` + §4 컨벤션을 따른다). 그 외 모듈(`strategy`, `user` 등)이 Compute 데이터가 필요하면 `backtest` 또는 + `asset` 모듈의 Use Case를 거친다(직접 호출 금지). Compute의 URL·응답 스키마를 이 어댑터 밖으로 + 노출하지 않는다. (`AI_AGENT.md` §2와 동일) + +--- + +## 3. Compute (Python + FastAPI) 모듈 구조 + +```text +compute-api/ +├── engine/ # Backtest Engine — 계산 로직 +│ ├── temporal/ # 4대 Temporal Rule 구현 (docs/DECISIONS.md ADR-003, ADR-004) +│ ├── conditions/ # Simple, Lookback, Change, Relative 평가 +│ ├── execution/ # Signal → Lag → Execution Resolution → Price Calculation +│ ├── metrics/ # CAGR, Sharpe, MDD 등 +│ └── pipeline.py # Strategy DSL → Validation → ... → Result (전체 파이프라인 엔트리) +├── ingestion/ # Data Ingestion — engine과 완전히 분리된 패키지 +│ ├── vendors/ # 벤더별 어댑터 (Tiingo, 크립토 벤더 등) +│ ├── corporate_actions/ # Split/Reverse Split/Dividend 정규화 +│ └── snapshot.py # Immutable Dataset Snapshot 생성 +├── api/ +│ ├── routes/backtests.py # POST /backtests (202 Accepted), GET /backtests/{id} +│ └── routes/assets.py # GET /assets, GET /assets/{symbol}/availability, GET /series (Data Explorer용 다중 자산 시계열 — `docs/USECASES.md`의 `GetSeries`) +└── worker/ # 비동기 Job 처리 (Job Queue Consumer) +``` + +**계산 파이프라인** (`engine/pipeline.py`): + +```text +Strategy DSL → Validation → Dataset Snapshot Resolution → Calendar Resolution +→ Signal Evaluation → Lag Resolution → Execution Session Resolution +→ Execution Price Calculation → Position Management → Exit +→ Fee/Slippage → Portfolio Calculation → Metrics → Result +``` + +`engine`과 `ingestion`은 서로를 import하지 않는다. `ingestion`이 실패해도 이미 존재하는 Snapshot으로 `engine`은 계속 동작해야 한다. + +--- + +## 4. 데이터베이스 — 단일 Postgres, 테이블 소유권 분리 + +물리적으로 하나의 Postgres 인스턴스를 공유한다(ADR-012). 소유권은 아래 표로 강제하며, **소유하지 않는 서비스는 해당 테이블에 쓰기 쿼리를 절대 작성하지 않는다** — 코드 리뷰에서 우선 확인 대상. + +| 테이블 | 소유(쓰기 권한) | 읽기 | +|---|---|---| +| `users`, `strategies`, `strategy_versions`, `subscriptions` | Core | Core만 | +| `backtest_runs`, `backtest_results`, `trades` | Core | Core만 (Compute는 응답을 반환할 뿐 직접 쓰지 않음) | +| `assets`, `market_calendars`, `corporate_actions` | Compute (Ingestion) | Core는 Compute API를 통해서만 조회, 직접 쿼리 금지 | +| `dataset_snapshots` | Compute (Ingestion) | Compute 내부, Core는 스냅샷 ID만 참조값으로 저장 | + +**Core가 Asset/Calendar 정보를 직접 DB에서 join하지 않는 이유**: 스키마가 같은 물리 DB에 있어도, Compute가 소유한 테이블은 반드시 Compute의 API(`GET /assets`)를 거쳐 조회한다. 이렇게 해야 나중에 실제로 DB를 분리해야 할 때(ADR-012 Revisit 조건) 애플리케이션 코드 변경 없이 인프라만 바꿀 수 있다. + +--- + +## 5. Web (Next.js) 구조 + +```text +web/ +├── app/ +│ ├── explorer/ # Data Explorer +│ ├── strategies/[id]/build/ # Strategy Builder (Level 1~3) +│ └── strategies/[id]/results/[runId]/ # 결과 페이지 +├── lib/api/ # Core API 클라이언트 (Compute를 직접 호출하는 코드는 존재하지 않는다) +└── components/ + ├── chart/ # lightweight-charts 기반 (Data Explorer, Trade Timeline) + └── viz/ # Recharts/D3 기반 (Equity Curve, Drawdown — 커스텀 시각화) +``` + +- 차트: 시계열 오버레이·줌/팬이 필요한 Data Explorer/Trade Timeline은 [lightweight-charts](https://github.com/tradingview/lightweight-charts)를, Equity Curve/Drawdown처럼 커스텀 시각화가 필요한 부분은 Recharts를 사용한다. +- API 클라이언트는 `openapi/core-api.yaml`을 기준으로 생성/구현한다(경로, 요청/응답 스키마는 이 스펙이 정본). +- 결과 페이지 필수 구성: Equity Curve(Strategy vs Execution Asset B&H), Drawdown Chart, Trade Timeline(가격 차트 위 Signal/Entry/Exit 마커, Cross-Market은 Signal↔Execution 구분), Trade Table, TQQQ/SOXL은 `docs/DECISIONS.md` ADR-016 경고 고정 표시. +- 상태 관리: 서버 상태는 TanStack Query. 별도 전역 상태 관리 라이브러리는 도입하지 않는다. +- 백테스트 결과 대기: `GET /backtests/{id}`를 2~3초 간격으로 폴링. WebSocket 미도입. + +--- + +## 6. Core ↔ Compute 통신 + +- 프로토콜: REST (JSON). 계약은 `context/openapi/compute-api.yaml`에서 관리하며, 두 레포 모두 이 스펙을 기준으로 구현한다. (Web↔Core 계약은 별도 `openapi/core-api.yaml` — §5 참고.) +- 백테스트 실행은 비동기다. + +```text +Core Compute + │ POST /backtests {strategyVersion, feeModel, period} + ├─────────────────────────────────────>│ + │ 202 Accepted {runId, status: PENDING} + │<─────────────────────────────────────┤ + │ │ (내부 Job Queue에서 처리) + │ GET /backtests/{runId} (폴링, 2~3초 간격) + ├─────────────────────────────────────>│ + │ 200 {status: RUNNING} │ + │<─────────────────────────────────────┤ + │ ... (반복) ... │ + │ GET /backtests/{runId} + ├─────────────────────────────────────>│ + │ 200 {status: COMPLETED, result: {...}} + │<─────────────────────────────────────┤ + │ BacktestResult 영속화 │ +``` + +- Compute는 인터넷에 직접 노출하지 않는다. Core만 호출 가능한 내부망 서비스로 배포한다. +- Job Queue: Compute 내부에서 Redis + RQ(또는 Celery)를 사용한다. 이 큐는 Compute 레포 내부 구현 상세이며 Core는 알 필요가 없다. + +--- + +## 7. 배포 단위 + +4개 레포 = 4개 컨테이너(Core, Compute, Web, 그리고 Ingestion을 별도 스케줄 워커로 분리할지는 Compute 레포 내에서 결정). Postgres, Redis, Object Storage는 공용 인프라로 별도 관리한다. + +Backtest Engine의 컨테이너 이미지는 **버전 태깅**한다(예: `compute-api:1.3.2`). `BacktestRun.engineVersion`에 이 태그를 기록해, 필요 시 과거 버전의 Engine 이미지로 특정 백테스트를 재현할 수 있게 한다(ADR-010 Determinism Contract). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..f483053 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,251 @@ +# DECISIONS.md — Architecture & Domain Decision Records + +이 문서는 지금까지 논의를 통해 확정한 결정을 ADR(Architecture Decision Record) 형식으로 기록한다. 각 ADR을 뒤집으려면 코드를 먼저 바꾸지 말고 이 문서를 먼저 갱신한다. + +--- + +## ADR-001 — MVP Asset Universe 고정 + +**결정**: MVP는 6개 자산으로 제한한다. + +- Execution Assets: `QQQ`, `SPY`, `TQQQ`, `SOXL`, `BTCUSDT` +- Signal/Reference 전용: `VIX` (Execution Asset으로는 사용하지 않음) + +일봉(Daily Bar)만 지원하며, Intraday 데이터는 다루지 않는다. + +**이유**: 자산 수·데이터 빈도를 늘리는 것보다 "가설 → 백테스트 → 재검증" 루프의 정확성과 속도를 검증하는 게 MVP의 목표이기 때문. 데이터 비용/컴퓨트 비용도 이 범위에서 통제 가능하다. + +**Alternatives considered**: 개별 미국 주식 전체 지원 — 기각. 진입장벽만 낮추고 검증해야 할 핵심 가치(Cross-Calendar 정합성, Determinism)와 무관하게 스코프만 키운다. + +--- + +## ADR-002 — Primary Signal Asset은 항상 명시적으로 지정 + +**결정**: 하나의 전략은 정확히 하나의 **Primary Signal Asset**을 가진다. 이 값은 시스템이 자동으로 추론하지 않고 사용자가 Strategy Builder에서 항상 명시적으로 선택한다. + +**이유**: `BTC.return(7) > QQQ.return(7)` 같은 Relative 조건에서 "첫 번째 Operand", "Execution Asset과 동일한 자산", "먼저 평가되는 자산" 등 여러 자동 추론 규칙이 가능했지만, 전부 DSL의 의미가 코드 구조나 입력 순서에 암묵적으로 의존하게 만든다는 문제가 있었다. 조건 순서를 바꿨을 뿐인데 백테스트 결과가 달라지는 버그를 원천 차단하기 위해 명시적 지정으로 확정한다. + +**단일 자산 조건의 경우**: UI에서는 자동으로 채워주되(UX), 내부 도메인 모델에는 항상 명시적 값을 저장한다(Domain). Fallback 규칙("Signal Asset 미지정 시 첫 번째 Operand")은 안전장치로만 코드에 남기고, 실제로는 UI가 항상 강제하므로 정상 경로에서 발동하지 않는다. + +**Condition에 등장하지 않는 자산을 Primary Signal Asset으로 지정하는 것도 허용한다** (예: `SIGNAL ASSET = BTCUSDT`, 조건은 `VIX.change(5) > 20%`). 다른 시장의 상태로 특정 자산의 진입 타이밍을 결정하는 것은 핵심 사용 사례이기 때문이다. + +--- + +## ADR-003 — 4대 Temporal Rule 고정 계약 + +**결정**: 모든 시간 관련 계산은 다음 4가지 규칙으로 환원된다. 이 표는 Backtest Engine 구현의 최상위 계약이다. + +| 요소 | 기준 | +|---|---| +| Metric Window (Return/Change/Lookback) | **Referenced Asset 자신의** Trading Session | +| Signal Timestamp | **Primary Signal Asset**의 Calendar | +| Lag / Holding Period(Exit) | **Primary Signal Asset**의 Signal Session | +| Execution (Entry/Exit 공통) | **Execution Asset**의 Next Available Session | + +**이유**: Cross-Calendar(24/7 Crypto vs 거래일 기준 US Equity) 조건에서 "N일"이 어느 자산 기준인지 모호하면 재현 불가능한 백테스트가 된다. Metric 계산과 Signal 타이밍을 서로 다른 개념으로 분리한 것이 핵심이다 — `BTC.return(7) > QQQ.return(7)`에서 두 Return은 각자의 Calendar로 계산되지만, 이 조건이 만든 Signal이 "언제 발생한 것"으로 취급되는지는 오직 Primary Signal Asset 하나로 결정된다. + +**Related**: ADR-002, ADR-004 + +--- + +## ADR-004 — Cross-Calendar Metric Anchor Rule + +**결정**: Referenced Asset에 Signal Timestamp 시점의 세션이 존재하지 않으면, **Signal Timestamp 이전에 이미 확정되어 있는 가장 최근 완료 세션**을 기준으로 Metric을 계산한다. 미래 세션을 미리 참조하지 않는다. + +예: BTC 토요일 신호 평가 시 QQQ는 토요일 세션이 없으므로, 직전 금요일 종가까지의 데이터로 `QQQ.return(7)`을 계산한다. + +**이유**: Point-in-Time Correctness(Look-ahead Bias 방지)를 Cross-Calendar 상황에서도 예외 없이 지키기 위한 규칙. 이 규칙이 없으면 "가장 가까운 세션"을 과거/미래 중 어느 쪽으로 찾을지가 구현자마다 달라질 수 있다. + +--- + +## ADR-005 — Missing Session은 Fail-fast, 임의 보정 금지 + +**결정**: 예상되는 세션(Expected Session)에 데이터가 없으면 이전 가격 복제, 선형 보간, 임의 가격 추정 등 어떤 형태의 보정도 하지 않고 **백테스트를 중단**하며 오류를 표시한다. 정상적인 Calendar 차이(예: QQQ의 토요일)는 Missing Data가 아니므로 이 규칙의 대상이 아니다. + +Primary Signal Asset의 결측은 특히 엄격하게 처리한다 — Signal Calendar 자체를 신뢰할 수 없게 만들기 때문이다. + +**이유**: 어떤 형태든 임의 보정은 Determinism과 정확성 신뢰를 동시에 훼손한다. 사용자에게 "정확하지만 때때로 실패하는" 도구가 "항상 답을 주지만 가끔 틀린" 도구보다 이 서비스의 포지셔닝(연구 도구, 신뢰 기반)에 맞다. + +**차트 렌더링과의 관계**: 차트에서 시각적으로 값을 이어 그리는 방식(interpolation)은 순수한 렌더링 정책이며 이 ADR의 대상이 아니다(Data Explorer는 별도 정책 적용 가능). 백테스트 계산에만 이 규칙이 강제된다. + +**Fatal Error 종류** (Backtest Engine이 실행을 중단하는 경우, `openapi/compute-api.yaml`의 `FatalErrorCode`와 동일): +```text +MISSING_REQUIRED_DATA # 이 ADR의 대상 — 예상 세션 결측 +DATASET_CORRUPTION +CALENDAR_RESOLUTION_FAILED +PRICE_DATA_MISSING +DSL_INVALID # 실행 전 Validation 단계에서 걸러짐 (Job을 큐에 넣지 않고 즉시 실패) +``` +이 중 `DSL_INVALID`만 실행 전(pre-flight) 검증이고, 나머지 4종은 실행 중 데이터 접근 시점에 발생한다. 반대로 Signal/Trade가 0건인 것은 오류가 아니라 **정상 완료**다(ADR-006). + +--- + +## ADR-006 — Zero Trades와 Low Sample Warning은 별도로 처리 + +**결정**: +- Trade Count < 10 → **Low Sample Warning** 표시 (`⚠ Only N trades occurred. Performance statistics may not be statistically meaningful.`) +- Trade Count = 0 → 별도 **Empty State**로 처리. 백테스트는 정상 완료하되 Win Rate/Sharpe/Profit Factor 등을 `0`으로 표시하지 않고 "조건을 충족한 신호가 한 번도 발생하지 않았습니다"라는 안내로 대체한다. + +**이유**: 0으로 나누는 연산(Win Rate = 승리 거래 / 전체 거래)을 그대로 노출하면 오해를 부르는 숫자(`Win Rate: 0%`처럼 "전략이 항상 실패했다"는 잘못된 인상)가 나갈 수 있다. + +--- + +## ADR-007 — Benchmark 정책: Execution Asset이 Primary + +**결정**: +- Primary Benchmark = **Execution Asset Buy & Hold** +- Secondary Reference = **Signal Asset Buy & Hold** (Cross-Market 전략에서만 참고용으로 별도 표시) + +Signal Asset을 Primary Benchmark로 사용하지 않는다. + +**이유**: "같은 자산을 그냥 들고 있는 것보다 전략이 나았는가"(Execution Asset 기준)와 "신호를 준 시장 자체보다 나았는가"(Signal Asset 기준)는 서로 다른 질문이며, 전자가 실제 투자 의사결정에 더 직접적으로 연결된다. + +--- + +## ADR-008 — Exit은 MVP에서 Time-based만 지원 + +**결정**: `HOLD N signal_sessions` 형태의 Time-based Exit만 지원한다. Condition-based Exit(`EXIT WHEN RSI > 50` 등)은 지원하지 않으며 **Phase 5**(`docs/ROADMAP.md` — Portfolio & Commercialization)에서 재검토한다. + +**이유**: Condition-based Exit을 포함하면 Exit Priority, Multiple Exit Condition 결합, Entry/Exit 동시 발생 처리, Position State 관리 등으로 DSL 복잡도가 급증한다. MVP는 "Entry Condition → 고정 보유 기간 → Outcome"이라는 단순한 형태를 빠르게 검증하는 것을 우선한다. + +--- + +## ADR-009 — Duplicate Entry Signal은 무시(Ignore) + +**결정**: 이미 포지션을 보유한 상태에서 새 Entry Signal이 발생하면 무시한다. Queue에 쌓지 않고, 기존 포지션을 강제로 교체하지도 않는다. + +**포지션 모델**: Long Only, Single Active Position, 100% Capital Allocation. Short/Multi-position/Portfolio Allocation은 MVP 범위 밖(Phase 5 이후 검토). + +**이유**: Determinism을 위해 포지션 상태 전이를 하나의 규칙으로 고정할 필요가 있다. Queue 기반 처리나 조건부 교체는 각각 별도의 정책 결정(우선순위, 타임아웃 등)을 요구해 MVP 범위를 벗어난다. + +--- + +## ADR-010 — Dataset Snapshot & Backtest Determinism Contract + +**결정**: 백테스트에 사용한 정규화 데이터는 **Immutable Dataset Snapshot**으로 저장한다. Backtest Result에는 `Dataset Snapshot ID`, `Strategy Version`, `Engine Version`, `Fee/Slippage Model`을 함께 기록한다. 동일한 4가지 입력이면 항상 동일한 결과가 나와야 한다(Determinism Contract). + +체결 규칙: `Reference Price = Execution Session Open`, Slippage/Fee는 고정 비율(매수 `Open × (1 + Slippage)`, 매도 `Open × (1 - Slippage)`). Intraday 체결 시뮬레이션(VWAP, Limit Order 등)은 지원하지 않는다. + +**이유**: Adjusted Price는 배당/분할 등으로 벤더 측에서 소급 변경(Restatement)될 수 있어, "현재 API 응답"만으로는 재현성을 보장할 수 없다. 스냅샷을 고정해야 "3개월 전에 실행한 백테스트를 지금 다시 열어도 같은 결과가 나온다"를 보장할 수 있다. + +--- + +## ADR-011 — Regulatory Positioning: 연구·시뮬레이션 도구 + +**결정**: 서비스는 "과거 데이터에 대한 연구·시뮬레이션 도구"로 포지셔닝하며 투자 추천을 제공하지 않는다. 결과 화면 문구는 항상 과거형/조건형으로 표현한다. + +```text +❌ 이 전략은 돈을 벌 수 있습니다 / 좋은 전략입니다 +✅ 선택한 과거 기간에서 해당 조건은 다음과 같은 결과를 보였습니다. +``` + +유료 플랜 출시 전 자본시장법·유사투자자문업 해당 여부에 대한 법률 검토를 진행한다(코드 작업과 별개의 트랙). + +**이유**: 백테스트 결과의 상업적 제공이 국내 규제상 어떤 범주에 해당하는지는 별도 법률 검토가 필요한 영역이며, 최소한 제품 문구 수준에서 확정적 수익 예측 표현을 배제해 리스크를 낮춘다. + +--- + +## ADR-012 — 기술 스택 및 레포/컨테이너 구조 + +**결정**: + +| 레포 | 스택 | 역할 | +|---|---|---| +| `core-api` | Kotlin + Spring Boot | Core Server (도메인/API) | +| `compute-api` | Python + FastAPI | Compute (Backtest Engine + Data Ingestion) | +| `web` | Next.js + TypeScript | 프론트엔드 | +| `context` | 문서 전용 | 이 문서들 + Core↔Compute OpenAPI 계약 | + +- 4개 레포, 4개 컨테이너로 분리한다. +- **DB는 단일 Postgres를 공유**하되 테이블 소유권을 문서(`docs/ARCHITECTURE.md` §4)로 명확히 구분한다. 서비스별 물리 DB 분리는 하지 않는다. +- Core↔Compute 통신은 **REST + 비동기 폴링**이다. Compute는 백테스트 요청을 즉시 accept(202)하고 내부 큐/워커로 처리하며, Core는 상태를 폴링한다. WebSocket/이벤트 브로커는 지금 도입하지 않는다. +- Web은 Compute를 직접 호출하지 않는다. 항상 Core를 거친다. + +**이유**: +- Kotlin은 도메인 로직(유저/전략/구독/사용량 제한)의 타입 안전성과 트랜잭션 관리에 강점이 있다. +- Python은 시계열 벡터 연산(Cross-Calendar 정렬, Metric 계산) 생태계가 압도적으로 유리하다. +- 백테스트는 동기 처리하기엔 무거울 수 있어(10년치 일봉 × Cross-Calendar 조인) 비동기로 분리해야 API 서버가 블로킹되지 않는다. +- DB 물리 분리는 지금 데이터 볼륨(6개 자산 × 일봉)에서 얻는 이득보다 운영 복잡도가 커서 보류한다. 테이블 소유권만 코드 리뷰 규칙으로 강제한다. + +**Alternatives considered**: DB도 서비스별로 완전 분리(참고 아키텍처 패턴) — 검토했으나 이번 프로젝트는 데이터 볼륨이 작고 팀 규모도 작아 오버엔지니어링으로 판단, 보류. 향후 트래픽/팀 규모가 커지면 재검토(§ Revisit 참고). + +**Revisit 조건**: Compute가 다루는 참조 데이터(Asset/Calendar/Snapshot) 볼륨이 커지거나, Core/Compute를 서로 다른 팀이 전담하게 되면 DB 분리를 다시 검토한다. + +--- + +## ADR-013 — StrategyVersion은 생성 후 불변 + +**결정**: Strategy는 여러 개의 **StrategyVersion**(불변 엔터티)을 가진다. 사용자가 조건을 수정하면 기존 버전을 덮어쓰지 않고 새 버전을 생성한다. 백테스트는 항상 특정 StrategyVersion을 참조한다. + +**이유**: ADR-010의 Determinism Contract가 성립하려면 "Strategy Version"이 시간에 따라 바뀌지 않는 고정된 참조여야 한다. 참고 프로젝트의 자연어 파싱 확인 흐름과 달리 이 서비스는 Form+Block UI로 직접 조건을 구성하므로, 별도의 mutable Draft 상태 없이 "백테스트 실행" 시점에 현재 편집 중인 조건을 새 StrategyVersion으로 확정하는 단순한 흐름을 사용한다. + +--- + +## ADR-014 — Crypto 데이터 벤더 리스크 (Phase 0 블로커) + +**결정**: BTCUSDT 데이터를 Binance 공개 API에서 직접 가져와 상업 서비스(특히 유료 플랜)에 그대로 사용하지 않는다. + +**이유**: Binance 이용약관은 "Binance 시장 데이터를 이용해 과금하거나 수익을 내는 서비스"를 별도 서면 동의 없이 명시적으로 금지한다. 개발/검증 단계에서 무료로 사용하는 것은 무방하나, 프로덕션 반영 전 CryptoCompare, Kaiko, CoinAPI 등 상업적 라이선스가 명시된 벤더로 전환하거나 Binance와 별도 계약을 체결해야 한다. + +**Status**: 미해결. Phase 0 체크리스트의 최우선 항목. + +--- + +## ADR-015 — AI는 MVP 범위 밖 (선제적 가드레일) + +**결정**: Phase 5 이전에는 Compute에 어떤 형태로든 LLM/AI 클라이언트 의존성을 추가하지 않는다. 향후 AI가 도입되더라도, AI는 자연어를 Strategy DSL로 변환하거나 이미 계산된 Backtest Result를 설명하는 역할만 하며 **직접 수치를 계산하거나 만들어내지 않는다**. + +**이유**: "AI가 투자를 추천하는 서비스가 아니다"라는 포지셔닝(ADR-011)을 아키텍처 레벨에서도 지키기 위해, Compute의 핵심 계산 경로에 AI가 끼어들 여지를 원천적으로 만들지 않는다. + +--- + +## ADR-016 — 레버리지 ETF 결과에는 구조적 경고를 항상 표시 + +**결정**: `TQQQ`, `SOXL` Execution Asset이 포함된 `BacktestResult`를 보여줄 때, Web은 다음 경고를 조건 없이 고정 표시한다(숨기거나 접을 수 없음). + +> ⚠ TQQQ/SOXL은 일일 레버리지 목표를 추구하는 상품으로, 변동성과 일일 복리 효과(volatility decay)에 의해 장기 성과가 기초지수의 단순 배수와 일치하지 않을 수 있습니다. + +**이유**: 레버리지 ETF는 횡보장에서 기초지수보다 구조적으로 손실이 누적되는 특성이 있어, 결과 화면의 수익률만 보면 "전략이 좋아서"인지 "레버리지 상품 특성 때문"인지 사용자가 오인하기 쉽다. ADR-011(투자 추천 아님)의 연장선으로, 결과 해석의 한계를 능동적으로 알린다. + +--- + +## ADR-017 — Core↔Compute 내부 인증: API Key + 네트워크 격리 + +**결정**: `compute-api`는 고정 API Key(요청 헤더, 예: `X-Internal-Api-Key`)를 요구하는 것을 최소 인증으로 삼는다. 여기에 배포 인프라가 표준으로 제공하는 네트워크 격리(VPC/보안 그룹, k8s `NetworkPolicy` 등 — 배포 대상이 정해지는 시점에 확정)를 두 번째 방어선으로 얹는다. 전송 구간 암호화(TLS)는 인증과 별개로 항상 활성화하며, 인프라가 제공하는 방식(로드밸런서 TLS 종료, 사설 인증서 등)을 따른다. + +mTLS는 지금 도입하지 않는다. + +**이유**: 배포 인프라(Kubernetes/VM/관리형 컨테이너 서비스 등)가 아직 정해지지 않았다(`docs/ARCHITECTURE.md` §7). API Key는 어느 인프라를 선택하든 동일하게 동작해 인프라 결정을 선제적으로 제약하지 않는 유일한 옵션이다. mTLS는 옵션 자체는 더 강력하지만 사설 CA 운영·인증서 로테이션 인프라가 필요해, 서비스 메시(Istio/Linkerd) 같은 게 이미 없다면 이 프로젝트 규모에 비해 운영 부담이 크다 — ADR-012에서 DB 물리 분리를 지금 규모엔 오버엔지니어링으로 보고 보류한 것과 같은 논리다. 네트워크 격리만으로는 방어선이 하나뿐이라 보안 그룹 설정 실수나 내부망의 다른 서비스가 침해당했을 때(lateral movement)에 취약해, API Key를 추가 계층으로 둔다. + +**API Key 관리**: 코드에 하드코딩하지 않는다. 배포 인프라가 정해지면 그 인프라의 시크릿 관리 방식(환경변수 주입, 클라우드 시크릿 매니저 등)을 따른다. 로테이션 주기는 운영 단계에서 정한다. + +**Revisit 조건**: (1) 서비스 메시 등 mTLS를 자동으로 관리해주는 인프라를 도입하게 되면 mTLS로 전환을 검토한다. (2) `compute-api`를 호출하는 내부 클라이언트가 `core-api` 외에 여러 개로 늘어나 클라이언트별 식별·개별 폐기(revocation)가 필요해지면 단일 고정 Key 대신 클라이언트별 Key 또는 mTLS로 전환을 검토한다. + +--- + +## ADR-018 — core-api: Feature-centric Gradle 멀티모듈 + Hexagonal 아키텍처 + +**결정**: `core-api`는 단일 Gradle 모듈에 패키지로 경계를 표현하는 대신, **feature(모듈)별로 물리적으로 +분리된 Gradle 모듈**을 사용한다. 각 feature는 `domain`(순수 도메인), `port`(inbound/outbound 인터페이스), +`application`(Use Case 구현), `adapter:`(기술별 구현체, 방향 구분 없이 기술 이름으로 명명)로 +나뉜다. 공통 요소는 `shared:kernel`(순수 primitive), `shared:infrastructure`(공통 기술 구현), `app`(실행 +진입점/composition root)에 둔다. + +세부 컨벤션(디렉터리 구조, Gradle project path, persistence 포트 네이밍 `{Domain}Store`/`{Domain}Reader` +— `Repository`라는 이름은 쓰지 않음, Spring component 규칙, ID 생성은 Snowflake 기반 typed ID를 +feature별 outbound port로 정의)은 **`core-api/AGENTS.md` §4가 정본**이다. 이 문서(`ARCHITECTURE.md`)는 +그 전체를 재설명하지 않고, feature 목록과 모듈 간 규칙(다른 레포에서도 알아야 하는 부분)만 유지한다. + +**이유**: `docs/ARCHITECTURE.md` §2의 "모듈은 서로의 domain을 직접 참조하지 않는다"는 규칙을 **컴파일 +타임에 강제**하기 위해서다. 단일 모듈 + 패키지 분리는 설정이 간단하지만 경계 위반이 테스트(ArchUnit)를 +돌려야만 잡힌다 — 컴파일 자체는 통과해버린다. 물리적으로 Gradle 모듈을 분리하면 애초에 다른 모듈의 +`domain`을 import하는 게 컴파일 에러가 되어, 사람이든 에이전트든 경계를 실수로 넘기 어렵다. 이 프로젝트가 +에이전트에게 상당 부분의 구현을 맡기는 만큼, "규칙을 지켜라"보다 "애초에 못 하게 만든다"는 방향이 +`docs/GIT_WORKFLOW.md`에서 이미 취한 것과 같은 원칙이다. + +**트레이드오프**: 설정 복잡도가 단일 모듈보다 높다(모듈별 `build.gradle`, 모듈 간 의존성 그래프 관리, +`build-logic`의 convention plugin 유지 등). 이 비용은 `gradle/libs.versions.toml`과 convention plugin +(`kotlin-common-conventions`, `domain-conventions` 등)으로 반복을 줄여 완화한다. + +**Revisit 조건**: 이 구조가 실제로 빌드 시간이나 개발 속도에 부담을 줄 정도로 무거워지면(feature 수가 +많아지며 Gradle 설정 관리 자체가 병목이 되는 경우), 덜 자주 바뀌는 feature들을 다시 묶는 것을 검토한다. diff --git a/docs/DOMAIN.md b/docs/DOMAIN.md new file mode 100644 index 0000000..b5bea9b --- /dev/null +++ b/docs/DOMAIN.md @@ -0,0 +1,187 @@ +# DOMAIN.md — 도메인 모델 + +Core와 Compute가 각각 소유하는 Aggregate를 구분한다. 소유하지 않는 서비스는 해당 데이터를 **읽기 전용**으로만 참조한다. + +--- + +## 1. Core가 소유하는 Aggregate + +> 아래 각 Aggregate의 `id`는 Snowflake 기반 typed ID를 사용한다(`docs/DECISIONS.md` ADR-018). +> 생성 방식 자체는 도메인이 알지 않으며, feature별 outbound port로 분리한다 — 구현 상세는 +> `core-api/AGENTS.md` §4 "Identifiers" 참고. + +### 1.1 Strategy (Aggregate Root) + +사용자가 정의한 투자 가설의 컨테이너. 여러 개의 불변 `StrategyVersion`을 가진다(ADR-013). + +```text +Strategy +├── id +├── ownerId (User) +├── name +└── versions: List +``` + +### 1.2 StrategyVersion (Entity, Immutable) + +```text +StrategyVersion +├── id +├── strategyId +├── createdAt +├── primarySignalAsset: AssetSymbol # ADR-002: 항상 명시적 +├── conditions: List # 하나의 Condition Group (AND/OR만, 중첩 없음) +├── executionAsset: AssetSymbol +├── lag: SignalSessions # Primary Signal Asset 기준 (ADR-003) +├── exit: TimeBasedExit(holdingSignalSessions: Int) # ADR-008: MVP는 이 형태만 허용 +└── positionPolicy: { longOnly: true, singlePosition: true, duplicateEntry: IGNORE } # ADR-009 +``` + +**불변식**: +- `primarySignalAsset`은 정확히 1개, null 불가. +- `primarySignalAsset`은 MVP Asset Universe(ADR-001) 6종(`QQQ`, `SPY`, `TQQQ`, `SOXL`, `BTCUSDT`, `VIX`) 중 하나여야 한다 — `VIX`도 Signal Asset으로는 허용된다(ADR-002). +- `executionAsset`은 MVP Asset Universe 중 **Execution Asset 하위 집합**(`QQQ`, `SPY`, `TQQQ`, `SOXL`, `BTCUSDT`)에만 속해야 한다 — `VIX`는 Execution Asset으로 지정할 수 없다(ADR-001). +- `conditions`는 최소 1개 이상. +- `conditions` 내 각 Operand의 Asset이 MVP Asset Universe(ADR-001)에 속해야 한다. +- `lag >= 0`, `exit.holdingSignalSessions > 0`, `MetricReference.window`가 존재하는 경우(`RETURN`/`CHANGE`) `window > 0`. +- 생성된 이후 `conditions`, `primarySignalAsset`, `executionAsset`, `lag`, `exit` 은 수정 불가 — 변경이 필요하면 새 `StrategyVersion`을 만든다. + +```text +Condition +├── operator: LT | GT | LTE | GTE +├── logicalCombinator: AND | OR | null # 두 번째 이후 Condition에만 존재 +├── operandA: MetricReference +└── operandB: MetricReference | LiteralValue + +MetricReference +├── asset: AssetSymbol +├── metric: SIMPLE | RETURN | CHANGE +└── window: Int | null # SIMPLE(Simple Comparison)은 window 없음 +``` + +**예시** — "QQQ가 5일간 7% 이상 하락하고 VIX가 5일간 20% 이상 상승하면, 3 Signal Session 후 TQQQ를 매수해 5 Signal Session 보유": + +```text +StrategyVersion { + primarySignalAsset: QQQ + conditions: [ + { operandA: {asset: QQQ, metric: RETURN, window: 5}, operator: LT, operandB: -0.07 }, + { logicalCombinator: AND, operandA: {asset: VIX, metric: CHANGE, window: 5}, operator: GT, operandB: 0.20 } + ] + lag: 3 + executionAsset: TQQQ + exit: { holdingSignalSessions: 5 } +} +``` + +Web은 이 구조를 그대로 자연어 문장(Strategy Preview)으로 변환해 사용자에게 보여준다 — DSL과 문장이 항상 1:1 대응해야 한다(`PreviewStrategy` Use Case, `docs/USECASES.md`). + +### 1.3 BacktestRun (Aggregate Root) + +하나의 백테스트 실행 요청과 상태를 추적한다. + +```text +BacktestRun +├── id +├── strategyVersionId +├── status: PENDING | RUNNING | COMPLETED | FAILED +├── requestedPeriod: { start, end } +├── actualPeriod: { start, end } # Asset Availability 교집합 반영 후 (ADR-001 관련) +├── feeModel: { commission: Percent, slippage: Percent } +├── datasetSnapshotId # Compute가 실행 시점에 사용한 스냅샷 참조 +├── engineVersion +├── createdAt +└── failureReason: String | null # Fail-fast(ADR-005)로 중단된 경우 +``` + +**불변식**: +- `status`가 `COMPLETED`가 되려면 `BacktestResult`가 반드시 함께 존재해야 한다. +- `status`가 `FAILED`이면 `failureReason`이 필수. +- 상태 전이: `PENDING → RUNNING → (COMPLETED | FAILED)`. 역방향 전이 없음. 재시도는 새 `BacktestRun`을 생성한다. +- `datasetSnapshotId`, `engineVersion`은 **Compute가 실행을 시작한 이후에만 채워진다** — `PENDING` 상태에서는 `null`이다. Core가 요청 시점에 스냅샷을 미리 지정하지 않고, Compute가 실행 시점에 선택한 값을 응답으로 돌려받아 기록한다. + +### 1.4 BacktestResult (Entity, BacktestRun에 종속) + +Compute가 계산한 결과를 Core가 영속화한 읽기 모델. Compute는 이 데이터를 직접 저장하지 않는다 — 계산해서 Core에 반환하면 Core가 소유권을 갖는다. + +```text +BacktestResult +├── backtestRunId +├── metrics: { totalReturn, cagr, mdd, sharpe, winRate, tradeCount, avgTradeReturn, avgHoldingPeriod, profitFactor } +├── equityCurve: List<{ date, value }> +├── trades: List +├── benchmark: { primary: BuyAndHoldResult, secondaryReference: BuyAndHoldResult | null } # ADR-007 +├── signalExecutionDelay: { median, max, distribution } +├── sampleSizeWarning: NONE | LOW | ZERO # ADR-006 +└── dataIntegrityStatus: { datasetSnapshotId, corporateActionsApplied, pointInTimeValidationPassed } # datasetSnapshotId는 BacktestRun.datasetSnapshotId와 동일 값 (필드명 통일) + +Trade +├── signalTime, entryTime, entryPrice +├── exitTime, exitPrice +├── returnPct +└── holdingPeriod +``` + +### 1.5 User / Subscription + +`Subscription.tier: FREE | PRO`. Free/Pro 차이는 사용량·범위 제한이며 별도 도메인 로직(가격, 결제)은 없다 — 결제 연동은 Phase 5. + +| | Free | Pro | +|---|---|---| +| Asset | 제한된 Asset | 전체 Asset | +| 백테스트 기간 | 제한된 기간 | 확장된 기간 | +| 월 백테스트 횟수 | 제한 | 높은 한도 | +| 전략 저장/비교, Export | 미제공 | 제공 | + +정확한 제한 수치는 데이터/컴퓨트 비용 검증 후 결정(Phase 0/2). `GetUsage` Use Case(`docs/USECASES.md`)로 남은 한도를 조회한다. + +--- + +## 2. Compute가 소유하는 참조 데이터 (Reference Data) + +Core는 이 데이터를 **읽기 전용**으로만 참조한다. 쓰기는 Compute의 Ingestion 파이프라인만 수행한다. + +### 2.1 Asset + +```text +Asset +├── symbol +├── calendar: US_EQUITY | CRYPTO_UTC # ADR-003. 시장 구분은 이 값으로 충분 (US_EQUITY↔주식/ETF, CRYPTO_UTC↔크립토), 별도 market 필드 없음 +├── listingDate +├── dataAvailability: { firstDate, lastDate } +└── corporateActions: List + +CorporateAction +├── type: SPLIT | REVERSE_SPLIT | DIVIDEND +├── effectiveDate +└── ratio | amount +``` + +### 2.2 DatasetSnapshot (Immutable) + +```text +DatasetSnapshot +├── id (version) +├── createdAt +├── source: VendorName +├── coverage: { assets: List, start, end } +├── adjustmentPolicy +└── storagePath # Object Storage 상의 Parquet 경로 +``` + +**불변식**: 한번 생성된 Snapshot은 절대 수정하지 않는다. 새 데이터가 들어오면 새 Snapshot을 생성한다(ADR-010). + +--- + +## 3. Core ↔ Compute 협업 흐름 + +```text +1. Core: StrategyVersion 확정 +2. Core: BacktestRun(PENDING) 생성 → Compute에 계산 요청 (strategyVersion 전체 payload 전달) +3. Compute: 최신 DatasetSnapshot 선택 (또는 요청에 명시된 snapshot 사용) +4. Compute: Signal Evaluation → Lag → Execution → Position → Metrics 계산 (docs/ARCHITECTURE.md §3 파이프라인) +5. Compute: BacktestResult payload를 Core에 반환 (자체 저장 안 함) +6. Core: BacktestRun.status = COMPLETED, BacktestResult 영속화 +``` + +Compute는 `Strategy`, `User`, `Subscription`을 전혀 모른다 — 요청받은 StrategyVersion 내용과 파라미터만으로 순수 계산을 수행하는 상태 없는(stateless) 서비스로 취급한다. diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md new file mode 100644 index 0000000..40c48fe --- /dev/null +++ b/docs/GIT_WORKFLOW.md @@ -0,0 +1,82 @@ +# GIT_WORKFLOW.md — 에이전트를 포함한 모든 기여자의 git 규칙 + +이 문서는 `core-api`, `compute-api`, `web` 세 레포에 공통으로 적용된다. 코딩 에이전트(Codex 등)와 +사람 기여자 모두 이 규칙을 따른다. + +## 1. 왜 이 문서가 필요한가 + +에이전트가 지시를 항상 완벽하게 따른다고 가정하지 않는다. 그래서 이 문서는 두 층으로 나뉜다: + +- **정책** (이 문서에 적힌 규칙) — 에이전트에게 방향을 알려주는 용도 +- **강제** (GitHub 저장소 설정) — 에이전트가 정책을 어기더라도 실제로 못 하게 막는 용도 + +**둘 중 강제가 진짜다.** §5의 브랜치 보호 설정이 꺼져 있다면, 이 문서의 나머지는 참고용일 뿐 안전장치가 +아니다. + +## 2. 에이전트가 자율적으로 해도 되는 것 / 안 되는 것 + +| 행위 | 에이전트 자율 수행 | 비고 | +|---|---|---| +| 로컬 커밋 | ✅ | 자주, 작은 단위로 커밋한다. 되돌리기 쉬운 지점을 많이 남기는 게 목적 | +| 브랜치 생성/전환 | ✅ | `main`/`develop`은 제외 | +| feature 브랜치로 push | ✅ | 자기가 만든 브랜치에 한함 | +| PR 생성 | ✅ | 생성은 제안일 뿐 반영이 아니므로 허용. §4 형식을 따른다 | +| `main`에 직접 push | ❌ | 항상 사람이 PR을 통해서만 | +| PR 머지 | ❌ | 항상 사람이 리뷰 후 직접 수행 | +| force-push | ❌ | 자기 혼자 쓰는 브랜치가 아니면 절대 금지. 자기 브랜치라도 먼저 확인 | +| 브랜치 삭제 | ❌ | 자기가 만든 feature 브랜치의 머지 후 정리 정도만, 그 외엔 사람이 | +| 원격 저장소 설정 변경(보호 규칙, 시크릿 등) | ❌ | 항상 사람이 | + +## 3. 브랜치/커밋 컨벤션 + +**브랜치명**: `/-<짧은-설명>` — 예: `feature/strategy-crud`, `fix/backtest-lag-offby-one` + +- `type`: `feature`, `fix`, `chore`, `refactor`, `docs` 중 하나 + +**커밋 메시지**: [Conventional Commits](https://www.conventionalcommits.org/) 형식을 따른다. + +```text +(): <설명> + +<본문 — 무엇을, 왜. 관련 있으면 ADR/Use Case 번호를 남긴다> +``` + +예: + +```text +feat(strategy): StrategyVersion 생성/조회 API 구현 + +docs/USECASES.md의 DefineStrategyVersion, GetStrategyVersion 구현. +docs/DOMAIN.md §1.2 불변식(executionAsset은 VIX 제외) 검증 포함. +``` + +## 4. PR 규칙 + +- 제목: 커밋 컨벤션과 동일한 형식 +- 본문에 반드시 포함: + - 이 PR이 구현하는 Use Case(`docs/USECASES.md`) 또는 ADR(`docs/DECISIONS.md`) 번호 + - **Core↔Compute API 계약이 바뀌는 PR이라면**, 상대 레포의 PR 링크를 반드시 같이 남긴다 + (`docs/AI_AGENT.md` §4와 동일 규칙 — 한쪽 레포만 보고 응답 필드를 추측해 구현하지 않는다) +- 머지 전 CI가 통과해야 한다: 빌드, 테스트, (core-api의 경우) ArchUnit 모듈 경계 테스트 +- 리뷰어 승인 없이 머지 버튼이 아예 안 보이는 게 정상이다 — §5 확인 + +## 5. 실제 강제 설정 (GitHub 저장소별, 최초 1회) + +`core-api`, `compute-api`, `web` 각 레포의 `main` 브랜치에 다음을 켠다 +(Settings → Branches → Branch protection rules): + +- [ ] **Require a pull request before merging** — 직접 push 차단 +- [ ] **Require approvals** (최소 1) — 리뷰 없이 머지 불가 +- [ ] **Require status checks to pass before merging** — CI(빌드/테스트/ArchUnit) 통과 필수 +- [ ] **Do not allow force pushes** — `main`에 force-push 차단 +- [ ] **Do not allow deletions** — `main` 삭제 차단 + +에이전트를 push 가능한 계정/토큰으로 쓰고 있다면, 그 계정도 위 규칙의 예외(bypass)에 넣지 않는다 — +예외를 넣는 순간 이 문서의 강제력이 사라진다. + +## 6. 에이전트 실행 환경 설정 권장값 + +- Sandbox: `workspace-write` (파일시스템은 레포 안에서만 쓰기 허용) +- Approval: 완전 자동(`never`)보다는 `on-request`/`untrusted` 등 위험한 명령 전에 확인받는 옵션을 권장 + — 정확한 플래그명은 설치된 Codex CLI 버전의 `codex --help`로 확인한다(이 부분은 버전마다 자주 바뀐다) +- 가능하면 에이전트 세션에는 `main` push 권한이 없는 자격 증명을 쓴다 — §5가 뚫려도 이중 방어가 되도록 diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md new file mode 100644 index 0000000..1c63a5b --- /dev/null +++ b/docs/GLOSSARY.md @@ -0,0 +1,57 @@ +# GLOSSARY.md — 도메인 용어 ↔ 코드 네이밍 + +에이전트는 변수명/클래스명/필드명을 지을 때 이 표를 그대로 따른다. 임의로 축약하거나 동의어로 바꾸지 않는다(`AI_AGENT.md` §2). + +| 용어 (한글) | 용어 (영문/코드) | 정의 | Related ADR | +|---|---|---|---| +| 가설 | Hypothesis | 사용자가 검증하고 싶은 투자 아이디어. 코드 상 별도 Aggregate는 아니며 `Strategy`로 구현됨 | — | +| 전략 | `Strategy` | 가설을 조건으로 표현한 컨테이너. 여러 `StrategyVersion`을 가짐 | ADR-013 | +| 전략 버전 | `StrategyVersion` | 불변 엔터티. 생성 후 수정 불가 | ADR-013 | +| 신호 자산 | `signalAsset` | 조건 계산에 사용되는 자산 (Primary와 구분됨) | ADR-002 | +| 기준 신호 자산 | `primarySignalAsset` | Signal Timestamp/Lag/Exit의 시간 기준이 되는 단 하나의 자산. 항상 명시적으로 지정 | ADR-002, ADR-003 | +| 조건 참조 자산 | `conditionReferenceAsset` | 조건 계산에 쓰이지만 시간 기준은 아닌 자산 (예: VIX) | ADR-002 | +| 실행 자산 | `executionAsset` | 실제로 매수/매도하는 자산 | ADR-003 | +| 신호 캘린더 | `signalCalendar` | Primary Signal Asset이 속한 Calendar(`US_EQUITY` \| `CRYPTO_UTC`) | ADR-003 | +| 거래 세션 | Trading Session | 특정 자산이 거래되는 하루 단위. Metric Window(Return/Change/Lookback) 계산은 **Referenced Asset 자신의** Trading Session을 기준으로 한다 | ADR-003 | +| 신호 세션 | Signal Session | **Primary Signal Asset 캘린더 기준**으로 셈하는 세션 단위. Trading Session과 달리 항상 Primary Signal Asset 하나의 캘린더로 고정되며, `lag`(`SignalSessions`)와 `exit.holdingSignalSessions`가 이 단위로 계산된다 | ADR-003 | +| 지표 윈도우 | `metricWindow` | Return/Change 등 계산 시 사용하는 세션 개수. Referenced Asset 자신의 Calendar 기준 | ADR-003 | +| 지표 앵커 규칙 | Cross-Calendar Metric Anchor Rule | Referenced Asset에 해당 세션이 없을 때 가장 최근 완료 세션을 기준으로 계산하는 규칙 | ADR-004 | +| 지연 | `lag` | Signal 확정과 실제 매수 사이의 지연(Primary Signal Asset 세션 수) | ADR-003 | +| 체결 | `execution` | 실제 주문이 이뤄지는 것. Execution Asset의 Next Available Session에서 발생 | ADR-003 | +| 다음 가능 세션 | Next Available Session | Execution Asset이 거래 가능한 가장 이른 세션 | ADR-003 | +| 결측 세션 | Missing Session | 데이터가 없는 세션. 예상된 결측(정상 Calendar 차이)과 예상치 못한 결측(Fail-fast 대상)으로 구분 | ADR-005 | +| 신호-체결 지연 | Signal-to-Execution Delay | Cross-Market 전략에서 Signal Timestamp와 실제 Execution Timestamp의 차이 | — | +| 중복 진입 | Duplicate Entry | 포지션 보유 중 새 Entry Signal 발생. MVP는 무시(Ignore) | ADR-009 | +| 청산 | `exit` | 포지션 종료. MVP는 Time-based Exit만 지원 | ADR-008 | +| 데이터셋 스냅샷 | `DatasetSnapshot` | 특정 시점에 정규화되어 저장된 불변 데이터셋. 재현성의 기준 | ADR-010 | +| 결정론성 | Determinism | 동일 입력(Strategy Version + Dataset Snapshot + Engine Version + Fee/Slippage) → 동일 결과 | ADR-010 | +| 시점 정확성 | Point-in-Time Correctness | 계산 시점 이후의 데이터를 절대 참조하지 않는 원칙 | ADR-004 | +| 미래참조편향 | Look-ahead Bias | Point-in-Time Correctness를 위반해 미래 정보를 사용하는 오류. 방지 대상 | ADR-004 | +| 기준가 | Reference Price | 체결 시 사용하는 기준 가격. `Execution Session Open`으로 고정 | ADR-010 | +| 낮은 표본 경고 | Low Sample Warning | Trade Count < 10일 때 표시하는 경고 | ADR-006 | +| 무거래 결과 | Zero Trades / Empty State | Trade Count = 0일 때의 별도 처리 | ADR-006 | +| 기본 벤치마크 | Primary Benchmark | Execution Asset Buy & Hold | ADR-007 | +| 참고 벤치마크 | Secondary Reference | Signal Asset Buy & Hold (Cross-Market 전략에서만) | ADR-007 | +| 레버리지 ETF 경고 | Leveraged ETF Warning | TQQQ/SOXL 결과에 고정 표시하는 구조적 특성 안내 | ADR-016 | +| 백테스트 실행 | `BacktestRun` | 하나의 백테스트 요청과 상태(PENDING/RUNNING/COMPLETED/FAILED) | — | +| 백테스트 결과 | `BacktestResult` | 계산이 끝난 뒤 Core가 영속화하는 지표/차트/거래 내역 | — | +| 재검증율 | Second Backtest Rate | 첫 백테스트 후 조건을 수정해 재실행하는 비율. MVP의 핵심 성공 지표 | — | +| 단계적 노출 | Progressive Disclosure | Strategy Builder UI를 Level 1(Simple) → 2(AND/OR) → 3(Relative/Lag/Cross-Market)로 점진 노출 | — | + +--- + +## Condition Metric 타입 + +| 타입 | 코드 상 값 | 설명 | +|---|---|---| +| Simple Comparison | `SIMPLE` | `VIX > 25` 형태, window 없음 | +| Lookback | `RETURN` | `QQQ.return(20) > 0.10` | +| Change | `CHANGE` | `VIX.change(5) > 0.20` | +| Relative | 위 두 타입의 조합으로 표현 (별도 타입 아님) | `QQQ.return(20) > SPY.return(20)` — operandB도 MetricReference인 경우 | + +## Market Calendar + +| 값 | 대상 자산 | 특징 | +|---|---|---| +| `US_EQUITY` | QQQ, SPY, TQQQ, SOXL, VIX | 거래일 기준, 주말/미국 공휴일 제외 | +| `CRYPTO_UTC` | BTCUSDT | 24/7, UTC 기준 Daily Session | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3015983 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# ⚠ 자동 동기화된 문서 + +이 폴더(`docs/`, `openapi/`)는 `context` 레포에서 +`scripts/sync.sh`로 동기화된 것입니다. **직접 수정하지 마세요** — +원본을 고치고 이 스크립트를 다시 실행해야 합니다. + +동기화 시각: 2026-08-10 10:34:01 +동기화 대상: core-api diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..f717e7a --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,114 @@ +# ROADMAP.md — Phase별 범위 + +에이전트는 지금 어느 Phase에 있는지 먼저 확인하고, **다음 Phase의 기능을 미리 구현하지 않는다.** 각 Phase는 이전 Phase의 종료 조건을 만족해야 다음으로 넘어간다. + +--- + +## Phase 0 — Feasibility Validation + +**성격**: 코드보다 검증이 우선인 스파이크 단계. 이 Phase의 산출물은 "결정"이지 "기능"이 아니다. + +**범위**: + +*Data* +- 데이터 벤더 확정 (US Equity/ETF, Crypto 각각) — 상업적 이용·재배포·캐싱 권리 확인 (ADR-014 우선 해결) +- API Rate Limit, 비용 확인 +- 데이터 품질 샘플 검증 + +*Asset Availability & Corporate Action* +- Listing Date, First Available Date, Data Coverage 확인 +- TQQQ/SOXL Reverse Split 처리 검증 +- Adjusted Price Restatement 정책 확인 +- Dataset Snapshot 저장 가능 여부(라이선스) 확인 + +*Calendar & Temporal Semantics* +- Market Calendar(CRYPTO_UTC / US_EQUITY) 정의, Holiday Calendar 확보 +- Cross-Calendar Metric Anchor Rule(ADR-004) 실제 데이터로 검증 +- Primary Signal Asset 결측 시나리오 검증 +- Missing Session Fail-fast 정책 검증 + +*Strategy DSL* +- 실제 투자 가설 30~50개 수집 → DSL로 표현, **80% 이상 표현 가능**이 목표 +- 표현 불가능한 가설 분류 (Lookback/Lag/Relative/Multi-asset/Portfolio 등) + +*Competition* +- 경쟁 제품(TradingView, QuantConnect, Composer, Portfolio123, 국내 퀀트 서비스) 직접 사용, 동일 가설 구현 비교 + +*UX* +- Strategy Builder Level 1~3 프로토타입으로 TTFB(Time to First Backtest) 예비 측정 + +*Compliance* +- 유사투자자문업 관련성 등 금융규제 예비 검토 (정식 Legal Review는 `docs/DECISIONS.md` ADR-011 — 유료 플랜 출시 전 필수) + +**종료 조건**: 데이터 벤더 확정, DSL 표현 가능 비율 80% 이상 확인, Phase 1 착수 가능 상태. + +**DSL Test Cases** (Cross-Calendar/Cross-Market 검증용): + +```text +1. 단일 자산 조건 (QQQ.return(5) < -7% → BUY QQQ) +2. Signal Asset ≠ Execution Asset (QQQ 조건 → TQQQ 매수) +3. AND 조건 (QQQ + VIX → TQQQ 매수) +4. Signal Asset이 조건에 직접 등장하지 않는 경우 (BTC Signal, VIX 조건 → 허용) +5. Cross-Market 진입 (BTC Signal → TQQQ Execution) +6. BTC 주말 신호 → 월요일 TQQQ 체결 +7. QQQ 예상 세션 결측 → Fail-fast +8. BTC 예상 세션 결측 → Fail-fast +9. BTC 토요일 Relative 신호 (BTC.return(7) > QQQ.return(7)) → QQQ는 가장 최근 완료 세션(금) 기준 +10. Relative + Lag → Primary Signal Asset(BTC) 기준으로 세션 카운트 +11. Multiple Signal References + Lag → Primary Signal Asset Calendar만 기준 +12. Duplicate Entry → Ignore +13. Zero Trades → 정상 완료 + Empty State +14. Low Sample → Warning +15. Reverse Split → 과거 가격 정확히 조정 +16. Dataset Version 변경 후에도 기존 Backtest 결과 재현 가능 +``` + +--- + +## Phase 1 — Core MVP + +**성격**: 실제 서비스 코드 작성이 시작되는 단계. `docs/USECASES.md`에 정의된 Command/Query가 이 Phase의 범위다. + +**범위**: + +| 영역 | 포함 | +|---|---| +| Data | 6개 Asset(ADR-001) Daily OHLC, Calendar, Corporate Action, Dataset Snapshot | +| Strategy | Primary Signal Asset, Execution Asset, Simple/Lookback/Change/Relative Condition, AND/OR, Lag, Time-based Exit, Long Only, Single Position, Duplicate Entry Ignore | +| Backtest | Next Available Session 체결, Open 기준가, 고정 Fee/Slippage, Point-in-Time Validation, Missing Session Fail-fast, Determinism | +| Result | Equity Curve, Drawdown, Trade Table/Timeline, Return/CAGR/Sharpe/MDD/Win Rate, Benchmark, Low Sample Warning, Zero Trade Empty State, Data Integrity 표시 | +| UX | Data Explorer, Strategy Builder(Progressive Disclosure Level 1~3) | + +**명시적 제외**: Condition-based Exit, Short, Multi-position, Strategy 비교, 자연어 입력, AI 전 영역. + +**종료 조건**: 사용자 테스트에서 TTFB Simple ≤ 3분 / Advanced ≤ 5분 확인, Backtest 결과가 결정론적으로 재현됨을 자동 테스트로 검증. + +--- + +## Phase 2 — Product Validation + +**성격**: 기능 추가보다 계측·실험이 중심. 코드 변경은 대부분 분석/실험을 위한 계측(instrumentation)이다. + +**범위**: Second Backtest Rate, Level Transition Rate, Unsupported Hypothesis Rate 등 핵심 지표 계측. 가격 실험(Free/Pro 구분 확정). 리텐션 측정. + +**종료 조건**: Second Backtest Rate가 유의미한 수준으로 관찰됨 — 이게 낮으면 Phase 1로 돌아가 UX/DSL을 재검토한다. + +--- + +## Phase 3 — Research Expansion + +**범위**: Correlation, Conditional Return, Lead-Lag, Market Regime. 백테스트 이전 단계에서 더 많은 가설을 발견할 수 있도록 Data Explorer를 확장. + +--- + +## Phase 4 — Robust Validation + +**범위**: Out-of-Sample Test, Walk-forward Validation, Parameter Sensitivity, Overfitting Detection, Monte Carlo Simulation. "과거에 우연히 잘 맞은 전략인지"를 검증하는 기능군. + +--- + +## Phase 5 — Portfolio & Commercialization + +**범위**: Multi-position, Asset Allocation, Rebalancing, Position Sizing, Condition-based Exit(ADR-008 재검토), 구독/결제 정식 도입, AI Research Interface(자연어 → DSL, 검증된 엔진 결과를 AI가 설명 — ADR-015 원칙 유지). + +**주의**: 이 Phase에서도 AI는 직접 수치를 계산하지 않는다(ADR-015). diff --git a/docs/USECASES.md b/docs/USECASES.md new file mode 100644 index 0000000..76d695f --- /dev/null +++ b/docs/USECASES.md @@ -0,0 +1,59 @@ +# USECASES.md — Command / Query 목록 + +Phase 1(Core MVP) 범위의 Use Case만 다룬다. 신규 Use Case를 추가할 때는 어느 Aggregate에 속하는지, Core/Compute 중 어디서 처리되는지 명시한다. 아래 Core 쪽 Use Case의 정확한 REST 경로/스키마는 `openapi/core-api.yaml`이 정본이다 — 이 표는 Use Case 단위 요약이고, 실제 구현은 그 스펙을 따른다. + +--- + +## Strategy 모듈 (Core) + +| Use Case | 타입 | 설명 | +|---|---|---| +| `CreateStrategy` | Command | 빈 Strategy 생성 (이름만 지정) | +| `DefineStrategyVersion` | Command | Primary Signal Asset, Condition, Execution Asset, Lag, Exit을 지정해 새 `StrategyVersion` 생성. 불변식(`docs/DOMAIN.md` §1.2) 검증 포함 | +| `PreviewStrategy` | Query | 현재 편집 중인 조건을 자연어 문장으로 미리 보여줌 (DSL과 1:1 대응) | +| `GetStrategy` | Query | Strategy와 그 버전 목록 조회 | +| `ListStrategies` | Query | 사용자의 전략 목록 (Pro: 무제한, Free: 개수 제한) | + +## Backtest 모듈 (Core, Compute 오케스트레이션) + +| Use Case | 타입 | 설명 | +|---|---|---| +| `RunBacktest` | Command | `StrategyVersion` + 기간 + Fee/Slippage로 `BacktestRun(PENDING)` 생성, Compute에 비동기 요청 | +| `PollBacktestStatus` | Query | `BacktestRun.status` 조회 (Web이 폴링) | +| `GetBacktestResult` | Query | `COMPLETED` 상태의 `BacktestRun`에 대한 `BacktestResult` 전체(지표, Equity Curve, Trade Table 등) 조회 | +| `ListBacktestRuns` | Query | 특정 Strategy의 백테스트 실행 이력 (Second Backtest Rate 계측의 데이터 소스) | + +**주의**: `RunBacktest`는 항상 비동기다. 동기로 결과를 바로 반환하는 Use Case를 만들지 않는다(`AI_AGENT.md` §2). + +## Asset / Data Explorer (Core는 프록시, 실제 데이터는 Compute 소유) + +Data Explorer는 가설을 세우기 **이전** 단계(Product Loop의 "Observe")를 지원하는 읽기 전용 탐색 화면이다. Strategy 상태를 변경하지 않으며, 통계 분석(Correlation/Lead-Lag 등)은 Phase 3로 미룬다. + +| Use Case | 타입 | 설명 | +|---|---|---| +| `ListAssets` | Query | MVP Asset Universe(6종) 목록과 메타데이터 | +| `GetAssetAvailability` | Query | 특정 Asset의 `listingDate`, `dataAvailability` — Strategy Builder에서 기간 제약 안내에 사용 | +| `GetSeries` | Query | Data Explorer용 시계열 조회 (가격/Return/정규화 값 중 선택). 여러 Asset을 동시에 오버레이 조회 가능(예: `BTCUSDT`+`QQQ` 정규화 비교) | + +## Compute 내부 Use Case (Compute 소유, Core는 호출만) + +| Use Case | 타입 | 설명 | +|---|---|---| +| `ExecuteBacktest` | Command (내부) | Strategy DSL을 받아 `docs/ARCHITECTURE.md` §3 파이프라인을 실행하고 `BacktestResult` payload 반환. DSL Validation(Asset 존재, Calendar 호환성, Window/Lag Validity 등)은 **이 파이프라인의 첫 단계**로 내장되어 있으며, 별도로 독립 호출 가능한 엔드포인트는 아니다 — 실패 시 Job을 큐에 넣지 않고 즉시 `400`으로 반환한다(`openapi/compute-api.yaml`의 `POST /backtests` 400 응답) | +| `IngestDailyData` | Command (스케줄) | 벤더 API → 정규화 → Corporate Action 처리 → 새 `DatasetSnapshot` 생성. 사용자 요청과 무관하게 매일 1회 실행 | + +## User / Subscription (Core) + +| Use Case | 타입 | 설명 | +|---|---|---| +| `RegisterUser` | Command | 회원가입 | +| `GetUsage` | Query | 이번 달 백테스트 실행 횟수 등 사용량 (Free/Pro 제한 확인용) | +| `UpgradeSubscription` | Command | Free → Pro 전환 (Phase 5에서 실제 결제 연동, MVP는 상태값만) | + +--- + +## Use Case 작성 규칙 + +- 하나의 Use Case는 하나의 Aggregate만 수정한다. 여러 Aggregate를 동시에 바꿔야 한다면 별도 Use Case로 쪼개고, 필요하면 Application 레이어에서 순차 호출한다. +- Command는 항상 결과로 최소한의 식별자(`id`)를 반환하고, 상세 조회는 별도 Query로 분리한다(CQRS 원칙 경량 적용). +- Compute를 호출하는 Use Case는 `backtest` 또는 `asset` 모듈의 `ComputeClient` 포트만 사용한다 — `RunBacktest`는 `backtest` 모듈의 포트를, `ListAssets`/`GetAssetAvailability`/`GetSeries`는 `asset` 모듈의 포트를 사용한다. 그 외 모듈은 Compute를 직접 호출하지 않는다(`docs/ARCHITECTURE.md` §2). diff --git a/openapi/compute-api.yaml b/openapi/compute-api.yaml new file mode 100644 index 0000000..b1efb7d --- /dev/null +++ b/openapi/compute-api.yaml @@ -0,0 +1,651 @@ +openapi: 3.0.3 +info: + title: compute API + version: "0.1.0" + description: | + Core(`core-api`) → Compute(`compute-api`) 내부 계약. + + - 이 스펙은 `context` 레포가 소유한다. 두 레포 모두 이 스펙을 기준으로 구현하며, + 한쪽 레포만 보고 응답 필드를 추측해 구현하지 않는다(`AI_AGENT.md` §1). + - Compute는 인터넷에 직접 노출하지 않는다. Core만 호출 가능한 내부망 서비스로 배포한다 + (`docs/ARCHITECTURE.md` §6, `AI_AGENT.md` §3). + - Compute는 `Strategy`/`User`/`Subscription`을 모른다 — 상태 없는(stateless) 계산 서비스이며, + 요청마다 필요한 값(StrategyVersion 전체, Fee/Slippage, 기간 등)을 전부 payload로 받는다 + (`docs/DOMAIN.md` §3). + - 필드명은 `docs/GLOSSARY.md`의 코드 네이밍을 그대로 따른다. 임의로 축약하거나 동의어로 바꾸지 않는다. + + ## 미확정 사항 (TODO — 구현 전 팀 확인 필요) + - `POST /backtests`의 폴링 간격(Web은 2~3초, `docs/ARCHITECTURE.md` §5)과 Compute 내부 Job + 처리 SLA(타임아웃 등)는 아직 계약에 없다. + - Rate Limit/동시 요청 처리 정책은 정의되지 않았다. + +servers: + - url: http://compute.internal:8000 + description: 내부망 전용. 외부 노출 금지(`AI_AGENT.md` §3). + +security: + - InternalApiKey: [] + +tags: + - name: backtests + description: 비동기 백테스트 실행 (`docs/USECASES.md`의 `ExecuteBacktest`) + - name: assets + description: Asset/Calendar 참조 데이터 및 시계열 (`docs/USECASES.md`의 `ListAssets`, `GetAssetAvailability`, `GetSeries`) + +paths: + /backtests: + post: + operationId: createBacktest + tags: [backtests] + summary: 백테스트 실행 요청 (항상 비동기, `docs/ARCHITECTURE.md` §6) + description: | + 요청을 즉시 accept(202)하고 내부 Job Queue로 처리한다. 동기로 결과를 반환하지 않는다 + (`docs/USECASES.md` "주의" 문구, `AI_AGENT.md` §2). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBacktestRequest' + responses: + '202': + description: 요청 접수됨. 계산은 아직 시작 전이거나 큐에 있음. + content: + application/json: + schema: + $ref: '#/components/schemas/BacktestAccepted' + '400': + description: | + StrategyVersion payload가 DSL Validation을 통과하지 못함 + (Asset 존재 여부, Data Availability, Calendar Compatibility, Condition/Window/Lag + Validity, Execution Asset Availability, Exit Validity — `docs/DOMAIN.md` §1.2 불변식 + 및 `docs/USECASES.md`의 `ExecuteBacktest` 설명). 이 경우 Job을 큐에 넣지 않고 즉시 + 실패를 반환한다 — Fail-fast는 런타임 데이터 결측(ADR-005 Fatal Error)에만 적용되며, + 입력 자체가 무효면 실행 전에 걸러낸다. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + + /backtests/{runId}: + get: + operationId: getBacktestStatus + tags: [backtests] + summary: 백테스트 상태/결과 조회 (Core가 2~3초 간격으로 폴링) + parameters: + - name: runId + in: path + required: true + schema: + type: string + responses: + '200': + description: | + 상태에 따라 응답 형태가 달라진다. `status`가 `COMPLETED`면 `result`가 반드시 존재하고, + `FAILED`면 `failureReason`이 반드시 존재한다(`docs/DOMAIN.md` §1.3 불변식과 동일한 규칙을 + Compute 응답에도 적용). + content: + application/json: + schema: + $ref: '#/components/schemas/BacktestStatusResponse' + '404': + description: 존재하지 않는 runId. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /assets: + get: + operationId: listAssets + tags: [assets] + summary: MVP Asset Universe(6종) 목록과 메타데이터 (`ListAssets`) + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [assets] + properties: + assets: + type: array + items: + $ref: '#/components/schemas/Asset' + + /assets/{symbol}/availability: + get: + operationId: getAssetAvailability + tags: [assets] + summary: 특정 Asset의 상장일/데이터 가용 구간 (`GetAssetAvailability`) + description: Strategy Builder에서 기간 제약 안내에 사용(`docs/USECASES.md`). + parameters: + - name: symbol + in: path + required: true + schema: + $ref: '#/components/schemas/AssetSymbol' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AssetAvailability' + '404': + description: MVP Asset Universe에 없는 symbol. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /series: + get: + operationId: getSeries + tags: [assets] + summary: Data Explorer용 다중 자산 시계열 조회 (`GetSeries`) + description: | + `docs/USECASES.md`의 `GetSeries`(Data Explorer 데이터 소스). 여러 Asset을 동시에 조회할 수 있다. + Correlation/Lead-Lag 등 통계 분석은 포함하지 않는다 — 순수 시계열 조회만 지원한다(Phase 1 범위). + parameters: + - name: symbols + in: query + required: true + description: "쉼표로 구분된 Asset Symbol 목록 (예: `QQQ,BTCUSDT`)" + schema: + type: string + example: "QQQ,BTCUSDT" + - name: metric + in: query + required: true + schema: + $ref: '#/components/schemas/SeriesMetric' + - name: start + in: query + required: true + schema: + type: string + format: date + - name: end + in: query + required: true + schema: + type: string + format: date + responses: + '200': + description: | + 요청한 각 Asset의 `dataAvailability` 범위를 벗어나는 구간은 응답에서 제외한다(값을 + 만들어내지 않는다 — ADR-005와 동일한 원칙을 차트 데이터에도 적용. 단, 이는 계산 결과가 + 아닌 참조 데이터 조회이므로 ADR-005의 "백테스트 중단" 대상은 아니다). + content: + application/json: + schema: + type: object + required: [series] + properties: + series: + type: array + items: + $ref: '#/components/schemas/AssetSeries' + '400': + description: 존재하지 않는 symbol이 포함되었거나 start > end. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + securitySchemes: + InternalApiKey: + type: apiKey + in: header + name: X-Internal-Api-Key + description: | + core-api→compute-api 호출 시 필수(`docs/DECISIONS.md` ADR-017). 배포 인프라의 네트워크 격리(VPC/보안 + 그룹, k8s NetworkPolicy 등)와 함께 2중 방어로 사용한다. Key는 코드에 하드코딩하지 않으며, + 배포 인프라가 정해지면 그 인프라의 시크릿 관리 방식을 따른다. 전송 구간 TLS는 이 인증과 별개로 + 항상 활성화한다. + + schemas: + # ---- 공통 값 타입 ---- + AssetSymbol: + type: string + enum: [QQQ, SPY, TQQQ, SOXL, BTCUSDT, VIX] + description: MVP Asset Universe 6종(ADR-001). + + ExecutionAssetSymbol: + type: string + enum: [QQQ, SPY, TQQQ, SOXL, BTCUSDT] + description: Execution Asset 하위 집합 — VIX 제외(ADR-001, `docs/DOMAIN.md` §1.2 불변식). + + Percent: + type: number + format: double + description: 0.001 = 0.1% 형태의 소수 비율. + example: 0.001 + + Period: + type: object + required: [start, end] + properties: + start: + type: string + format: date + end: + type: string + format: date + + # ---- Strategy DSL (docs/DOMAIN.md §1.2) ---- + MetricType: + type: string + enum: [SIMPLE, RETURN, CHANGE] + description: | + SIMPLE=Simple Comparison(window 없음), RETURN=Lookback, CHANGE=Change + (`docs/GLOSSARY.md` Condition Metric 타입). Relative는 별도 타입이 아니라 + operandB가 MetricReference인 조합으로 표현된다. + + MetricReference: + type: object + required: [asset, metric] + properties: + asset: + $ref: '#/components/schemas/AssetSymbol' + metric: + $ref: '#/components/schemas/MetricType' + window: + type: integer + minimum: 1 + nullable: true + description: SIMPLE은 null. RETURN/CHANGE는 1 이상 필수(`docs/DOMAIN.md` §1.2 불변식). + + ComparisonOperator: + type: string + enum: [LT, GT, LTE, GTE] + + LogicalCombinator: + type: string + enum: [AND, OR] + nullable: true + description: 첫 번째 Condition은 null. MVP는 단일 Condition Group만 허용, 중첩 없음(§5.2). + + Condition: + type: object + required: [operator, operandA, operandB] + properties: + operator: + $ref: '#/components/schemas/ComparisonOperator' + logicalCombinator: + $ref: '#/components/schemas/LogicalCombinator' + operandA: + $ref: '#/components/schemas/MetricReference' + operandB: + description: MetricReference(Relative 조건) 또는 상수값(Literal). + oneOf: + - $ref: '#/components/schemas/MetricReference' + - type: number + + PositionPolicy: + type: object + description: | + MVP는 아래 값으로 고정된다(ADR-009). Compute가 별도로 검증할 필요는 없지만, Core가 + StrategyVersion 전체를 그대로 전달하는 원칙(`docs/DOMAIN.md` §3)에 따라 payload에 포함한다. + properties: + longOnly: + type: boolean + enum: [true] + singlePosition: + type: boolean + enum: [true] + duplicateEntry: + type: string + enum: [IGNORE] + + StrategyVersionPayload: + type: object + required: [primarySignalAsset, conditions, executionAsset, lag, exit] + description: | + `docs/DOMAIN.md` §1.2 StrategyVersion을 그대로 반영한 Compute 요청 payload. + Core는 StrategyVersionId가 아니라 이 payload 전체를 전달한다(Compute는 Strategy를 모름). + properties: + primarySignalAsset: + $ref: '#/components/schemas/AssetSymbol' + conditions: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Condition' + executionAsset: + $ref: '#/components/schemas/ExecutionAssetSymbol' + lag: + type: integer + minimum: 0 + description: Primary Signal Asset 기준 Signal Sessions(ADR-003). + exit: + type: object + required: [holdingSignalSessions] + properties: + holdingSignalSessions: + type: integer + minimum: 1 + description: MVP는 Time-based Exit만 허용(ADR-008). + positionPolicy: + $ref: '#/components/schemas/PositionPolicy' + + FeeModel: + type: object + required: [commission, slippage] + properties: + commission: + $ref: '#/components/schemas/Percent' + slippage: + $ref: '#/components/schemas/Percent' + description: | + 체결 규칙(ADR-010): Reference Price = Execution Session Open, + 매수 = Open × (1 + slippage), 매도 = Open × (1 - slippage). + + CreateBacktestRequest: + type: object + required: [strategyVersion, feeModel, period] + properties: + strategyVersionId: + type: string + description: | + Core 내부 참조용 식별자. Compute는 이 값을 해석하지 않고 응답에 그대로 echo한다 + (Core가 콜백 없는 폴링으로 결과를 자기 BacktestRun에 매핑하기 위함). + strategyVersion: + $ref: '#/components/schemas/StrategyVersionPayload' + feeModel: + $ref: '#/components/schemas/FeeModel' + period: + $ref: '#/components/schemas/Period' + datasetSnapshotId: + type: string + nullable: true + description: | + 명시하면 해당 Snapshot을 강제 사용(과거 백테스트 재현용, ADR-010). 생략하면 Compute가 + 최신 Snapshot을 선택한다(`docs/DOMAIN.md` §3 step 3). + + BacktestAccepted: + type: object + required: [runId, status] + properties: + runId: + type: string + strategyVersionId: + type: string + description: 요청에 포함됐다면 그대로 echo. + status: + type: string + enum: [PENDING] + + BacktestStatus: + type: string + enum: [PENDING, RUNNING, COMPLETED, FAILED] + description: 상태 전이는 PENDING → RUNNING → (COMPLETED | FAILED)만 허용, 역방향 없음(`docs/DOMAIN.md` §1.3). + + FatalErrorCode: + type: string + enum: + - MISSING_REQUIRED_DATA + - DATASET_CORRUPTION + - CALENDAR_RESOLUTION_FAILED + - PRICE_DATA_MISSING + - DSL_INVALID + description: | + `docs/DECISIONS.md` ADR-005 Fatal Error 5종을 그대로 코드화. 이 목록이 바뀌면 ADR-005를 + 먼저 갱신한다(`AI_AGENT.md` §1). + + BacktestResultMetrics: + type: object + description: | + `sampleSizeWarning`이 `ZERO`인 경우 이 값들을 그대로(0 등으로) 노출하지 않고 Empty State로 + 대체 표시하는 것은 Web/Core 책임이다(ADR-006). Compute는 계산된 raw 값을 그대로 반환한다. + properties: + totalReturn: { type: number } + cagr: { type: number } + mdd: { type: number } + sharpe: { type: number } + winRate: { type: number } + tradeCount: { type: integer } + avgTradeReturn: { type: number } + avgHoldingPeriod: { type: number } + profitFactor: { type: number } + + EquityCurvePoint: + type: object + required: [date, value] + properties: + date: { type: string, format: date } + value: { type: number } + + Trade: + type: object + required: [signalTime, entryTime, entryPrice, exitTime, exitPrice, returnPct, holdingPeriod] + properties: + signalTime: { type: string, format: date-time } + entryTime: { type: string, format: date-time } + entryPrice: { type: number } + exitTime: { type: string, format: date-time } + exitPrice: { type: number } + returnPct: { type: number } + holdingPeriod: { type: integer } + + BuyAndHoldResult: + type: object + description: | + ADR-007에 필드 상세가 정의돼 있지 않아 Core Metrics(§7.1)과 동일한 최소 집합으로 임시 + 구성함 — 팀 확인 필요(placeholder). + properties: + totalReturn: { type: number } + cagr: { type: number } + mdd: { type: number } + + SignalExecutionDelay: + type: object + properties: + median: { type: number } + max: { type: number } + distribution: + type: array + items: { type: number } + + SampleSizeWarning: + type: string + enum: [NONE, LOW, ZERO] + description: Trade Count < 10 → LOW, = 0 → ZERO(ADR-006). + + DataIntegrityStatus: + type: object + required: [datasetSnapshotId, corporateActionsApplied, pointInTimeValidationPassed] + properties: + datasetSnapshotId: + type: string + corporateActionsApplied: + type: boolean + pointInTimeValidationPassed: + type: boolean + + BacktestResult: + type: object + required: + - backtestRunId + - metrics + - equityCurve + - trades + - benchmark + - sampleSizeWarning + - dataIntegrityStatus + properties: + backtestRunId: { type: string } + metrics: + $ref: '#/components/schemas/BacktestResultMetrics' + equityCurve: + type: array + items: + $ref: '#/components/schemas/EquityCurvePoint' + trades: + type: array + items: + $ref: '#/components/schemas/Trade' + benchmark: + type: object + required: [primary] + properties: + primary: + $ref: '#/components/schemas/BuyAndHoldResult' + description: Execution Asset Buy & Hold(ADR-007). + secondaryReference: + $ref: '#/components/schemas/BuyAndHoldResult' + nullable: true + description: Signal Asset Buy & Hold. Cross-Market 전략에서만 존재(ADR-007). + signalExecutionDelay: + $ref: '#/components/schemas/SignalExecutionDelay' + sampleSizeWarning: + $ref: '#/components/schemas/SampleSizeWarning' + dataIntegrityStatus: + $ref: '#/components/schemas/DataIntegrityStatus' + + BacktestStatusResponse: + type: object + required: [runId, status] + properties: + runId: { type: string } + strategyVersionId: { type: string } + status: + $ref: '#/components/schemas/BacktestStatus' + datasetSnapshotId: + type: string + nullable: true + description: status가 PENDING이 아니게 되는 시점부터 존재(`docs/DOMAIN.md` §1.3 BacktestRun.datasetSnapshotId). + actualPeriod: + allOf: + - $ref: '#/components/schemas/Period' + nullable: true + description: Asset Availability 교집합 반영 후의 실제 기간(`docs/DOMAIN.md` §1.3). + engineVersion: + type: string + nullable: true + description: Compute 컨테이너 버전 태그(ADR-010 Determinism Contract, `docs/ARCHITECTURE.md` §7). + result: + $ref: '#/components/schemas/BacktestResult' + nullable: true + description: status가 COMPLETED일 때만 존재. + failureReason: + type: string + nullable: true + description: status가 FAILED일 때만 존재(`docs/DOMAIN.md` §1.3 불변식). + errorCode: + $ref: '#/components/schemas/FatalErrorCode' + + # ---- Asset / Series ---- + CorporateActionType: + type: string + enum: [SPLIT, REVERSE_SPLIT, DIVIDEND] + + CorporateAction: + type: object + required: [type, effectiveDate] + properties: + type: + $ref: '#/components/schemas/CorporateActionType' + effectiveDate: + type: string + format: date + ratio: + type: number + nullable: true + description: SPLIT/REVERSE_SPLIT에서 사용. + amount: + type: number + nullable: true + description: DIVIDEND에서 사용. + + Calendar: + type: string + enum: [US_EQUITY, CRYPTO_UTC] + + DataAvailability: + type: object + required: [firstDate, lastDate] + properties: + firstDate: { type: string, format: date } + lastDate: { type: string, format: date } + + Asset: + type: object + required: [symbol, calendar, listingDate, dataAvailability] + properties: + symbol: + $ref: '#/components/schemas/AssetSymbol' + calendar: + $ref: '#/components/schemas/Calendar' + description: 시장 구분은 이 값으로 충분하다(US_EQUITY↔주식/ETF, CRYPTO_UTC↔크립토). 별도 market 필드 없음(`docs/DOMAIN.md` §2.1). + listingDate: + type: string + format: date + dataAvailability: + $ref: '#/components/schemas/DataAvailability' + corporateActions: + type: array + items: + $ref: '#/components/schemas/CorporateAction' + + AssetAvailability: + type: object + required: [symbol, listingDate, dataAvailability] + properties: + symbol: + $ref: '#/components/schemas/AssetSymbol' + listingDate: + type: string + format: date + dataAvailability: + $ref: '#/components/schemas/DataAvailability' + + SeriesMetric: + type: string + enum: [PRICE, RETURN, NORMALIZED] + description: | + NORMALIZED는 조회 기간 시작점을 100으로 맞춘 값(`docs/USECASES.md`의 `GetSeries`). + + SeriesPoint: + type: object + required: [date, value] + properties: + date: { type: string, format: date } + value: { type: number } + + AssetSeries: + type: object + required: [symbol, points] + properties: + symbol: + $ref: '#/components/schemas/AssetSymbol' + points: + type: array + items: + $ref: '#/components/schemas/SeriesPoint' + + # ---- 공통 에러 ---- + ErrorResponse: + type: object + required: [message] + properties: + message: + type: string + + ValidationErrorResponse: + type: object + required: [message, errorCode] + properties: + message: + type: string + errorCode: + $ref: '#/components/schemas/FatalErrorCode' + details: + type: array + items: + type: string + description: 실패한 개별 Validation 항목(`docs/DOMAIN.md` §1.2 불변식). diff --git a/openapi/core-api.yaml b/openapi/core-api.yaml new file mode 100644 index 0000000..6991220 --- /dev/null +++ b/openapi/core-api.yaml @@ -0,0 +1,643 @@ +openapi: 3.0.3 +info: + title: core-api Public API + version: "0.1.0" + description: | + Web(`web`) → Core(`core-api`) 계약. Web은 이 스펙만 보고 구현하며, + Compute(`compute-api`)를 직접 호출하지 않는다(`AGENTS.md` §3, `AI_AGENT.md` §4). + + - 이 스펙은 `context` 레포가 소유한다. `compute-api.yaml`과 공유 도메인 타입 + (Asset/Condition/BacktestResult 등)을 파일 간 `$ref`로 재사용한다 — 같은 개념을 두 번 + 정의하지 않는다. + - 필드명은 `docs/GLOSSARY.md`의 코드 네이밍을 그대로 따른다(`docs/USECASES.md`). + - `RunBacktest`는 **항상 비동기**다(`AGENTS.md` §4, `AI_AGENT.md` §2). DSL 의미론적 오류 + (`compute-api.yaml`의 `DSL_INVALID` 등 `FatalErrorCode`)는 이 API에서 동기 400으로 나타나지 + 않고, 202 Accepted 이후 폴링 중 `status: FAILED`로 드러난다. 반면 StrategyVersion 자체의 + 구조적 불변식 위반(`docs/DOMAIN.md` §1.2)은 `DefineStrategyVersion` 시점에 동기 400으로 + 즉시 반환된다 — 이 둘은 서로 다른 계층의 검증이며 에러 스키마도 분리되어 있다(아래 + `StrategyVersionValidationError` vs `compute-api.yaml`의 `ValidationErrorResponse`). + + ## 미확정 사항 (TODO — 구현 전 팀 확인 필요) + - 인증 방식: 아래 `BearerAuth`는 자리표시자다. 이메일/비밀번호, OAuth, 매직링크 중 무엇으로 + 회원가입/로그인을 구현할지, JWT 갱신(refresh) 흐름은 어떻게 할지 확정되지 않았다 + (`docs/ARCHITECTURE.md` §2 `common/` "인증(Spring Security + JWT)"는 토큰 방식만 정했을 뿐 + 가입/로그인 플로우는 정의하지 않음). + - Free/Pro 정확한 제한 수치(월 백테스트 횟수, 허용 Asset, 백테스트 최대 기간)는 미정 + — `docs/DOMAIN.md` §1.5, Phase 0/2에서 결정. + - `ListStrategies`/`ListBacktestRuns`의 페이지네이션 방식(offset vs cursor)은 아래 스펙에 + offset 기반으로 잠정 정의했으나 팀 확인 필요. + +servers: + - url: https://api.refinvest.example.com + description: Web이 호출하는 Core public API. Compute는 이 서버 뒤에서 내부적으로만 호출됨. + +security: + - BearerAuth: [] + +tags: + - name: strategies + description: Strategy/StrategyVersion 생성·조회 (`docs/USECASES.md` Strategy 모듈) + - name: backtests + description: 백테스트 실행·조회 (`docs/USECASES.md` Backtest 모듈, 항상 비동기) + - name: assets + description: Data Explorer용 Asset/시계열 프록시 (`docs/USECASES.md` Asset / Data Explorer — Core는 Compute를 그대로 읽기 전용 프록시) + - name: account + description: 회원가입·사용량·구독 (`docs/USECASES.md` User / Subscription 모듈) + +paths: + # ============ Strategy ============ + /strategies: + post: + operationId: createStrategy + tags: [strategies] + summary: 빈 Strategy 생성 (`CreateStrategy`) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1, maxLength: 200 } + responses: + '201': + description: 생성됨. + content: + application/json: + schema: + $ref: '#/components/schemas/StrategySummary' + '401': { $ref: '#/components/responses/Unauthorized' } + + get: + operationId: listStrategies + tags: [strategies] + summary: 사용자의 전략 목록 (`ListStrategies`) + description: Free/Pro 개수 제한은 `docs/DOMAIN.md` §1.5 — 정확한 수치는 미정(TODO 참고). + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/SizeParam' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, page, size, total] + properties: + items: + type: array + items: + $ref: '#/components/schemas/StrategySummary' + page: { type: integer } + size: { type: integer } + total: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + + /strategies/{strategyId}: + get: + operationId: getStrategy + tags: [strategies] + summary: Strategy와 그 StrategyVersion 목록 조회 (`GetStrategy`) + parameters: + - $ref: '#/components/parameters/StrategyIdParam' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/StrategyDetail' + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /strategies/{strategyId}/versions: + post: + operationId: defineStrategyVersion + tags: [strategies] + summary: 새 StrategyVersion 생성 (`DefineStrategyVersion`) + description: | + `docs/DOMAIN.md` §1.2 불변식을 동기적으로 검증한다(Primary Signal Asset 정확히 1개, + Execution Asset이 `ExecutionAssetSymbol`(VIX 제외)에 속함, conditions 최소 1개, window/lag + 양수 등). `positionPolicy`는 요청 바디에 없다 — ADR-009에 따라 서버가 고정값 + (`longOnly: true, singlePosition: true, duplicateEntry: IGNORE`)으로 채운다. + 생성된 StrategyVersion은 불변이다(ADR-013) — 수정 API는 없고, 조건을 바꾸려면 이 엔드포인트를 + 다시 호출해 새 버전을 만든다. + parameters: + - $ref: '#/components/parameters/StrategyIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DefineStrategyVersionRequest' + responses: + '201': + description: 생성됨. + content: + application/json: + schema: + $ref: '#/components/schemas/StrategyVersion' + '400': + description: StrategyVersion 구조적 불변식 위반(`docs/DOMAIN.md` §1.2). Compute의 DSL 의미론적 검증(Calendar 호환성 등)과는 다른 계층 — 위 info.description 참고. + content: + application/json: + schema: + $ref: '#/components/schemas/StrategyVersionValidationError' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Free 플랜 Asset 제한 등으로 이 StrategyVersion을 만들 수 없음(`docs/DOMAIN.md` §1.5, 정확한 조건 TODO). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + + /strategies/{strategyId}/versions/preview: + post: + operationId: previewStrategy + tags: [strategies] + summary: 저장하지 않고 자연어 미리보기 생성 (`PreviewStrategy`) + description: | + `DefineStrategyVersionRequest`와 동일한 바디를 받아, StrategyVersion을 생성하지 않고 + 자연어 문장만 반환한다. 사용자가 조건을 편집하는 동안(Level 1~3, `docs/ROADMAP.md` Phase 1 + UX) 실시간으로 호출된다. DSL과 문장이 항상 1:1 대응해야 한다(`docs/DOMAIN.md` §1.2 예시). + parameters: + - $ref: '#/components/parameters/StrategyIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DefineStrategyVersionRequest' + responses: + '200': + description: | + OK. 구조적으로 불완전한 입력(예: conditions 빈 배열)이어도 가능한 범위까지 문장으로 + 표현하고 400을 던지지 않는다 — 미리보기는 저장 전 자유 편집 상태를 위한 것이기 때문. + content: + application/json: + schema: + type: object + required: [previewText] + properties: + previewText: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ============ Backtest ============ + /strategy-versions/{versionId}/backtests: + post: + operationId: runBacktest + tags: [backtests] + summary: 백테스트 실행 요청, 항상 비동기 (`RunBacktest`) + description: | + 요청 즉시 `BacktestRun(status=PENDING)`을 반환한다. 절대 동기로 결과를 기다리지 않는다 + (`AGENTS.md` §4, `AI_AGENT.md` §2). 실제 계산은 Core가 내부적으로 Compute의 + `POST /backtests`(`compute-api.yaml`)에 위임한다 — 이 위임은 Web에 노출되지 않는다. + parameters: + - name: versionId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [period, feeModel] + properties: + period: + $ref: 'compute-api.yaml#/components/schemas/Period' + feeModel: + $ref: 'compute-api.yaml#/components/schemas/FeeModel' + responses: + '202': + description: 접수됨. + content: + application/json: + schema: + $ref: '#/components/schemas/BacktestRunSummary' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: 월 백테스트 횟수 한도 초과 등(Free/Pro 제한, `docs/DOMAIN.md` §1.5). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + + /backtest-runs/{runId}: + get: + operationId: getBacktestRun + tags: [backtests] + summary: 상태 폴링 및 결과 조회 (`PollBacktestStatus`, `GetBacktestResult` 통합) + description: | + Web은 이 엔드포인트를 2~3초 간격으로 폴링한다(`docs/ARCHITECTURE.md` §5). `status`가 + `COMPLETED`일 때만 `result`가 존재하고, `FAILED`일 때만 `failureReason`/`errorCode`가 + 존재한다(`docs/DOMAIN.md` §1.3 불변식). + parameters: + - name: runId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BacktestRunDetail' + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /strategies/{strategyId}/backtest-runs: + get: + operationId: listBacktestRuns + tags: [backtests] + summary: 특정 Strategy의 백테스트 실행 이력 (`ListBacktestRuns`) + description: Second Backtest Rate 계측의 데이터 소스(`docs/ROADMAP.md` Phase 2). + parameters: + - $ref: '#/components/parameters/StrategyIdParam' + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/SizeParam' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, page, size, total] + properties: + items: + type: array + items: + $ref: '#/components/schemas/BacktestRunSummary' + page: { type: integer } + size: { type: integer } + total: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ============ Asset / Data Explorer (읽기 전용 프록시) ============ + /assets: + get: + operationId: listAssets + tags: [assets] + summary: MVP Asset Universe 목록 (`ListAssets`) + description: Core는 Compute의 `GET /assets`(`compute-api.yaml`) 응답을 그대로 프록시한다. Web은 Compute의 존재를 몰라도 된다(`AI_AGENT.md` §4). + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [assets] + properties: + assets: + type: array + items: + $ref: 'compute-api.yaml#/components/schemas/Asset' + '401': { $ref: '#/components/responses/Unauthorized' } + + /assets/{symbol}/availability: + get: + operationId: getAssetAvailability + tags: [assets] + summary: 특정 Asset의 상장일/데이터 가용 구간 (`GetAssetAvailability`) + parameters: + - name: symbol + in: path + required: true + schema: + $ref: 'compute-api.yaml#/components/schemas/AssetSymbol' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: 'compute-api.yaml#/components/schemas/AssetAvailability' + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /assets/series: + get: + operationId: getSeries + tags: [assets] + summary: Data Explorer용 다중 자산 시계열 (`GetSeries`) + description: Compute의 `GET /series`(`compute-api.yaml`)를 그대로 프록시한다. + parameters: + - name: symbols + in: query + required: true + description: "쉼표로 구분된 Asset Symbol 목록 (예: `QQQ,BTCUSDT`)" + schema: { type: string } + example: "QQQ,BTCUSDT" + - name: metric + in: query + required: true + schema: + $ref: 'compute-api.yaml#/components/schemas/SeriesMetric' + - name: start + in: query + required: true + schema: { type: string, format: date } + - name: end + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [series] + properties: + series: + type: array + items: + $ref: 'compute-api.yaml#/components/schemas/AssetSeries' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ============ Account ============ + /auth/register: + post: + operationId: registerUser + tags: [account] + summary: 회원가입 (`RegisterUser`) + description: 인증 흐름 상세는 미확정 — 위 info.description "미확정 사항" 참고. 아래 바디는 최소 형태 잠정안. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + name: { type: string, nullable: true } + responses: + '201': + description: 생성됨. + content: + application/json: + schema: + $ref: '#/components/schemas/User' + + /me/usage: + get: + operationId: getUsage + tags: [account] + summary: 이번 달 사용량 조회 (`GetUsage`) + description: 정확한 한도 수치는 미정(`docs/DOMAIN.md` §1.5) — `null`은 "아직 정해지지 않음/무제한"을 뜻하며 실제 구현 시 명확히 구분해야 한다. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Usage' + '401': { $ref: '#/components/responses/Unauthorized' } + + /me/subscription/upgrade: + post: + operationId: upgradeSubscription + tags: [account] + summary: Free → Pro 전환 (`UpgradeSubscription`) + description: MVP는 상태값만 변경한다 — 실제 결제 연동은 Phase 5(`docs/ROADMAP.md`). + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '401': { $ref: '#/components/responses/Unauthorized' } + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: 자리표시자. 가입/로그인 플로우 자체는 미확정 — 위 info.description 참고. + + parameters: + StrategyIdParam: + name: strategyId + in: path + required: true + schema: { type: string } + PageParam: + name: page + in: query + required: false + schema: { type: integer, minimum: 0, default: 0 } + SizeParam: + name: size + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + + responses: + Unauthorized: + description: 인증 실패. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + NotFound: + description: 존재하지 않거나 본인 소유가 아님. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + BadRequest: + description: 잘못된 요청. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + schemas: + ErrorResponse: + type: object + required: [message] + properties: + message: { type: string } + + # ---- Strategy ---- + StrategySummary: + type: object + required: [id, name, createdAt, latestVersionId] + properties: + id: { type: string } + name: { type: string } + createdAt: { type: string, format: date-time } + latestVersionId: + type: string + nullable: true + description: 아직 StrategyVersion이 하나도 없으면 null. + + StrategyDetail: + allOf: + - $ref: '#/components/schemas/StrategySummary' + - type: object + required: [versions] + properties: + versions: + type: array + items: + $ref: '#/components/schemas/StrategyVersion' + + DefineStrategyVersionRequest: + type: object + required: [primarySignalAsset, conditions, executionAsset, lag, exit] + description: | + `docs/DOMAIN.md` §1.2 StrategyVersion 중 사용자가 지정하는 필드만 포함한다. + `positionPolicy`는 서버가 ADR-009 고정값으로 채우므로 요청 바디에 없다 — `compute-api.yaml`의 + `StrategyVersionPayload`(Compute 전달용, positionPolicy 포함)와는 다른 스키마다. + properties: + primarySignalAsset: + $ref: 'compute-api.yaml#/components/schemas/AssetSymbol' + conditions: + type: array + minItems: 1 + items: + $ref: 'compute-api.yaml#/components/schemas/Condition' + executionAsset: + $ref: 'compute-api.yaml#/components/schemas/ExecutionAssetSymbol' + lag: + type: integer + minimum: 0 + exit: + type: object + required: [holdingSignalSessions] + properties: + holdingSignalSessions: { type: integer, minimum: 1 } + + StrategyVersion: + allOf: + - $ref: '#/components/schemas/DefineStrategyVersionRequest' + - type: object + required: [id, strategyId, createdAt] + properties: + id: { type: string } + strategyId: { type: string } + createdAt: { type: string, format: date-time } + + StrategyVersionValidationError: + type: object + required: [message, violations] + description: Compute의 `ValidationErrorResponse`(DSL 의미론적 오류)와는 다른, Core 도메인 불변식 위반 전용 스키마(`docs/DOMAIN.md` §1.2). + properties: + message: { type: string } + violations: + type: array + items: { type: string } + example: + - "primarySignalAsset은 정확히 1개여야 합니다" + - "executionAsset으로 VIX를 지정할 수 없습니다" + + # ---- Backtest ---- + BacktestRunStatus: + type: string + enum: [PENDING, RUNNING, COMPLETED, FAILED] + description: "`compute-api.yaml`의 `BacktestStatus`와 동일한 값 집합(`docs/DOMAIN.md` §1.3)." + + BacktestRunSummary: + type: object + required: [id, strategyId, strategyVersionId, status, requestedPeriod, feeModel, createdAt] + properties: + id: { type: string } + strategyId: { type: string } + strategyVersionId: { type: string } + status: + $ref: '#/components/schemas/BacktestRunStatus' + requestedPeriod: + $ref: 'compute-api.yaml#/components/schemas/Period' + feeModel: + $ref: 'compute-api.yaml#/components/schemas/FeeModel' + createdAt: { type: string, format: date-time } + + BacktestRunDetail: + allOf: + - $ref: '#/components/schemas/BacktestRunSummary' + - type: object + properties: + actualPeriod: + allOf: + - $ref: 'compute-api.yaml#/components/schemas/Period' + nullable: true + description: Asset Availability 교집합 반영 후 실제 기간. PENDING이면 null(`docs/DOMAIN.md` §1.3). + datasetSnapshotId: + type: string + nullable: true + description: PENDING이면 null — Compute가 실행 시작 후 채움(`docs/DOMAIN.md` §1.3 불변식). + engineVersion: + type: string + nullable: true + result: + allOf: + - $ref: 'compute-api.yaml#/components/schemas/BacktestResult' + nullable: true + description: status가 COMPLETED일 때만 존재. + failureReason: + type: string + nullable: true + description: status가 FAILED일 때만 존재. + errorCode: + allOf: + - $ref: 'compute-api.yaml#/components/schemas/FatalErrorCode' + nullable: true + + # ---- Account ---- + User: + type: object + required: [id, email, createdAt] + properties: + id: { type: string } + email: { type: string, format: email } + name: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + + SubscriptionTier: + type: string + enum: [FREE, PRO] + + Subscription: + type: object + required: [tier] + properties: + tier: + $ref: '#/components/schemas/SubscriptionTier' + + Usage: + type: object + required: [tier, backtestsUsedThisMonth] + description: "`docs/DOMAIN.md` §1.5 Free/Pro 표를 조회 시점 값으로 반영. 한도 필드는 수치 미정이라 nullable(TODO)." + properties: + tier: + $ref: '#/components/schemas/SubscriptionTier' + backtestsUsedThisMonth: { type: integer } + backtestMonthlyLimit: + type: integer + nullable: true + description: null = 무제한 또는 미정(TODO). + allowedAssets: + type: array + nullable: true + items: + $ref: 'compute-api.yaml#/components/schemas/AssetSymbol' + description: null = 전체 Asset 허용. + maxBacktestPeriodDays: + type: integer + nullable: true + description: null = 제한 없음 또는 미정(TODO). + strategySaveEnabled: { type: boolean } From 67547fb0aa0f9ecc1cdf07a1ce50a7bd60b47f80 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:48:54 +0900 Subject: [PATCH 2/6] chore(build): establish feature-centric Gradle modules Add nested feature projects and shared build conventions; move the boot entry point into app. Related: ADR-018. --- .gitignore | 6 +++ .../com/refinvest/RefinvestApplication.kt | 0 build-logic/build.gradle.kts | 16 ++++++++ build-logic/settings.gradle.kts | 9 +++++ .../main/kotlin/domain-conventions.gradle.kts | 33 ++++++++++++++++ .../kotlin/jpa-adapter-conventions.gradle.kts | 4 ++ .../kotlin-common-conventions.gradle.kts | 33 ++++++++++++++++ .../spring-adapter-conventions.gradle.kts | 13 +++++++ ...ng-boot-application-conventions.gradle.kts | 6 +++ build.gradle.kts | 39 ++----------------- gradle/libs.versions.toml | 25 ++++++++++++ settings.gradle.kts | 25 ++++++++++++ src/main/resources/application.yaml | 3 -- .../refinvest/RefinvestApplicationTests.kt | 13 ------- 14 files changed, 173 insertions(+), 52 deletions(-) rename {src => app/src}/main/kotlin/com/refinvest/RefinvestApplication.kt (100%) create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build-logic/src/main/kotlin/domain-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/jpa-adapter-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/kotlin-common-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/spring-adapter-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/spring-boot-application-conventions.gradle.kts create mode 100644 gradle/libs.versions.toml delete mode 100644 src/main/resources/application.yaml delete mode 100644 src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt diff --git a/.gitignore b/.gitignore index 5a979af..7679839 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ out/ ### Kotlin ### .kotlin + +### Env ### +.env + +### Local context checkout ### +/context/ diff --git a/src/main/kotlin/com/refinvest/RefinvestApplication.kt b/app/src/main/kotlin/com/refinvest/RefinvestApplication.kt similarity index 100% rename from src/main/kotlin/com/refinvest/RefinvestApplication.kt rename to app/src/main/kotlin/com/refinvest/RefinvestApplication.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..a84d2fb --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() + mavenCentral() +} + +dependencies { + implementation(libs.kotlin.gradle.plugin) + implementation(libs.kotlin.allopen) + implementation(libs.kotlin.noarg) + implementation(libs.spring.boot.gradle.plugin) + implementation(libs.spring.dependency.management.gradle.plugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..22cf2c8 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "refinvest-build-logic" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/build-logic/src/main/kotlin/domain-conventions.gradle.kts b/build-logic/src/main/kotlin/domain-conventions.gradle.kts new file mode 100644 index 0000000..ebdab3f --- /dev/null +++ b/build-logic/src/main/kotlin/domain-conventions.gradle.kts @@ -0,0 +1,33 @@ +import org.gradle.api.GradleException + +plugins { + id("kotlin-common-conventions") +} + +val forbiddenFrameworkGroups = listOf( + "org.springframework", + "jakarta.persistence", + "javax.persistence", + "org.hibernate", + "org.jetbrains.exposed", + "io.micronaut", + "io.quarkus", +) + +configurations.configureEach { + withDependencies { + val forbidden = filter { dependency -> + dependency.group?.let { group -> + forbiddenFrameworkGroups.any { forbiddenGroup -> + group == forbiddenGroup || group.startsWith("$forbiddenGroup.") + } + } == true + } + if (forbidden.isNotEmpty()) { + throw GradleException( + "Domain module $path must not depend on frameworks: " + + forbidden.joinToString { "${it.group}:${it.name}" }, + ) + } + } +} diff --git a/build-logic/src/main/kotlin/jpa-adapter-conventions.gradle.kts b/build-logic/src/main/kotlin/jpa-adapter-conventions.gradle.kts new file mode 100644 index 0000000..0f9bd60 --- /dev/null +++ b/build-logic/src/main/kotlin/jpa-adapter-conventions.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("spring-adapter-conventions") + kotlin("plugin.jpa") +} diff --git a/build-logic/src/main/kotlin/kotlin-common-conventions.gradle.kts b/build-logic/src/main/kotlin/kotlin-common-conventions.gradle.kts new file mode 100644 index 0000000..a5d05c4 --- /dev/null +++ b/build-logic/src/main/kotlin/kotlin-common-conventions.gradle.kts @@ -0,0 +1,33 @@ +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.tasks.testing.Test + +plugins { + `java-library` + kotlin("jvm") +} + +repositories { + mavenCentral() +} + +java { + toolchain.languageVersion = JavaLanguageVersion.of(25) +} + +kotlin { + compilerOptions.freeCompilerArgs.addAll( + "-Xjsr305=strict", + "-Xannotation-default-target=param-property", + ) +} + +val libs = extensions.getByType().named("libs") + +dependencies { + "testImplementation"(libs.findLibrary("kotlin-test-junit5").get()) + "testRuntimeOnly"(libs.findLibrary("junit-platform-launcher").get()) +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/build-logic/src/main/kotlin/spring-adapter-conventions.gradle.kts b/build-logic/src/main/kotlin/spring-adapter-conventions.gradle.kts new file mode 100644 index 0000000..a004df6 --- /dev/null +++ b/build-logic/src/main/kotlin/spring-adapter-conventions.gradle.kts @@ -0,0 +1,13 @@ +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + id("kotlin-common-conventions") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + +dependencyManagement { + imports { + mavenBom(SpringBootPlugin.BOM_COORDINATES) + } +} diff --git a/build-logic/src/main/kotlin/spring-boot-application-conventions.gradle.kts b/build-logic/src/main/kotlin/spring-boot-application-conventions.gradle.kts new file mode 100644 index 0000000..a33d9e7 --- /dev/null +++ b/build-logic/src/main/kotlin/spring-boot-application-conventions.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("kotlin-common-conventions") + kotlin("plugin.spring") + id("org.springframework.boot") + id("io.spring.dependency-management") +} diff --git a/build.gradle.kts b/build.gradle.kts index 0890bdb..33f0337 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,37 +1,4 @@ -plugins { - kotlin("jvm") version "2.3.21" - kotlin("plugin.spring") version "2.3.21" - id("org.springframework.boot") version "4.1.0" - id("io.spring.dependency-management") version "1.1.7" -} - -group = "com.refinvest" -version = "0.0.1-SNAPSHOT" - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(25) - } -} - -repositories { - mavenCentral() -} - -dependencies { - implementation("org.springframework.boot:spring-boot-starter") - implementation("org.jetbrains.kotlin:kotlin-reflect") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -kotlin { - compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property") - } -} - -tasks.withType { - useJUnitPlatform() +allprojects { + group = "com.refinvest" + version = "0.0.1-SNAPSHOT" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..1145fe7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +[versions] +kotlin = "2.3.21" +spring-boot = "4.1.0" +spring-dependency-management = "1.1.7" +spring-framework = "7.0.8" + +[libraries] +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-allopen = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" } +kotlin-noarg = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" } +kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" } +spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" } +spring-dependency-management-gradle-plugin = { module = "io.spring.gradle:dependency-management-plugin", version.ref = "spring-dependency-management" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } +spring-boot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc" } +spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" } +spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } +spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" } +spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } +postgresql = { module = "org.postgresql:postgresql" } +h2 = { module = "com.h2database:h2" } +jackson-module-kotlin = { module = "tools.jackson.module:jackson-module-kotlin" } +spring-context = { module = "org.springframework:spring-context", version.ref = "spring-framework" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d98e3f8..ce326e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,26 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = "refinvest" + +include( + ":strategy:domain", + ":strategy:port", + ":strategy:application", + ":strategy:adapter:snowflake", + ":strategy:adapter:web", + ":strategy:adapter:persistence", + ":backtest:domain", + ":backtest:port", + ":backtest:application", + ":backtest:adapter:compute", + ":backtest:adapter:snowflake", + ":shared:kernel", + ":shared:infrastructure", + ":app", +) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index 7b7434b..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: refinvest diff --git a/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt b/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt deleted file mode 100644 index 0606c0e..0000000 --- a/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.refinvest - -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest - -@SpringBootTest -class RefinvestApplicationTests { - - @Test - fun contextLoads() { - } - -} From 96323facfad244cd74ee5c8ce543d5e9f35234f8 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:49:02 +0900 Subject: [PATCH 3/6] feat(shared): add domain kernel and Snowflake infrastructure Provide shared domain building blocks and the technology-specific Snowflake generator. Related: ADR-018; typed identifier architecture. --- shared/infrastructure/build.gradle.kts | 1 + .../infrastructure/id/SnowflakeIdGenerator.kt | 55 +++++++++++++++++++ .../id/SnowflakeIdGeneratorTest.kt | 34 ++++++++++++ shared/kernel/build.gradle.kts | 1 + .../core/common/domain/AggregateRoot.kt | 14 +++++ .../core/common/domain/DomainEntity.kt | 5 ++ .../core/common/domain/DomainEvent.kt | 8 +++ .../core/common/domain/Identifier.kt | 5 ++ 8 files changed, 123 insertions(+) create mode 100644 shared/infrastructure/build.gradle.kts create mode 100644 shared/infrastructure/src/main/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGenerator.kt create mode 100644 shared/infrastructure/src/test/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGeneratorTest.kt create mode 100644 shared/kernel/build.gradle.kts create mode 100644 shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/AggregateRoot.kt create mode 100644 shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEntity.kt create mode 100644 shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEvent.kt create mode 100644 shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/Identifier.kt diff --git a/shared/infrastructure/build.gradle.kts b/shared/infrastructure/build.gradle.kts new file mode 100644 index 0000000..6f110b1 --- /dev/null +++ b/shared/infrastructure/build.gradle.kts @@ -0,0 +1 @@ +plugins { id("kotlin-common-conventions") } diff --git a/shared/infrastructure/src/main/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGenerator.kt b/shared/infrastructure/src/main/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGenerator.kt new file mode 100644 index 0000000..807f699 --- /dev/null +++ b/shared/infrastructure/src/main/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGenerator.kt @@ -0,0 +1,55 @@ +package com.refinvest.core.shared.infrastructure.id + +import java.time.Clock + +class SnowflakeIdGenerator( + private val nodeId: Long, + private val clock: Clock = Clock.systemUTC(), +) { + private var lastTimestamp = -1L + private var sequence = 0L + + init { + require(nodeId in 0..MAX_NODE_ID) { "nodeId must be between 0 and $MAX_NODE_ID" } + } + + @Synchronized + fun next(): Long { + var timestamp = clock.millis() + check(timestamp >= CUSTOM_EPOCH_MILLIS) { "Clock is before the Snowflake epoch" } + check(timestamp >= lastTimestamp) { "Clock moved backwards" } + + if (timestamp == lastTimestamp) { + sequence = (sequence + 1) and MAX_SEQUENCE + if (sequence == 0L) { + timestamp = waitForNextMillis(lastTimestamp) + } + } else { + sequence = 0L + } + + lastTimestamp = timestamp + return ((timestamp - CUSTOM_EPOCH_MILLIS) shl TIMESTAMP_SHIFT) or + (nodeId shl NODE_SHIFT) or + sequence + } + + private fun waitForNextMillis(previousTimestamp: Long): Long { + var timestamp = clock.millis() + while (timestamp <= previousTimestamp) { + Thread.onSpinWait() + timestamp = clock.millis() + } + return timestamp + } + + companion object { + private const val CUSTOM_EPOCH_MILLIS = 1_704_067_200_000L // 2024-01-01T00:00:00Z + private const val NODE_BITS = 10 + private const val SEQUENCE_BITS = 12 + private const val NODE_SHIFT = SEQUENCE_BITS + private const val TIMESTAMP_SHIFT = NODE_BITS + SEQUENCE_BITS + private const val MAX_NODE_ID = (1L shl NODE_BITS) - 1 + private const val MAX_SEQUENCE = (1L shl SEQUENCE_BITS) - 1 + } +} diff --git a/shared/infrastructure/src/test/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGeneratorTest.kt b/shared/infrastructure/src/test/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGeneratorTest.kt new file mode 100644 index 0000000..7e41cda --- /dev/null +++ b/shared/infrastructure/src/test/kotlin/com/refinvest/core/shared/infrastructure/id/SnowflakeIdGeneratorTest.kt @@ -0,0 +1,34 @@ +package com.refinvest.core.shared.infrastructure.id + +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SnowflakeIdGeneratorTest { + @Test + fun `generates ordered unique positive ids`() { + val generator = SnowflakeIdGenerator(nodeId = 7) + + val ids = List(1_000) { generator.next() } + + assertEquals(ids.size, ids.toSet().size) + assertEquals(ids.sorted(), ids) + assertTrue(ids.all { it > 0 }) + } + + @Test + fun `rejects an invalid node id`() { + assertFailsWith { SnowflakeIdGenerator(nodeId = 1_024) } + } + + @Test + fun `rejects a clock before the custom epoch`() { + val clock = Clock.fixed(Instant.parse("2023-12-31T23:59:59Z"), ZoneOffset.UTC) + + assertFailsWith { SnowflakeIdGenerator(0, clock).next() } + } +} diff --git a/shared/kernel/build.gradle.kts b/shared/kernel/build.gradle.kts new file mode 100644 index 0000000..65a0915 --- /dev/null +++ b/shared/kernel/build.gradle.kts @@ -0,0 +1 @@ +plugins { id("domain-conventions") } diff --git a/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/AggregateRoot.kt b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/AggregateRoot.kt new file mode 100644 index 0000000..2356e4b --- /dev/null +++ b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/AggregateRoot.kt @@ -0,0 +1,14 @@ +package com.refinvest.core.common.domain + +abstract class AggregateRoot>( + override val id: ID, +) : DomainEntity(id) { + private val recordedDomainEvents = mutableListOf() + + protected fun record(event: DomainEvent) { + recordedDomainEvents += event + } + + internal fun pullDomainEvents(): List = + recordedDomainEvents.toList().also { recordedDomainEvents.clear() } +} diff --git a/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEntity.kt b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEntity.kt new file mode 100644 index 0000000..6eff169 --- /dev/null +++ b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEntity.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.common.domain + +abstract class DomainEntity>( + open val id: ID, +) diff --git a/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEvent.kt b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEvent.kt new file mode 100644 index 0000000..001b922 --- /dev/null +++ b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/DomainEvent.kt @@ -0,0 +1,8 @@ +package com.refinvest.core.common.domain + +import java.time.Instant + +/** A module-internal domain byproduct, not an inter-module messaging contract. */ +interface DomainEvent { + val occurredAt: Instant +} diff --git a/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/Identifier.kt b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/Identifier.kt new file mode 100644 index 0000000..dd30836 --- /dev/null +++ b/shared/kernel/src/main/kotlin/com/refinvest/core/common/domain/Identifier.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.common.domain + +interface Identifier { + val value: ID +} From 468cdeccde2b67929a711657272c7f3f6741bfb2 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:49:07 +0900 Subject: [PATCH 4/6] feat(strategy): add strategy create and get vertical slices Add feature modules, HTTP adapters, persistence adapters, and application services for strategy creation and retrieval. Related: ADR-002, ADR-013; Use Cases DefineStrategyVersion, GetStrategy. --- app/build.gradle.kts | 16 +++ .../core/config/IdGenerationConfiguration.kt | 15 +++ .../core/config/StrategyConfiguration.kt | 20 +++ app/src/main/resources/application.yaml | 25 ++++ .../refinvest/RefinvestApplicationTests.kt | 118 ++++++++++++++++++ strategy/adapter/persistence/build.gradle.kts | 5 + .../persistence/JpaStrategyReaderAdapter.kt | 21 ++++ .../persistence/JpaStrategyStoreAdapter.kt | 21 ++++ .../out/persistence/StrategyJpaEntity.kt | 20 +++ .../out/persistence/StrategyJpaReader.kt | 7 ++ .../out/persistence/StrategyJpaStore.kt | 5 + strategy/adapter/snowflake/build.gradle.kts | 6 + .../out/id/SnowflakeStrategyIdGenerator.kt | 13 ++ .../id/SnowflakeStrategyIdGeneratorTest.kt | 19 +++ strategy/adapter/web/build.gradle.kts | 7 ++ .../web/strategy/StrategyController.kt | 51 ++++++++ .../strategy/create/CreateStrategyRequest.kt | 10 ++ .../strategy/create/CreateStrategyResponse.kt | 10 ++ .../web/strategy/get/GetStrategyResponse.kt | 11 ++ strategy/application/build.gradle.kts | 5 + .../strategy/create/CreateStrategyService.kt | 30 +++++ .../strategy/get/GetStrategyService.kt | 22 ++++ .../create/CreateStrategyServiceTest.kt | 37 ++++++ .../strategy/get/GetStrategyServiceTest.kt | 34 +++++ strategy/domain/build.gradle.kts | 4 + .../core/strategy/domain/Strategy.kt | 42 +++++++ .../core/strategy/domain/StrategyDsl.kt | 70 +++++++++++ .../strategy/domain/StrategyIdentifiers.kt | 11 ++ .../core/strategy/domain/StrategyVersion.kt | 59 +++++++++ .../core/strategy/domain/StrategyTest.kt | 35 ++++++ .../strategy/domain/StrategyVersionTest.kt | 98 +++++++++++++++ strategy/port/build.gradle.kts | 2 + .../strategy/create/CreateStrategyCommand.kt | 5 + .../strategy/create/CreateStrategyResult.kt | 9 ++ .../strategy/create/CreateStrategyUseCase.kt | 5 + .../inbound/strategy/get/GetStrategyQuery.kt | 7 ++ .../inbound/strategy/get/GetStrategyResult.kt | 12 ++ .../strategy/get/GetStrategyUseCase.kt | 5 + .../port/outbound/MemberIdProvider.kt | 8 ++ .../port/outbound/StrategyIdGenerator.kt | 7 ++ .../strategy/port/outbound/StrategyReader.kt | 16 +++ .../strategy/port/outbound/StrategyStore.kt | 7 ++ 42 files changed, 930 insertions(+) create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/kotlin/com/refinvest/core/config/IdGenerationConfiguration.kt create mode 100644 app/src/main/kotlin/com/refinvest/core/config/StrategyConfiguration.kt create mode 100644 app/src/main/resources/application.yaml create mode 100644 app/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt create mode 100644 strategy/adapter/persistence/build.gradle.kts create mode 100644 strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyReaderAdapter.kt create mode 100644 strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyStoreAdapter.kt create mode 100644 strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaEntity.kt create mode 100644 strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaReader.kt create mode 100644 strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaStore.kt create mode 100644 strategy/adapter/snowflake/build.gradle.kts create mode 100644 strategy/adapter/snowflake/src/main/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGenerator.kt create mode 100644 strategy/adapter/snowflake/src/test/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGeneratorTest.kt create mode 100644 strategy/adapter/web/build.gradle.kts create mode 100644 strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/StrategyController.kt create mode 100644 strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyRequest.kt create mode 100644 strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyResponse.kt create mode 100644 strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/get/GetStrategyResponse.kt create mode 100644 strategy/application/build.gradle.kts create mode 100644 strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyService.kt create mode 100644 strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyService.kt create mode 100644 strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyServiceTest.kt create mode 100644 strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyServiceTest.kt create mode 100644 strategy/domain/build.gradle.kts create mode 100644 strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/Strategy.kt create mode 100644 strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyDsl.kt create mode 100644 strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyIdentifiers.kt create mode 100644 strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyVersion.kt create mode 100644 strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyTest.kt create mode 100644 strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyVersionTest.kt create mode 100644 strategy/port/build.gradle.kts create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyCommand.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyResult.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyUseCase.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyQuery.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyResult.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyUseCase.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/MemberIdProvider.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyIdGenerator.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyReader.kt create mode 100644 strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..83577c8 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/kotlin/com/refinvest/core/config/IdGenerationConfiguration.kt b/app/src/main/kotlin/com/refinvest/core/config/IdGenerationConfiguration.kt new file mode 100644 index 0000000..4f9d457 --- /dev/null +++ b/app/src/main/kotlin/com/refinvest/core/config/IdGenerationConfiguration.kt @@ -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) + +} diff --git a/app/src/main/kotlin/com/refinvest/core/config/StrategyConfiguration.kt b/app/src/main/kotlin/com/refinvest/core/config/StrategyConfiguration.kt new file mode 100644 index 0000000..bbf822b --- /dev/null +++ b/app/src/main/kotlin/com/refinvest/core/config/StrategyConfiguration.kt @@ -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) } + +} diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml new file mode 100644 index 0000000..bc537b4 --- /dev/null +++ b/app/src/main/resources/application.yaml @@ -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} diff --git a/app/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt b/app/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt new file mode 100644 index 0000000..8d36d70 --- /dev/null +++ b/app/src/test/kotlin/com/refinvest/RefinvestApplicationTests.kt @@ -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()) + } + +} diff --git a/strategy/adapter/persistence/build.gradle.kts b/strategy/adapter/persistence/build.gradle.kts new file mode 100644 index 0000000..1bca3db --- /dev/null +++ b/strategy/adapter/persistence/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { id("jpa-adapter-conventions") } +dependencies { + implementation(project(":strategy:port")) + implementation(libs.spring.boot.starter.data.jpa) +} diff --git a/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyReaderAdapter.kt b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyReaderAdapter.kt new file mode 100644 index 0000000..c197b95 --- /dev/null +++ b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyReaderAdapter.kt @@ -0,0 +1,21 @@ +package com.refinvest.core.strategy.adapter.out.persistence + +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.port.outbound.StrategyReadModel +import com.refinvest.core.strategy.port.outbound.StrategyReader +import org.springframework.stereotype.Repository + +@Repository +class JpaStrategyReaderAdapter( + private val strategyJpaReader: StrategyJpaReader, +) : StrategyReader { + override fun findById(id: StrategyId): StrategyReadModel? = + strategyJpaReader.findById(id.value)?.let { strategy -> + StrategyReadModel( + id = StrategyId(strategy.id), + name = strategy.name, + createdAt = strategy.createdAt, + latestVersionId = null, + ) + } +} diff --git a/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyStoreAdapter.kt b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyStoreAdapter.kt new file mode 100644 index 0000000..e1a6290 --- /dev/null +++ b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/JpaStrategyStoreAdapter.kt @@ -0,0 +1,21 @@ +package com.refinvest.core.strategy.adapter.out.persistence + +import com.refinvest.core.strategy.domain.Strategy +import com.refinvest.core.strategy.port.outbound.StrategyStore +import org.springframework.stereotype.Repository + +@Repository +class JpaStrategyStoreAdapter( + private val strategyJpaStore: StrategyJpaStore, +) : StrategyStore { + override fun save(strategy: Strategy) { + strategyJpaStore.save( + StrategyJpaEntity( + id = strategy.id.value, + memberId = strategy.memberId.value, + name = strategy.name, + createdAt = strategy.createdAt, + ), + ) + } +} diff --git a/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaEntity.kt b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaEntity.kt new file mode 100644 index 0000000..138ab21 --- /dev/null +++ b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaEntity.kt @@ -0,0 +1,20 @@ +package com.refinvest.core.strategy.adapter.out.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +@Entity +@Table(name = "strategies") +class StrategyJpaEntity( + @Id + var id: Long, + @Column(name = "member_id", nullable = false) + var memberId: Long, + @Column(nullable = false, length = 200) + var name: String, + @Column(name = "created_at", nullable = false) + var createdAt: Instant, +) diff --git a/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaReader.kt b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaReader.kt new file mode 100644 index 0000000..169d6f7 --- /dev/null +++ b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaReader.kt @@ -0,0 +1,7 @@ +package com.refinvest.core.strategy.adapter.out.persistence + +import org.springframework.data.repository.Repository + +interface StrategyJpaReader : Repository { + fun findById(id: Long): StrategyJpaEntity? +} diff --git a/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaStore.kt b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaStore.kt new file mode 100644 index 0000000..b323bef --- /dev/null +++ b/strategy/adapter/persistence/src/main/kotlin/com/refinvest/core/strategy/adapter/out/persistence/StrategyJpaStore.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.strategy.adapter.out.persistence + +import org.springframework.data.repository.CrudRepository + +interface StrategyJpaStore : CrudRepository diff --git a/strategy/adapter/snowflake/build.gradle.kts b/strategy/adapter/snowflake/build.gradle.kts new file mode 100644 index 0000000..3cc1e75 --- /dev/null +++ b/strategy/adapter/snowflake/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { id("spring-adapter-conventions") } +dependencies { + api(project(":strategy:port")) + implementation(project(":shared:infrastructure")) + implementation(libs.spring.context) +} diff --git a/strategy/adapter/snowflake/src/main/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGenerator.kt b/strategy/adapter/snowflake/src/main/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGenerator.kt new file mode 100644 index 0000000..63560c4 --- /dev/null +++ b/strategy/adapter/snowflake/src/main/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGenerator.kt @@ -0,0 +1,13 @@ +package com.refinvest.core.strategy.adapter.out.id + +import com.refinvest.core.shared.infrastructure.id.SnowflakeIdGenerator +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.port.outbound.StrategyIdGenerator +import org.springframework.stereotype.Component + +@Component +class SnowflakeStrategyIdGenerator( + private val snowflakeIdGenerator: SnowflakeIdGenerator, +) : StrategyIdGenerator { + override fun next(): StrategyId = StrategyId(snowflakeIdGenerator.next()) +} diff --git a/strategy/adapter/snowflake/src/test/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGeneratorTest.kt b/strategy/adapter/snowflake/src/test/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGeneratorTest.kt new file mode 100644 index 0000000..6810c07 --- /dev/null +++ b/strategy/adapter/snowflake/src/test/kotlin/com/refinvest/core/strategy/adapter/out/id/SnowflakeStrategyIdGeneratorTest.kt @@ -0,0 +1,19 @@ +package com.refinvest.core.strategy.adapter.out.id + +import com.refinvest.core.shared.infrastructure.id.SnowflakeIdGenerator +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class SnowflakeStrategyIdGeneratorTest { + @Test + fun `wraps a Snowflake value in a StrategyId`() { + val generator = SnowflakeStrategyIdGenerator(SnowflakeIdGenerator(nodeId = 1)) + + val first = generator.next() + val second = generator.next() + + assertTrue(first.value > 0) + assertNotEquals(first, second) + } +} diff --git a/strategy/adapter/web/build.gradle.kts b/strategy/adapter/web/build.gradle.kts new file mode 100644 index 0000000..6ecf2b6 --- /dev/null +++ b/strategy/adapter/web/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { id("spring-adapter-conventions") } +dependencies { + implementation(project(":strategy:port")) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) +} diff --git a/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/StrategyController.kt b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/StrategyController.kt new file mode 100644 index 0000000..660e245 --- /dev/null +++ b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/StrategyController.kt @@ -0,0 +1,51 @@ +package com.refinvest.core.strategy.adapter.web.strategy + +import com.refinvest.core.strategy.adapter.web.strategy.create.CreateStrategyRequest +import com.refinvest.core.strategy.adapter.web.strategy.create.CreateStrategyResponse +import com.refinvest.core.strategy.adapter.web.strategy.get.GetStrategyResponse +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyCommand +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyUseCase +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyQuery +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyUseCase +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/strategies") +class StrategyController( + private val createStrategyUseCase: CreateStrategyUseCase, + private val getStrategyUseCase: GetStrategyUseCase, +) { + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@Valid @RequestBody request: CreateStrategyRequest): CreateStrategyResponse { + val result = createStrategyUseCase.execute(CreateStrategyCommand(name = request.name)) + return CreateStrategyResponse( + id = result.id.value.toString(), + name = request.name, + createdAt = result.createdAt, + latestVersionId = null, + ) + } + + @GetMapping("/{strategyId}") + fun get(@PathVariable strategyId: Long): GetStrategyResponse { + val result = getStrategyUseCase.execute(GetStrategyQuery(StrategyId(strategyId))) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Strategy not found") + return GetStrategyResponse( + id = result.id.value.toString(), + name = result.name, + createdAt = result.createdAt, + latestVersionId = result.latestVersionId?.value?.toString(), + ) + } +} diff --git a/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyRequest.kt b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyRequest.kt new file mode 100644 index 0000000..176be4b --- /dev/null +++ b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyRequest.kt @@ -0,0 +1,10 @@ +package com.refinvest.core.strategy.adapter.web.strategy.create + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +data class CreateStrategyRequest( + @field:NotBlank + @field:Size(max = 200) + val name: String, +) diff --git a/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyResponse.kt b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyResponse.kt new file mode 100644 index 0000000..b89217d --- /dev/null +++ b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/create/CreateStrategyResponse.kt @@ -0,0 +1,10 @@ +package com.refinvest.core.strategy.adapter.web.strategy.create + +import java.time.Instant + +data class CreateStrategyResponse( + val id: String, + val name: String, + val createdAt: Instant, + val latestVersionId: String?, +) diff --git a/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/get/GetStrategyResponse.kt b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/get/GetStrategyResponse.kt new file mode 100644 index 0000000..2913129 --- /dev/null +++ b/strategy/adapter/web/src/main/kotlin/com/refinvest/core/strategy/adapter/web/strategy/get/GetStrategyResponse.kt @@ -0,0 +1,11 @@ +package com.refinvest.core.strategy.adapter.web.strategy.get + +import java.time.Instant + +data class GetStrategyResponse( + val id: String, + val name: String, + val createdAt: Instant, + val latestVersionId: String?, + val versions: List = emptyList(), +) diff --git a/strategy/application/build.gradle.kts b/strategy/application/build.gradle.kts new file mode 100644 index 0000000..9b64912 --- /dev/null +++ b/strategy/application/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { id("kotlin-common-conventions") } +dependencies { + implementation(project(":strategy:port")) + implementation(libs.spring.context) +} diff --git a/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyService.kt b/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyService.kt new file mode 100644 index 0000000..c01f3ea --- /dev/null +++ b/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyService.kt @@ -0,0 +1,30 @@ +package com.refinvest.core.strategy.application.strategy.create + +import com.refinvest.core.strategy.domain.Strategy +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyCommand +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyResult +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyUseCase +import com.refinvest.core.strategy.port.outbound.MemberIdProvider +import com.refinvest.core.strategy.port.outbound.StrategyIdGenerator +import com.refinvest.core.strategy.port.outbound.StrategyStore +import org.springframework.stereotype.Service +import java.time.Clock + +@Service +class CreateStrategyService( + private val strategyStore: StrategyStore, + private val strategyIdGenerator: StrategyIdGenerator, + private val memberIdProvider: MemberIdProvider, + private val clock: Clock, +) : CreateStrategyUseCase { + override fun execute(command: CreateStrategyCommand): CreateStrategyResult { + val strategy = Strategy.create( + id = strategyIdGenerator.next(), + memberId = memberIdProvider.currentMemberId(), + name = command.name, + createdAt = clock.instant(), + ) + strategyStore.save(strategy) + return CreateStrategyResult(strategy.id, strategy.createdAt) + } +} diff --git a/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyService.kt b/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyService.kt new file mode 100644 index 0000000..ea3ea0c --- /dev/null +++ b/strategy/application/src/main/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyService.kt @@ -0,0 +1,22 @@ +package com.refinvest.core.strategy.application.strategy.get + +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyQuery +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyResult +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyUseCase +import com.refinvest.core.strategy.port.outbound.StrategyReader +import org.springframework.stereotype.Service + +@Service +class GetStrategyService( + private val strategyReader: StrategyReader, +) : GetStrategyUseCase { + override fun execute(query: GetStrategyQuery): GetStrategyResult? = + strategyReader.findById(query.strategyId)?.let { strategy -> + GetStrategyResult( + id = strategy.id, + name = strategy.name, + createdAt = strategy.createdAt, + latestVersionId = strategy.latestVersionId, + ) + } +} diff --git a/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyServiceTest.kt b/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyServiceTest.kt new file mode 100644 index 0000000..1c2f746 --- /dev/null +++ b/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/create/CreateStrategyServiceTest.kt @@ -0,0 +1,37 @@ +package com.refinvest.core.strategy.application.strategy.create + +import com.refinvest.core.strategy.domain.MemberId +import com.refinvest.core.strategy.domain.Strategy +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.port.inbound.strategy.create.CreateStrategyCommand +import com.refinvest.core.strategy.port.outbound.MemberIdProvider +import com.refinvest.core.strategy.port.outbound.StrategyIdGenerator +import com.refinvest.core.strategy.port.outbound.StrategyStore +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CreateStrategyServiceTest { + @Test + fun `creates an empty strategy saves it and returns its id`() { + val id = StrategyId(1L) + val memberId = MemberId(2L) + var saved: Strategy? = null + val service = CreateStrategyService( + strategyStore = StrategyStore { saved = it }, + strategyIdGenerator = StrategyIdGenerator { id }, + memberIdProvider = MemberIdProvider { memberId }, + clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC), + ) + + val result = service.execute(CreateStrategyCommand("volatility hypothesis")) + + assertEquals(id, result.id) + assertEquals(memberId, saved?.memberId) + assertEquals("volatility hypothesis", saved?.name) + assertTrue(saved?.versions?.isEmpty() == true) + } +} diff --git a/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyServiceTest.kt b/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyServiceTest.kt new file mode 100644 index 0000000..01a32b1 --- /dev/null +++ b/strategy/application/src/test/kotlin/com/refinvest/core/strategy/application/strategy/get/GetStrategyServiceTest.kt @@ -0,0 +1,34 @@ +package com.refinvest.core.strategy.application.strategy.get + +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.port.inbound.strategy.get.GetStrategyQuery +import com.refinvest.core.strategy.port.outbound.StrategyReadModel +import com.refinvest.core.strategy.port.outbound.StrategyReader +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GetStrategyServiceTest { + @Test + fun `returns the reader projection as a use case result`() { + val id = StrategyId(1L) + val service = GetStrategyService( + StrategyReader { + StrategyReadModel(id, "volatility hypothesis", Instant.parse("2026-08-11T00:00:00Z"), null) + }, + ) + + val result = service.execute(GetStrategyQuery(id)) + + assertEquals(id, result?.id) + assertEquals("volatility hypothesis", result?.name) + } + + @Test + fun `returns null when the reader cannot find the strategy`() { + val service = GetStrategyService(StrategyReader { null }) + + assertNull(service.execute(GetStrategyQuery(StrategyId(1L)))) + } +} diff --git a/strategy/domain/build.gradle.kts b/strategy/domain/build.gradle.kts new file mode 100644 index 0000000..2f845c0 --- /dev/null +++ b/strategy/domain/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { id("domain-conventions") } +dependencies { + api(project(":shared:kernel")) +} diff --git a/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/Strategy.kt b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/Strategy.kt new file mode 100644 index 0000000..60d753f --- /dev/null +++ b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/Strategy.kt @@ -0,0 +1,42 @@ +package com.refinvest.core.strategy.domain + +import com.refinvest.core.common.domain.AggregateRoot +import java.time.Instant + +class Strategy private constructor( + override val id: StrategyId, + val memberId: MemberId, + val name: String, + val createdAt: Instant, + versions: List, +) : AggregateRoot(id) { + private val mutableVersions = versions.toMutableList() + val versions: List + get() = mutableVersions.toList() + + fun addVersion(version: StrategyVersion) { + require(version.strategyId == id) { "StrategyVersion belongs to another Strategy" } + mutableVersions += version + } + + companion object { + fun create( + id: StrategyId, + memberId: MemberId, + name: String, + createdAt: Instant, + ): Strategy { + require(name.isNotBlank()) { "Strategy name must not be blank" } + require(name.length <= MAX_NAME_LENGTH) { "Strategy name must not exceed $MAX_NAME_LENGTH characters" } + return Strategy( + id = id, + memberId = memberId, + name = name, + createdAt = createdAt, + versions = emptyList(), + ) + } + + private const val MAX_NAME_LENGTH = 200 + } +} diff --git a/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyDsl.kt b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyDsl.kt new file mode 100644 index 0000000..8a95b40 --- /dev/null +++ b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyDsl.kt @@ -0,0 +1,70 @@ +package com.refinvest.core.strategy.domain + +enum class AssetSymbol { + QQQ, + SPY, + TQQQ, + SOXL, + BTCUSDT, + VIX; + + companion object { + fun from(value: String): AssetSymbol = + entries.firstOrNull { it.name == value } + ?: throw IllegalArgumentException("Unsupported MVP asset: $value") + } +} + +enum class MetricType { SIMPLE, RETURN, CHANGE } + +data class MetricReference( + val asset: AssetSymbol, + val metric: MetricType, + val window: Int? = null, +) { + init { + when (metric) { + MetricType.SIMPLE -> require(window == null) { "SIMPLE metric must not have a window" } + MetricType.RETURN, MetricType.CHANGE -> require(window != null && window > 0) { + "$metric metric requires a positive window" + } + } + } +} + +sealed interface ConditionOperand + +data class MetricOperand(val reference: MetricReference) : ConditionOperand + +data class LiteralValue(val value: Double) : ConditionOperand + +enum class ComparisonOperator { LT, GT, LTE, GTE } + +enum class LogicalCombinator { AND, OR } + +data class Condition( + val operator: ComparisonOperator, + val logicalCombinator: LogicalCombinator? = null, + val operandA: MetricReference, + val operandB: ConditionOperand, +) + +@JvmInline +value class SignalSessions(val value: Int) { + init { + require(value >= 0) { "SignalSessions must be zero or greater" } + } +} + +@JvmInline +value class TimeBasedExit(val holdingSignalSessions: Int) { + init { + require(holdingSignalSessions > 0) { "holdingSignalSessions must be positive" } + } +} + +data object PositionPolicy { + const val longOnly: Boolean = true + const val singlePosition: Boolean = true + const val duplicateEntry: String = "IGNORE" +} diff --git a/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyIdentifiers.kt b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyIdentifiers.kt new file mode 100644 index 0000000..f3d5c6f --- /dev/null +++ b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyIdentifiers.kt @@ -0,0 +1,11 @@ +package com.refinvest.core.strategy.domain + +import com.refinvest.core.common.domain.Identifier +@JvmInline +value class StrategyId(override val value: Long) : Identifier + +@JvmInline +value class StrategyVersionId(override val value: Long) : Identifier + +@JvmInline +value class MemberId(override val value: Long) : Identifier diff --git a/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyVersion.kt b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyVersion.kt new file mode 100644 index 0000000..01f16f4 --- /dev/null +++ b/strategy/domain/src/main/kotlin/com/refinvest/core/strategy/domain/StrategyVersion.kt @@ -0,0 +1,59 @@ +package com.refinvest.core.strategy.domain + +import com.refinvest.core.common.domain.DomainEntity +import java.time.Instant + +class StrategyVersion private constructor( + override val id: StrategyVersionId, + val strategyId: StrategyId, + val createdAt: Instant, + val primarySignalAsset: AssetSymbol, + conditions: List, + val executionAsset: AssetSymbol, + val lag: SignalSessions, + val exit: TimeBasedExit, +) : DomainEntity(id) { + val conditions: List = conditions.toList() + val positionPolicy: PositionPolicy = PositionPolicy + + init { + require(this.conditions.isNotEmpty()) { "conditions must contain at least one condition" } + require(executionAsset in EXECUTION_ASSETS) { "$executionAsset cannot be an execution asset" } + require(this.conditions.first().logicalCombinator == null) { + "The first condition must not have a logicalCombinator" + } + require(this.conditions.drop(1).all { it.logicalCombinator != null }) { + "Every condition after the first requires a logicalCombinator" + } + } + + companion object { + private val EXECUTION_ASSETS = setOf( + AssetSymbol.QQQ, + AssetSymbol.SPY, + AssetSymbol.TQQQ, + AssetSymbol.SOXL, + AssetSymbol.BTCUSDT, + ) + + fun create( + id: StrategyVersionId, + strategyId: StrategyId, + createdAt: Instant, + primarySignalAsset: AssetSymbol, + conditions: List, + executionAsset: AssetSymbol, + lag: SignalSessions, + exit: TimeBasedExit, + ): StrategyVersion = StrategyVersion( + id = id, + strategyId = strategyId, + createdAt = createdAt, + primarySignalAsset = primarySignalAsset, + conditions = conditions, + executionAsset = executionAsset, + lag = lag, + exit = exit, + ) + } +} diff --git a/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyTest.kt b/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyTest.kt new file mode 100644 index 0000000..128e64b --- /dev/null +++ b/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyTest.kt @@ -0,0 +1,35 @@ +package com.refinvest.core.strategy.domain + +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class StrategyTest { + @Test + fun `rejects a version owned by another strategy`() { + val strategy = Strategy.create( + id = StrategyId(1L), + memberId = MemberId(2L), + name = "strategy", + createdAt = Instant.parse("2026-08-11T00:00:00Z"), + ) + val otherStrategyVersion = StrategyVersion.create( + id = StrategyVersionId(3L), + strategyId = StrategyId(4L), + createdAt = Instant.parse("2026-08-11T00:00:00Z"), + primarySignalAsset = AssetSymbol.QQQ, + conditions = listOf( + Condition( + operator = ComparisonOperator.GT, + operandA = MetricReference(AssetSymbol.QQQ, MetricType.SIMPLE), + operandB = LiteralValue(0.0), + ), + ), + executionAsset = AssetSymbol.QQQ, + lag = SignalSessions(0), + exit = TimeBasedExit(1), + ) + + assertFailsWith { strategy.addVersion(otherStrategyVersion) } + } +} diff --git a/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyVersionTest.kt b/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyVersionTest.kt new file mode 100644 index 0000000..a248992 --- /dev/null +++ b/strategy/domain/src/test/kotlin/com/refinvest/core/strategy/domain/StrategyVersionTest.kt @@ -0,0 +1,98 @@ +package com.refinvest.core.strategy.domain + +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StrategyVersionTest { + private val strategyId = StrategyId(1L) + + @Test + fun `rejects an asset outside the MVP universe`() { + assertFailsWith { AssetSymbol.from("AAPL") } + } + + @Test + fun `rejects empty conditions`() { + assertFailsWith { + version(conditions = emptyList()) + } + } + + @Test + fun `rejects VIX as execution asset`() { + assertFailsWith { + version(executionAsset = AssetSymbol.VIX) + } + } + + @Test + fun `rejects a non-positive metric window`() { + assertFailsWith { + MetricReference(AssetSymbol.QQQ, MetricType.RETURN, 0) + } + } + + @Test + fun `rejects a window on a simple metric`() { + assertFailsWith { + MetricReference(AssetSymbol.QQQ, MetricType.SIMPLE, 1) + } + } + + @Test + fun `rejects a negative lag`() { + assertFailsWith { SignalSessions(-1) } + } + + @Test + fun `rejects a non-positive holding period`() { + assertFailsWith { TimeBasedExit(0) } + } + + @Test + fun `rejects logical combinator on the first condition`() { + assertFailsWith { + version(conditions = listOf(condition(LogicalCombinator.AND))) + } + } + + @Test + fun `rejects a missing logical combinator after the first condition`() { + assertFailsWith { + version(conditions = listOf(condition(), condition())) + } + } + + @Test + fun `copies conditions so a created version stays immutable`() { + val conditions = mutableListOf(condition()) + val version = version(conditions = conditions) + + conditions += condition(LogicalCombinator.AND) + + assertEquals(1, version.conditions.size) + } + + private fun version( + conditions: List = listOf(condition()), + executionAsset: AssetSymbol = AssetSymbol.QQQ, + ): StrategyVersion = StrategyVersion.create( + id = StrategyVersionId(2L), + strategyId = strategyId, + createdAt = Instant.parse("2026-08-11T00:00:00Z"), + primarySignalAsset = AssetSymbol.QQQ, + conditions = conditions, + executionAsset = executionAsset, + lag = SignalSessions(0), + exit = TimeBasedExit(1), + ) + + private fun condition(logicalCombinator: LogicalCombinator? = null): Condition = Condition( + operator = ComparisonOperator.GT, + logicalCombinator = logicalCombinator, + operandA = MetricReference(AssetSymbol.QQQ, MetricType.SIMPLE), + operandB = LiteralValue(0.0), + ) +} diff --git a/strategy/port/build.gradle.kts b/strategy/port/build.gradle.kts new file mode 100644 index 0000000..3173e75 --- /dev/null +++ b/strategy/port/build.gradle.kts @@ -0,0 +1,2 @@ +plugins { id("kotlin-common-conventions") } +dependencies { api(project(":strategy:domain")) } diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyCommand.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyCommand.kt new file mode 100644 index 0000000..8cf0e32 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyCommand.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.strategy.port.inbound.strategy.create + +data class CreateStrategyCommand( + val name: String, +) diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyResult.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyResult.kt new file mode 100644 index 0000000..183b324 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyResult.kt @@ -0,0 +1,9 @@ +package com.refinvest.core.strategy.port.inbound.strategy.create + +import com.refinvest.core.strategy.domain.StrategyId +import java.time.Instant + +data class CreateStrategyResult( + val id: StrategyId, + val createdAt: Instant, +) diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyUseCase.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyUseCase.kt new file mode 100644 index 0000000..6745df6 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/create/CreateStrategyUseCase.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.strategy.port.inbound.strategy.create + +fun interface CreateStrategyUseCase { + fun execute(command: CreateStrategyCommand): CreateStrategyResult +} diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyQuery.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyQuery.kt new file mode 100644 index 0000000..d0498c9 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyQuery.kt @@ -0,0 +1,7 @@ +package com.refinvest.core.strategy.port.inbound.strategy.get + +import com.refinvest.core.strategy.domain.StrategyId + +data class GetStrategyQuery( + val strategyId: StrategyId, +) diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyResult.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyResult.kt new file mode 100644 index 0000000..f418a4c --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyResult.kt @@ -0,0 +1,12 @@ +package com.refinvest.core.strategy.port.inbound.strategy.get + +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.domain.StrategyVersionId +import java.time.Instant + +data class GetStrategyResult( + val id: StrategyId, + val name: String, + val createdAt: Instant, + val latestVersionId: StrategyVersionId?, +) diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyUseCase.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyUseCase.kt new file mode 100644 index 0000000..a00a914 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/inbound/strategy/get/GetStrategyUseCase.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.strategy.port.inbound.strategy.get + +fun interface GetStrategyUseCase { + fun execute(query: GetStrategyQuery): GetStrategyResult? +} diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/MemberIdProvider.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/MemberIdProvider.kt new file mode 100644 index 0000000..8decb3d --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/MemberIdProvider.kt @@ -0,0 +1,8 @@ +package com.refinvest.core.strategy.port.outbound + +import com.refinvest.core.strategy.domain.MemberId + +/** Supplies the member executing a Strategy use case; authentication will provide this in production. */ +fun interface MemberIdProvider { + fun currentMemberId(): MemberId +} diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyIdGenerator.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyIdGenerator.kt new file mode 100644 index 0000000..bf27bff --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyIdGenerator.kt @@ -0,0 +1,7 @@ +package com.refinvest.core.strategy.port.outbound + +import com.refinvest.core.strategy.domain.StrategyId + +fun interface StrategyIdGenerator { + fun next(): StrategyId +} diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyReader.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyReader.kt new file mode 100644 index 0000000..25e70fa --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyReader.kt @@ -0,0 +1,16 @@ +package com.refinvest.core.strategy.port.outbound + +import com.refinvest.core.strategy.domain.StrategyId +import com.refinvest.core.strategy.domain.StrategyVersionId +import java.time.Instant + +fun interface StrategyReader { + fun findById(id: StrategyId): StrategyReadModel? +} + +data class StrategyReadModel( + val id: StrategyId, + val name: String, + val createdAt: Instant, + val latestVersionId: StrategyVersionId?, +) diff --git a/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyStore.kt b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyStore.kt new file mode 100644 index 0000000..018a9f8 --- /dev/null +++ b/strategy/port/src/main/kotlin/com/refinvest/core/strategy/port/outbound/StrategyStore.kt @@ -0,0 +1,7 @@ +package com.refinvest.core.strategy.port.outbound + +import com.refinvest.core.strategy.domain.Strategy + +fun interface StrategyStore { + fun save(strategy: Strategy) +} From 2ee8d5f612d1a550fc5b5913dfbdc263bc04ee07 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:49:16 +0900 Subject: [PATCH 5/6] feat(backtest): add pending backtest run slice Add BacktestRun domain invariants and create the PENDING run through the RunBacktest port. Related: Use Case RunBacktest; BacktestRun and BacktestResult invariants. --- backtest/adapter/compute/build.gradle.kts | 4 + .../adapter/out/compute/package-info.java | 4 + backtest/adapter/snowflake/build.gradle.kts | 6 + .../out/id/SnowflakeBacktestRunIdGenerator.kt | 13 ++ .../id/SnowflakeBacktestRunIdGeneratorTest.kt | 19 +++ backtest/application/build.gradle.kts | 5 + .../backtest/run/RunBacktestService.kt | 29 ++++ .../backtest/run/RunBacktestServiceTest.kt | 47 ++++++ backtest/domain/build.gradle.kts | 4 + .../backtest/domain/BacktestIdentifiers.kt | 24 +++ .../core/backtest/domain/BacktestResult.kt | 77 +++++++++ .../core/backtest/domain/BacktestRun.kt | 152 ++++++++++++++++++ .../core/backtest/domain/BacktestRunTest.kt | 72 +++++++++ backtest/port/build.gradle.kts | 4 + .../backtest/run/RunBacktestCommand.kt | 11 ++ .../inbound/backtest/run/RunBacktestResult.kt | 7 + .../backtest/run/RunBacktestUseCase.kt | 5 + .../port/outbound/BacktestRunStore.kt | 12 ++ .../backtest/port/outbound/ComputeClient.kt | 65 ++++++++ 19 files changed, 560 insertions(+) create mode 100644 backtest/adapter/compute/build.gradle.kts create mode 100644 backtest/adapter/compute/src/main/java/com/refinvest/core/backtest/adapter/out/compute/package-info.java create mode 100644 backtest/adapter/snowflake/build.gradle.kts create mode 100644 backtest/adapter/snowflake/src/main/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGenerator.kt create mode 100644 backtest/adapter/snowflake/src/test/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGeneratorTest.kt create mode 100644 backtest/application/build.gradle.kts create mode 100644 backtest/application/src/main/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestService.kt create mode 100644 backtest/application/src/test/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestServiceTest.kt create mode 100644 backtest/domain/build.gradle.kts create mode 100644 backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestIdentifiers.kt create mode 100644 backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestResult.kt create mode 100644 backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestRun.kt create mode 100644 backtest/domain/src/test/kotlin/com/refinvest/core/backtest/domain/BacktestRunTest.kt create mode 100644 backtest/port/build.gradle.kts create mode 100644 backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestCommand.kt create mode 100644 backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestResult.kt create mode 100644 backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestUseCase.kt create mode 100644 backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/BacktestRunStore.kt create mode 100644 backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/ComputeClient.kt diff --git a/backtest/adapter/compute/build.gradle.kts b/backtest/adapter/compute/build.gradle.kts new file mode 100644 index 0000000..a00a13c --- /dev/null +++ b/backtest/adapter/compute/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { id("kotlin-common-conventions") } +dependencies { + implementation(project(":backtest:port")) +} diff --git a/backtest/adapter/compute/src/main/java/com/refinvest/core/backtest/adapter/out/compute/package-info.java b/backtest/adapter/compute/src/main/java/com/refinvest/core/backtest/adapter/out/compute/package-info.java new file mode 100644 index 0000000..f34ca0a --- /dev/null +++ b/backtest/adapter/compute/src/main/java/com/refinvest/core/backtest/adapter/out/compute/package-info.java @@ -0,0 +1,4 @@ +/** + * Compute integration adapter boundary. The ComputeClient implementation and polling are intentionally deferred. + */ +package com.refinvest.core.backtest.adapter.out.compute; diff --git a/backtest/adapter/snowflake/build.gradle.kts b/backtest/adapter/snowflake/build.gradle.kts new file mode 100644 index 0000000..dec5cbb --- /dev/null +++ b/backtest/adapter/snowflake/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { id("spring-adapter-conventions") } +dependencies { + api(project(":backtest:port")) + implementation(project(":shared:infrastructure")) + implementation(libs.spring.context) +} diff --git a/backtest/adapter/snowflake/src/main/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGenerator.kt b/backtest/adapter/snowflake/src/main/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGenerator.kt new file mode 100644 index 0000000..be5c07f --- /dev/null +++ b/backtest/adapter/snowflake/src/main/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGenerator.kt @@ -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()) +} diff --git a/backtest/adapter/snowflake/src/test/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGeneratorTest.kt b/backtest/adapter/snowflake/src/test/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGeneratorTest.kt new file mode 100644 index 0000000..b2123ff --- /dev/null +++ b/backtest/adapter/snowflake/src/test/kotlin/com/refinvest/core/backtest/adapter/out/id/SnowflakeBacktestRunIdGeneratorTest.kt @@ -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) + } +} diff --git a/backtest/application/build.gradle.kts b/backtest/application/build.gradle.kts new file mode 100644 index 0000000..c58854c --- /dev/null +++ b/backtest/application/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { id("kotlin-common-conventions") } +dependencies { + implementation(project(":backtest:port")) + implementation(libs.spring.context) +} diff --git a/backtest/application/src/main/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestService.kt b/backtest/application/src/main/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestService.kt new file mode 100644 index 0000000..70268f2 --- /dev/null +++ b/backtest/application/src/main/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestService.kt @@ -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) + } +} diff --git a/backtest/application/src/test/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestServiceTest.kt b/backtest/application/src/test/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestServiceTest.kt new file mode 100644 index 0000000..f8ab817 --- /dev/null +++ b/backtest/application/src/test/kotlin/com/refinvest/core/backtest/application/backtest/run/RunBacktestServiceTest.kt @@ -0,0 +1,47 @@ +package com.refinvest.core.backtest.application.backtest.run + +import com.refinvest.core.backtest.domain.BacktestRun +import com.refinvest.core.backtest.domain.BacktestRunId +import com.refinvest.core.backtest.domain.BacktestRunStatus +import com.refinvest.core.backtest.domain.FeeModel +import com.refinvest.core.backtest.domain.Percent +import com.refinvest.core.backtest.domain.Period +import com.refinvest.core.backtest.domain.StrategyVersionId +import com.refinvest.core.backtest.port.inbound.backtest.run.RunBacktestCommand +import com.refinvest.core.backtest.port.outbound.BacktestRunIdGenerator +import com.refinvest.core.backtest.port.outbound.BacktestRunStore +import java.math.BigDecimal +import java.time.Clock +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RunBacktestServiceTest { + @Test + fun `creates and persists only a pending backtest run`() { + var saved: BacktestRun? = null + val id = BacktestRunId(10L) + val service = RunBacktestService( + backtestRunStore = BacktestRunStore { saved = it }, + backtestRunIdGenerator = BacktestRunIdGenerator { id }, + clock = Clock.fixed(Instant.parse("2026-08-11T00:00:00Z"), ZoneOffset.UTC), + ) + + val result = service.execute( + RunBacktestCommand( + strategyVersionId = StrategyVersionId(20L), + period = Period(LocalDate.parse("2025-01-01"), LocalDate.parse("2025-12-31")), + feeModel = FeeModel(Percent(BigDecimal.ZERO), Percent(BigDecimal.ZERO)), + ), + ) + + assertEquals(id, result.id) + assertEquals(BacktestRunStatus.PENDING, saved?.status) + assertEquals(StrategyVersionId(20L), saved?.strategyVersionId) + assertNull(saved?.datasetSnapshotId) + assertNull(saved?.engineVersion) + } +} diff --git a/backtest/domain/build.gradle.kts b/backtest/domain/build.gradle.kts new file mode 100644 index 0000000..2f845c0 --- /dev/null +++ b/backtest/domain/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { id("domain-conventions") } +dependencies { + api(project(":shared:kernel")) +} diff --git a/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestIdentifiers.kt b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestIdentifiers.kt new file mode 100644 index 0000000..4bbab61 --- /dev/null +++ b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestIdentifiers.kt @@ -0,0 +1,24 @@ +package com.refinvest.core.backtest.domain + +import com.refinvest.core.common.domain.Identifier + +@JvmInline +value class BacktestRunId(override val value: Long) : Identifier + +/** A reference to the Strategy bounded context; this is not Strategy's domain type. */ +@JvmInline +value class StrategyVersionId(override val value: Long) : Identifier + +@JvmInline +value class DatasetSnapshotId(val value: String) { + init { + require(value.isNotBlank()) { "datasetSnapshotId must not be blank" } + } +} + +@JvmInline +value class EngineVersion(val value: String) { + init { + require(value.isNotBlank()) { "engineVersion must not be blank" } + } +} diff --git a/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestResult.kt b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestResult.kt new file mode 100644 index 0000000..ffb0317 --- /dev/null +++ b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestResult.kt @@ -0,0 +1,77 @@ +package com.refinvest.core.backtest.domain + +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate + +class BacktestResult( + val backtestRunId: BacktestRunId, + val metrics: BacktestResultMetrics, + equityCurve: List, + trades: List, + val benchmark: Benchmark, + val signalExecutionDelay: SignalExecutionDelay, + val sampleSizeWarning: SampleSizeWarning, + val dataIntegrityStatus: DataIntegrityStatus, +) { + val equityCurve: List = equityCurve.toList() + val trades: List = trades.toList() +} + +data class BacktestResultMetrics( + val totalReturn: BigDecimal, + val cagr: BigDecimal, + val mdd: BigDecimal, + val sharpe: BigDecimal, + val winRate: BigDecimal, + val tradeCount: Int, + val avgTradeReturn: BigDecimal, + val avgHoldingPeriod: BigDecimal, + val profitFactor: BigDecimal, +) + +data class EquityCurvePoint( + val date: LocalDate, + val value: BigDecimal, +) + +data class Trade( + val signalTime: Instant, + val entryTime: Instant, + val entryPrice: BigDecimal, + val exitTime: Instant, + val exitPrice: BigDecimal, + val returnPct: BigDecimal, + val holdingPeriod: Int, +) + +data class Benchmark( + val primary: BuyAndHoldResult, + val secondaryReference: BuyAndHoldResult?, +) + +data class BuyAndHoldResult( + val totalReturn: BigDecimal, + val cagr: BigDecimal, + val mdd: BigDecimal, +) + +class SignalExecutionDelay( + val median: BigDecimal, + val max: BigDecimal, + distribution: List, +) { + val distribution: List = distribution.toList() +} + +enum class SampleSizeWarning { + NONE, + LOW, + ZERO, +} + +data class DataIntegrityStatus( + val datasetSnapshotId: DatasetSnapshotId, + val corporateActionsApplied: Boolean, + val pointInTimeValidationPassed: Boolean, +) diff --git a/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestRun.kt b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestRun.kt new file mode 100644 index 0000000..f7dfd9c --- /dev/null +++ b/backtest/domain/src/main/kotlin/com/refinvest/core/backtest/domain/BacktestRun.kt @@ -0,0 +1,152 @@ +package com.refinvest.core.backtest.domain + +import com.refinvest.core.common.domain.AggregateRoot +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate + +enum class BacktestRunStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED, +} + +data class Period( + val start: LocalDate, + val end: LocalDate, +) { + init { + require(!end.isBefore(start)) { "period end must not be before start" } + } +} + +@JvmInline +value class Percent(val value: BigDecimal) { + init { + require(value >= BigDecimal.ZERO) { "percent must not be negative" } + } +} + +data class FeeModel( + val commission: Percent, + val slippage: Percent, +) + +class BacktestRun private constructor( + override val id: BacktestRunId, + val strategyVersionId: StrategyVersionId, + val requestedPeriod: Period, + val feeModel: FeeModel, + val createdAt: Instant, + status: BacktestRunStatus, + actualPeriod: Period?, + datasetSnapshotId: DatasetSnapshotId?, + engineVersion: EngineVersion?, + result: BacktestResult?, + failureReason: String?, +) : AggregateRoot(id) { + var status: BacktestRunStatus = status + private set + var actualPeriod: Period? = actualPeriod + private set + var datasetSnapshotId: DatasetSnapshotId? = datasetSnapshotId + private set + var engineVersion: EngineVersion? = engineVersion + private set + var result: BacktestResult? = result + private set + var failureReason: String? = failureReason + private set + + init { + validateState() + } + + fun start( + actualPeriod: Period, + datasetSnapshotId: DatasetSnapshotId, + engineVersion: EngineVersion, + ) { + require(status == BacktestRunStatus.PENDING) { "BacktestRun can start only from PENDING" } + status = BacktestRunStatus.RUNNING + this.actualPeriod = actualPeriod + this.datasetSnapshotId = datasetSnapshotId + this.engineVersion = engineVersion + validateState() + } + + fun complete(result: BacktestResult) { + require(status == BacktestRunStatus.RUNNING) { "BacktestRun can complete only from RUNNING" } + require(result.backtestRunId == id) { "BacktestResult belongs to another BacktestRun" } + require(result.dataIntegrityStatus.datasetSnapshotId == datasetSnapshotId) { + "BacktestResult datasetSnapshotId must match BacktestRun" + } + status = BacktestRunStatus.COMPLETED + this.result = result + validateState() + } + + fun fail(failureReason: String) { + require(status == BacktestRunStatus.RUNNING) { "BacktestRun can fail only from RUNNING" } + require(failureReason.isNotBlank()) { "failureReason must not be blank" } + status = BacktestRunStatus.FAILED + this.failureReason = failureReason + validateState() + } + + private fun validateState() { + when (status) { + BacktestRunStatus.PENDING -> { + require(actualPeriod == null) { "PENDING BacktestRun must not have actualPeriod" } + require(datasetSnapshotId == null) { "PENDING BacktestRun must not have datasetSnapshotId" } + require(engineVersion == null) { "PENDING BacktestRun must not have engineVersion" } + require(result == null) { "PENDING BacktestRun must not have a result" } + require(failureReason == null) { "PENDING BacktestRun must not have a failureReason" } + } + BacktestRunStatus.RUNNING -> { + require(actualPeriod != null) { "RUNNING BacktestRun requires actualPeriod" } + require(datasetSnapshotId != null) { "RUNNING BacktestRun requires datasetSnapshotId" } + require(engineVersion != null) { "RUNNING BacktestRun requires engineVersion" } + require(result == null) { "RUNNING BacktestRun must not have a result" } + require(failureReason == null) { "RUNNING BacktestRun must not have a failureReason" } + } + BacktestRunStatus.COMPLETED -> { + require(actualPeriod != null && datasetSnapshotId != null && engineVersion != null) { + "COMPLETED BacktestRun requires Compute execution metadata" + } + require(result != null) { "COMPLETED BacktestRun requires a BacktestResult" } + require(failureReason == null) { "COMPLETED BacktestRun must not have a failureReason" } + } + BacktestRunStatus.FAILED -> { + require(actualPeriod != null && datasetSnapshotId != null && engineVersion != null) { + "FAILED BacktestRun requires Compute execution metadata" + } + require(result == null) { "FAILED BacktestRun must not have a result" } + require(!failureReason.isNullOrBlank()) { "FAILED BacktestRun requires a failureReason" } + } + } + } + + companion object { + fun createPending( + id: BacktestRunId, + strategyVersionId: StrategyVersionId, + requestedPeriod: Period, + feeModel: FeeModel, + createdAt: Instant, + ): BacktestRun = BacktestRun( + id = id, + strategyVersionId = strategyVersionId, + requestedPeriod = requestedPeriod, + feeModel = feeModel, + createdAt = createdAt, + status = BacktestRunStatus.PENDING, + actualPeriod = null, + datasetSnapshotId = null, + engineVersion = null, + result = null, + failureReason = null, + ) + } +} diff --git a/backtest/domain/src/test/kotlin/com/refinvest/core/backtest/domain/BacktestRunTest.kt b/backtest/domain/src/test/kotlin/com/refinvest/core/backtest/domain/BacktestRunTest.kt new file mode 100644 index 0000000..ffe952b --- /dev/null +++ b/backtest/domain/src/test/kotlin/com/refinvest/core/backtest/domain/BacktestRunTest.kt @@ -0,0 +1,72 @@ +package com.refinvest.core.backtest.domain + +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class BacktestRunTest { + @Test + fun `creates a pending run without Compute execution metadata`() { + val run = pendingRun() + + assertEquals(BacktestRunStatus.PENDING, run.status) + assertNull(run.datasetSnapshotId) + assertNull(run.engineVersion) + assertNull(run.actualPeriod) + } + + @Test + fun `allows only the forward state transition from pending to running`() { + val run = pendingRun() + + run.start(period(), DatasetSnapshotId("snapshot-1"), EngineVersion("engine-1")) + + assertEquals(BacktestRunStatus.RUNNING, run.status) + assertFailsWith { + run.start(period(), DatasetSnapshotId("snapshot-2"), EngineVersion("engine-2")) + } + } + + @Test + fun `rejects completion before Compute begins`() { + val run = pendingRun() + + assertFailsWith { run.complete(resultFor(run, DatasetSnapshotId("snapshot-1"))) } + } + + @Test + fun `requires a failure reason when failing`() { + val run = pendingRun() + run.start(period(), DatasetSnapshotId("snapshot-1"), EngineVersion("engine-1")) + + assertFailsWith { run.fail(" ") } + } + + private fun pendingRun(): BacktestRun = BacktestRun.createPending( + id = BacktestRunId(1L), + strategyVersionId = StrategyVersionId(2L), + requestedPeriod = period(), + feeModel = FeeModel(Percent(BigDecimal.ZERO), Percent(BigDecimal.ZERO)), + createdAt = Instant.parse("2026-08-11T00:00:00Z"), + ) + + private fun period(): Period = Period(LocalDate.parse("2025-01-01"), LocalDate.parse("2025-12-31")) + + private fun resultFor(run: BacktestRun, snapshotId: DatasetSnapshotId): BacktestResult = BacktestResult( + backtestRunId = run.id, + metrics = BacktestResultMetrics( + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + 0, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + ), + equityCurve = emptyList(), + trades = emptyList(), + benchmark = Benchmark(BuyAndHoldResult(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO), null), + signalExecutionDelay = SignalExecutionDelay(BigDecimal.ZERO, BigDecimal.ZERO, emptyList()), + sampleSizeWarning = SampleSizeWarning.ZERO, + dataIntegrityStatus = DataIntegrityStatus(snapshotId, false, true), + ) +} diff --git a/backtest/port/build.gradle.kts b/backtest/port/build.gradle.kts new file mode 100644 index 0000000..f284d75 --- /dev/null +++ b/backtest/port/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { id("kotlin-common-conventions") } +dependencies { + api(project(":backtest:domain")) +} diff --git a/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestCommand.kt b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestCommand.kt new file mode 100644 index 0000000..7a232b9 --- /dev/null +++ b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestCommand.kt @@ -0,0 +1,11 @@ +package com.refinvest.core.backtest.port.inbound.backtest.run + +import com.refinvest.core.backtest.domain.FeeModel +import com.refinvest.core.backtest.domain.Period +import com.refinvest.core.backtest.domain.StrategyVersionId + +data class RunBacktestCommand( + val strategyVersionId: StrategyVersionId, + val period: Period, + val feeModel: FeeModel, +) diff --git a/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestResult.kt b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestResult.kt new file mode 100644 index 0000000..a56bfe4 --- /dev/null +++ b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestResult.kt @@ -0,0 +1,7 @@ +package com.refinvest.core.backtest.port.inbound.backtest.run + +import com.refinvest.core.backtest.domain.BacktestRunId + +data class RunBacktestResult( + val id: BacktestRunId, +) diff --git a/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestUseCase.kt b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestUseCase.kt new file mode 100644 index 0000000..b1d1efc --- /dev/null +++ b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/inbound/backtest/run/RunBacktestUseCase.kt @@ -0,0 +1,5 @@ +package com.refinvest.core.backtest.port.inbound.backtest.run + +fun interface RunBacktestUseCase { + fun execute(command: RunBacktestCommand): RunBacktestResult +} diff --git a/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/BacktestRunStore.kt b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/BacktestRunStore.kt new file mode 100644 index 0000000..186ba63 --- /dev/null +++ b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/BacktestRunStore.kt @@ -0,0 +1,12 @@ +package com.refinvest.core.backtest.port.outbound + +import com.refinvest.core.backtest.domain.BacktestRun +import com.refinvest.core.backtest.domain.BacktestRunId + +fun interface BacktestRunStore { + fun save(backtestRun: BacktestRun) +} + +fun interface BacktestRunIdGenerator { + fun next(): BacktestRunId +} diff --git a/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/ComputeClient.kt b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/ComputeClient.kt new file mode 100644 index 0000000..51d6588 --- /dev/null +++ b/backtest/port/src/main/kotlin/com/refinvest/core/backtest/port/outbound/ComputeClient.kt @@ -0,0 +1,65 @@ +package com.refinvest.core.backtest.port.outbound + +import com.refinvest.core.backtest.domain.DatasetSnapshotId +import com.refinvest.core.backtest.domain.FeeModel +import com.refinvest.core.backtest.domain.Period +import com.refinvest.core.backtest.domain.StrategyVersionId + +/** + * Port for Compute's asynchronous backtest API. + * + * TODO: a later orchestration step must obtain this complete payload through strategy application + * before invoking this port. RunBacktest currently creates only a PENDING BacktestRun. + */ +fun interface ComputeClient { + fun requestBacktest(request: ComputeBacktestRequest): ComputeBacktestAccepted +} + +data class ComputeBacktestRequest( + val strategyVersionId: StrategyVersionId, + val strategyVersion: StrategyVersionPayload, + val feeModel: FeeModel, + val period: Period, + val datasetSnapshotId: DatasetSnapshotId? = null, +) + +data class ComputeBacktestAccepted( + val runId: String, +) + +/** Mirrors compute-api's StrategyVersionPayload without coupling to strategy domain. */ +data class StrategyVersionPayload( + val primarySignalAsset: String, + val conditions: List, + val executionAsset: String, + val lag: Int, + val exit: TimeBasedExitPayload, + val positionPolicy: PositionPolicyPayload, +) + +data class ComputeConditionPayload( + val operator: String, + val logicalCombinator: String?, + val operandA: MetricReferencePayload, + val operandB: ComputeConditionOperandPayload, +) + +sealed interface ComputeConditionOperandPayload + +data class MetricOperandPayload(val value: MetricReferencePayload) : ComputeConditionOperandPayload + +data class LiteralOperandPayload(val value: Double) : ComputeConditionOperandPayload + +data class MetricReferencePayload( + val asset: String, + val metric: String, + val window: Int?, +) + +data class TimeBasedExitPayload(val holdingSignalSessions: Int) + +data class PositionPolicyPayload( + val longOnly: Boolean, + val singlePosition: Boolean, + val duplicateEntry: String, +) From efb8eeed843e4fdd381569f5d01fd2413c53ae33 Mon Sep 17 00:00:00 2001 From: liveforpresent Date: Thu, 13 Aug 2026 16:50:40 +0900 Subject: [PATCH 6/6] docs(agent): document core-api module and persistence conventions Keep repository-local agent rules focused on module boundaries, ports, persistence, and the external context source of truth. Related: ADR-018; Use Cases DefineStrategyVersion, GetStrategy, RunBacktest. --- AGENTS.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a65dfe --- /dev/null +++ b/AGENTS.md @@ -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는 `/{domain,port,application,adapter/}`로 구성한다. +- Gradle project path는 `::` 또는 + `::adapter:`를 사용하며 `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는 `:port`에 기능 단위로 둔다. + 전역 `command/`, `query/`, `result/`, `usecase/` 디렉터리는 만들지 않는다. +- Outbound capability interface도 `:port`에 둔다. Command persistence는 + `{Domain}Store`, 독립적인 read-only 조회는 `{Domain}Reader`를 사용한다. Aggregate를 + 검증하기 위한 command-side 조회는 Store 책임이다. +- Application service는 `:application`에서 inbound port를 구현하고 outbound + port만 의존한다. concrete adapter, JPA, PostgreSQL, Web 기술을 직접 의존하지 않는다. +- HTTP adapter는 `: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는 `: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에 + 둔다.